Compare commits
33 Commits
jderehag/d
...
v0.10.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e755b721c | ||
|
|
b244d9f98c | ||
|
|
9e3dbc5dfb | ||
|
|
4cf980fb97 | ||
|
|
5bde55f8d4 | ||
|
|
0d4a4ccad7 | ||
|
|
56a0e8aa6e | ||
|
|
2a5bb6304d | ||
|
|
322a880a02 | ||
|
|
ded31078d4 | ||
|
|
dcbe3475ed | ||
|
|
338a88fb5a | ||
|
|
7eb1551e4b | ||
|
|
0414f924e6 | ||
|
|
9456871271 | ||
|
|
5b4edef785 | ||
|
|
6b81d0d703 | ||
|
|
4097637169 | ||
|
|
9bd66e7297 | ||
|
|
883b0724e0 | ||
|
|
e0bb867948 | ||
|
|
ca28f503b7 | ||
|
|
c83028abc2 | ||
|
|
60406ca8fb | ||
|
|
e878c3c83b | ||
|
|
bdd3fe8899 | ||
|
|
3cfaf689e7 | ||
|
|
b41da03e8a | ||
|
|
ef14b9acb6 | ||
|
|
6f73adaef6 | ||
|
|
e2ff758003 | ||
|
|
748a99c9c4 | ||
|
|
db2d764cce |
54
package-lock.json
generated
54
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -3305,6 +3305,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||
@@ -3453,6 +3462,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 +8078,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 +10212,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 +11964,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
@@ -11963,7 +11999,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -11972,6 +12008,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 +12018,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 +12039,7 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
@@ -12009,7 +12047,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
@@ -12019,6 +12057,7 @@
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
"debug": "^4.4.3",
|
||||
@@ -12028,7 +12067,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.2",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.9.2",
|
||||
"minServerVersion": "0.10.2",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
|
||||
import { BrowserWindow, Notification, 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,50 @@ 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 }
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"notifications:show",
|
||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||
if (!Notification.isSupported()) {
|
||||
return { ok: false, reason: "unsupported" }
|
||||
}
|
||||
|
||||
const title = typeof payload?.title === "string" ? payload.title : "CodeNomad"
|
||||
const body = typeof payload?.body === "string" ? payload.body : ""
|
||||
try {
|
||||
const notification = new Notification({ title, body })
|
||||
notification.show()
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -473,6 +473,14 @@ if (isMac) {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// Required for Windows notifications / taskbar grouping.
|
||||
// Keep in sync with desktop app identifier.
|
||||
try {
|
||||
app.setAppUserModelId("ai.neuralnomads.codenomad.client")
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
startCli()
|
||||
|
||||
if (isMac) {
|
||||
|
||||
@@ -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,18 @@ 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")
|
||||
// Avoid collisions with an already-running server (and dual-stack ::/0.0.0.0 quirks)
|
||||
// by forcing an ephemeral port in dev.
|
||||
args.push("--http-port", "0")
|
||||
} 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,8 @@ 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)),
|
||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.2",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import http from "http"
|
||||
import https from "https"
|
||||
import { Readable } from "stream"
|
||||
|
||||
export type PluginEvent = {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
@@ -16,7 +20,8 @@ export function getCodeNomadConfig(): CodeNomadConfig {
|
||||
}
|
||||
|
||||
export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||
const baseUrl = config.baseUrl.replace(/\/+$/, "")
|
||||
const rawBaseUrl = (config.baseUrl ?? "").trim()
|
||||
const baseUrl = rawBaseUrl.replace(/\/+$/, "")
|
||||
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
|
||||
const authorization = buildInstanceAuthorizationHeader()
|
||||
|
||||
@@ -42,10 +47,10 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||
const hasBody = init?.body !== undefined
|
||||
const headers = buildHeaders(init?.headers, hasBody)
|
||||
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
// The CodeNomad plugin only talks to the local CodeNomad server.
|
||||
// Use a single request implementation that tolerates custom/self-signed certs
|
||||
// without disabling TLS verification for the whole Node process.
|
||||
return nodeFetch(url, { ...init, headers }, { rejectUnauthorized: false })
|
||||
}
|
||||
|
||||
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
@@ -87,6 +92,91 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
async function nodeFetch(
|
||||
url: string,
|
||||
init: RequestInit & { headers?: Record<string, string> },
|
||||
tls: { rejectUnauthorized: boolean },
|
||||
): Promise<Response> {
|
||||
const parsed = new URL(url)
|
||||
const isHttps = parsed.protocol === "https:"
|
||||
const requestFn = isHttps ? https.request : http.request
|
||||
|
||||
const method = (init.method ?? "GET").toUpperCase()
|
||||
const headers = init.headers ?? {}
|
||||
const body = init.body
|
||||
|
||||
return await new Promise<Response>((resolve, reject) => {
|
||||
const req = requestFn(
|
||||
{
|
||||
protocol: parsed.protocol,
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
method,
|
||||
headers,
|
||||
...(isHttps ? { rejectUnauthorized: tls.rejectUnauthorized } : {}),
|
||||
},
|
||||
(res) => {
|
||||
const responseHeaders = new Headers()
|
||||
for (const [key, value] of Object.entries(res.headers)) {
|
||||
if (value === undefined) continue
|
||||
if (Array.isArray(value)) {
|
||||
responseHeaders.set(key, value.join(", "))
|
||||
} else {
|
||||
responseHeaders.set(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Node stream -> Web ReadableStream for Response.
|
||||
const webBody = Readable.toWeb(res) as unknown as ReadableStream<Uint8Array>
|
||||
resolve(new Response(webBody, { status: res.statusCode ?? 0, headers: responseHeaders }))
|
||||
},
|
||||
)
|
||||
|
||||
const signal = init.signal
|
||||
const abort = () => {
|
||||
const err = new Error("Request aborted")
|
||||
;(err as any).name = "AbortError"
|
||||
req.destroy(err)
|
||||
reject(err)
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
abort()
|
||||
return
|
||||
}
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
req.once("close", () => signal.removeEventListener("abort", abort))
|
||||
}
|
||||
|
||||
req.once("error", reject)
|
||||
|
||||
if (body === undefined || body === null) {
|
||||
req.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof body === "string") {
|
||||
req.end(body)
|
||||
return
|
||||
}
|
||||
|
||||
if (body instanceof Uint8Array) {
|
||||
req.end(Buffer.from(body))
|
||||
return
|
||||
}
|
||||
|
||||
if (body instanceof ArrayBuffer) {
|
||||
req.end(Buffer.from(new Uint8Array(body)))
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback for less common BodyInit types.
|
||||
req.end(String(body))
|
||||
})
|
||||
}
|
||||
|
||||
function requireEnv(key: string): string {
|
||||
const value = process.env[key]
|
||||
if (!value || !value.trim()) {
|
||||
|
||||
@@ -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.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.2",
|
||||
"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.2",
|
||||
"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({}),
|
||||
@@ -24,6 +25,12 @@ const PreferencesSchema = z.object({
|
||||
showUsageMetrics: z.boolean().default(true),
|
||||
autoCleanupBlankSessions: z.boolean().default(true),
|
||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||
|
||||
// OS notifications
|
||||
osNotificationsEnabled: z.boolean().default(false),
|
||||
osNotificationsAllowWhenVisible: z.boolean().default(false),
|
||||
notifyOnNeedsInput: z.boolean().default(true),
|
||||
notifyOnIdle: z.boolean().default(true),
|
||||
})
|
||||
|
||||
const RecentFolderSchema = z.object({
|
||||
|
||||
@@ -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,125 @@ 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"
|
||||
|
||||
// Use an explicit IPv4 loopback address for the "local" URL.
|
||||
// On macOS, `localhost` often resolves to ::1 first, and it is possible to have
|
||||
// another instance bound on IPv6 while this instance binds IPv4 (or vice versa),
|
||||
// which can lead clients to talk to the wrong process.
|
||||
const localUrl = `${localProtocol}://127.0.0.1:${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 +492,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
|
||||
}
|
||||
}
|
||||
836
packages/tauri-app/Cargo.lock
generated
836
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.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -22,3 +22,5 @@ tauri-plugin-dialog = "2"
|
||||
dirs = "5"
|
||||
tauri-plugin-opener = "2"
|
||||
url = "2"
|
||||
tauri-plugin-keepawake = "0.1.1"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"core:menu:default",
|
||||
"dialog:allow-open",
|
||||
"opener:allow-default-urls",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"notification:allow-notify",
|
||||
"notification:allow-show",
|
||||
"core:webview:allow-set-webview-zoom"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}}
|
||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||
@@ -2378,6 +2378,234 @@
|
||||
"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 configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
"const": "notification:default",
|
||||
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-batch",
|
||||
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-cancel",
|
||||
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-check-permissions",
|
||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-create-channel",
|
||||
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-delete-channel",
|
||||
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-active",
|
||||
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-pending",
|
||||
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-is-permission-granted",
|
||||
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-list-channels",
|
||||
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-notify",
|
||||
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-permission-state",
|
||||
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-action-types",
|
||||
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-listener",
|
||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-remove-active",
|
||||
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-request-permission",
|
||||
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-show",
|
||||
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-batch",
|
||||
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-cancel",
|
||||
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-check-permissions",
|
||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-create-channel",
|
||||
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-delete-channel",
|
||||
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-active",
|
||||
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-pending",
|
||||
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-is-permission-granted",
|
||||
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-list-channels",
|
||||
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-notify",
|
||||
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-permission-state",
|
||||
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-action-types",
|
||||
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-listener",
|
||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-remove-active",
|
||||
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-request-permission",
|
||||
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-show",
|
||||
"markdownDescription": "Denies the show 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,234 @@
|
||||
"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 configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||
"type": "string",
|
||||
"const": "notification:default",
|
||||
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-batch",
|
||||
"markdownDescription": "Enables the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-cancel",
|
||||
"markdownDescription": "Enables the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-check-permissions",
|
||||
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-create-channel",
|
||||
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-delete-channel",
|
||||
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-active",
|
||||
"markdownDescription": "Enables the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-get-pending",
|
||||
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-is-permission-granted",
|
||||
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-list-channels",
|
||||
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-notify",
|
||||
"markdownDescription": "Enables the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-permission-state",
|
||||
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-action-types",
|
||||
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-register-listener",
|
||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-remove-active",
|
||||
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-request-permission",
|
||||
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:allow-show",
|
||||
"markdownDescription": "Enables the show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the batch command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-batch",
|
||||
"markdownDescription": "Denies the batch command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the cancel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-cancel",
|
||||
"markdownDescription": "Denies the cancel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check_permissions command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-check-permissions",
|
||||
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the create_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-create-channel",
|
||||
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete_channel command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-delete-channel",
|
||||
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-active",
|
||||
"markdownDescription": "Denies the get_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_pending command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-get-pending",
|
||||
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the is_permission_granted command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-is-permission-granted",
|
||||
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the list_channels command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-list-channels",
|
||||
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the notify command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-notify",
|
||||
"markdownDescription": "Denies the notify command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the permission_state command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-permission-state",
|
||||
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_action_types command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-action-types",
|
||||
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-register-listener",
|
||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_active command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-remove-active",
|
||||
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the request_permission command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-request-permission",
|
||||
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the show command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "notification:deny-show",
|
||||
"markdownDescription": "Denies the show 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,8 @@ fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_keepawake::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(navigation_guard)
|
||||
.manage(AppState {
|
||||
manager: CliProcessManager::new(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.9.5",
|
||||
"version": "0.10.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -19,6 +19,7 @@
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
"debug": "^4.4.3",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
@@ -27,7 +28,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,
|
||||
@@ -329,32 +354,34 @@ const App: Component = () => {
|
||||
<Dialog open={Boolean(launchError())} modal>
|
||||
<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-6">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||
{t("app.launchError.description")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-3xl p-6 flex flex-col gap-6 max-h-[80vh] min-h-0 overflow-hidden">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||
{t("app.launchError.description")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<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">{t("app.launchError.binaryPathLabel")}</p>
|
||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||
</div>
|
||||
<div class={`flex flex-col gap-4 ${launchErrorMessage() ? "flex-1 min-h-0" : ""}`}>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex-shrink-0">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
|
||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||
</div>
|
||||
|
||||
<Show when={launchErrorMessage()}>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex flex-col gap-2 flex-1 min-h-0">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide">{t("app.launchError.errorOutputLabel")}</p>
|
||||
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words overflow-auto flex-1 min-h-0">{launchErrorMessage()}</pre>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={launchErrorMessage()}>
|
||||
<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">{t("app.launchError.errorOutputLabel")}</p>
|
||||
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Show when={launchError()?.missingBinary}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
<div class="flex justify-end gap-2">
|
||||
<Show when={launchError()?.missingBinary}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={handleLaunchErrorAdvanced}
|
||||
>
|
||||
{t("app.launchError.openAdvancedSettings")}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { Instance } from "../types/instance"
|
||||
import InstanceTab from "./instance-tab"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import { Plus, MonitorUp } from "lucide-solid"
|
||||
import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
import NotificationsSettingsModal from "./notifications-settings-modal"
|
||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
|
||||
interface InstanceTabsProps {
|
||||
instances: Map<string, Instance>
|
||||
@@ -18,6 +22,21 @@ interface InstanceTabsProps {
|
||||
|
||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const { preferences } = useConfig()
|
||||
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
|
||||
|
||||
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
|
||||
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
|
||||
const notificationIcon = createMemo(() => {
|
||||
if (!notificationsSupported()) return BellOff
|
||||
return notificationsEnabled() ? Bell : BellOff
|
||||
})
|
||||
|
||||
const notificationTitle = createMemo(() => {
|
||||
if (!notificationsSupported()) return "Notifications unsupported"
|
||||
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="tab-bar tab-bar-instance">
|
||||
<div class="tab-container" role="tablist">
|
||||
@@ -54,6 +73,16 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
<ThemeModeToggle class="new-tab-button" />
|
||||
|
||||
<button
|
||||
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
||||
onClick={() => setNotificationsOpen(true)}
|
||||
title={notificationTitle()}
|
||||
aria-label={notificationTitle()}
|
||||
>
|
||||
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
@@ -67,6 +96,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
|
||||
</div>
|
||||
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
|
||||
const seenTimelineMessageIds = new Set<string>()
|
||||
const seenTimelineSegmentKeys = new Set<string>()
|
||||
const timelinePartCountsByMessageId = new Map<string, number>()
|
||||
let pendingTimelineMessagePartUpdates = new Set<string>()
|
||||
let pendingTimelinePartUpdateFrame: number | null = null
|
||||
|
||||
function makeTimelineKey(segment: TimelineSegment) {
|
||||
return `${segment.messageId}:${segment.id}:${segment.type}`
|
||||
@@ -104,6 +107,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
function seedTimeline() {
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
timelinePartCountsByMessageId.clear()
|
||||
const ids = untrack(messageIds)
|
||||
const resolvedStore = untrack(store)
|
||||
const segments: TimelineSegment[] = []
|
||||
@@ -111,6 +115,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
if (!record) return
|
||||
seenTimelineMessageIds.add(messageId)
|
||||
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
|
||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
@@ -125,6 +130,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
function appendTimelineForMessage(messageId: string) {
|
||||
const record = untrack(() => store().getMessage(messageId))
|
||||
if (!record) return
|
||||
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
|
||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||
if (built.length === 0) return
|
||||
const newSegments: TimelineSegment[] = []
|
||||
@@ -490,8 +496,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
})
|
||||
|
||||
let previousTimelineIds: string[] = []
|
||||
let previousLastTimelineMessageId: string | null = null
|
||||
let previousLastTimelinePartCount = 0
|
||||
|
||||
createEffect(() => {
|
||||
const loading = Boolean(props.loading)
|
||||
@@ -499,11 +503,15 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
|
||||
if (loading) {
|
||||
previousTimelineIds = []
|
||||
previousLastTimelineMessageId = null
|
||||
previousLastTimelinePartCount = 0
|
||||
setTimelineSegments([])
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
timelinePartCountsByMessageId.clear()
|
||||
pendingTimelineMessagePartUpdates.clear()
|
||||
if (pendingTimelinePartUpdateFrame !== null) {
|
||||
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||
pendingTimelinePartUpdateFrame = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -545,6 +553,14 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||
return next
|
||||
})
|
||||
|
||||
// Keep part count tracking in sync with id replacement.
|
||||
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
|
||||
if (existingPartCount !== undefined) {
|
||||
timelinePartCountsByMessageId.delete(oldId)
|
||||
timelinePartCountsByMessageId.set(newId, existingPartCount)
|
||||
}
|
||||
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
@@ -568,30 +584,95 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
previousTimelineIds = ids.slice()
|
||||
})
|
||||
|
||||
function clearPendingTimelinePartUpdateFrame() {
|
||||
if (pendingTimelinePartUpdateFrame !== null) {
|
||||
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
|
||||
pendingTimelinePartUpdateFrame = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTimelinePartUpdateFlush() {
|
||||
if (pendingTimelinePartUpdateFrame !== null) return
|
||||
pendingTimelinePartUpdateFrame = requestAnimationFrame(() => {
|
||||
pendingTimelinePartUpdateFrame = null
|
||||
if (pendingTimelineMessagePartUpdates.size === 0) return
|
||||
const changedIds = Array.from(pendingTimelineMessagePartUpdates)
|
||||
pendingTimelineMessagePartUpdates = new Set<string>()
|
||||
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
|
||||
setTimelineSegments((prev) => {
|
||||
let next = prev
|
||||
|
||||
for (const changedId of changedIds) {
|
||||
// Remove old segments for this message.
|
||||
next = next.filter((segment) => segment.messageId !== changedId)
|
||||
|
||||
const record = resolvedStore.getMessage(changedId)
|
||||
const rebuilt = record ? buildTimelineSegments(props.instanceId, record, t) : []
|
||||
|
||||
// Insert rebuilt segments in the correct place based on session message order.
|
||||
if (rebuilt.length > 0) {
|
||||
let insertAt = next.length
|
||||
const changedIndex = ids.indexOf(changedId)
|
||||
if (changedIndex >= 0) {
|
||||
for (let i = changedIndex + 1; i < ids.length; i++) {
|
||||
const followingId = ids[i]
|
||||
const existingIndex = next.findIndex((segment) => segment.messageId === followingId)
|
||||
if (existingIndex >= 0) {
|
||||
insertAt = existingIndex
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
next = [...next.slice(0, insertAt), ...rebuilt, ...next.slice(insertAt)]
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the segment key set since we may have removed/replaced segments.
|
||||
seenTimelineSegmentKeys.clear()
|
||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||
return next
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Keep timeline segments in sync when message parts are added/removed.
|
||||
// Part deletion does not remove message ids from the session, so we must
|
||||
// explicitly replace segments for messages whose part count changed.
|
||||
createEffect(() => {
|
||||
if (props.loading) return
|
||||
const ids = messageIds()
|
||||
if (ids.length === 0) return
|
||||
const lastId = ids[ids.length - 1]
|
||||
if (!lastId) return
|
||||
const record = store().getMessage(lastId)
|
||||
if (!record) return
|
||||
const partCount = record.partIds.length
|
||||
if (lastId === previousLastTimelineMessageId && partCount === previousLastTimelinePartCount) {
|
||||
return
|
||||
const resolvedStore = store()
|
||||
|
||||
let hasChanges = false
|
||||
for (const messageId of ids) {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
const partCount = record?.partIds.length ?? 0
|
||||
const previousCount = timelinePartCountsByMessageId.get(messageId)
|
||||
|
||||
if (previousCount === undefined) {
|
||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||
continue
|
||||
}
|
||||
|
||||
if (previousCount !== partCount) {
|
||||
timelinePartCountsByMessageId.set(messageId, partCount)
|
||||
pendingTimelineMessagePartUpdates.add(messageId)
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
previousLastTimelineMessageId = lastId
|
||||
previousLastTimelinePartCount = partCount
|
||||
const built = buildTimelineSegments(props.instanceId, record, t)
|
||||
const newSegments: TimelineSegment[] = []
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
if (seenTimelineSegmentKeys.has(key)) return
|
||||
seenTimelineSegmentKeys.add(key)
|
||||
newSegments.push(segment)
|
||||
})
|
||||
if (newSegments.length > 0) {
|
||||
setTimelineSegments((prev) => [...prev, ...newSegments])
|
||||
|
||||
// Drop tracking for ids that are no longer present.
|
||||
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
|
||||
if (!ids.includes(trackedId)) {
|
||||
timelinePartCountsByMessageId.delete(trackedId)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
scheduleTimelinePartUpdateFlush()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -758,6 +839,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
}
|
||||
clearScrollToBottomFrames()
|
||||
clearPendingTimelinePartUpdateFrame()
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
}
|
||||
|
||||
@@ -276,6 +276,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
const [tooltipSize, setTooltipSize] = createSignal<{ width: number; height: number }>({ width: 360, height: 420 })
|
||||
const [tooltipElement, setTooltipElement] = createSignal<HTMLDivElement | null>(null)
|
||||
let hoverTimer: number | null = null
|
||||
let closeTimer: number | null = null
|
||||
const showTools = () => props.showToolSegments ?? true
|
||||
|
||||
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
||||
@@ -292,10 +293,30 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
hoverTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const clearCloseTimer = () => {
|
||||
if (closeTimer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(closeTimer)
|
||||
closeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (typeof window === "undefined") return
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
// Small delay so the pointer can travel from the segment to the tooltip.
|
||||
closeTimer = window.setTimeout(() => {
|
||||
closeTimer = null
|
||||
setHoveredSegment(null)
|
||||
setHoverAnchorRect(null)
|
||||
}, 160)
|
||||
}
|
||||
|
||||
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
|
||||
if (typeof window === "undefined") return
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
const target = event.currentTarget as HTMLButtonElement
|
||||
hoverTimer = window.setTimeout(() => {
|
||||
const rect = target.getBoundingClientRect()
|
||||
@@ -305,9 +326,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearHoverTimer()
|
||||
setHoveredSegment(null)
|
||||
setHoverAnchorRect(null)
|
||||
scheduleClose()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -326,7 +345,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
setTooltipCoords({ top: clampedTop, left: clampedLeft })
|
||||
})
|
||||
|
||||
onCleanup(() => clearHoverTimer())
|
||||
onCleanup(() => {
|
||||
clearHoverTimer()
|
||||
clearCloseTimer()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const activeId = props.activeMessageId
|
||||
@@ -432,6 +454,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
ref={(element) => setTooltipElement(element)}
|
||||
class="message-timeline-tooltip"
|
||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||
onMouseEnter={() => clearCloseTimer()}
|
||||
onMouseLeave={() => scheduleClose()}
|
||||
>
|
||||
<MessagePreview
|
||||
messageId={data().messageId}
|
||||
|
||||
232
packages/ui/src/components/notifications-settings-modal.tsx
Normal file
232
packages/ui/src/components/notifications-settings-modal.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Component, Show, createEffect, createResource } from "solid-js"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import {
|
||||
getOsNotificationCapability,
|
||||
requestOsNotificationPermission,
|
||||
type OsNotificationPermission,
|
||||
} from "../lib/os-notifications"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
|
||||
interface NotificationsSettingsModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function formatPermissionLabel(permission: OsNotificationPermission): string {
|
||||
switch (permission) {
|
||||
case "granted":
|
||||
return "Granted"
|
||||
case "denied":
|
||||
return "Denied"
|
||||
case "default":
|
||||
return "Not granted"
|
||||
case "unsupported":
|
||||
return "Unsupported"
|
||||
default:
|
||||
return String(permission)
|
||||
}
|
||||
}
|
||||
|
||||
const NotificationsSettingsModal: Component<NotificationsSettingsModalProps> = (props) => {
|
||||
const { preferences, updatePreferences } = useConfig()
|
||||
|
||||
const [capability, { refetch }] = createResource(() => getOsNotificationCapability())
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
void refetch()
|
||||
}
|
||||
})
|
||||
|
||||
const handleEnableToggle = async (enabled: boolean) => {
|
||||
if (!enabled) {
|
||||
updatePreferences({ osNotificationsEnabled: false })
|
||||
return
|
||||
}
|
||||
|
||||
const cap = capability()
|
||||
if (cap && !cap.supported) {
|
||||
showToastNotification({
|
||||
title: "Notifications",
|
||||
message: cap.info ?? "OS notifications are not supported in this environment.",
|
||||
variant: "warning",
|
||||
})
|
||||
updatePreferences({ osNotificationsEnabled: false })
|
||||
return
|
||||
}
|
||||
|
||||
const permission = await requestOsNotificationPermission()
|
||||
if (permission !== "granted") {
|
||||
showToastNotification({
|
||||
title: "Notifications",
|
||||
message:
|
||||
permission === "denied"
|
||||
? "Notification permission denied. Enable notifications in your system/browser settings."
|
||||
: "Notification permission not granted.",
|
||||
variant: "warning",
|
||||
})
|
||||
updatePreferences({ osNotificationsEnabled: false })
|
||||
return
|
||||
}
|
||||
|
||||
updatePreferences({ osNotificationsEnabled: true })
|
||||
void refetch()
|
||||
}
|
||||
|
||||
const handleRequestPermission = async () => {
|
||||
const cap = capability()
|
||||
if (cap && !cap.supported) {
|
||||
showToastNotification({
|
||||
title: "Notifications",
|
||||
message: cap.info ?? "Notifications are not supported in this environment.",
|
||||
variant: "warning",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const permission = await requestOsNotificationPermission()
|
||||
if (permission === "granted") {
|
||||
showToastNotification({
|
||||
title: "Notifications",
|
||||
message: "Permission granted. You can now enable notifications.",
|
||||
variant: "success",
|
||||
duration: 6000,
|
||||
})
|
||||
void refetch()
|
||||
return
|
||||
}
|
||||
|
||||
showToastNotification({
|
||||
title: "Notifications",
|
||||
message:
|
||||
permission === "denied"
|
||||
? "Permission denied. You may need to enable notifications in your system/browser settings."
|
||||
: "Permission not granted.",
|
||||
variant: "warning",
|
||||
})
|
||||
void refetch()
|
||||
}
|
||||
|
||||
const supported = () => capability()?.supported ?? false
|
||||
const permissionLabel = () => formatPermissionLabel(capability()?.permission ?? "unsupported")
|
||||
const infoMessage = () => capability()?.info
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Notifications</Dialog.Title>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Session Status Notifications</h3>
|
||||
</div>
|
||||
|
||||
<div class="panel-body space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-primary">Enable</div>
|
||||
<div class="text-xs text-secondary">Permission: {permissionLabel()}</div>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(preferences().osNotificationsEnabled)}
|
||||
disabled={!supported() && capability.state === "ready"}
|
||||
onChange={(e) => void handleEnableToggle(e.currentTarget.checked)}
|
||||
/>
|
||||
<span class="text-sm">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Show when={supported() && (capability()?.permission ?? "unsupported") !== "granted"}>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="text-sm text-primary">Request permission</div>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
|
||||
onClick={() => void handleRequestPermission()}
|
||||
>
|
||||
Request
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-primary">Notify when app is focused</div>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(preferences().osNotificationsAllowWhenVisible)}
|
||||
disabled={!preferences().osNotificationsEnabled}
|
||||
onChange={(e) => updatePreferences({ osNotificationsAllowWhenVisible: e.currentTarget.checked })}
|
||||
/>
|
||||
<span class="text-sm">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Show when={Boolean(infoMessage())}>
|
||||
<div class="text-xs text-secondary">{infoMessage()}</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!supported() && capability.state === "ready"}>
|
||||
<div class="text-xs text-secondary">
|
||||
Notifications are not supported in this environment. The bell icon stays disabled.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="border-t pt-4" style={{ "border-color": "var(--border-base)" }}>
|
||||
<div class="text-sm font-semibold text-primary mb-2">Notify me when</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="text-sm text-primary">Session needs input</div>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(preferences().notifyOnNeedsInput)}
|
||||
disabled={!preferences().osNotificationsEnabled}
|
||||
onChange={(e) => updatePreferences({ notifyOnNeedsInput: e.currentTarget.checked })}
|
||||
/>
|
||||
<span class="text-sm">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="text-sm text-primary">Session becomes idle</div>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(preferences().notifyOnIdle)}
|
||||
disabled={!preferences().osNotificationsEnabled}
|
||||
onChange={(e) => updatePreferences({ notifyOnIdle: e.currentTarget.checked })}
|
||||
/>
|
||||
<span class="text-sm">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t flex justify-end" style={{ "border-color": "var(--border-base)" }}>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationsSettingsModal
|
||||
@@ -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!
|
||||
}
|
||||
204
packages/ui/src/lib/os-notifications.ts
Normal file
204
packages/ui/src/lib/os-notifications.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { isElectronHost, isTauriHost } from "./runtime-env"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
export type OsNotificationPermission = "granted" | "denied" | "default" | "unsupported"
|
||||
|
||||
export type OsNotificationCapability = {
|
||||
supported: boolean
|
||||
permission: OsNotificationPermission
|
||||
info?: string
|
||||
}
|
||||
|
||||
export type OsNotificationPayload = {
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
function hasWebNotificationApi(): boolean {
|
||||
return typeof window !== "undefined" && typeof (window as any).Notification !== "undefined"
|
||||
}
|
||||
|
||||
function getWebPermission(): OsNotificationPermission {
|
||||
if (!hasWebNotificationApi()) return "unsupported"
|
||||
const permission = (window as any).Notification.permission as string
|
||||
if (permission === "granted") return "granted"
|
||||
if (permission === "denied") return "denied"
|
||||
return "default"
|
||||
}
|
||||
|
||||
async function requestWebPermission(): Promise<OsNotificationPermission> {
|
||||
if (!hasWebNotificationApi()) return "unsupported"
|
||||
try {
|
||||
const next = await (window as any).Notification.requestPermission()
|
||||
if (next === "granted") return "granted"
|
||||
if (next === "denied") return "denied"
|
||||
return "default"
|
||||
} catch (error) {
|
||||
log.warn("[os-notifications] requestPermission failed", error)
|
||||
return getWebPermission()
|
||||
}
|
||||
}
|
||||
|
||||
async function sendWebNotification(payload: OsNotificationPayload): Promise<void> {
|
||||
if (!hasWebNotificationApi()) {
|
||||
throw new Error("Web notifications not supported")
|
||||
}
|
||||
|
||||
// Browsers generally require permission prior to sending.
|
||||
if (getWebPermission() !== "granted") {
|
||||
throw new Error("Web notification permission not granted")
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-new
|
||||
new (window as any).Notification(payload.title, { body: payload.body })
|
||||
}
|
||||
|
||||
function hasElectronNotifier(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
const api = (window as Window & { electronAPI?: any }).electronAPI
|
||||
return Boolean(api && typeof api.showNotification === "function")
|
||||
}
|
||||
|
||||
export function isOsNotificationSupportedSync(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
if (isElectronHost()) {
|
||||
return hasElectronNotifier()
|
||||
}
|
||||
if (isTauriHost()) {
|
||||
// The authoritative check requires async import; treat Tauri as supported and let the
|
||||
// settings modal surface missing plugin/capability errors.
|
||||
return true
|
||||
}
|
||||
return hasWebNotificationApi()
|
||||
}
|
||||
|
||||
async function sendElectronNotification(payload: OsNotificationPayload): Promise<void> {
|
||||
const api = (window as Window & { electronAPI?: any }).electronAPI
|
||||
if (!api || typeof api.showNotification !== "function") {
|
||||
throw new Error("Electron notification bridge unavailable")
|
||||
}
|
||||
await api.showNotification(payload)
|
||||
}
|
||||
|
||||
async function getTauriNotificationModule(): Promise<any | null> {
|
||||
try {
|
||||
const mod = await import("@tauri-apps/plugin-notification")
|
||||
return mod
|
||||
} catch (error) {
|
||||
log.info("[os-notifications] tauri notification plugin not available", error as any)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getTauriPermission(): Promise<OsNotificationPermission> {
|
||||
const mod = await getTauriNotificationModule()
|
||||
if (!mod) return "unsupported"
|
||||
try {
|
||||
const granted = await mod.isPermissionGranted()
|
||||
return granted ? "granted" : "default"
|
||||
} catch (error) {
|
||||
log.warn("[os-notifications] failed to check tauri notification permission", error)
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
async function requestTauriPermission(): Promise<OsNotificationPermission> {
|
||||
const mod = await getTauriNotificationModule()
|
||||
if (!mod) return "unsupported"
|
||||
try {
|
||||
const result = await mod.requestPermission()
|
||||
if (result === "granted") return "granted"
|
||||
if (result === "denied") return "denied"
|
||||
return "default"
|
||||
} catch (error) {
|
||||
log.warn("[os-notifications] failed to request tauri notification permission", error)
|
||||
return await getTauriPermission()
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTauriNotification(payload: OsNotificationPayload): Promise<void> {
|
||||
const mod = await getTauriNotificationModule()
|
||||
if (!mod) {
|
||||
throw new Error("Tauri notification plugin unavailable")
|
||||
}
|
||||
await mod.sendNotification({ title: payload.title, body: payload.body })
|
||||
}
|
||||
|
||||
export async function getOsNotificationCapability(): Promise<OsNotificationCapability> {
|
||||
if (typeof window === "undefined") {
|
||||
return { supported: false, permission: "unsupported", info: "Not available in this environment." }
|
||||
}
|
||||
|
||||
if (isElectronHost()) {
|
||||
if (!hasElectronNotifier()) {
|
||||
return {
|
||||
supported: false,
|
||||
permission: "unsupported",
|
||||
info: "Electron notification bridge is not available.",
|
||||
}
|
||||
}
|
||||
|
||||
// Electron notifications are controlled by OS-level settings; Electron doesn't expose a reliable permission probe.
|
||||
return {
|
||||
supported: true,
|
||||
permission: "granted",
|
||||
info: "Notifications are managed by your OS notification settings.",
|
||||
}
|
||||
}
|
||||
|
||||
if (isTauriHost()) {
|
||||
const permission = await getTauriPermission()
|
||||
const supported = permission !== "unsupported"
|
||||
return {
|
||||
supported,
|
||||
permission,
|
||||
info: supported ? undefined : "Tauri notification support is not available in this build.",
|
||||
}
|
||||
}
|
||||
|
||||
// Web
|
||||
const permission = getWebPermission()
|
||||
const supported = permission !== "unsupported"
|
||||
return {
|
||||
supported,
|
||||
permission,
|
||||
info: supported
|
||||
? undefined
|
||||
: "This browser does not support OS notifications (or notifications are blocked by the environment).",
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestOsNotificationPermission(): Promise<OsNotificationPermission> {
|
||||
if (typeof window === "undefined") return "unsupported"
|
||||
|
||||
if (isElectronHost()) {
|
||||
// Electron permissions are handled by the OS. No explicit request mechanism.
|
||||
return hasElectronNotifier() ? "granted" : "unsupported"
|
||||
}
|
||||
|
||||
if (isTauriHost()) {
|
||||
return await requestTauriPermission()
|
||||
}
|
||||
|
||||
return await requestWebPermission()
|
||||
}
|
||||
|
||||
export async function sendOsNotification(payload: OsNotificationPayload): Promise<void> {
|
||||
if (typeof window === "undefined") {
|
||||
return
|
||||
}
|
||||
|
||||
if (isElectronHost()) {
|
||||
await sendElectronNotification(payload)
|
||||
return
|
||||
}
|
||||
|
||||
if (isTauriHost()) {
|
||||
await sendTauriNotification(payload)
|
||||
return
|
||||
}
|
||||
|
||||
await sendWebNotification(payload)
|
||||
}
|
||||
@@ -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, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
||||
import { fetchCommands, clearCommands } from "./commands"
|
||||
import { preferences } from "./preferences"
|
||||
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
||||
@@ -40,6 +41,8 @@ const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boole
|
||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map())
|
||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
||||
// Track which worktree a permission was enqueued under (by permission request id).
|
||||
const permissionWorktreeSlugByInstance = new Map<string, Map<string, string>>()
|
||||
|
||||
const [questionQueues, setQuestionQueues] = createSignal<Map<string, QuestionRequest[]>>(new Map())
|
||||
const [activeQuestionId, setActiveQuestionId] = createSignal<Map<string, string | null>>(new Map())
|
||||
@@ -136,10 +139,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 +160,7 @@ function releaseInstanceResources(instanceId: string) {
|
||||
if (!instance) return
|
||||
|
||||
if (instance.client) {
|
||||
sdkManager.destroyClient(instanceId)
|
||||
sdkManager.destroyClientsForInstance(instanceId)
|
||||
}
|
||||
sseManager.seedStatus(instanceId, "disconnected")
|
||||
}
|
||||
@@ -227,6 +230,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)
|
||||
@@ -673,6 +678,16 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL
|
||||
if (sessionId) {
|
||||
incrementSessionPendingCount(instanceId, sessionId)
|
||||
setSessionPendingPermission(instanceId, sessionId, true)
|
||||
|
||||
// Record the worktree slug at the time the permission is enqueued.
|
||||
// This is used to respond in the same worktree context even from the global permission center.
|
||||
const slug = getWorktreeSlugForSession(instanceId, sessionId)
|
||||
let byPermissionId = permissionWorktreeSlugByInstance.get(instanceId)
|
||||
if (!byPermissionId) {
|
||||
byPermissionId = new Map()
|
||||
permissionWorktreeSlugByInstance.set(instanceId, byPermissionId)
|
||||
}
|
||||
byPermissionId.set(permission.id, slug)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,6 +721,8 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
|
||||
|
||||
const removed = removedPermission
|
||||
if (removed) {
|
||||
// Use the id we were asked to remove (avoids type inference edge cases).
|
||||
permissionWorktreeSlugByInstance.get(instanceId)?.delete(permissionId)
|
||||
const removedSessionId = getPermissionSessionId(removed)
|
||||
if (removedSessionId) {
|
||||
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
|
||||
@@ -726,6 +743,7 @@ function clearPermissionQueue(instanceId: string): void {
|
||||
return next
|
||||
})
|
||||
clearSessionPendingCounts(instanceId)
|
||||
permissionWorktreeSlugByInstance.delete(instanceId)
|
||||
recomputeActiveInterruption(instanceId)
|
||||
}
|
||||
|
||||
@@ -874,8 +892,13 @@ async function sendPermissionResponse(
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = permissionWorktreeSlugByInstance.get(instanceId)?.get(requestId)
|
||||
const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root"
|
||||
const worktreeSlug = stored ?? fallback
|
||||
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
|
||||
|
||||
await requestData(
|
||||
instance.client.permission.reply({
|
||||
client.permission.reply({
|
||||
requestID: requestId,
|
||||
reply,
|
||||
}),
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface Preferences {
|
||||
showThinkingBlocks: boolean
|
||||
thinkingBlocksExpansion: ExpansionPreference
|
||||
showTimelineTools: boolean
|
||||
promptSubmitOnEnter: boolean
|
||||
lastUsedBinary?: string
|
||||
locale?: string
|
||||
environmentVariables: Record<string, string>
|
||||
@@ -48,6 +49,12 @@ export interface Preferences {
|
||||
showUsageMetrics: boolean
|
||||
autoCleanupBlankSessions: boolean
|
||||
listeningMode: ListeningMode
|
||||
|
||||
// OS notifications
|
||||
osNotificationsEnabled: boolean
|
||||
osNotificationsAllowWhenVisible: boolean
|
||||
notifyOnNeedsInput: boolean
|
||||
notifyOnIdle: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +80,7 @@ const defaultPreferences: Preferences = {
|
||||
showThinkingBlocks: false,
|
||||
thinkingBlocksExpansion: "expanded",
|
||||
showTimelineTools: true,
|
||||
promptSubmitOnEnter: false,
|
||||
environmentVariables: {},
|
||||
modelRecents: [],
|
||||
modelFavorites: [],
|
||||
@@ -83,6 +91,11 @@ const defaultPreferences: Preferences = {
|
||||
showUsageMetrics: true,
|
||||
autoCleanupBlankSessions: true,
|
||||
listeningMode: "local",
|
||||
|
||||
osNotificationsEnabled: false,
|
||||
osNotificationsAllowWhenVisible: false,
|
||||
notifyOnNeedsInput: true,
|
||||
notifyOnIdle: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +133,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,
|
||||
@@ -132,6 +146,12 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
||||
showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
|
||||
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions,
|
||||
listeningMode: sanitized.listeningMode ?? defaultPreferences.listeningMode,
|
||||
|
||||
osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultPreferences.osNotificationsEnabled,
|
||||
osNotificationsAllowWhenVisible:
|
||||
sanitized.osNotificationsAllowWhenVisible ?? defaultPreferences.osNotificationsAllowWhenVisible,
|
||||
notifyOnNeedsInput: sanitized.notifyOnNeedsInput ?? defaultPreferences.notifyOnNeedsInput,
|
||||
notifyOnIdle: sanitized.notifyOnIdle ?? defaultPreferences.notifyOnIdle,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +401,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 +514,7 @@ interface ConfigContextValue {
|
||||
toggleShowTimelineTools: typeof toggleShowTimelineTools
|
||||
toggleUsageMetrics: typeof toggleUsageMetrics
|
||||
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
|
||||
togglePromptSubmitOnEnter: typeof togglePromptSubmitOnEnter
|
||||
|
||||
setDiffViewMode: typeof setDiffViewMode
|
||||
setToolOutputExpansion: typeof setToolOutputExpansion
|
||||
@@ -526,6 +551,7 @@ const configContextValue: ConfigContextValue = {
|
||||
toggleShowTimelineTools,
|
||||
toggleUsageMetrics,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
togglePromptSubmitOnEnter,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
@@ -585,6 +611,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",
|
||||
)
|
||||
|
||||
|
||||
@@ -16,12 +16,19 @@ import type { MessageStatus } from "./message-v2/types"
|
||||
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
import { getPermissionId, getPermissionKind, getRequestIdFromPermissionReply } from "../types/permission"
|
||||
import {
|
||||
getPermissionId,
|
||||
getPermissionKind,
|
||||
getPermissionSessionId,
|
||||
getRequestIdFromPermissionReply,
|
||||
} from "../types/permission"
|
||||
import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "../types/permission"
|
||||
import { getQuestionId, getRequestIdFromQuestionReply } from "../types/question"
|
||||
import { getQuestionId, getQuestionSessionId, getRequestIdFromQuestionReply } from "../types/question"
|
||||
import type { QuestionRequest } from "../types/question"
|
||||
import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2"
|
||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||
import { sendOsNotification } from "../lib/os-notifications"
|
||||
import { preferences } from "./preferences"
|
||||
import {
|
||||
instances,
|
||||
addPermissionToQueue,
|
||||
@@ -37,6 +44,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,
|
||||
@@ -56,6 +64,34 @@ import type { InstanceMessageStore } from "./message-v2/instance-store"
|
||||
const log = getLogger("sse")
|
||||
const pendingSessionFetches = new Map<string, Promise<void>>()
|
||||
|
||||
function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean {
|
||||
if (typeof document === "undefined") return false
|
||||
const pref = preferences()
|
||||
if (!pref.osNotificationsEnabled) return false
|
||||
if (!pref.osNotificationsAllowWhenVisible && document.visibilityState === "visible") return false
|
||||
if (kind === "needsInput") return Boolean(pref.notifyOnNeedsInput)
|
||||
if (kind === "idle") return Boolean(pref.notifyOnIdle)
|
||||
return false
|
||||
}
|
||||
|
||||
function getInstanceDisplayName(instanceId: string): string {
|
||||
const instanceFolder = instances().get(instanceId)?.folder ?? instanceId
|
||||
return instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
|
||||
}
|
||||
|
||||
function getSessionTitle(instanceId: string, sessionId: string | undefined | null): string {
|
||||
if (!sessionId) return ""
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
const title = session?.title?.trim()
|
||||
return title && title.length > 0 ? title : sessionId
|
||||
}
|
||||
|
||||
function fireOsNotification(payload: { title: string; body: string }) {
|
||||
void sendOsNotification(payload).catch((error) => {
|
||||
log.warn("Failed to send OS notification", error)
|
||||
})
|
||||
}
|
||||
|
||||
interface TuiToastEvent {
|
||||
type: "tui.toast.show"
|
||||
properties: {
|
||||
@@ -81,19 +117,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 +183,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 +200,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 +248,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 +432,14 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
|
||||
const sessionId = event.properties?.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
ensureSessionStatus(instanceId, sessionId, "idle")
|
||||
if (shouldSendOsNotification("idle")) {
|
||||
const title = getInstanceDisplayName(instanceId)
|
||||
const label = getSessionTitle(instanceId, sessionId)
|
||||
const body = label ? `Session "${label}" is idle` : "Session is idle"
|
||||
fireOsNotification({ title, body })
|
||||
}
|
||||
|
||||
ensureSessionStatus(instanceId, sessionId, "idle", (event as any)?.directory)
|
||||
log.info(`[SSE] Session idle: ${sessionId}`)
|
||||
}
|
||||
|
||||
@@ -390,7 +448,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 +464,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))
|
||||
@@ -488,6 +546,14 @@ function handlePermissionUpdated(instanceId: string, event: { type: string; prop
|
||||
log.info(`[SSE] Permission request: ${getPermissionId(permission)} (${getPermissionKind(permission)})`)
|
||||
addPermissionToQueue(instanceId, permission)
|
||||
upsertPermissionV2(instanceId, permission)
|
||||
|
||||
if (shouldSendOsNotification("needsInput")) {
|
||||
const title = getInstanceDisplayName(instanceId)
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
const label = getSessionTitle(instanceId, sessionId)
|
||||
const body = label ? `Session "${label}" needs permission` : "Session needs permission"
|
||||
fireOsNotification({ title, body })
|
||||
}
|
||||
}
|
||||
|
||||
function handlePermissionReplied(instanceId: string, event: { type: string; properties?: PermissionReplyEventPropertiesLike } | any): void {
|
||||
@@ -507,6 +573,14 @@ function handleQuestionAsked(instanceId: string, event: { type: string; properti
|
||||
log.info(`[SSE] Question asked: ${getQuestionId(request)}`)
|
||||
addQuestionToQueue(instanceId, request)
|
||||
upsertQuestionV2(instanceId, request)
|
||||
|
||||
if (shouldSendOsNotification("needsInput")) {
|
||||
const title = getInstanceDisplayName(instanceId)
|
||||
const sessionId = getQuestionSessionId(request)
|
||||
const label = getSessionTitle(instanceId, sessionId)
|
||||
const body = label ? `Session "${label}" needs input` : "Session needs input"
|
||||
fireOsNotification({ title, body })
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuestionAnswered(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -211,7 +211,8 @@
|
||||
.message-timeline-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
/* Allow interacting with the preview (copy/delete/etc). */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.message-preview {
|
||||
|
||||
@@ -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;
|
||||
|
||||
6
packages/ui/src/types/global.d.ts
vendored
6
packages/ui/src/types/global.d.ts
vendored
@@ -25,7 +25,11 @@ declare global {
|
||||
onCliStatus?: (callback: (data: unknown) => void) => () => void
|
||||
onCliError?: (callback: (data: unknown) => void) => () => void
|
||||
getCliStatus?: () => Promise<unknown>
|
||||
restartCli?: () => Promise<unknown>
|
||||
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
||||
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
||||
|
||||
showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }>
|
||||
}
|
||||
|
||||
interface TauriDialogModule {
|
||||
@@ -46,5 +50,3 @@ declare global {
|
||||
codenomadLogger?: LoggerControls
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
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>
|
||||
}
|
||||
@@ -53,11 +53,15 @@ export default defineConfig({
|
||||
workbox: {
|
||||
// Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
|
||||
navigateFallback: null,
|
||||
// Only precache static assets (avoid caching HTML documents / routes).
|
||||
globPatterns: ["**/*.{js,css,png,jpg,jpeg,svg,webp,ico,woff,woff2,ttf,eot,json,webmanifest}"],
|
||||
globIgnores: ["**/*.html"],
|
||||
// Only cache static UI assets; never cache API traffic.
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url, request }) => {
|
||||
if (url.pathname.startsWith("/api/")) return false
|
||||
if (request.destination === "document") return false
|
||||
return ["script", "style", "image", "font"].includes(request.destination)
|
||||
},
|
||||
handler: "CacheFirst",
|
||||
|
||||
Reference in New Issue
Block a user