Compare commits
23 Commits
jderehag/d
...
v0.10.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcbe3475ed | ||
|
|
338a88fb5a | ||
|
|
7eb1551e4b | ||
|
|
0414f924e6 | ||
|
|
9456871271 | ||
|
|
5b4edef785 | ||
|
|
6b81d0d703 | ||
|
|
4097637169 | ||
|
|
9bd66e7297 | ||
|
|
883b0724e0 | ||
|
|
e0bb867948 | ||
|
|
ca28f503b7 | ||
|
|
c83028abc2 | ||
|
|
60406ca8fb | ||
|
|
e878c3c83b | ||
|
|
bdd3fe8899 | ||
|
|
3cfaf689e7 | ||
|
|
b41da03e8a | ||
|
|
ef14b9acb6 | ||
|
|
6f73adaef6 | ||
|
|
e2ff758003 | ||
|
|
748a99c9c4 | ||
|
|
db2d764cce |
44
package-lock.json
generated
44
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -3453,6 +3453,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-forge": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
|
||||
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/plist": {
|
||||
"version": "3.0.5",
|
||||
"dev": true,
|
||||
@@ -8059,6 +8069,15 @@
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
|
||||
"integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==",
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"dev": true,
|
||||
@@ -10184,6 +10203,14 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tauri-plugin-keepawake-api": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
|
||||
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": ">=2.0.0-beta.6"
|
||||
}
|
||||
},
|
||||
"node_modules/temp-dir": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||
@@ -11928,7 +11955,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
@@ -11963,7 +11990,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -11972,6 +11999,7 @@
|
||||
"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",
|
||||
@@ -11981,6 +12009,7 @@
|
||||
"codenomad": "dist/bin.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"ts-node": "^10.9.2",
|
||||
@@ -12001,7 +12030,7 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
@@ -12009,7 +12038,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
@@ -12028,7 +12057,8 @@
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.1",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -347,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>) {
|
||||
@@ -387,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.1",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.1.42"
|
||||
"@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,6 +68,42 @@ 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.
|
||||
@@ -71,8 +119,7 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
|
||||
|
||||
> **TLS requirement**
|
||||
> Browsers require a secure (`https://`) connection for PWA installation.
|
||||
> If you host CodeNomad on a remote machine, serve it behind a reverse proxy (e.g. Caddy, nginx) with a valid TLS certificate.
|
||||
> Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
||||
> 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`
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.1",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.1",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -21,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": {
|
||||
@@ -31,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({}),
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -22,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -27,7 +27,8 @@
|
||||
"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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,7 +12,7 @@ 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 Drawer from "@suid/material/Drawer"
|
||||
@@ -20,7 +20,6 @@ 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"
|
||||
@@ -36,6 +35,7 @@ import {
|
||||
getSessionFamily,
|
||||
getSessionInfo,
|
||||
getSessionThreads,
|
||||
loadMessages,
|
||||
sessions,
|
||||
setActiveParentSession,
|
||||
setActiveSession,
|
||||
@@ -64,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"
|
||||
@@ -94,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"
|
||||
@@ -152,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)")
|
||||
@@ -214,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(() => {
|
||||
@@ -617,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
|
||||
@@ -727,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)" }} />
|
||||
}
|
||||
|
||||
|
||||
@@ -795,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()
|
||||
}
|
||||
|
||||
@@ -863,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 text-primary">
|
||||
<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">
|
||||
@@ -910,23 +918,24 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
enableFilterBar
|
||||
enableFilterBar={showSessionSearch()}
|
||||
showHeader={false}
|
||||
showFooter={false}
|
||||
/>
|
||||
|
||||
<div class="session-sidebar-separator" />
|
||||
<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)}
|
||||
/>
|
||||
<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 text-primary">
|
||||
<Typography variant="subtitle2" class="uppercase tracking-wide text-xs font-semibold text-primary">
|
||||
{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,52 +1390,12 @@ 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" />
|
||||
</span>
|
||||
<span
|
||||
class={`status-indicator ${connectionStatusClass()}`}
|
||||
aria-label={t("instanceShell.connection.ariaLabel", { status: connectionStatusLabel() })}
|
||||
>
|
||||
<span class="status-dot" />
|
||||
</span>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
ref={setRightToggleButtonEl}
|
||||
color="inherit"
|
||||
onClick={handleRightAppBarButtonClick}
|
||||
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">
|
||||
<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")}
|
||||
@@ -1341,101 +1408,65 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</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-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>
|
||||
</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()}
|
||||
<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"
|
||||
onClick={handleRightAppBarButtonClick}
|
||||
aria-label={rightAppBarButtonLabel()}
|
||||
size="small"
|
||||
aria-expanded={rightDrawerState() !== "floating-closed"}
|
||||
>
|
||||
{rightAppBarButtonIcon()}
|
||||
</IconButton>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
@@ -1489,9 +1520,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
</Box>
|
||||
|
||||
{renderRightPanel()}
|
||||
</Box>
|
||||
|
||||
{renderRightPanel()}
|
||||
</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") {
|
||||
@@ -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,7 +2,7 @@ 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, Search, Square, CheckSquare, MinusSquare } 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"
|
||||
@@ -20,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")
|
||||
@@ -49,7 +50,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const [isRenaming, setIsRenaming] = createSignal(false)
|
||||
|
||||
const [filterQuery, setFilterQuery] = createSignal("")
|
||||
const normalizedQuery = createMemo(() => filterQuery().trim().toLowerCase())
|
||||
const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : ""))
|
||||
|
||||
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
|
||||
|
||||
@@ -353,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)
|
||||
@@ -459,6 +473,12 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
{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
|
||||
|
||||
@@ -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-muted"
|
||||
const headingClass = "text-xs font-semibold text-muted uppercase tracking-wide"
|
||||
|
||||
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={`session-context-panel px-4 py-2 ${props.class ?? ""}`}>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-primary">
|
||||
<div class={headingClass}>{t("contextUsagePanel.headings.tokens")}</div>
|
||||
<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">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -132,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[]) => {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 表示を使用",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 视图",
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
@@ -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,5 +1,6 @@
|
||||
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"
|
||||
@@ -83,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) {
|
||||
@@ -204,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),
|
||||
}),
|
||||
@@ -227,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")
|
||||
@@ -256,7 +263,7 @@ async function executeCustomCommand(
|
||||
}
|
||||
|
||||
await requestData(
|
||||
instance.client.session.command({
|
||||
client.session.command({
|
||||
sessionID: sessionId,
|
||||
...(body as any),
|
||||
}),
|
||||
@@ -270,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")
|
||||
@@ -278,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,
|
||||
@@ -293,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",
|
||||
@@ -370,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")
|
||||
@@ -381,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,
|
||||
}),
|
||||
@@ -403,8 +419,11 @@ async function deleteMessagePart(instanceId: string, sessionId: string, messageI
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
await requestData(
|
||||
instance.client.part.delete({
|
||||
client.part.delete({
|
||||
sessionID: sessionId,
|
||||
messageID: messageId,
|
||||
partID: partId,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { tGlobal } from "../lib/i18n"
|
||||
|
||||
import { loadMessages } from "./session-api"
|
||||
import { getOrCreateWorktreeClient, getRootClient, getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees"
|
||||
import {
|
||||
applyPartUpdateV2,
|
||||
replaceMessageIdV2,
|
||||
@@ -81,19 +82,34 @@ function applySessionStatus(instanceId: string, sessionId: string, status: Sessi
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchSessionInfo(instanceId: string, sessionId: string): Promise<Session | null> {
|
||||
async function fetchSessionInfo(instanceId: string, sessionId: string, directory?: string): Promise<Session | null> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) return null
|
||||
|
||||
const slugFromDirectory = getWorktreeSlugForDirectory(instanceId, directory)
|
||||
const slug = slugFromDirectory ?? getWorktreeSlugForSession(instanceId, sessionId)
|
||||
const client = getOrCreateWorktreeClient(instanceId, slug)
|
||||
const rootClient = getRootClient(instanceId)
|
||||
|
||||
try {
|
||||
const info = await requestData<any>(
|
||||
instance.client.session.get({ sessionID: sessionId }),
|
||||
client.session.get({ sessionID: sessionId }),
|
||||
"session.get",
|
||||
)
|
||||
|
||||
let fetchedStatus: SessionStatus = "idle"
|
||||
try {
|
||||
const statuses = await requestData<Record<string, any>>(instance.client.session.status(), "session.status")
|
||||
let statuses: Record<string, any> = {}
|
||||
try {
|
||||
statuses = await requestData<Record<string, any>>(rootClient.session.status(), "session.status")
|
||||
} catch {
|
||||
statuses = await requestData<Record<string, any>>(client.session.status(), "session.status")
|
||||
}
|
||||
// Session status is global-ish; prefer the root context when available.
|
||||
// (OpenCode may scope status by directory in older builds.)
|
||||
// If root fails, fall back to the worktree-scoped client.
|
||||
//
|
||||
// Note: requestData throws on error, so we catch below.
|
||||
const rawStatus = (info as any)?.status ?? statuses?.[sessionId]
|
||||
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
|
||||
fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle"
|
||||
@@ -132,7 +148,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSessionStatus(instanceId: string, sessionId: string, status: SessionStatus) {
|
||||
function ensureSessionStatus(instanceId: string, sessionId: string, status: SessionStatus, directory?: string) {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const existing = instanceSessions?.get(sessionId)
|
||||
if (existing) {
|
||||
@@ -149,7 +165,7 @@ function ensureSessionStatus(instanceId: string, sessionId: string, status: Sess
|
||||
}
|
||||
|
||||
const pending = (async () => {
|
||||
const fetched = await fetchSessionInfo(instanceId, sessionId)
|
||||
const fetched = await fetchSessionInfo(instanceId, sessionId, directory)
|
||||
if (!fetched) return
|
||||
applySessionStatus(instanceId, sessionId, status)
|
||||
})()
|
||||
@@ -197,7 +213,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId
|
||||
if (!sessionId || !messageId) return
|
||||
if (part.type === "compaction") {
|
||||
ensureSessionStatus(instanceId, sessionId, "compacting")
|
||||
ensureSessionStatus(instanceId, sessionId, "compacting", (event as any)?.directory)
|
||||
}
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
@@ -381,7 +397,7 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
|
||||
const sessionId = event.properties?.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
ensureSessionStatus(instanceId, sessionId, "idle")
|
||||
ensureSessionStatus(instanceId, sessionId, "idle", (event as any)?.directory)
|
||||
log.info(`[SSE] Session idle: ${sessionId}`)
|
||||
}
|
||||
|
||||
@@ -390,7 +406,7 @@ function handleSessionStatus(instanceId: string, event: EventSessionStatus): voi
|
||||
if (!sessionId) return
|
||||
|
||||
const status = mapSdkSessionStatus(event.properties.status)
|
||||
ensureSessionStatus(instanceId, sessionId, status)
|
||||
ensureSessionStatus(instanceId, sessionId, status, (event as any)?.directory)
|
||||
log.info(`[SSE] Session status updated: ${sessionId}`, { status })
|
||||
}
|
||||
|
||||
@@ -406,7 +422,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
|
||||
session.status = "working"
|
||||
})
|
||||
} else {
|
||||
ensureSessionStatus(instanceId, sessionID, "working")
|
||||
ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory)
|
||||
}
|
||||
|
||||
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload session after compaction", error))
|
||||
|
||||
@@ -8,6 +8,7 @@ import { instances } from "./instances"
|
||||
import { showConfirmDialog } from "./alerts"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
||||
import { tGlobal } from "../lib/i18n"
|
||||
|
||||
const log = getLogger("session")
|
||||
@@ -602,12 +603,14 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede
|
||||
return isFreshSession
|
||||
}
|
||||
let messages: any[] = []
|
||||
try {
|
||||
messages = await requestData<any[]>(
|
||||
instance.client.session.messages({ sessionID: session.id }),
|
||||
"session.messages",
|
||||
)
|
||||
} catch (error) {
|
||||
try {
|
||||
const worktreeSlug = getWorktreeSlugForSession(instanceId, session.id)
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
messages = await requestData<any[]>(
|
||||
client.session.messages({ sessionID: session.id }),
|
||||
"session.messages",
|
||||
)
|
||||
} catch (error) {
|
||||
log.error(`Failed to fetch messages for session ${session.id}`, error)
|
||||
return isFreshSession
|
||||
}
|
||||
|
||||
366
packages/ui/src/stores/worktrees.ts
Normal file
366
packages/ui/src/stores/worktrees.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { WorktreeDescriptor, WorktreeMap } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { sdkManager, type OpencodeClient } from "../lib/sdk-manager"
|
||||
import { sessions } from "./session-state"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
const [worktreesByInstance, setWorktreesByInstance] = createSignal<Map<string, WorktreeDescriptor[]>>(new Map())
|
||||
const [worktreeMapByInstance, setWorktreeMapByInstance] = createSignal<Map<string, WorktreeMap>>(new Map())
|
||||
const [gitRepoStatusByInstance, setGitRepoStatusByInstance] = createSignal<Map<string, boolean | null>>(new Map())
|
||||
|
||||
const worktreeLoads = new Map<string, Promise<void>>()
|
||||
const mapLoads = new Map<string, Promise<void>>()
|
||||
|
||||
function normalizeMap(input?: WorktreeMap | null): WorktreeMap {
|
||||
if (!input || typeof input !== "object") {
|
||||
return { version: 1, defaultWorktreeSlug: "root", parentSessionWorktreeSlug: {} }
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
defaultWorktreeSlug: input.defaultWorktreeSlug || "root",
|
||||
parentSessionWorktreeSlug: input.parentSessionWorktreeSlug ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWorktreesLoaded(instanceId: string): Promise<void> {
|
||||
if (!instanceId) return
|
||||
if (worktreesByInstance().has(instanceId) && gitRepoStatusByInstance().has(instanceId)) return
|
||||
const existing = worktreeLoads.get(instanceId)
|
||||
if (existing) return existing
|
||||
|
||||
const task = serverApi
|
||||
.fetchWorktrees(instanceId)
|
||||
.then((response) => {
|
||||
setWorktreesByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, response.worktrees ?? [])
|
||||
return next
|
||||
})
|
||||
|
||||
setGitRepoStatusByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, typeof response.isGitRepo === "boolean" ? response.isGitRepo : null)
|
||||
return next
|
||||
})
|
||||
|
||||
// If we already loaded a worktree mapping, drop stale slugs.
|
||||
if (worktreeMapByInstance().has(instanceId)) {
|
||||
void pruneWorktreeMap(instanceId).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn("Failed to load worktrees", { instanceId, error })
|
||||
setWorktreesByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, [])
|
||||
return next
|
||||
})
|
||||
|
||||
// Preserve any previous value; if unknown, keep it unknown.
|
||||
setGitRepoStatusByInstance((prev) => {
|
||||
if (prev.has(instanceId)) return prev
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, null)
|
||||
return next
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
worktreeLoads.delete(instanceId)
|
||||
})
|
||||
|
||||
worktreeLoads.set(instanceId, task)
|
||||
return task
|
||||
}
|
||||
|
||||
async function reloadWorktrees(instanceId: string): Promise<void> {
|
||||
if (!instanceId) return
|
||||
await serverApi
|
||||
.fetchWorktrees(instanceId)
|
||||
.then((response) => {
|
||||
setWorktreesByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, response.worktrees ?? [])
|
||||
return next
|
||||
})
|
||||
|
||||
setGitRepoStatusByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, typeof response.isGitRepo === "boolean" ? response.isGitRepo : null)
|
||||
return next
|
||||
})
|
||||
|
||||
if (worktreeMapByInstance().has(instanceId)) {
|
||||
void pruneWorktreeMap(instanceId).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn("Failed to reload worktrees", { instanceId, error })
|
||||
})
|
||||
}
|
||||
|
||||
function getGitRepoStatus(instanceId: string): boolean | null {
|
||||
return gitRepoStatusByInstance().get(instanceId) ?? null
|
||||
}
|
||||
|
||||
async function createWorktree(instanceId: string, slug: string): Promise<{ slug: string; directory: string; branch?: string }> {
|
||||
if (!instanceId) {
|
||||
throw new Error("Missing instanceId")
|
||||
}
|
||||
const trimmed = (slug ?? "").trim()
|
||||
if (!trimmed) {
|
||||
throw new Error("Worktree name is required")
|
||||
}
|
||||
return await serverApi.createWorktree(instanceId, { slug: trimmed })
|
||||
}
|
||||
|
||||
async function deleteWorktree(instanceId: string, slug: string, options?: { force?: boolean }): Promise<void> {
|
||||
if (!instanceId) {
|
||||
throw new Error("Missing instanceId")
|
||||
}
|
||||
const trimmed = (slug ?? "").trim()
|
||||
if (!trimmed || trimmed === "root") {
|
||||
throw new Error("Invalid worktree")
|
||||
}
|
||||
await serverApi.deleteWorktree(instanceId, trimmed, options)
|
||||
}
|
||||
|
||||
async function ensureWorktreeMapLoaded(instanceId: string): Promise<void> {
|
||||
if (!instanceId) return
|
||||
if (worktreeMapByInstance().has(instanceId)) return
|
||||
const existing = mapLoads.get(instanceId)
|
||||
if (existing) return existing
|
||||
|
||||
const task = serverApi
|
||||
.readWorktreeMap(instanceId)
|
||||
.then((map) => {
|
||||
setWorktreeMapByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, normalizeMap(map))
|
||||
return next
|
||||
})
|
||||
|
||||
// If worktrees are already loaded, prune any mappings that reference missing worktrees.
|
||||
if (worktreesByInstance().has(instanceId)) {
|
||||
void pruneWorktreeMap(instanceId).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn("Failed to load worktree map", { instanceId, error })
|
||||
setWorktreeMapByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, normalizeMap(null))
|
||||
return next
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
mapLoads.delete(instanceId)
|
||||
})
|
||||
|
||||
mapLoads.set(instanceId, task)
|
||||
return task
|
||||
}
|
||||
|
||||
async function reloadWorktreeMap(instanceId: string): Promise<void> {
|
||||
if (!instanceId) return
|
||||
await serverApi
|
||||
.readWorktreeMap(instanceId)
|
||||
.then((map) => {
|
||||
setWorktreeMapByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, normalizeMap(map))
|
||||
return next
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn("Failed to reload worktree map", { instanceId, error })
|
||||
})
|
||||
}
|
||||
|
||||
function getWorktrees(instanceId: string): WorktreeDescriptor[] {
|
||||
return worktreesByInstance().get(instanceId) ?? []
|
||||
}
|
||||
|
||||
function getWorktreeMap(instanceId: string): WorktreeMap {
|
||||
return worktreeMapByInstance().get(instanceId) ?? normalizeMap(null)
|
||||
}
|
||||
|
||||
function isWorktreeSlugAvailable(instanceId: string, slug: string): boolean {
|
||||
const normalized = (slug ?? "").trim() || "root"
|
||||
if (normalized === "root") return true
|
||||
|
||||
const list = getWorktrees(instanceId)
|
||||
// If worktrees aren't loaded yet, don't force root incorrectly.
|
||||
if (list.length === 0) return true
|
||||
return list.some((wt) => wt.slug === normalized)
|
||||
}
|
||||
|
||||
function normalizeWorktreeSlug(instanceId: string, slug: string): string {
|
||||
const normalized = (slug ?? "").trim() || "root"
|
||||
if (normalized === "root") return "root"
|
||||
return isWorktreeSlugAvailable(instanceId, normalized) ? normalized : "root"
|
||||
}
|
||||
|
||||
async function pruneWorktreeMap(instanceId: string): Promise<boolean> {
|
||||
const current = getWorktreeMap(instanceId)
|
||||
const available = new Set(getWorktrees(instanceId).map((wt) => wt.slug))
|
||||
available.add("root")
|
||||
|
||||
let changed = false
|
||||
let nextDefault = current.defaultWorktreeSlug || "root"
|
||||
if (!available.has(nextDefault)) {
|
||||
nextDefault = "root"
|
||||
changed = true
|
||||
}
|
||||
|
||||
const nextMapping: Record<string, string> = { ...(current.parentSessionWorktreeSlug ?? {}) }
|
||||
for (const [sessionId, slug] of Object.entries(nextMapping)) {
|
||||
if (!available.has(slug)) {
|
||||
delete nextMapping[sessionId]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return false
|
||||
|
||||
const next: WorktreeMap = {
|
||||
version: 1,
|
||||
defaultWorktreeSlug: nextDefault,
|
||||
parentSessionWorktreeSlug: nextMapping,
|
||||
}
|
||||
|
||||
setWorktreeMapByInstance((prev) => {
|
||||
const map = new Map(prev)
|
||||
map.set(instanceId, next)
|
||||
return map
|
||||
})
|
||||
|
||||
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
|
||||
log.warn("Failed to persist pruned worktree map", { instanceId, error })
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function getDefaultWorktreeSlug(instanceId: string): string {
|
||||
return normalizeWorktreeSlug(instanceId, getWorktreeMap(instanceId).defaultWorktreeSlug || "root")
|
||||
}
|
||||
|
||||
async function setDefaultWorktreeSlug(instanceId: string, slug: string): Promise<void> {
|
||||
await ensureWorktreeMapLoaded(instanceId)
|
||||
const current = getWorktreeMap(instanceId)
|
||||
const nextSlug = normalizeWorktreeSlug(instanceId, slug)
|
||||
const next: WorktreeMap = { ...current, defaultWorktreeSlug: nextSlug }
|
||||
setWorktreeMapByInstance((prev) => {
|
||||
const map = new Map(prev)
|
||||
map.set(instanceId, next)
|
||||
return map
|
||||
})
|
||||
|
||||
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
|
||||
log.warn("Failed to persist default worktree", { instanceId, slug: nextSlug, error })
|
||||
})
|
||||
}
|
||||
|
||||
function getParentSessionId(instanceId: string, sessionId: string): string {
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) return sessionId
|
||||
return session.parentId ?? session.id
|
||||
}
|
||||
|
||||
function getWorktreeSlugForParentSession(instanceId: string, parentSessionId: string): string {
|
||||
const map = getWorktreeMap(instanceId)
|
||||
const candidate = map.parentSessionWorktreeSlug[parentSessionId] ?? map.defaultWorktreeSlug ?? "root"
|
||||
return normalizeWorktreeSlug(instanceId, candidate)
|
||||
}
|
||||
|
||||
function getWorktreeSlugForSession(instanceId: string, sessionId: string): string {
|
||||
const parentId = getParentSessionId(instanceId, sessionId)
|
||||
return getWorktreeSlugForParentSession(instanceId, parentId)
|
||||
}
|
||||
|
||||
async function setWorktreeSlugForParentSession(instanceId: string, parentSessionId: string, slug: string): Promise<void> {
|
||||
await ensureWorktreeMapLoaded(instanceId)
|
||||
const current = getWorktreeMap(instanceId)
|
||||
const normalizedSlug = normalizeWorktreeSlug(instanceId, slug)
|
||||
const nextMapping = { ...(current.parentSessionWorktreeSlug ?? {}) }
|
||||
nextMapping[parentSessionId] = normalizedSlug
|
||||
const next: WorktreeMap = { ...current, parentSessionWorktreeSlug: nextMapping }
|
||||
setWorktreeMapByInstance((prev) => {
|
||||
const map = new Map(prev)
|
||||
map.set(instanceId, next)
|
||||
return map
|
||||
})
|
||||
|
||||
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
|
||||
log.warn("Failed to persist session worktree mapping", { instanceId, parentSessionId, slug: normalizedSlug, error })
|
||||
})
|
||||
}
|
||||
|
||||
async function removeParentSessionMapping(instanceId: string, parentSessionId: string): Promise<void> {
|
||||
await ensureWorktreeMapLoaded(instanceId)
|
||||
const current = getWorktreeMap(instanceId)
|
||||
if (!current.parentSessionWorktreeSlug[parentSessionId]) return
|
||||
const nextMapping = { ...(current.parentSessionWorktreeSlug ?? {}) }
|
||||
delete nextMapping[parentSessionId]
|
||||
const next: WorktreeMap = { ...current, parentSessionWorktreeSlug: nextMapping }
|
||||
setWorktreeMapByInstance((prev) => {
|
||||
const map = new Map(prev)
|
||||
map.set(instanceId, next)
|
||||
return map
|
||||
})
|
||||
|
||||
await serverApi.writeWorktreeMap(instanceId, next).catch((error) => {
|
||||
log.warn("Failed to persist session worktree mapping removal", { instanceId, parentSessionId, error })
|
||||
})
|
||||
}
|
||||
|
||||
function getWorktreeSlugForDirectory(instanceId: string, directory: string | undefined): string | null {
|
||||
if (!directory) return null
|
||||
const list = getWorktrees(instanceId)
|
||||
const match = list.find((wt) => wt.directory === directory)
|
||||
return match?.slug ?? null
|
||||
}
|
||||
|
||||
function buildWorktreeProxyPath(instanceId: string, slug: string): string {
|
||||
const normalizedSlug = normalizeWorktreeSlug(instanceId, slug || "root")
|
||||
return `/workspaces/${encodeURIComponent(instanceId)}/worktrees/${encodeURIComponent(normalizedSlug)}/instance`
|
||||
}
|
||||
|
||||
function getOrCreateWorktreeClient(instanceId: string, slug: string): OpencodeClient {
|
||||
const normalized = normalizeWorktreeSlug(instanceId, slug || "root")
|
||||
const proxyPath = buildWorktreeProxyPath(instanceId, normalized)
|
||||
return sdkManager.createClient(instanceId, proxyPath, normalized)
|
||||
}
|
||||
|
||||
function getRootClient(instanceId: string): OpencodeClient {
|
||||
return getOrCreateWorktreeClient(instanceId, "root")
|
||||
}
|
||||
|
||||
export {
|
||||
worktreesByInstance,
|
||||
worktreeMapByInstance,
|
||||
gitRepoStatusByInstance,
|
||||
ensureWorktreesLoaded,
|
||||
reloadWorktrees,
|
||||
reloadWorktreeMap,
|
||||
ensureWorktreeMapLoaded,
|
||||
getGitRepoStatus,
|
||||
getWorktrees,
|
||||
getWorktreeMap,
|
||||
getDefaultWorktreeSlug,
|
||||
setDefaultWorktreeSlug,
|
||||
getParentSessionId,
|
||||
getWorktreeSlugForParentSession,
|
||||
getWorktreeSlugForSession,
|
||||
setWorktreeSlugForParentSession,
|
||||
removeParentSessionMapping,
|
||||
getWorktreeSlugForDirectory,
|
||||
buildWorktreeProxyPath,
|
||||
getOrCreateWorktreeClient,
|
||||
getRootClient,
|
||||
createWorktree,
|
||||
deleteWorktree,
|
||||
}
|
||||
@@ -171,6 +171,15 @@
|
||||
ring-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Worktree selector separators */
|
||||
.worktree-selector-item {
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.worktree-selector-item:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.selector-favorites-toggle {
|
||||
@apply p-2 rounded border transition-colors flex items-center justify-center;
|
||||
background-color: var(--surface-base);
|
||||
|
||||
@@ -245,6 +245,29 @@
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* Session list worktree pill */
|
||||
.status-indicator.worktree-indicator {
|
||||
/* Match session title in selected state. */
|
||||
color: var(--text-primary);
|
||||
/* Use inactive session title color as the tint source. */
|
||||
background-color: color-mix(in oklab, var(--text-secondary) 18%, transparent);
|
||||
border-color: color-mix(in oklab, var(--text-secondary) 28%, transparent);
|
||||
text-transform: none;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.session-item-active .status-indicator.worktree-indicator {
|
||||
background-color: color-mix(in oklab, var(--text-primary) 14%, transparent);
|
||||
border-color: color-mix(in oklab, var(--text-primary) 22%, transparent);
|
||||
}
|
||||
|
||||
.status-indicator.worktree-indicator .worktree-indicator-label {
|
||||
white-space: nowrap;
|
||||
max-width: 10rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
@apply flex-1 flex items-center justify-center p-12;
|
||||
|
||||
2
packages/ui/src/types/global.d.ts
vendored
2
packages/ui/src/types/global.d.ts
vendored
@@ -26,6 +26,7 @@ declare global {
|
||||
onCliError?: (callback: (data: unknown) => void) => () => void
|
||||
getCliStatus?: () => Promise<unknown>
|
||||
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
||||
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
||||
}
|
||||
|
||||
interface TauriDialogModule {
|
||||
@@ -47,4 +48,3 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
10
packages/ui/src/types/tauri-plugin-keepawake-api.d.ts
vendored
Normal file
10
packages/ui/src/types/tauri-plugin-keepawake-api.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
declare module "tauri-plugin-keepawake-api" {
|
||||
export interface KeepAwakeConfig {
|
||||
display?: boolean
|
||||
idle?: boolean
|
||||
sleep?: boolean
|
||||
}
|
||||
|
||||
export function start(config?: KeepAwakeConfig): Promise<void>
|
||||
export function stop(): Promise<void>
|
||||
}
|
||||
Reference in New Issue
Block a user