diff --git a/.github/workflows/comment-pr-artifacts.yml b/.github/workflows/comment-pr-artifacts.yml index 86fda296..b7f0fb2a 100644 --- a/.github/workflows/comment-pr-artifacts.yml +++ b/.github/workflows/comment-pr-artifacts.yml @@ -4,6 +4,7 @@ on: pull_request_target: types: - opened + - edited - synchronize - reopened - ready_for_review @@ -19,7 +20,7 @@ jobs: runs-on: ubuntu-latest env: ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }} - ACTOR: ${{ github.actor }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} BASE_REF: ${{ github.event.pull_request.base.ref }} IS_DRAFT: ${{ github.event.pull_request.draft }} PR_NUMBER: ${{ github.event.pull_request.number }} @@ -37,7 +38,7 @@ jobs: fi normalized=",${ALLOWED_ACTORS}," - if [[ "$normalized" == *",${ACTOR},"* ]]; then + if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then echo "allowed=true" >> "$GITHUB_OUTPUT" else echo "allowed=false" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 442055c4..cf8a11a2 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -4,6 +4,7 @@ on: pull_request: types: - opened + - edited - synchronize - reopened - ready_for_review @@ -23,7 +24,7 @@ jobs: allowed: ${{ steps.auth.outputs.allowed }} env: ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }} - ACTOR: ${{ github.actor }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} BASE_REF: ${{ github.event.pull_request.base.ref }} steps: - name: Check PR authorization @@ -37,11 +38,11 @@ jobs: fi normalized=",${ALLOWED_ACTORS}," - if [[ "$normalized" == *",${ACTOR},"* ]]; then + if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then echo "allowed=true" >> "$GITHUB_OUTPUT" else echo "allowed=false" >> "$GITHUB_OUTPUT" - echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2 + echo "Skipping builds for PR by unauthorized author targeting $BASE_REF" >&2 fi build: diff --git a/.github/workflows/restrict-non-dev-prs.yml b/.github/workflows/restrict-non-dev-prs.yml index 11d43ba9..ab27f943 100644 --- a/.github/workflows/restrict-non-dev-prs.yml +++ b/.github/workflows/restrict-non-dev-prs.yml @@ -4,6 +4,7 @@ on: pull_request_target: types: - opened + - edited - reopened - synchronize @@ -17,7 +18,7 @@ jobs: runs-on: ubuntu-latest env: ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }} - ACTOR: ${{ github.actor }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} PR_NUMBER: ${{ github.event.pull_request.number }} BASE_REF: ${{ github.event.pull_request.base.ref }} steps: @@ -27,7 +28,7 @@ jobs: run: | set -euo pipefail normalized=",${ALLOWED_ACTORS}," - if [[ "$normalized" == *",${ACTOR},"* ]]; then + if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then echo "authorized=true" >> "$GITHUB_OUTPUT" else echo "authorized=false" >> "$GITHUB_OUTPUT" @@ -50,5 +51,5 @@ jobs: - name: Fail unauthorized PR if: ${{ steps.auth.outputs.authorized != 'true' }} run: | - echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2 + echo "PR author $PR_AUTHOR is not allowed to open PRs targeting $BASE_REF" >&2 exit 1 diff --git a/README.md b/README.md index ea82aba7..99fe3154 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,127 @@ # CodeNomad -## A fast, multi-instance workspace for running OpenCode sessions. +## The AI Coding Cockpit for OpenCode -CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. It delivers a premium, low-latency workspace that favors speed, clarity, and direct control. +CodeNomad transforms OpenCode from a terminal tool into a **premium desktop workspace** — built for developers who live inside AI coding sessions for hours and need control, speed, and clarity. + +> OpenCode gives you the engine. CodeNomad gives you the cockpit. ![Multi-instance workspace](docs/screenshots/newSession.png) -_Manage multiple OpenCode sessions side-by-side._ -
-📸 More Screenshots +--- -![Command palette overlay](docs/screenshots/command-palette.png) -_Global command palette for keyboard-first control._ +## Features -![Image Previews](docs/screenshots/image-previews.png) -_Rich media previews for images and assets._ +- **🚀 Multi-Instance Workspace** +- **🌐 Remote Access** +- **🧠 Session Management** +- **🎙️ Voice Input & Speech** +- **🌳 Git Worktrees** +- **💬 Rich Message Experience** +- **⌨️ Command Palette** +- **📁 File System Browser** +- **🔐 Authentication & Security** +- **🔔 Notifications** +- **🎨 Theming** +- **🌍 Internationalization** -![Browser Support](docs/screenshots/browser-support.png) -_Browser support via CodeNomad Server._ - -
+--- ## Getting Started -Choose the way that fits your workflow: +### 🖥️ Desktop App -### 🖥️ Desktop App (Recommended) -The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window. +Available as both Electron and Tauri builds — choose based on your preference. -- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases). -- **Run**: Install and launch like any other app. +Download the latest installer for your platform from [Releases](https://github.com/shantur/CodeNomad/releases). -### 🦀 Tauri App (Experimental) -We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development. - -- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases). -- **Source**: Check out `packages/tauri-app` if you're interested in contributing. +| Platform | Formats | +|----------|---------| +| macOS | DMG, ZIP (Universal: Intel + Apple Silicon) | +| Windows | NSIS Installer, ZIP (x64, ARM64) | +| Linux | AppImage, deb, tar.gz (x64, ARM64) | ### 💻 CodeNomad Server -Run CodeNomad as a local server and access it via your web browser. Perfect for remote development (SSH/VPN) or running as a service. + +Run as a local server and access via browser. Perfect for remote development. ```bash npx @neuralnomads/codenomad --launch ``` -Full server/CLI documentation (flags + env vars, TLS, auth, remote access): -- [packages/server/README.md](packages/server/README.md) - -To see all available options: - -```bash -npx @neuralnomads/codenomad --help -``` +See [Server Documentation](packages/server/README.md) for flags, TLS, auth, and remote access. ### 🧪 Dev Releases -Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch. + +Bleeding-edge builds from the `dev` branch: ```bash npx @neuralnomads/codenomad-dev --launch ``` -## Highlights - -- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs. -- **Long-Session Native**: Scroll through massive transcripts without hitches. -- **Command Palette**: A single global palette to jump tabs, launch tools, and control everything. -- **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow. +--- ## Requirements -- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`. -- **Node.js 18+**: Required if running the CLI server or building from source. +- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH` +- **Node.js 18+** — for server mode or building from source -## Troubleshooting +--- -### macOS says the app is damaged -If macOS reports that "CodeNomad.app is damaged and can't be opened," Gatekeeper flagged the download because the app is not yet notarized. You can clear the quarantine flag after moving CodeNomad into `/Applications`: +## Development -```bash -xattr -l /Applications/CodeNomad.app -xattr -dr com.apple.quarantine /Applications/CodeNomad.app -``` - -After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it. - -### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately -On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away. - -Try running with one of these environment variables: - -```bash -# Most reliable workaround (can reduce rendering performance) -WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad - -# Alternative for some Wayland setups -__NV_DISABLE_EXPLICIT_SYNC=1 codenomad -``` - -If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`: - -```bash -#!/bin/bash -export WEBKIT_DISABLE_DMABUF_RENDERER=1 -exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@" -``` - -Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702 - -## Architecture & Development - -CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation: +CodeNomad is a monorepo built with: | Package | Description | |---------|-------------| -| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. | -| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. | -| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. | +| **[packages/server](packages/server/README.md)** | Core logic & CLI — workspaces, OpenCode proxy, API, auth, speech | +| **[packages/ui](packages/ui/README.md)** | SolidJS frontend — reactive, fast, beautiful | +| **[packages/electron-app](packages/electron-app/README.md)** | Desktop shell — process management, IPC, native dialogs | +| **[packages/tauri-app](packages/tauri-app)** | Tauri desktop shell (experimental) | -### Quick Build -To build the Desktop App from source: +### Quick Start -1. Clone the repo. -2. Run `npm install` (requires pnpm or npm 7+ for workspaces). -3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`. +```bash +git clone https://github.com/NeuralNomadsAI/CodeNomad.git +cd CodeNomad +npm install +npm run dev +``` -[![Star History Chart](https://api.star-history.com/svg?repos=NeuralNomadsAI/CodeNomad&type=Date)](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date) +--- +## Troubleshooting + +
+macOS: "CodeNomad.app is damaged and can't be opened" + +Gatekeeper flag due to missing notarization. Clear the quarantine attribute: + +```bash +xattr -dr com.apple.quarantine /Applications/CodeNomad.app +``` + +On Intel Macs, also check **System Settings → Privacy & Security** on first launch. +
+ +
+Linux (Wayland + NVIDIA): Tauri App closes immediately + +WebKitGTK DMA-BUF/GBM issue. Run with: + +```bash +WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad +``` + +See full workaround in the original README. +
+ +--- + +## Community + +[![Star History](https://api.star-history.com/svg?repos=NeuralNomadsAI/CodeNomad&type=Date)](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date) + +--- + +**Built with ♥ by [Neural Nomads](https://github.com/NeuralNomadsAI)** · [MIT License](LICENSE) diff --git a/docs/screenshots/browser-support.png b/docs/screenshots/browser-support.png deleted file mode 100644 index 5bfb8076..00000000 Binary files a/docs/screenshots/browser-support.png and /dev/null differ diff --git a/docs/screenshots/command-palette.png b/docs/screenshots/command-palette.png deleted file mode 100644 index 38479993..00000000 Binary files a/docs/screenshots/command-palette.png and /dev/null differ diff --git a/docs/screenshots/image-previews.png b/docs/screenshots/image-previews.png deleted file mode 100644 index 25bcd678..00000000 Binary files a/docs/screenshots/image-previews.png and /dev/null differ diff --git a/docs/screenshots/newSession.png b/docs/screenshots/newSession.png index cb86e799..b2b0cbf4 100644 Binary files a/docs/screenshots/newSession.png and b/docs/screenshots/newSession.png differ diff --git a/package-lock.json b/package-lock.json index e831a610..33fd41c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.13.3", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.13.3", + "version": "0.14.0", "license": "MIT", "dependencies": { "7zip-bin": "^5.2.0", @@ -12068,7 +12068,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.13.3", + "version": "0.14.0", "license": "MIT", "dependencies": { "@codenomad/ui": "file:../ui", @@ -12105,7 +12105,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.13.3", + "version": "0.14.0", "license": "MIT", "dependencies": { "@fastify/cors": "^8.5.0", @@ -12147,7 +12147,7 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.13.3", + "version": "0.14.0", "license": "MIT", "devDependencies": { "@tauri-apps/cli": "^2.9.4" @@ -12155,7 +12155,7 @@ }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.13.3", + "version": "0.14.0", "license": "MIT", "dependencies": { "@git-diff-view/solid": "^0.0.8", diff --git a/package.json b/package.json index 6398dd9b..3c7422c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.13.3", + "version": "0.14.0", "private": true, "description": "CodeNomad monorepo workspace", "license": "MIT", diff --git a/packages/cloudflare/release-config.json b/packages/cloudflare/release-config.json index f0c6f42d..768c3c95 100644 --- a/packages/cloudflare/release-config.json +++ b/packages/cloudflare/release-config.json @@ -1,4 +1,4 @@ { - "minServerVersion": "0.13.3", + "minServerVersion": "0.14.0", "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" } diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index fad560cf..dbe26458 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -4,6 +4,23 @@ export interface Env { export default { async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url) + + if (url.pathname === "/version.json") { + const response = await env.ASSETS.fetch(request) + + const newHeaders = new Headers(response.headers) + newHeaders.set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") + newHeaders.set("Pragma", "no-cache") + newHeaders.set("Expires", "0") + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }) + } + return env.ASSETS.fetch(request) }, } diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 10e4b3c6..5189bad3 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -117,6 +117,28 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }), ) + ipcMain.handle( + "remote:openWindow", + async ( + _event, + payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }, + ): Promise<{ ok: boolean }> => { + const opener = (mainWindow as BrowserWindow & { + __codenomadOpenRemoteWindow?: (payload: { + id: string + name: string + baseUrl: string + skipTlsVerify: boolean + }) => Promise + }).__codenomadOpenRemoteWindow + if (!opener) { + throw new Error("Remote window opening is not available") + } + await opener(payload) + return { ok: true } + }, + ) + ipcMain.handle( "notifications:show", async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => { diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 55e3dbad..eeee81e4 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -1,7 +1,7 @@ import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron" import http from "node:http" import https from "node:https" -import { existsSync } from "fs" +import { existsSync, mkdirSync } from "fs" import { dirname, join } from "path" import { fileURLToPath } from "url" import { createApplicationMenu } from "./menu" @@ -14,6 +14,31 @@ const mainDirname = dirname(mainFilename) const isMac = process.platform === "darwin" +function configureDevStoragePaths() { + if (app.isPackaged) { + return + } + + const appName = "CodeNomad" + + try { + app.setName(appName) + + const userDataPath = join(app.getPath("appData"), appName) + const sessionDataPath = join(userDataPath, "session-data") + + mkdirSync(userDataPath, { recursive: true }) + mkdirSync(sessionDataPath, { recursive: true }) + + app.setPath("userData", userDataPath) + app.setPath("sessionData", sessionDataPath) + } catch (error) { + console.warn("[cli] failed to configure dev storage paths", error) + } +} + +configureDevStoragePaths() + const cliManager = new CliProcessManager() let mainWindow: BrowserWindow | null = null let currentCliUrl: string | null = null @@ -21,6 +46,8 @@ let pendingCliUrl: string | null = null let pendingBootstrapToken: string | null = null let showingLoadingScreen = false let preloadingView: BrowserView | null = null +const remoteWindowOrigins = new Map>() +const insecureWindowOrigins = new Map>() if (isMac) { app.commandLine.appendSwitch("disable-spell-checking") @@ -93,8 +120,13 @@ function loadLoadingScreen(window: BrowserWindow) { }) } -function getAllowedRendererOrigins(): string[] { +function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] { const origins = new Set() + if (window) { + for (const origin of remoteWindowOrigins.get(window.id) ?? []) { + origins.add(origin) + } + } const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL] for (const candidate of rendererCandidates) { if (!candidate) { @@ -109,13 +141,13 @@ function getAllowedRendererOrigins(): string[] { return Array.from(origins) } -function shouldOpenExternally(url: string): boolean { +function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean { try { const parsed = new URL(url) if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return true } - const allowedOrigins = getAllowedRendererOrigins() + const allowedOrigins = getAllowedRendererOrigins(window) return !allowedOrigins.includes(parsed.origin) } catch { return false @@ -128,7 +160,7 @@ function setupNavigationGuards(window: BrowserWindow) { } window.webContents.setWindowOpenHandler(({ url }) => { - if (shouldOpenExternally(url)) { + if (shouldOpenExternally(url, window)) { handleExternal(url) return { action: "deny" } } @@ -136,13 +168,54 @@ function setupNavigationGuards(window: BrowserWindow) { }) window.webContents.on("will-navigate", (event, url) => { - if (shouldOpenExternally(url)) { + if (shouldOpenExternally(url, window)) { event.preventDefault() handleExternal(url) } }) } +function setWindowAllowedOrigin(window: BrowserWindow, url: string) { + try { + const origin = new URL(url).origin + remoteWindowOrigins.set(window.id, new Set([origin])) + } catch (error) { + console.warn("[cli] failed to store allowed origin", url, error) + } +} + +function clearWindowAllowedOrigin(window: BrowserWindow) { + remoteWindowOrigins.delete(window.id) +} + +function addWindowInsecureOrigin(window: BrowserWindow, url: string) { + try { + const origin = new URL(url).origin + insecureWindowOrigins.set(window.id, new Set([origin])) + } catch (error) { + console.warn("[cli] failed to store insecure origin", url, error) + } +} + +function clearWindowInsecureOrigin(window: BrowserWindow) { + insecureWindowOrigins.delete(window.id) +} + +function isInsecureOriginAllowed(url: string) { + try { + const targetOrigin = new URL(url).origin + for (const origins of insecureWindowOrigins.values()) { + if (origins.has(targetOrigin)) { + return true + } + } + } catch { + return false + } + + return false +} + let cachedPreloadPath: string | null = null function getPreloadPath() { if (cachedPreloadPath && existsSync(cachedPreloadPath)) { @@ -207,25 +280,30 @@ function createWindow() { }, }) - setupNavigationGuards(mainWindow) + const window = mainWindow + + setupNavigationGuards(window) if (isMac) { - mainWindow.webContents.session.setSpellCheckerEnabled(false) + window.webContents.session.setSpellCheckerEnabled(false) } showingLoadingScreen = true currentCliUrl = null - loadLoadingScreen(mainWindow) + clearWindowAllowedOrigin(window) + loadLoadingScreen(window) if (process.env.NODE_ENV === "development") { - mainWindow.webContents.openDevTools({ mode: "detach" }) + window.webContents.openDevTools({ mode: "detach" }) } - createApplicationMenu(mainWindow) - setupCliIPC(mainWindow, cliManager) + createApplicationMenu(window) + setupCliIPC(window, cliManager) - mainWindow.on("closed", () => { + window.on("closed", () => { destroyPreloadingView() + clearWindowAllowedOrigin(window) + clearWindowInsecureOrigin(window) mainWindow = null currentCliUrl = null pendingCliUrl = null @@ -322,10 +400,66 @@ function finalizeCliSwap(url: string) { return } + const window = mainWindow showingLoadingScreen = false currentCliUrl = url + setWindowAllowedOrigin(window, url) pendingCliUrl = null - mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error)) + window.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error)) +} + +function buildRemoteWindowTitle(name: string, baseUrl: string) { + try { + const parsed = new URL(baseUrl) + return `${name} - ${parsed.host}` + } catch { + return `${name} - ${baseUrl}` + } +} + +function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) { + const escapedName = name.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char)) + const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char)) + const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char] ?? char)) + return `${escapedName}

${escapedName}

Could not connect to the remote server.

${escapedMessage}

${escapedUrl}
` +} + +async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }) { + const targetUrl = new URL(payload.baseUrl) + const title = buildRemoteWindowTitle(payload.name, payload.baseUrl) + const window = new BrowserWindow({ + width: 1400, + height: 900, + minWidth: 800, + minHeight: 600, + backgroundColor: "#1a1a1a", + icon: getIconPath(), + title, + webPreferences: { + preload: getPreloadPath(), + contextIsolation: true, + nodeIntegration: false, + spellcheck: !isMac, + }, + }) + + setWindowAllowedOrigin(window, targetUrl.toString()) + if (payload.skipTlsVerify) { + addWindowInsecureOrigin(window, targetUrl.toString()) + } + + setupNavigationGuards(window) + window.on("closed", () => { + clearWindowAllowedOrigin(window) + clearWindowInsecureOrigin(window) + }) + + try { + await window.loadURL(targetUrl.toString()) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildRemoteErrorHtml(payload.name, payload.baseUrl, message))}`) + } } let bootstrapExchangeInFlight = false @@ -504,6 +638,17 @@ app.whenReady().then(() => { } createWindow() + ;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow + + app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => { + if (isInsecureOriginAllowed(url)) { + event.preventDefault() + console.warn("[cli] allowing insecure remote certificate for", url, error) + callback(true) + return + } + callback(false) + }) app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 7790be39..9cb4a041 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -539,7 +539,7 @@ export class CliProcessManager extends EventEmitter { } private buildCliArgs(options: StartOptions, host: string): string[] { - const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName] + const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"] if (options.dev) { // Dev: run plain HTTP + Vite dev server proxy. diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 06cb9cad..4cfbe2bd 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -23,6 +23,7 @@ const electronAPI = { requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"), setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)), showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload), + openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload), } contextBridge.exposeInMainWorld("electronAPI", electronAPI) diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index c4307504..20c8fcf1 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.13.3", + "version": "0.14.0", "description": "CodeNomad - AI coding assistant", "license": "MIT", "author": { diff --git a/packages/opencode-config/plugin/lib/background-process.ts b/packages/opencode-config/plugin/lib/background-process.ts index 689ce519..15198288 100644 --- a/packages/opencode-config/plugin/lib/background-process.ts +++ b/packages/opencode-config/plugin/lib/background-process.ts @@ -13,6 +13,11 @@ type BackgroundProcess = { outputSizeBytes?: number } +type BackgroundProcessNotificationRequest = { + sessionID: string + directory: string +} + type BackgroundProcessOptions = { baseDir: string } @@ -36,12 +41,19 @@ export function createBackgroundProcessTools(config: CodeNomadConfig, options: B args: { title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"), command: tool.schema.string().describe("Shell command to run in the workspace"), + notify: tool.schema.boolean().optional().describe("Notify the current session when the process ends"), }, - async execute(args) { + async execute(args, context) { assertCommandWithinBase(args.command, options.baseDir) + const notification: BackgroundProcessNotificationRequest | undefined = args.notify + ? { + sessionID: context.sessionID, + directory: context.directory, + } + : undefined const process = await request("", { method: "POST", - body: JSON.stringify({ title: args.title, command: args.command }), + body: JSON.stringify({ title: args.title, command: args.command, notify: args.notify, notification }), }) return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}` diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 9d0466ef..79d0ac52 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.13.3", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.13.3", + "version": "0.14.0", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", diff --git a/packages/server/package.json b/packages/server/package.json index fc1781f6..dafda21e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.13.3", + "version": "0.14.0", "description": "CodeNomad Server", "license": "MIT", "author": { diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index e901e335..b2a1e577 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -170,6 +170,24 @@ export interface InstanceStreamEvent { [key: string]: unknown } +export type SideCarKind = "port" + +export type SideCarPrefixMode = "strip" | "preserve" + +export type SideCarStatus = "running" | "stopped" + +export interface SideCar { + id: string + kind: SideCarKind + name: string + port: number + insecure: boolean + prefixMode: SideCarPrefixMode + status: SideCarStatus + createdAt: string + updatedAt: string +} + export interface BinaryRecord { id: string path: string @@ -244,12 +262,40 @@ export interface VoiceModeStateResponse { enabled: boolean } +export interface RemoteServerProfile { + id: string + name: string + baseUrl: string + skipTlsVerify: boolean + createdAt: string + updatedAt: string + lastConnectedAt?: string +} + +export interface RemoteServerProbeRequest { + baseUrl: string + skipTlsVerify?: boolean +} + +export interface RemoteServerProbeResponse { + ok: boolean + reachable: boolean + normalizedUrl: string + skipTlsVerify: boolean + requiresAuth: boolean + authenticated: boolean + error?: string + errorCode?: string +} + export type WorkspaceEventType = | "workspace.created" | "workspace.started" | "workspace.error" | "workspace.stopped" | "workspace.log" + | "sidecar.updated" + | "sidecar.removed" | "storage.configChanged" | "storage.stateChanged" | "instance.dataChanged" @@ -262,6 +308,8 @@ export type WorkspaceEventPayload = | { type: "workspace.error"; workspace: WorkspaceDescriptor } | { type: "workspace.stopped"; workspaceId: string } | { type: "workspace.log"; entry: WorkspaceLogEntry } + | { type: "sidecar.updated"; sidecar: SideCar } + | { type: "sidecar.removed"; sidecarId: string } | { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "instance.dataChanged"; instanceId: string; data: InstanceData } @@ -328,6 +376,8 @@ export interface ServerMeta { export type BackgroundProcessStatus = "running" | "stopped" | "error" +export type BackgroundProcessTerminalReason = "finished" | "failed" | "user_stopped" | "user_terminated" + export interface BackgroundProcess { id: string workspaceId: string @@ -340,6 +390,8 @@ export interface BackgroundProcess { stoppedAt?: string exitCode?: number outputSizeBytes?: number + terminalReason?: BackgroundProcessTerminalReason + notifyEnabled?: boolean } export interface BackgroundProcessListResponse { diff --git a/packages/server/src/auth/manager.ts b/packages/server/src/auth/manager.ts index f12b8761..4e234ace 100644 --- a/packages/server/src/auth/manager.ts +++ b/packages/server/src/auth/manager.ts @@ -104,13 +104,18 @@ export class AuthManager { } getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null { + return this.getSessionFromHeaders(request.headers) + } + + getSessionFromHeaders(headers: { cookie?: string | string[] | undefined }): { username: string; sessionId: string } | null { if (!this.authEnabled) { // When auth is disabled, treat all requests as authenticated. // We still return a stable username so callers can display it. return { username: this.init.username, sessionId: "auth-disabled" } } - const cookies = parseCookies(request.headers.cookie) + const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie.join("; ") : headers.cookie + const cookies = parseCookies(cookieHeader) const sessionId = cookies[this.cookieName] const session = this.sessionManager.getSession(sessionId) if (!session) return null diff --git a/packages/server/src/background-processes/manager.ts b/packages/server/src/background-processes/manager.ts index 53fdf919..3c62f7b1 100644 --- a/packages/server/src/background-processes/manager.ts +++ b/packages/server/src/background-processes/manager.ts @@ -5,7 +5,7 @@ import { randomBytes } from "crypto" import type { EventBus } from "../events/bus" import type { WorkspaceManager } from "../workspaces/manager" import type { Logger } from "../logger" -import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types" +import type { BackgroundProcess, BackgroundProcessStatus, BackgroundProcessTerminalReason } from "../api-types" const ROOT_DIR = ".codenomad/background_processes" const INDEX_FILE = "index.json" @@ -27,6 +27,31 @@ interface RunningProcess { outputPath: string exitPromise: Promise workspaceId: string + completion?: ProcessCompletion +} + +interface ProcessCompletion { + reason: BackgroundProcessTerminalReason + endContext: "normal" | "workspace_cleanup" + removeAfterFinalize?: boolean +} + +interface BackgroundProcessNotificationState { + sessionID: string + directory: string + sentAt?: string +} + +interface PersistedBackgroundProcess extends BackgroundProcess { + notify?: BackgroundProcessNotificationState +} + +interface StartOptions { + notify?: boolean + notification?: { + sessionID: string + directory: string + } } export class BackgroundProcessManager { @@ -41,14 +66,14 @@ export class BackgroundProcessManager { const records = await this.readIndex(workspaceId) const enriched = await Promise.all( records.map(async (record) => ({ - ...record, + ...this.toPublicProcess(record), outputSizeBytes: await this.getOutputSize(workspaceId, record.id), })), ) return enriched } - async start(workspaceId: string, title: string, command: string): Promise { + async start(workspaceId: string, title: string, command: string, options: StartOptions = {}): Promise { const workspace = this.deps.workspaceManager.get(workspaceId) if (!workspace) { throw new Error("Workspace not found") @@ -73,8 +98,7 @@ export class BackgroundProcessManager { this.killProcessTree(child, "SIGTERM") }) - const record: BackgroundProcess = { - + const record: PersistedBackgroundProcess = { id, workspaceId, title, @@ -84,6 +108,20 @@ export class BackgroundProcessManager { pid: child.pid, startedAt: new Date().toISOString(), outputSizeBytes: 0, + notify: options.notify && options.notification + ? { + sessionID: options.notification.sessionID, + directory: options.notification.directory, + } + : undefined, + } + + const runningState: RunningProcess = { + id, + child, + outputPath, + exitPromise: Promise.resolve(), + workspaceId, } const exitPromise = new Promise((resolve) => { @@ -91,18 +129,21 @@ export class BackgroundProcessManager { await new Promise((resolve) => outputStream.end(resolve)) this.running.delete(id) - record.status = this.statusFromExit(code) + const completion = runningState.completion ?? this.completionFromExit(code) + + record.terminalReason = completion.reason + record.status = this.statusFromReason(completion.reason) record.exitCode = code === null ? undefined : code record.stoppedAt = new Date().toISOString() - await this.upsertIndex(workspaceId, record) - record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id) - this.publishUpdate(workspaceId, record) + await this.finalizeRecord(workspaceId, record, completion) resolve() }) }) - this.running.set(id, { id, child, outputPath, exitPromise, workspaceId }) + runningState.exitPromise = exitPromise + + this.running.set(id, runningState) let lastPublishAt = 0 const maybePublishSize = () => { @@ -128,7 +169,7 @@ export class BackgroundProcessManager { await this.upsertIndex(workspaceId, record) record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id) this.publishUpdate(workspaceId, record) - return record + return this.toPublicProcess(record) } async stop(workspaceId: string, processId: string): Promise { @@ -139,19 +180,21 @@ export class BackgroundProcessManager { const running = this.running.get(processId) if (running?.child && !running.child.killed) { + running.completion = { reason: "user_stopped", endContext: "normal" } this.killProcessTree(running.child, "SIGTERM") await this.waitForExit(running) + const updated = await this.findProcess(workspaceId, processId) + return updated ? this.toPublicProcess(updated) : this.toPublicProcess(record) } if (record.status === "running") { record.status = "stopped" + record.terminalReason = "user_stopped" record.stoppedAt = new Date().toISOString() - await this.upsertIndex(workspaceId, record) - record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id) - this.publishUpdate(workspaceId, record) + await this.finalizeRecord(workspaceId, record, { reason: "user_stopped", endContext: "normal" }) } - return record + return this.toPublicProcess(record) } async terminate(workspaceId: string, processId: string): Promise { @@ -160,17 +203,19 @@ export class BackgroundProcessManager { const running = this.running.get(processId) if (running?.child && !running.child.killed) { + running.completion = { reason: "user_terminated", endContext: "normal", removeAfterFinalize: true } this.killProcessTree(running.child, "SIGTERM") await this.waitForExit(running) + return } - await this.removeFromIndex(workspaceId, processId) - await this.removeProcessDir(workspaceId, processId) - - this.deps.eventBus.publish({ - type: "instance.event", - instanceId: workspaceId, - event: { type: "background.process.removed", properties: { processId } }, + record.status = "stopped" + record.terminalReason = "user_terminated" + record.stoppedAt = new Date().toISOString() + await this.finalizeRecord(workspaceId, record, { + reason: "user_terminated", + endContext: "normal", + removeAfterFinalize: true, }) } @@ -266,6 +311,11 @@ export class BackgroundProcessManager { private async cleanupWorkspace(workspaceId: string) { for (const [, running] of this.running.entries()) { if (running.workspaceId !== workspaceId) continue + running.completion = { + reason: "user_terminated", + endContext: "workspace_cleanup", + removeAfterFinalize: true, + } this.killProcessTree(running.child, "SIGTERM") await this.waitForExit(running) } @@ -356,10 +406,17 @@ export class BackgroundProcessManager { return args } - private statusFromExit(code: number | null): BackgroundProcessStatus { - if (code === null) return "stopped" - if (code === 0) return "stopped" - return "error" + private completionFromExit(code: number | null): ProcessCompletion { + if (code === 0) { + return { reason: "finished", endContext: "normal" } + } + + return { reason: "failed", endContext: "normal" } + } + + private statusFromReason(reason: BackgroundProcessTerminalReason): BackgroundProcessStatus { + if (reason === "failed") return "error" + return "stopped" } private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise { @@ -423,25 +480,25 @@ export class BackgroundProcessManager { return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE) } - private async findProcess(workspaceId: string, processId: string): Promise { + private async findProcess(workspaceId: string, processId: string): Promise { const records = await this.readIndex(workspaceId) return records.find((entry) => entry.id === processId) ?? null } - private async readIndex(workspaceId: string): Promise { + private async readIndex(workspaceId: string): Promise { const indexPath = await this.getIndexPath(workspaceId) if (!existsSync(indexPath)) return [] try { const raw = await fs.readFile(indexPath, "utf-8") const parsed = JSON.parse(raw) - return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : [] + return Array.isArray(parsed) ? (parsed as PersistedBackgroundProcess[]) : [] } catch { return [] } } - private async upsertIndex(workspaceId: string, record: BackgroundProcess) { + private async upsertIndex(workspaceId: string, record: PersistedBackgroundProcess) { const records = await this.readIndex(workspaceId) const index = records.findIndex((entry) => entry.id === record.id) if (index >= 0) { @@ -458,7 +515,7 @@ export class BackgroundProcessManager { await this.writeIndex(workspaceId, next) } - private async writeIndex(workspaceId: string, records: BackgroundProcess[]) { + private async writeIndex(workspaceId: string, records: PersistedBackgroundProcess[]) { const indexPath = await this.getIndexPath(workspaceId) await fs.mkdir(path.dirname(indexPath), { recursive: true }) await fs.writeFile(indexPath, JSON.stringify(records, null, 2)) @@ -503,14 +560,139 @@ export class BackgroundProcessManager { } } - private publishUpdate(workspaceId: string, record: BackgroundProcess) { + private publishUpdate(workspaceId: string, record: PersistedBackgroundProcess) { this.deps.eventBus.publish({ type: "instance.event", instanceId: workspaceId, - event: { type: "background.process.updated", properties: { process: record } }, + event: { type: "background.process.updated", properties: { process: this.toPublicProcess(record) } }, }) } + private toPublicProcess(record: PersistedBackgroundProcess): BackgroundProcess { + return { + id: record.id, + workspaceId: record.workspaceId, + title: record.title, + command: record.command, + cwd: record.cwd, + status: record.status, + pid: record.pid, + startedAt: record.startedAt, + stoppedAt: record.stoppedAt, + exitCode: record.exitCode, + outputSizeBytes: record.outputSizeBytes, + terminalReason: record.terminalReason, + notifyEnabled: Boolean(record.notify), + } + } + + private async finalizeRecord(workspaceId: string, record: PersistedBackgroundProcess, completion: ProcessCompletion) { + if (this.shouldSendCompletionPrompt(record, completion)) { + try { + await this.sendCompletionPrompt(workspaceId, record) + if (record.notify) { + record.notify.sentAt = new Date().toISOString() + } + } catch (error) { + this.deps.logger.warn({ err: error, workspaceId, processId: record.id }, "Failed to send background process completion prompt") + } + } + + if (completion.removeAfterFinalize) { + await this.removeFromIndex(workspaceId, record.id) + await this.removeProcessDir(workspaceId, record.id) + + this.deps.eventBus.publish({ + type: "instance.event", + instanceId: workspaceId, + event: { type: "background.process.removed", properties: { processId: record.id } }, + }) + return + } + + await this.upsertIndex(workspaceId, record) + record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id) + this.publishUpdate(workspaceId, record) + } + + private shouldSendCompletionPrompt(record: PersistedBackgroundProcess, completion: ProcessCompletion) { + if (completion.endContext === "workspace_cleanup") return false + if (!record.notify) return false + return !record.notify.sentAt + } + + private async sendCompletionPrompt(workspaceId: string, record: PersistedBackgroundProcess) { + const notify = record.notify + if (!notify || !record.terminalReason) return + + if (!this.deps.workspaceManager.get(workspaceId)) { + throw new Error("Workspace not found") + } + + const port = this.deps.workspaceManager.getInstancePort(workspaceId) + if (!port) { + throw new Error("Workspace instance is not ready") + } + + const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async` + const headers: Record = { + "content-type": "application/json", + "x-opencode-directory": /[^\x00-\x7F]/.test(notify.directory) ? encodeURIComponent(notify.directory) : notify.directory, + } + + const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId) + if (authorization) { + headers.authorization = authorization + } + + const response = await fetch(targetUrl, { + method: "POST", + headers, + body: JSON.stringify({ + parts: [ + { + type: "text", + text: this.buildSyntheticCompletionPrompt(record), + synthetic: true, + }, + ], + }), + }) + + if (!response.ok) { + const message = await response.text().catch(() => "") + throw new Error(message || `Prompt request failed with ${response.status}`) + } + } + + private buildCompletionPrompt(record: PersistedBackgroundProcess): string { + const ref = `Background process "${record.title}" (${record.id})` + + switch (record.terminalReason) { + case "finished": + return `${ref} finished successfully.` + case "failed": + return record.exitCode === undefined ? `${ref} failed.` : `${ref} failed with exit code ${record.exitCode}.` + case "user_stopped": + return `${ref} was stopped by user.` + case "user_terminated": + return `${ref} was terminated by user.` + } + + return `${ref} ended.` + } + + private buildSyntheticCompletionPrompt(record: PersistedBackgroundProcess): string { + return `${this.escapeTaggedText(this.buildCompletionPrompt(record))}` + } + + private escapeTaggedText(input: string): string { + return input + .replace(/&/g, "&") + .replace(//g, ">") + } + private generateId(): string { const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15) const random = randomBytes(3).toString("hex") diff --git a/packages/server/src/config/schema.ts b/packages/server/src/config/schema.ts index c4781113..b26062ef 100644 --- a/packages/server/src/config/schema.ts +++ b/packages/server/src/config/schema.ts @@ -26,6 +26,7 @@ const PreferencesSchema = z showUsageMetrics: z.boolean().default(true), autoCleanupBlankSessions: z.boolean().default(true), listeningMode: z.enum(["local", "all"]).default("local"), + logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"), // OS notifications osNotificationsEnabled: z.boolean().default(false), diff --git a/packages/server/src/events/bus.ts b/packages/server/src/events/bus.ts index 7673f00a..fd1e3ce6 100644 --- a/packages/server/src/events/bus.ts +++ b/packages/server/src/events/bus.ts @@ -24,6 +24,8 @@ export class EventBus extends EventEmitter { this.on("workspace.error", handler) this.on("workspace.stopped", handler) this.on("workspace.log", handler) + this.on("sidecar.updated", handler) + this.on("sidecar.removed", handler) this.on("storage.configChanged", handler) this.on("storage.stateChanged", handler) this.on("instance.dataChanged", handler) @@ -35,6 +37,8 @@ export class EventBus extends EventEmitter { this.off("workspace.error", handler) this.off("workspace.stopped", handler) this.off("workspace.log", handler) + this.off("sidecar.updated", handler) + this.off("sidecar.removed", handler) this.off("storage.configChanged", handler) this.off("storage.stateChanged", handler) this.off("instance.dataChanged", handler) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 20179023..72c98425 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -24,6 +24,10 @@ import { resolveHttpsOptions } from "./server/tls" import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses" import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { SpeechService } from "./speech/service" +import { SideCarManager } from "./sidecars/manager" +import { ClientConnectionManager } from "./clients/connection-manager" +import { PluginChannelManager } from "./plugins/channel" +import { VoiceModeManager } from "./plugins/voice-mode" const require = createRequire(import.meta.url) @@ -315,6 +319,11 @@ async function main() { const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) const instanceStore = new InstanceStore(configLocation.instancesDir) const speechService = new SpeechService(settings, logger.child({ component: "speech" })) + const sidecarManager = new SideCarManager({ + settings, + eventBus, + logger: logger.child({ component: "sidecars" }), + }) const instanceEventBridge = new InstanceEventBridge({ workspaceManager, eventBus, @@ -372,6 +381,14 @@ async function main() { const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host) + const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" })) + const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" })) + const voiceModeManager = new VoiceModeManager({ + connections: clientConnectionManager, + channel: pluginChannel, + logger: logger.child({ component: "voice-mode" }), + }) + const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT) const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT) @@ -400,7 +417,11 @@ async function main() { serverMeta, instanceStore, speechService, + sidecarManager, authManager, + clientConnectionManager, + pluginChannel, + voiceModeManager, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: uiResolution.uiDevServerUrl, logger, @@ -421,7 +442,11 @@ async function main() { serverMeta, instanceStore, speechService, + sidecarManager, authManager, + clientConnectionManager, + pluginChannel, + voiceModeManager, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: undefined, logger, @@ -520,6 +545,18 @@ async function main() { logger.warn({ err: error }, "Instance event bridge shutdown failed") } + try { + await sidecarManager.shutdown() + } catch (error) { + logger.error({ err: error }, "SideCar manager shutdown failed") + } + + try { + clientConnectionManager.shutdown() + } catch (error) { + logger.warn({ err: error }, "Client connection manager shutdown failed") + } + try { await workspaceManager.shutdown() logger.info("Workspace manager shutdown complete") diff --git a/packages/server/src/plugins/voice-mode.ts b/packages/server/src/plugins/voice-mode.ts index 3e2f8cb5..a44ae4e6 100644 --- a/packages/server/src/plugins/voice-mode.ts +++ b/packages/server/src/plugins/voice-mode.ts @@ -19,13 +19,13 @@ export class VoiceModeManager { }) } - setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): void { + setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): boolean { if (enabled && !this.options.connections.isConnected(connection)) { this.options.logger.debug( { instanceId, clientId: connection.clientId, connectionId: connection.connectionId }, "Ignoring voice mode enable for disconnected client connection", ) - return + return false } const key = getConnectionKey(connection) @@ -44,6 +44,7 @@ export class VoiceModeManager { this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection") this.publishIfChanged(instanceId) + return true } syncInstance(instanceId: string): void { @@ -76,7 +77,10 @@ export class VoiceModeManager { this.aggregateByInstance.delete(instanceId) } - this.options.logger.debug({ instanceId, enabled }, "Broadcasting aggregate voice mode") + this.options.logger.debug( + { instanceId, enabled }, + "Broadcasting aggregate voice mode", + ) this.options.channel.send(instanceId, buildVoiceModeEvent(enabled)) } } diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 61f82535..ee5355c5 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -3,7 +3,9 @@ import cors from "@fastify/cors" import fastifyStatic from "@fastify/static" import replyFrom from "@fastify/reply-from" import fs from "fs" +import { connect as connectTcp, type Socket } from "net" import path from "path" +import { connect as connectTls, type TLSSocket } from "tls" import { fetch } from "undici" import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" @@ -22,6 +24,8 @@ import { registerPluginRoutes } from "./routes/plugin" import { registerBackgroundProcessRoutes } from "./routes/background-processes" import { registerWorktreeRoutes } from "./routes/worktrees" import { registerSpeechRoutes } from "./routes/speech" +import { registerRemoteServerRoutes } from "./routes/remote-servers" +import { registerSideCarRoutes } from "./routes/sidecars" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" import { BackgroundProcessManager } from "../background-processes/manager" @@ -32,6 +36,7 @@ import type { SpeechService } from "../speech/service" import { ClientConnectionManager } from "../clients/connection-manager" import { PluginChannelManager } from "../plugins/channel" import { VoiceModeManager } from "../plugins/voice-mode" +import type { SideCarManager } from "../sidecars/manager" interface HttpServerDeps { bindHost: string @@ -47,7 +52,11 @@ interface HttpServerDeps { serverMeta: ServerMeta instanceStore: InstanceStore speechService: SpeechService + sidecarManager: SideCarManager authManager: AuthManager + clientConnectionManager: ClientConnectionManager + pluginChannel: PluginChannelManager + voiceModeManager: VoiceModeManager uiStaticDir: string uiDevServerUrl?: string logger: Logger @@ -176,13 +185,6 @@ export function createHttpServer(deps: HttpServerDeps) { eventBus: deps.eventBus, logger: deps.logger.child({ component: "background-processes" }), }) - const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" })) - const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" })) - const voiceModeManager = new VoiceModeManager({ - connections: clientConnectionManager, - channel: pluginChannel, - logger: deps.logger.child({ component: "voice-mode" }), - }) registerAuthRoutes(app, { authManager: deps.authManager }) @@ -203,7 +205,7 @@ export function createHttpServer(deps: HttpServerDeps) { const session = deps.authManager.getSessionFromRequest(request) - const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") + const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/") if (requiresAuthForApi && !session) { // Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth. const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/) @@ -262,7 +264,7 @@ export function createHttpServer(deps: HttpServerDeps) { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger, - connectionManager: clientConnectionManager, + connectionManager: deps.clientConnectionManager, }) registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager }) registerStorageRoutes(app, { @@ -270,13 +272,21 @@ export function createHttpServer(deps: HttpServerDeps) { eventBus: deps.eventBus, workspaceManager: deps.workspaceManager, }) + registerRemoteServerRoutes(app, { logger: apiLogger }) registerSpeechRoutes(app, { speechService: deps.speechService }) + registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager }) + registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger }) + setupSideCarWebSocketProxy(app, { + sidecarManager: deps.sidecarManager, + authManager: deps.authManager, + logger: proxyLogger, + }) registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger, - channel: pluginChannel, - voiceModeManager, + channel: deps.pluginChannel, + voiceModeManager: deps.voiceModeManager, }) registerBackgroundProcessRoutes(app, { backgroundProcessManager }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) @@ -342,7 +352,6 @@ export function createHttpServer(deps: HttpServerDeps) { }, stop: () => { closeSseClients() - clientConnectionManager.shutdown() return app.close() }, } @@ -353,6 +362,68 @@ interface InstanceProxyDeps { logger: Logger } +interface SideCarProxyDeps { + sidecarManager: SideCarManager + logger: Logger +} + +interface SideCarWebSocketProxyDeps extends SideCarProxyDeps { + authManager: AuthManager +} + +function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps) { + const proxyBaseHandler = async ( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ) => { + await proxySideCarRequest({ + request, + reply, + sidecarManager: deps.sidecarManager, + logger: deps.logger, + pathSuffix: "", + }) + } + + const proxyWildcardHandler = async ( + request: FastifyRequest<{ Params: { id: string; "*": string } }>, + reply: FastifyReply, + ) => { + await proxySideCarRequest({ + request, + reply, + sidecarManager: deps.sidecarManager, + logger: deps.logger, + pathSuffix: request.params["*"] ?? "", + }) + } + + app.all("/sidecars/:id", proxyBaseHandler) + app.all("/sidecars/:id/*", proxyWildcardHandler) +} + +function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocketProxyDeps) { + app.server.on("upgrade", (request, socket, head) => { + const rawUrl = request.url ?? "/" + const parsed = parseSideCarUpgradePath(rawUrl) + if (!parsed) { + return + } + + void proxySideCarWebSocketUpgrade({ + request, + socket: socket as Socket, + head, + sidecarId: parsed.sidecarId, + incomingPath: parsed.pathname, + search: parsed.search, + sidecarManager: deps.sidecarManager, + authManager: deps.authManager, + logger: deps.logger, + }) + }) +} + function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) { app.register(async (instance) => { instance.removeAllContentTypeParsers() @@ -837,3 +908,281 @@ function buildProxyHeaders(headers: FastifyRequest["headers"]): Record= 0 ? (args.request.raw.url ?? args.request.url ?? "").slice(queryIndex) : "" + const pathSuffix = args.pathSuffix ?? "" + const requestPath = pathSuffix ? `${args.sidecarManager.buildProxyBasePath(sidecarId)}/${pathSuffix.replace(/^\/+/, "")}` : args.sidecarManager.buildProxyBasePath(sidecarId) + const targetPath = args.sidecarManager.buildTargetPath(sidecarId, requestPath, search) + const targetOrigin = args.sidecarManager.buildTargetOrigin(sidecar) + const targetUrl = `${targetOrigin}${targetPath}` + args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar") + + await args.reply.from(targetUrl, { + rewriteRequestHeaders: (_originalRequest, headers) => + sanitizeSideCarProxyRequestHeaders(headers as Record, targetOrigin), + rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode), + onError: (reply, { error }) => { + args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request") + if (!reply.sent) { + reply.code(502).send({ error: "SideCar proxy failed" }) + } + }, + }) +} + +function parseSideCarUpgradePath(rawUrl: string): { sidecarId: string; pathname: string; search: string } | null { + let parsed: URL + try { + parsed = new URL(rawUrl, "http://localhost") + } catch { + return null + } + + const match = parsed.pathname.match(/^\/sidecars\/([^/]+)(?:\/.*)?$/) + if (!match) { + return null + } + + try { + return { + sidecarId: decodeURIComponent(match[1] ?? ""), + pathname: parsed.pathname, + search: parsed.search, + } + } catch { + return null + } +} + +async function proxySideCarWebSocketUpgrade(args: { + request: import("http").IncomingMessage + socket: Socket + head: Buffer + sidecarId: string + incomingPath: string + search: string + sidecarManager: SideCarManager + authManager: AuthManager + logger: Logger +}) { + const { request, socket, head, sidecarId, incomingPath, search, sidecarManager, authManager, logger } = args + + if (!isWebSocketUpgradeRequest(request)) { + rejectUpgrade(socket, 400, "Bad Request") + return + } + + const session = authManager.getSessionFromHeaders(request.headers) + if (!session) { + rejectUpgrade(socket, 401, "Unauthorized") + return + } + + const sidecar = await sidecarManager.get(sidecarId) + if (!sidecar) { + rejectUpgrade(socket, 404, "Not Found") + return + } + + const targetOrigin = sidecarManager.buildTargetOrigin(sidecar) + const targetPath = sidecarManager.buildTargetPath(sidecarId, incomingPath, search) + const targetUrl = new URL(`${targetOrigin}${targetPath}`) + logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar") + + const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl) + + const closeBoth = () => { + if (!socket.destroyed) { + socket.destroy() + } + if (!upstream.destroyed) { + upstream.destroy() + } + } + + upstream.once("error", (error) => { + logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket") + rejectUpgrade(socket, 502, "Bad Gateway") + if (!upstream.destroyed) { + upstream.destroy() + } + }) + + socket.once("error", (error) => { + logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored") + if (!upstream.destroyed) { + upstream.destroy() + } + }) + + upstream.once(readyEvent, () => { + try { + upstream.write(buildSideCarWebSocketRequest(request, targetUrl)) + if (head.length > 0) { + upstream.write(head) + } + upstream.pipe(socket) + socket.pipe(upstream) + } catch (error) { + logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade") + closeBoth() + } + }) + + upstream.once("close", () => { + if (!socket.destroyed) { + socket.end() + } + }) + + socket.once("close", () => { + if (!upstream.destroyed) { + upstream.end() + } + }) +} + +function createSideCarUpstreamSocket(targetUrl: URL): { socket: Socket | TLSSocket; readyEvent: "connect" | "secureConnect" } { + const port = Number(targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80)) + if (targetUrl.protocol === "https:") { + return { + socket: connectTls({ + host: targetUrl.hostname, + port, + servername: targetUrl.hostname, + }), + readyEvent: "secureConnect", + } + } + return { + socket: connectTcp(port, targetUrl.hostname), + readyEvent: "connect", + } +} + +function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, targetUrl: URL): string { + const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}` + const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n` + const headerLines: string[] = [] + const rawHeaders = request.rawHeaders ?? [] + const blockedHeaders = getBlockedSideCarRequestHeaders() + + for (let index = 0; index < rawHeaders.length; index += 2) { + const key = rawHeaders[index] + const value = rawHeaders[index + 1] + if (!key || value === undefined) continue + const lower = key.toLowerCase() + if (blockedHeaders.has(lower)) continue + if (lower === "origin") { + headerLines.push(`Origin: ${targetUrl.origin}\r\n`) + continue + } + headerLines.push(`${key}: ${value}\r\n`) + } + + const hostValue = targetUrl.port ? `${targetUrl.hostname}:${targetUrl.port}` : targetUrl.hostname + headerLines.push(`Host: ${hostValue}\r\n`) + headerLines.push("\r\n") + + return requestLine + headerLines.join("") +} + +function isWebSocketUpgradeRequest(request: import("http").IncomingMessage): boolean { + const upgrade = request.headers.upgrade + if (typeof upgrade !== "string" || upgrade.toLowerCase() !== "websocket") { + return false + } + const connection = request.headers.connection + const connectionValue = Array.isArray(connection) ? connection.join(",") : connection ?? "" + return connectionValue.toLowerCase().split(",").map((part) => part.trim()).includes("upgrade") +} + +function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) { + if (socket.destroyed) { + return + } + socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`) + socket.destroy() +} + +function rewriteSideCarResponseHeaders( + headers: Record, + sidecarId: string, + targetOrigin: string, + prefixMode: "strip" | "preserve", +) { + if (prefixMode === "preserve") { + return headers + } + + const next = { ...headers } + const locationHeader = next.location + const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader + if (!location) { + return next + } + + const publicBase = `/sidecars/${encodeURIComponent(sidecarId)}` + + if (location.startsWith("/")) { + next.location = `${publicBase}${location}` + return next + } + + try { + const parsed = new URL(location) + if (parsed.origin === targetOrigin) { + next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}` + } + } catch { + // Relative redirects should continue to resolve against the public sidecar path. + } + + return next +} + +function sanitizeSideCarProxyRequestHeaders( + headers: Record, + targetOrigin: string, +): Record { + const blockedHeaders = getBlockedSideCarRequestHeaders() + const next: Record = {} + + for (const [key, value] of Object.entries(headers)) { + if (!value) continue + if (blockedHeaders.has(key.toLowerCase())) continue + next[key] = value + } + + next.origin = targetOrigin + return next +} + +function getBlockedSideCarRequestHeaders(): Set { + return new Set([ + "host", + "authorization", + "proxy-authorization", + "forwarded", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-port", + "x-forwarded-proto", + ]) +} diff --git a/packages/server/src/server/routes/background-processes.ts b/packages/server/src/server/routes/background-processes.ts index c9520416..df7bfca3 100644 --- a/packages/server/src/server/routes/background-processes.ts +++ b/packages/server/src/server/routes/background-processes.ts @@ -9,6 +9,21 @@ interface RouteDeps { const StartSchema = z.object({ title: z.string().trim().min(1), command: z.string().trim().min(1), + notify: z.boolean().optional(), + notification: z + .object({ + sessionID: z.string().trim().min(1), + directory: z.string().trim().min(1), + }) + .optional(), +}).superRefine((value, ctx) => { + if (value.notify && !value.notification) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Notification metadata is required when notify is enabled", + path: ["notification"], + }) + } }) const OutputQuerySchema = z.object({ @@ -27,7 +42,10 @@ export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: Rout app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => { const payload = StartSchema.parse(request.body ?? {}) - const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command) + const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command, { + notify: payload.notify, + notification: payload.notification, + }) reply.code(201) return process }) diff --git a/packages/server/src/server/routes/plugin.ts b/packages/server/src/server/routes/plugin.ts index daa7630e..aef57007 100644 --- a/packages/server/src/server/routes/plugin.ts +++ b/packages/server/src/server/routes/plugin.ts @@ -66,11 +66,17 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) { } const payload = VoiceModeStateSchema.parse(request.body ?? {}) - deps.voiceModeManager.setEnabled( + const applied = deps.voiceModeManager.setEnabled( request.params.id, { clientId: payload.clientId, connectionId: payload.connectionId }, payload.enabled, ) + + if (payload.enabled && !applied) { + reply.code(409).send({ error: "Client connection not active for voice mode enable" }) + return + } + return { enabled: payload.enabled } }) diff --git a/packages/server/src/server/routes/remote-servers.ts b/packages/server/src/server/routes/remote-servers.ts new file mode 100644 index 00000000..86c00569 --- /dev/null +++ b/packages/server/src/server/routes/remote-servers.ts @@ -0,0 +1,166 @@ +import { Agent, fetch } from "undici" +import type { FastifyInstance } from "fastify" +import { z } from "zod" +import type { Logger } from "../../logger" +import type { RemoteServerProbeResponse } from "../../api-types" + +interface RouteDeps { + logger: Logger +} + +const ProbeSchema = z.object({ + baseUrl: z.string().min(1), + skipTlsVerify: z.boolean().optional(), +}) + +const PROBE_TIMEOUT_MS = 8_000 + +export function registerRemoteServerRoutes(app: FastifyInstance, deps: RouteDeps) { + app.post("/api/remote-servers/probe", async (request, reply) => { + try { + const body = ProbeSchema.parse(request.body ?? {}) + return await probeRemoteServer(body.baseUrl, Boolean(body.skipTlsVerify)) + } catch (error) { + deps.logger.warn({ err: error }, "Failed to probe remote server") + reply.code(400) + return { error: error instanceof Error ? error.message : "Invalid request" } + } + }) +} + +async function probeRemoteServer(baseUrl: string, skipTlsVerify: boolean): Promise { + const normalizedUrl = normalizeBaseUrl(baseUrl) + const probeUrl = new URL("./api/auth/status", `${normalizedUrl}/`) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS) + const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined + + try { + const response = await fetch(probeUrl, { + method: "GET", + dispatcher, + signal: controller.signal, + headers: { + Accept: "application/json", + }, + }) + + if (!response.ok) { + return { + ok: false, + reachable: true, + normalizedUrl, + skipTlsVerify, + requiresAuth: false, + authenticated: false, + error: `Remote server returned HTTP ${response.status}`, + errorCode: "http_error", + } + } + + const payload = (await response.json()) as { authenticated?: unknown } + if (typeof payload?.authenticated !== "boolean") { + return { + ok: false, + reachable: true, + normalizedUrl, + skipTlsVerify, + requiresAuth: false, + authenticated: false, + error: "Remote server did not return a valid CodeNomad auth response", + errorCode: "invalid_server", + } + } + + return { + ok: true, + reachable: true, + normalizedUrl, + skipTlsVerify, + requiresAuth: !payload.authenticated, + authenticated: payload.authenticated, + } + } catch (error) { + const message = describeProbeError(error) + return { + ok: false, + reachable: false, + normalizedUrl, + skipTlsVerify, + requiresAuth: false, + authenticated: false, + error: message.message, + errorCode: message.code, + } + } finally { + clearTimeout(timeout) + await dispatcher?.close().catch(() => {}) + } +} + +function normalizeBaseUrl(input: string): string { + const parsed = new URL(input.trim()) + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Server URL must use http:// or https://") + } + + parsed.hash = "" + parsed.search = "" + parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/" + const value = parsed.toString() + return parsed.pathname === "/" ? value.replace(/\/$/, "") : value.replace(/\/$/, "") +} + +function describeProbeError(error: unknown): { code: string; message: string } { + const chain = unwrapErrorChain(error) + const detailed = + chain.find((entry) => { + const code = (entry?.code ?? "").toString() + return Boolean(code) && code !== "UND_ERR_RESPONSE_STATUS_CODE" + }) ?? chain[0] + + const code = (detailed?.code ?? "").toString() + const exactMessage = detailed?.message?.trim() || chain.find((entry) => entry.message?.trim())?.message?.trim() + + if (code === "DEPTH_ZERO_SELF_SIGNED_CERT" || code === "SELF_SIGNED_CERT_IN_CHAIN" || code === "CERT_HAS_EXPIRED") { + return { + code: "tls_error", + message: "Certificate check failed while connecting to the remote server.", + } + } + + return { + code: + code === "ERR_INVALID_URL" + ? "invalid_url" + : code === "ECONNREFUSED" + ? "connection_refused" + : code === "ENOTFOUND" + ? "dns_error" + : code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR" + ? "timeout" + : code + ? code.toLowerCase() + : "probe_failed", + message: exactMessage || "Failed to connect to the remote server.", + } +} + +function unwrapErrorChain(error: unknown): Array<{ code?: unknown; message?: string }> { + const results: Array<{ code?: unknown; message?: string }> = [] + let current: unknown = error + const seen = new Set() + + while (current && typeof current === "object" && !seen.has(current)) { + seen.add(current) + const entry = current as { code?: unknown; message?: string; cause?: unknown } + results.push({ code: entry.code, message: entry.message }) + current = entry.cause + } + + if (results.length === 0 && error instanceof Error) { + results.push({ message: error.message }) + } + + return results +} diff --git a/packages/server/src/server/routes/sidecars.ts b/packages/server/src/server/routes/sidecars.ts new file mode 100644 index 00000000..61220377 --- /dev/null +++ b/packages/server/src/server/routes/sidecars.ts @@ -0,0 +1,56 @@ +import { FastifyInstance } from "fastify" +import { z } from "zod" +import type { SideCarManager } from "../../sidecars/manager" + +interface RouteDeps { + sidecarManager: SideCarManager +} + +const SideCarCreateSchema = z.object({ + kind: z.literal("port").default("port"), + name: z.string().trim().min(1), + port: z.number().int().min(1).max(65535), + insecure: z.boolean().default(false), + prefixMode: z.enum(["strip", "preserve"]).default("strip"), +}) + +const SideCarUpdateSchema = SideCarCreateSchema.omit({ kind: true }).partial().refine((value) => Object.keys(value).length > 0, { + message: "At least one field is required", +}) + +export function registerSideCarRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get("/api/sidecars", async () => { + return { sidecars: await deps.sidecarManager.list() } + }) + + app.post("/api/sidecars", async (request, reply) => { + try { + const body = SideCarCreateSchema.parse(request.body ?? {}) + const sidecar = await deps.sidecarManager.create(body) + reply.code(201) + return sidecar + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Failed to create SideCar" } + } + }) + + app.put<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => { + try { + const body = SideCarUpdateSchema.parse(request.body ?? {}) + return await deps.sidecarManager.update(request.params.id, body) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Failed to update SideCar" } + } + }) + + app.delete<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => { + const removed = await deps.sidecarManager.delete(request.params.id) + if (!removed) { + reply.code(404) + return { error: "SideCar not found" } + } + reply.code(204) + }) +} diff --git a/packages/server/src/settings/migrate.ts b/packages/server/src/settings/migrate.ts index e693a96d..d734ea3e 100644 --- a/packages/server/src/settings/migrate.ts +++ b/packages/server/src/settings/migrate.ts @@ -107,6 +107,10 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co if (typeof listeningMode === "string") { serverConfig.listeningMode = listeningMode } + const logLevel = preferences.logLevel + if (typeof logLevel === "string") { + serverConfig.logLevel = logLevel + } const lastUsedBinary = preferences.lastUsedBinary if (typeof lastUsedBinary === "string") { serverConfig.opencodeBinary = lastUsedBinary @@ -135,6 +139,7 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co const moved = new Set([ "environmentVariables", "listeningMode", + "logLevel", "lastUsedBinary", "modelRecents", "modelFavorites", diff --git a/packages/server/src/settings/service.ts b/packages/server/src/settings/service.ts index 45924076..f4f0409c 100644 --- a/packages/server/src/settings/service.ts +++ b/packages/server/src/settings/service.ts @@ -1,6 +1,7 @@ import type { Logger } from "../logger" import type { EventBus } from "../events/bus" import type { ConfigLocation } from "../config/location" +import { z } from "zod" import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store" import { migrateSettingsLayout } from "./migrate" import type { WorkspaceEventPayload } from "../api-types" @@ -8,6 +9,54 @@ import { sanitizeConfigOwner } from "./public-config" export type DocKind = "config" | "state" +const CanonicalLogLevelSchema = z.preprocess( + (value) => (typeof value === "string" ? value.trim().toUpperCase() : value), + z.enum(["DEBUG", "INFO", "WARN", "ERROR"]), +) + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function isDeepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true + try { + return JSON.stringify(a) === JSON.stringify(b) + } catch { + return false + } +} + +function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc { + if (!isPlainObject(value)) { + return {} + } + + const next: SettingsDoc = { ...value } + const parsedLogLevel = CanonicalLogLevelSchema.safeParse(next.logLevel) + if (parsedLogLevel.success) { + next.logLevel = parsedLogLevel.data + } else if (next.logLevel !== undefined) { + next.logLevel = "DEBUG" + } + return next +} + +function normalizeConfigDoc(doc: SettingsDoc): SettingsDoc { + if (!isPlainObject(doc)) { + return {} + } + + if (!isPlainObject(doc.server)) { + return doc + } + + return { + ...doc, + server: normalizeServerConfigOwner(doc.server as SettingsDoc), + } +} + export class SettingsService { private readonly configStore: YamlDocStore private readonly stateStore: YamlDocStore @@ -23,22 +72,44 @@ export class SettingsService { } getDoc(kind: DocKind): SettingsDoc { - return kind === "config" ? this.configStore.get() : this.stateStore.get() + if (kind !== "config") { + return this.stateStore.get() + } + + const current = this.configStore.get() + const normalized = normalizeConfigDoc(current) + if (!isDeepEqual(current, normalized)) { + this.configStore.replace(normalized) + } + return normalized } mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc { - const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch) + const updated = + kind === "config" + ? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch))) + : this.stateStore.mergePatch(patch) this.publish(kind, "*") return updated } getOwner(kind: DocKind, owner: string): SettingsDoc { - return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner) + if (kind !== "config") { + return this.stateStore.getOwner(owner) + } + + return owner === "server" + ? normalizeServerConfigOwner(this.getDoc("config").server as SettingsDoc) + : this.getDoc("config")[owner] as SettingsDoc } mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc { const updated = - kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch) + kind === "config" + ? owner === "server" + ? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch))) + : this.configStore.mergePatchOwner(owner, patch) + : this.stateStore.mergePatchOwner(owner, patch) this.publish(kind, owner, updated) return updated } diff --git a/packages/server/src/sidecars/manager.ts b/packages/server/src/sidecars/manager.ts new file mode 100644 index 00000000..57593c30 --- /dev/null +++ b/packages/server/src/sidecars/manager.ts @@ -0,0 +1,256 @@ +import { connect } from "net" +import type { EventBus } from "../events/bus" +import type { Logger } from "../logger" +import type { SettingsService } from "../settings/service" +import type { SideCar, SideCarKind, SideCarPrefixMode, SideCarStatus } from "../api-types" + +interface SideCarManagerOptions { + settings: SettingsService + eventBus: EventBus + logger: Logger +} + +interface SideCarConfigRecord { + id: string + kind: SideCarKind + name: string + port: number + insecure: boolean + prefixMode: SideCarPrefixMode + createdAt: string + updatedAt: string +} + +interface SideCarRuntimeRecord { + status: SideCarStatus +} + +export class SideCarManager { + private readonly configs = new Map() + private readonly runtime = new Map() + + constructor(private readonly options: SideCarManagerOptions) { + for (const record of this.loadConfiguredSideCars()) { + this.configs.set(record.id, record) + this.runtime.set(record.id, { status: "stopped" }) + } + + queueMicrotask(() => { + for (const record of this.configs.values()) { + void this.refreshPortSideCar(record.id).catch((error) => { + this.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port") + }) + } + }) + } + + async list(): Promise { + await this.refreshPortStatuses() + return Array.from(this.configs.values()).map((record) => this.toSideCar(record)) + } + + async get(id: string): Promise { + if (!this.configs.has(id)) return undefined + await this.refreshPortSideCar(id) + return this.toSideCar(this.requireConfig(id)) + } + + async create(input: { + kind: SideCarKind + name: string + port: number + insecure: boolean + prefixMode: SideCarPrefixMode + }): Promise { + const normalizedName = input.name.trim() + const id = this.buildSideCarId(normalizedName) + if (this.configs.has(id)) { + throw new Error(`SideCar '${id}' already exists`) + } + + const now = new Date().toISOString() + const record: SideCarConfigRecord = { + id, + kind: input.kind, + name: normalizedName, + port: input.port, + insecure: input.insecure, + prefixMode: input.prefixMode, + createdAt: now, + updatedAt: now, + } + + this.configs.set(record.id, record) + this.runtime.set(record.id, { status: "stopped" }) + this.persistConfigs() + await this.refreshPortSideCar(record.id) + return this.toSideCar(record) + } + + async update( + id: string, + input: Partial<{ + name: string + port: number + insecure: boolean + prefixMode: SideCarPrefixMode + }>, + ): Promise { + const record = this.requireConfig(id) + + record.name = typeof input.name === "string" ? input.name.trim() : record.name + record.port = typeof input.port === "number" ? input.port : record.port + record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure + record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode + record.updatedAt = new Date().toISOString() + + this.persistConfigs() + await this.refreshPortSideCar(id) + return this.toSideCar(record) + } + + async delete(id: string): Promise { + const record = this.configs.get(id) + if (!record) return false + + this.configs.delete(id) + this.runtime.delete(id) + this.persistConfigs() + this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id }) + return true + } + + async shutdown() { + return + } + + buildTargetOrigin(sidecar: Pick): string { + const protocol = sidecar.insecure ? "http" : "https" + return `${protocol}://127.0.0.1:${sidecar.port}` + } + + buildProxyBasePath(id: string): string { + return `/sidecars/${encodeURIComponent(id)}` + } + + buildTargetPath(id: string, incomingPath: string, search = ""): string { + const record = this.requireConfig(id) + const publicBase = this.buildProxyBasePath(id) + const normalizedPath = incomingPath || publicBase + + if (record.prefixMode === "preserve") { + return `${normalizedPath}${search}` + } + + let stripped = normalizedPath.startsWith(publicBase) ? normalizedPath.slice(publicBase.length) : normalizedPath + if (!stripped || stripped === "/") { + stripped = "/" + } else if (!stripped.startsWith("/")) { + stripped = `/${stripped}` + } + return `${stripped}${search}` + } + + private async refreshPortStatuses() { + await Promise.all(Array.from(this.configs.values()).map((record) => this.refreshPortSideCar(record.id))) + } + + private async refreshPortSideCar(id: string) { + const record = this.configs.get(id) + if (!record) return + const isAvailable = await this.isPortAvailable(record.port) + const current = this.runtime.get(id) + const nextStatus: SideCarStatus = isAvailable ? "running" : "stopped" + if (current?.status === nextStatus) { + return + } + + this.runtime.set(id, { status: nextStatus }) + record.updatedAt = new Date().toISOString() + this.publish(id) + } + + private publish(id: string) { + const record = this.configs.get(id) + if (!record) return + this.options.eventBus.publish({ type: "sidecar.updated", sidecar: this.toSideCar(record) }) + } + + private toSideCar(record: SideCarConfigRecord): SideCar { + const runtime = this.runtime.get(record.id) + return { + id: record.id, + kind: record.kind, + name: record.name, + port: record.port, + insecure: record.insecure, + prefixMode: record.prefixMode, + status: runtime?.status ?? "stopped", + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } + } + + private requireConfig(id: string): SideCarConfigRecord { + const record = this.configs.get(id) + if (!record) { + throw new Error("SideCar not found") + } + return record + } + + private persistConfigs() { + const sidecars = Array.from(this.configs.values()).map((record) => ({ ...record })) + this.options.settings.mergePatchOwner("config", "server", { sidecars }) + } + + private loadConfiguredSideCars(): SideCarConfigRecord[] { + const serverConfig = this.options.settings.getOwner("config", "server") as { sidecars?: unknown } + const list = Array.isArray(serverConfig?.sidecars) ? serverConfig.sidecars : [] + const records: SideCarConfigRecord[] = [] + for (const item of list) { + if (!item || typeof item !== "object") continue + const record = item as Record + const kind = record.kind === "port" ? "port" : null + const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : null + const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : null + const port = typeof record.port === "number" && Number.isInteger(record.port) ? record.port : null + if (!kind || !id || !name || !port) continue + + const insecure = record.insecure === true + const prefixMode = record.prefixMode === "preserve" ? "preserve" : "strip" + const createdAt = typeof record.createdAt === "string" && record.createdAt ? record.createdAt : new Date().toISOString() + const updatedAt = typeof record.updatedAt === "string" && record.updatedAt ? record.updatedAt : createdAt + records.push({ id, kind, name, port, insecure, prefixMode, createdAt, updatedAt }) + } + return records + } + + private isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const socket = connect({ port, host: "127.0.0.1" }, () => { + socket.end() + resolve(true) + }) + socket.once("error", () => { + socket.destroy() + resolve(false) + }) + }) + } + + private buildSideCarId(name: string): string { + const normalized = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-|-$/g, "") + + if (!normalized) { + throw new Error("SideCar name must include letters or numbers") + } + + return normalized + } +} diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 805b8f95..dc939758 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -142,12 +142,15 @@ export class WorkspaceManager { [OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword, } + const logLevel = (serverConfig as any)?.logLevel + try { const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({ workspaceId: id, folder: workspacePath, binaryPath: resolvedBinaryPath, environment, + logLevel, onExit: (info) => this.handleProcessExit(info.workspaceId, info), }) diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index 0246fbfd..1269f0b7 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -116,6 +116,7 @@ interface LaunchOptions { folder: string binaryPath: string environment?: Record + logLevel?: string onExit?: (info: ProcessExitInfo) => void } @@ -139,7 +140,8 @@ export class WorkspaceRuntime { async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise; getLastOutput: () => string }> { this.validateFolder(options.folder) - const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"] + const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG" + const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel] const env = { ...process.env, ...(options.environment ?? {}) } let exitResolve: ((info: ProcessExitInfo) => void) | null = null diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index 2268df75..a16d13f4 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -458,7 +458,7 @@ dependencies = [ [[package]] name = "codenomad-tauri" -version = "0.13.3" +version = "0.14.0" dependencies = [ "anyhow", "dirs 5.0.1", diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index 2be47336..c09a70ac 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.13.3", + "version": "0.14.0", "private": true, "license": "MIT", "scripts": { diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index 13a89b1f..e3b61de3 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codenomad-tauri" -version = "0.13.3" +version = "0.14.0" edition = "2021" license = "MIT" @@ -28,4 +28,4 @@ url = "2" tauri-plugin-notification = "2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] } +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] } diff --git a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json index 1e595706..3ab025a9 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/capabilities.json +++ b/packages/tauri-app/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}} +{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}} \ No newline at end of file diff --git a/packages/tauri-app/src-tauri/gen/schemas/macOS-schema.json b/packages/tauri-app/src-tauri/gen/schemas/macOS-schema.json index 3b65da09..f7ab8174 100644 --- a/packages/tauri-app/src-tauri/gen/schemas/macOS-schema.json +++ b/packages/tauri-app/src-tauri/gen/schemas/macOS-schema.json @@ -2378,6 +2378,72 @@ "const": "dialog:deny-save", "markdownDescription": "Denies the save command without any pre-configured scope." }, + { + "description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n", + "type": "string", + "const": "global-shortcut:default", + "markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n" + }, + { + "description": "Enables the is_registered command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-is-registered", + "markdownDescription": "Enables the is_registered command without any pre-configured scope." + }, + { + "description": "Enables the register command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-register", + "markdownDescription": "Enables the register command without any pre-configured scope." + }, + { + "description": "Enables the register_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-register-all", + "markdownDescription": "Enables the register_all command without any pre-configured scope." + }, + { + "description": "Enables the unregister command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-unregister", + "markdownDescription": "Enables the unregister command without any pre-configured scope." + }, + { + "description": "Enables the unregister_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:allow-unregister-all", + "markdownDescription": "Enables the unregister_all command without any pre-configured scope." + }, + { + "description": "Denies the is_registered command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-is-registered", + "markdownDescription": "Denies the is_registered command without any pre-configured scope." + }, + { + "description": "Denies the register command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-register", + "markdownDescription": "Denies the register command without any pre-configured scope." + }, + { + "description": "Denies the register_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-register-all", + "markdownDescription": "Denies the register_all command without any pre-configured scope." + }, + { + "description": "Denies the unregister command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-unregister", + "markdownDescription": "Denies the unregister command without any pre-configured scope." + }, + { + "description": "Denies the unregister_all command without any pre-configured scope.", + "type": "string", + "const": "global-shortcut:deny-unregister-all", + "markdownDescription": "Denies the unregister_all command without any pre-configured scope." + }, { "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", "type": "string", diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 20bb522d..358523e3 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -5,9 +5,13 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::VecDeque; use std::env; +#[cfg(windows)] +use std::ffi::c_void; use std::ffi::OsStr; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; +#[cfg(windows)] +use std::mem::{size_of, zeroed}; use std::net::TcpStream; #[cfg(unix)] use std::os::unix::process::CommandExt; @@ -19,12 +23,95 @@ use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url}; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; #[cfg(windows)] use std::os::windows::process::CommandExt; +#[cfg(windows)] +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; +#[cfg(windows)] +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, +}; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x08000000; +#[cfg(windows)] +#[derive(Debug)] +struct WindowsJobObject { + // The desktop wrapper may observe only a short-lived Node wrapper PID while the real + // server and workspace descendants continue running below it. KILL_ON_JOB_CLOSE gives + // Tauri an OS-owned handle for the whole subtree instead of relying on a single PID. + handle: HANDLE, +} + +#[cfg(windows)] +impl WindowsJobObject { + fn create() -> anyhow::Result { + let handle = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) }; + if handle.is_null() { + return Err(anyhow::anyhow!( + "CreateJobObjectW failed: {}", + std::io::Error::last_os_error() + )); + } + + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() }; + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + + let ok = unsafe { + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + &mut info as *mut _ as *mut c_void, + size_of::() as u32, + ) + }; + if ok == 0 { + let err = std::io::Error::last_os_error(); + unsafe { + CloseHandle(handle); + } + return Err(anyhow::anyhow!("SetInformationJobObject failed: {}", err)); + } + + Ok(Self { handle }) + } + + fn assign_child(&self, child: &Child) -> anyhow::Result<()> { + let process_handle = child.as_raw_handle() as HANDLE; + let ok = unsafe { AssignProcessToJobObject(self.handle, process_handle) }; + if ok == 0 { + return Err(anyhow::anyhow!( + "AssignProcessToJobObject failed: {}", + std::io::Error::last_os_error() + )); + } + + Ok(()) + } +} + +#[cfg(windows)] +impl Drop for WindowsJobObject { + fn drop(&mut self) { + if !self.handle.is_null() { + unsafe { + CloseHandle(self.handle); + } + } + } +} + +#[cfg(windows)] +unsafe impl Send for WindowsJobObject {} + +#[cfg(windows)] +unsafe impl Sync for WindowsJobObject {} + fn log_line(message: &str) { println!("[tauri-cli] {message}"); } @@ -363,6 +450,8 @@ impl Default for CliStatus { pub struct CliProcessManager { status: Arc>, child: Arc>>, + #[cfg(windows)] + job: Arc>>, ready: Arc, bootstrap_token: Arc>>, } @@ -372,6 +461,8 @@ impl CliProcessManager { Self { status: Arc::new(Mutex::new(CliStatus::default())), child: Arc::new(Mutex::new(None)), + #[cfg(windows)] + job: Arc::new(Mutex::new(None)), ready: Arc::new(AtomicBool::new(false)), bootstrap_token: Arc::new(Mutex::new(None)), } @@ -394,6 +485,8 @@ impl CliProcessManager { let status_arc = self.status.clone(); let child_arc = self.child.clone(); + #[cfg(windows)] + let job_arc = self.job.clone(); let ready_flag = self.ready.clone(); let token_arc = self.bootstrap_token.clone(); thread::spawn(move || { @@ -401,6 +494,8 @@ impl CliProcessManager { app.clone(), status_arc.clone(), child_arc, + #[cfg(windows)] + job_arc, ready_flag, token_arc, dev, @@ -420,11 +515,12 @@ impl CliProcessManager { } pub fn stop(&self) -> anyhow::Result<()> { + #[cfg(windows)] + let _job = self.job.lock().take(); + let mut child_opt = self.child.lock(); if let Some(mut child) = child_opt.take() { log_line(&format!("stopping CLI pid={}", child.id())); - #[cfg(windows)] - let mut forced_tree_shutdown = false; #[cfg(unix)] unsafe { let pid = child.id() as i32; @@ -446,18 +542,16 @@ impl CliProcessManager { Ok(Some(_)) => break, Ok(None) => { #[cfg(windows)] - if !forced_tree_shutdown - && start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) - { + if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) { log_line(&format!( "regular Windows shutdown still running after {}ms; escalating pid={}", CLI_WINDOWS_FORCE_GRACE_MS, child.id() )); - forced_tree_shutdown = true; if !kill_process_tree_windows(child.id(), true) { let _ = child.kill(); } + break; } if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) { @@ -476,11 +570,7 @@ impl CliProcessManager { } #[cfg(windows)] { - if !forced_tree_shutdown - && !kill_process_tree_windows(child.id(), true) - { - let _ = child.kill(); - } else if forced_tree_shutdown { + if !kill_process_tree_windows(child.id(), true) { let _ = child.kill(); } } @@ -491,6 +581,9 @@ impl CliProcessManager { Err(_) => break, } } + } else { + #[cfg(windows)] + log_line("tracked CLI process already exited; dropping Windows job object to reap descendants"); } let mut status = self.status.lock(); @@ -511,6 +604,7 @@ impl CliProcessManager { app: AppHandle, status: Arc>, child_holder: Arc>>, + #[cfg(windows)] job_holder: Arc>>, ready: Arc, bootstrap_token: Arc>>, dev: bool, @@ -534,7 +628,9 @@ impl CliProcessManager { log_line(&format!("using cwd={}", c.display())); } - let command_info = if supports_user_shell() { + let use_user_shell = supports_user_shell(); + + let command_info = if use_user_shell { log_line("spawning via user shell"); ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?) } else { @@ -545,7 +641,7 @@ impl CliProcessManager { }) }; - if !supports_user_shell() { + if !use_user_shell { if which::which(&resolution.node_binary).is_err() { return Err(anyhow::anyhow!( "Node binary not found. Make sure Node.js is installed." @@ -559,6 +655,8 @@ impl CliProcessManager { let mut c = Command::new(&cmd.shell); c.args(&cmd.args) .env("ELECTRON_RUN_AS_NODE", "1") + .env_remove("npm_config_prefix") + .env_remove("NPM_CONFIG_PREFIX") .stdout(Stdio::piped()) .stderr(Stdio::piped()); configure_spawn(&mut c); @@ -588,6 +686,22 @@ impl CliProcessManager { let pid = child.id(); log_line(&format!("spawned pid={pid}")); + #[cfg(windows)] + match WindowsJobObject::create().and_then(|job| { + job.assign_child(&child)?; + Ok(job) + }) { + Ok(job) => { + log_line(&format!("attached pid={pid} to Windows job object")); + *job_holder.lock() = Some(job); + } + Err(err) => { + log_line(&format!( + "failed to attach pid={pid} to Windows job object; falling back to taskkill-only cleanup: {err}" + )); + } + } + { let mut locked = status.lock(); locked.pid = Some(pid); @@ -619,26 +733,41 @@ impl CliProcessManager { .map(BufReader::new); if let Some(reader) = stdout { - Self::process_stream( - reader, - "stdout", - &app_clone, - &status_clone, - &ready_clone, - &token_clone, - auth_cookie_name_clone.as_str(), - ); + let app = app_clone.clone(); + let status = status_clone.clone(); + let ready = ready_clone.clone(); + let token = token_clone.clone(); + let auth_cookie_name = auth_cookie_name_clone.clone(); + thread::spawn(move || { + Self::process_stream( + reader, + "stdout", + &app, + &status, + &ready, + &token, + auth_cookie_name.as_str(), + ); + }); } + if let Some(reader) = stderr { - Self::process_stream( - reader, - "stderr", - &app_clone, - &status_clone, - &ready_clone, - &token_clone, - auth_cookie_name_clone.as_str(), - ); + let app = app_clone.clone(); + let status = status_clone.clone(); + let ready = ready_clone.clone(); + let token = token_clone.clone(); + let auth_cookie_name = auth_cookie_name_clone.clone(); + thread::spawn(move || { + Self::process_stream( + reader, + "stderr", + &app, + &status, + &ready, + &token, + auth_cookie_name.as_str(), + ); + }); } }); @@ -646,6 +775,8 @@ impl CliProcessManager { let status_clone = status.clone(); let ready_clone = ready.clone(); let child_holder_clone = child_holder.clone(); + #[cfg(windows)] + let job_holder_clone = job_holder.clone(); thread::spawn(move || { let timeout = Duration::from_secs(60); thread::sleep(timeout); @@ -700,6 +831,10 @@ impl CliProcessManager { // Drop the handle after the process exits so other callers // don't attempt to stop/kill a finished process. *guard = None; + #[cfg(windows)] + { + let _ = job_holder_clone.lock().take(); + } Some(status) } None => None, @@ -757,8 +892,8 @@ impl CliProcessManager { auth_cookie_name: &str, ) { let mut buffer = String::new(); - let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok(); - let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok(); + let local_url_regex = + Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok(); let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:"; loop { @@ -800,38 +935,6 @@ impl CliProcessManager { ); continue; } - - if line.to_lowercase().contains("http server listening") { - if let Some(port) = http_regex - .as_ref() - .and_then(|re| re.captures(line).and_then(|c| c.get(1))) - .and_then(|m| m.as_str().parse::().ok()) - { - Self::mark_ready( - app, - status, - ready, - bootstrap_token, - auth_cookie_name, - format!("http://localhost:{port}"), - ); - continue; - } - - if let Ok(value) = serde_json::from_str::(line) { - if let Some(port) = value.get("port").and_then(|p| p.as_u64()) { - Self::mark_ready( - app, - status, - ready, - bootstrap_token, - auth_cookie_name, - format!("http://localhost:{}", port), - ); - continue; - } - } - } } } Err(_) => break, @@ -976,6 +1079,7 @@ impl CliEntry { "--auth-cookie-name".to_string(), auth_cookie_name.to_string(), "--generate-token".to_string(), + "--unrestricted-root".to_string(), ]; if dev { @@ -1031,27 +1135,58 @@ impl CliEntry { } fn resolve_tsx(_app: &AppHandle) -> Option { - let candidates = vec![ - std::env::current_dir() - .ok() + let cwd = std::env::current_dir().ok(); + let workspace = workspace_root(); + let mut candidates = vec![ + cwd.as_ref() + .map(|p| p.join("node_modules/tsx/dist/cli.mjs")), + cwd.as_ref() + .map(|p| p.join("node_modules/tsx/dist/cli.cjs")), + cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")), + cwd.as_ref() + .map(|p| p.join("../node_modules/tsx/dist/cli.mjs")), + cwd.as_ref() + .map(|p| p.join("../node_modules/tsx/dist/cli.cjs")), + cwd.as_ref() + .map(|p| p.join("../node_modules/tsx/dist/cli.js")), + cwd.as_ref() + .map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")), + cwd.as_ref() + .map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")), + cwd.as_ref() + .map(|p| p.join("../../node_modules/tsx/dist/cli.js")), + workspace + .as_ref() + .map(|p| p.join("node_modules/tsx/dist/cli.mjs")), + workspace + .as_ref() + .map(|p| p.join("node_modules/tsx/dist/cli.cjs")), + workspace + .as_ref() .map(|p| p.join("node_modules/tsx/dist/cli.js")), - std::env::current_exe().ok().and_then(|ex| { - ex.parent() - .map(|p| p.join("../node_modules/tsx/dist/cli.js")) - }), ]; + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.mjs"))); + candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.cjs"))); + candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.js"))); + } + } + first_existing(candidates) } fn resolve_dev_entry(_app: &AppHandle) -> Option { + let cwd = std::env::current_dir().ok(); + let workspace = workspace_root(); let candidates = vec![ - std::env::current_dir() - .ok() + workspace + .as_ref() .map(|p| p.join("packages/server/src/index.ts")), - std::env::current_dir() - .ok() - .map(|p| p.join("../server/src/index.ts")), + cwd.as_ref().map(|p| p.join("packages/server/src/index.ts")), + cwd.as_ref().map(|p| p.join("../server/src/index.ts")), + cwd.as_ref().map(|p| p.join("../../server/src/index.ts")), ]; first_existing(candidates) @@ -1153,11 +1288,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec { .unwrap_or("") .to_lowercase(); - if shell_name.contains("zsh") { - vec!["-l".into(), "-i".into(), "-c".into(), command.into()] - } else { - vec!["-l".into(), "-c".into(), command.into()] - } + let _ = shell_name; + vec!["-l".into(), "-c".into(), command.into()] } fn first_existing(paths: Vec>) -> Option { diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 7dfd12b0..47a91b17 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -6,13 +6,16 @@ use cli_manager::{CliProcessManager, CliStatus}; use keepawake::KeepAwake; use serde::Deserialize; use serde_json::json; +use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::webview::Webview; -use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry}; +use tauri::{ + AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry, +}; use tauri_plugin_global_shortcut::{ Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState, }; @@ -30,7 +33,7 @@ use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false); const DEFAULT_ZOOM_LEVEL: f64 = 1.0; -const ZOOM_STEP: f64 = 0.2; +const ZOOM_STEP: f64 = 0.1; const MIN_ZOOM_LEVEL: f64 = 0.2; const MAX_ZOOM_LEVEL: f64 = 5.0; @@ -41,6 +44,16 @@ pub struct AppState { pub manager: CliProcessManager, pub wake_lock: Mutex>, pub zoom_level: Mutex, + pub remote_origins: Mutex>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemoteWindowPayload { + id: String, + name: String, + base_url: String, + skip_tls_verify: bool, } #[derive(Debug, Default, Deserialize)] @@ -118,11 +131,32 @@ fn should_allow_internal(url: &Url) -> bool { } } -fn intercept_navigation(webview: &Webview, url: &Url) -> bool { +fn should_allow_window_origin( + app_handle: &AppHandle, + window_label: &str, + url: &Url, +) -> bool { if should_allow_internal(url) { return true; } + let state = app_handle.state::(); + let Ok(allowed) = state.remote_origins.lock() else { + return false; + }; + if let Some(origin) = allowed.get(window_label) { + return origin == &url.origin().ascii_serialization(); + } + + false +} + +fn intercept_navigation(webview: &Webview, url: &Url) -> bool { + let window_label = webview.label().to_string(); + if should_allow_window_origin(&webview.app_handle(), &window_label, url) { + return true; + } + if let Err(err) = webview .app_handle() .opener() @@ -133,6 +167,58 @@ fn intercept_navigation(webview: &Webview, url: &Url) -> bool { false } +#[tauri::command] +fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> { + if payload.skip_tls_verify && payload.base_url.starts_with("https://") { + return Err( + "Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect, or use the CodeNomad Electron app." + .to_string(), + ); + } + + let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?; + let label = format!("remote-{}", payload.id); + let title = format!( + "{} - {}", + payload.name, + parsed.host_str().unwrap_or(payload.base_url.as_str()) + ); + + if let Some(existing) = app.get_webview_window(&label) { + let _ = existing.navigate(parsed.clone()); + let _ = existing.set_title(&title); + let _ = existing.show(); + let _ = existing.unminimize(); + let _ = existing.set_focus(); + return Ok(()); + } + + app.state::() + .remote_origins + .lock() + .map_err(|err| err.to_string())? + .insert(label.clone(), parsed.origin().ascii_serialization()); + + let window = + WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone())) + .title(title) + .inner_size(1400.0, 900.0) + .min_inner_size(800.0, 600.0) + .build() + .map_err(|err| err.to_string())?; + + let app_handle = app.clone(); + window.on_window_event(move |event| { + if let WindowEvent::Destroyed = event { + if let Ok(mut origins) = app_handle.state::().remote_origins.lock() { + origins.remove(&label); + } + } + }); + + Ok(()) +} + fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec { paths .iter() @@ -286,6 +372,7 @@ fn main() { manager: CliProcessManager::new(), wake_lock: Mutex::new(None), zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL), + remote_origins: Mutex::new(HashMap::new()), }) .setup(|app| { set_windows_app_user_model_id(); @@ -323,7 +410,8 @@ fn main() { cli_get_status, cli_restart, wake_lock_start, - wake_lock_stop + wake_lock_stop, + open_remote_window ]) .on_menu_event(|app_handle, event| { match event.id().0.as_str() { @@ -455,11 +543,24 @@ fn main() { event: tauri::WindowEvent::CloseRequested { api, .. }, .. } => { - // Ensure we have time to stop the CLI process before the app exits. + // Let windows close normally. App shutdown is handled only after the + // last window is actually gone so remote windows can outlive `main`. + let _ = api; + } + tauri::RunEvent::WindowEvent { + event: tauri::WindowEvent::Destroyed, + .. + } => { + if !app_handle.webview_windows().is_empty() { + return; + } + + // Stop the CLI only when the final window is gone and the app is + // truly exiting. if QUIT_REQUESTED.swap(true, Ordering::SeqCst) { return; } - api.prevent_close(); + let app = app_handle.clone(); std::thread::spawn(move || { if let Some(state) = app.try_state::() { diff --git a/packages/tauri-app/src-tauri/tauri.conf.json b/packages/tauri-app/src-tauri/tauri.conf.json index 8a1b4103..0c08e1f3 100644 --- a/packages/tauri-app/src-tauri/tauri.conf.json +++ b/packages/tauri-app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CodeNomad", - "version": "0.13.3", + "version": "0.14.0", "identifier": "ai.neuralnomads.codenomad.client", "build": { "beforeDevCommand": "npm run dev:bootstrap", diff --git a/packages/ui/package.json b/packages/ui/package.json index 04e49654..151a0302 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.13.3", + "version": "0.14.0", "private": true, "license": "MIT", "type": "module", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 0e4e13b9..462c0c16 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -10,7 +10,10 @@ import InstanceTabs from "./components/instance-tabs" import InstanceDisconnectedModal from "./components/instance-disconnected-modal" import InstanceShell from "./components/instance/instance-shell2" import { SettingsScreen } from "./components/settings-screen" +import { SideCarPickerDialog } from "./components/sidecar-picker-dialog" +import { SideCarView } from "./components/sidecar-view" import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context" +import { showAlertDialog } from "./stores/alerts" import { initGithubStars } from "./stores/github-stars" import { useCommands } from "./lib/hooks/use-commands" @@ -23,7 +26,6 @@ import { runtimeEnv } from "./lib/runtime-env" import { useI18n } from "./lib/i18n" import { setWakeLockDesired } from "./lib/native/wake-lock" import { - hasInstances, isSelectingFolder, setIsSelectingFolder, showFolderSelection, @@ -33,10 +35,7 @@ import { useConfig } from "./stores/preferences" import { createInstance, instances, - activeInstanceId, - setActiveInstanceId, stopInstance, - getActiveInstance, disconnectedInstance, acknowledgeDisconnectedInstance, } from "./stores/instances" @@ -53,6 +52,22 @@ import { import { getInstanceSessionIndicatorStatus } from "./stores/session-status" import { openSettings } from "./stores/settings-screen" +import { + closeSidecarTab, + ensureSidecarsLoaded, + openSidecarTab, +} from "./stores/sidecars" +import { + activeAppTab, + activeAppTabId, + appTabs, + ensureActiveAppTab, + getAdjacentAppTabId, + getAppTabById, + selectAppTab, + selectInstanceTab, + selectSidecarTab, +} from "./stores/app-tabs" const log = getLogger("actions") @@ -77,6 +92,7 @@ const App: Component = () => { } = useConfig() const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) + const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false) const phoneQuery = useMediaQuery("(max-width: 767px)") const isPhoneLayout = createMemo(() => phoneQuery()) @@ -206,8 +222,7 @@ const App: Component = () => { }) createEffect(() => { - instances() - hasInstances() + appTabs() requestAnimationFrame(() => updateInstanceTabBarHeight()) }) @@ -219,7 +234,15 @@ const App: Component = () => { onCleanup(() => window.removeEventListener("resize", handleResize)) }) - const activeInstance = createMemo(() => getActiveInstance()) + createEffect(() => { + appTabs() + ensureActiveAppTab() + }) + + const activeInstance = createMemo(() => { + const tab = activeAppTab() + return tab?.kind === "instance" ? tab.instance : null + }) const activeSessionIdForInstance = createMemo(() => { const instance = activeInstance() if (!instance) return null @@ -244,6 +267,7 @@ const App: Component = () => { recordWorkspaceLaunch(folderPath, selectedBinary) clearLaunchError() const instanceId = await createInstance(folderPath, selectedBinary) + selectInstanceTab(instanceId) setShowFolderSelection(false) log.info("Created instance", { @@ -270,8 +294,27 @@ const App: Component = () => { } function handleNewInstanceRequest() { - if (hasInstances()) { - setShowFolderSelection(true) + setShowFolderSelection(true) + } + + function handleOpenSidecarPicker() { + setSidecarPickerOpen(true) + void ensureSidecarsLoaded() + } + + async function handleOpenSidecar(sidecarId: string) { + try { + const tab = await openSidecarTab(sidecarId) + selectSidecarTab(tab.token) + setShowFolderSelection(false) + setSidecarPickerOpen(false) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + showAlertDialog(message, { + variant: "error", + title: t("sidecars.open.errorTitle"), + }) + log.error("Failed to open SideCar", error) } } @@ -332,6 +375,23 @@ const App: Component = () => { } } + async function handleCloseAppTab(tabId: string) { + const tab = getAppTabById(tabId) + if (!tab) return + + const fallbackTabId = activeAppTabId() === tabId ? getAdjacentAppTabId(tabId) : activeAppTabId() + + if (tab.kind === "instance") { + await handleCloseInstance(tab.instance.id) + } else { + closeSidecarTab(tab.sidecarTab.token) + } + + if (!getAppTabById(tabId)) { + ensureActiveAppTab(fallbackTabId) + } + } + const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => { if (!instanceId || !sessionId || sessionId === "info") return await updateSessionAgent(instanceId, sessionId, agent) @@ -361,6 +421,7 @@ const App: Component = () => { setThinkingBlocksExpansion, setToolInputsVisibility, handleNewInstanceRequest, + handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""), handleCloseInstance, handleNewSession, handleCloseSession, @@ -371,6 +432,7 @@ const App: Component = () => { useAppLifecycle({ setEscapeInDebounce, handleNewInstanceRequest, + handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""), handleCloseInstance, handleNewSession, handleCloseSession, @@ -470,52 +532,60 @@ const App: Component = () => { void handleCloseAppTab(tabId)} onNew={handleNewInstanceRequest} /> - - - {(instance) => { - const isActiveInstance = () => activeInstanceId() === instance.id - const isVisible = () => isActiveInstance() && !showFolderSelection() - return ( -
- - handleCloseSession(instance.id, sessionId)} - onNewSession={() => handleNewSession(instance.id)} - handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} - handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} - onExecuteCommand={executeCommand} - tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()} - mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()} - onEnterMobileFullscreen={() => void enterMobileFullscreen()} - onExitMobileFullscreen={() => void exitMobileFullscreen()} - /> - - -
- ) + + {(tab) => { + const isVisible = () => activeAppTabId() === tab.id && !showFolderSelection() + return tab.kind === "instance" ? ( +
+ + handleCloseSession(tab.instance.id, sessionId)} + onNewSession={() => handleNewSession(tab.instance.id)} + handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(tab.instance.id, sessionId, agent)} + handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(tab.instance.id, sessionId, model)} + onExecuteCommand={executeCommand} + tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()} + mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()} + onEnterMobileFullscreen={() => void enterMobileFullscreen()} + onExitMobileFullscreen={() => void exitMobileFullscreen()} + /> + +
+ ) : ( +
+ +
+ ) }}
@@ -525,6 +595,7 @@ const App: Component = () => {
@@ -534,6 +605,7 @@ const App: Component = () => { { setShowFolderSelection(false) clearLaunchError() @@ -544,6 +616,7 @@ const App: Component = () => { + setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} /> diff --git a/packages/ui/src/components/diff-viewer.tsx b/packages/ui/src/components/diff-viewer.tsx index 91ae0813..5dd64208 100644 --- a/packages/ui/src/components/diff-viewer.tsx +++ b/packages/ui/src/components/diff-viewer.tsx @@ -1,4 +1,4 @@ -import { createMemo, Show, createEffect, onCleanup } from "solid-js" +import { createMemo, Show, createEffect } from "solid-js" import { DiffView, DiffModeEnum } from "@git-diff-view/solid" import "@git-diff-view/solid/styles/diff-view-pure.css" import { disableCache } from "@git-diff-view/core" @@ -20,6 +20,7 @@ interface ToolCallDiffViewerProps { filePath?: string theme: "light" | "dark" mode: DiffViewMode + wrap?: boolean onRendered?: () => void cachedHtml?: string cacheEntryParams?: CacheEntryParams @@ -31,11 +32,183 @@ type DiffData = { hunks: string[] } -type CaptureContext = { - theme: ToolCallDiffViewerProps["theme"] - mode: DiffViewMode - diffText: string - cacheEntryParams?: CacheEntryParams +function measureTextWidth(container: HTMLElement, text: string, source: HTMLElement) { + const computed = window.getComputedStyle(source) + const probe = document.createElement("span") + probe.textContent = text || "" + probe.style.position = "absolute" + probe.style.visibility = "hidden" + probe.style.pointerEvents = "none" + probe.style.display = "inline-block" + probe.style.width = "auto" + probe.style.maxWidth = "none" + probe.style.whiteSpace = "nowrap" + probe.style.fontFamily = computed.fontFamily + probe.style.fontSize = computed.fontSize + probe.style.fontWeight = computed.fontWeight + probe.style.fontStyle = computed.fontStyle + probe.style.letterSpacing = computed.letterSpacing + probe.style.fontVariant = computed.fontVariant + probe.style.textTransform = computed.textTransform + probe.style.lineHeight = computed.lineHeight + container.appendChild(probe) + const width = Math.ceil(probe.getBoundingClientRect().width) + probe.remove() + return width +} + +function computeCompactWidth( + container: HTMLElement, + entries: Array<{ text: string; source: HTMLElement }>, + maxWidthPx = 40, +) { + const measuredLabelWidthPx = entries.reduce((max, entry) => { + return Math.max(max, measureTextWidth(container, entry.text, entry.source)) + }, 0) + const fallbackTextLength = entries.reduce((max, entry) => Math.max(max, entry.text.length), 1) + const fallbackWidthPx = Math.round(fallbackTextLength * 7 + 4) + return Math.max(2, Math.min(maxWidthPx, measuredLabelWidthPx > 0 ? measuredLabelWidthPx + 2 : fallbackWidthPx)) +} + +function applyCompactUnifiedGutter(container: HTMLElement, wrap: boolean) { + const tableWrapper = container.querySelector(".unified-diff-table-wrapper") + const table = container.querySelector(".unified-diff-table") + const numberCol = container.querySelector(".unified-diff-table-num-col") + const gutterRows = container.querySelectorAll(".diff-line-num") + const hunkGutters = container.querySelectorAll(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper") + const entries: Array<{ gutter: HTMLElement; label: HTMLElement; text: string }> = [] + + if (table) { + if (wrap) { + table.classList.add("table-fixed") + table.style.tableLayout = "fixed" + table.style.width = "100%" + table.style.minWidth = "100%" + } else { + table.classList.remove("table-fixed") + table.style.tableLayout = "auto" + table.style.width = "max-content" + table.style.minWidth = "100%" + } + } + + gutterRows.forEach((gutter) => { + const oldSpan = gutter.querySelector("[data-line-old-num]") + const newSpan = gutter.querySelector("[data-line-new-num]") + const spacer = gutter.querySelector(".shrink-0") + const flexWrapper = gutter.querySelector(":scope > .flex") + const currentLabel = gutter.querySelector(":scope > .tool-call-diff-compact-line-number") + + const oldText = oldSpan?.textContent?.trim() ?? "" + const newText = newSpan?.textContent?.trim() ?? "" + const hasUsableNew = newText.length > 0 && newText !== "0" + const hasUsableOld = oldText.length > 0 && oldText !== "0" + const visibleText = hasUsableNew ? newText : hasUsableOld ? oldText : newText || oldText + + if (flexWrapper) flexWrapper.style.display = "none" + if (spacer) spacer.style.display = "none" + if (oldSpan) { oldSpan.style.display = "none"; oldSpan.style.width = "auto" } + if (newSpan) { newSpan.style.display = "none"; newSpan.style.width = "auto" } + + gutter.style.paddingLeft = "1px" + gutter.style.paddingRight = "1px" + gutter.style.textAlign = "left" + + const label = currentLabel ?? document.createElement("span") + label.className = "tool-call-diff-compact-line-number" + label.textContent = visibleText + label.setAttribute("aria-hidden", visibleText ? "false" : "true") + if (!currentLabel) gutter.appendChild(label) + + entries.push({ gutter, label, text: visibleText }) + }) + + const gutterWidthPx = computeCompactWidth(container, entries.map((entry) => ({ text: entry.text, source: entry.label }))) + const gutterWidth = `${gutterWidthPx}px` + const compactAsideWidth = `${Math.max(8, gutterWidthPx - 10)}px` + + if (tableWrapper) { + tableWrapper.style.setProperty("--diff-aside-width", compactAsideWidth) + tableWrapper.style.setProperty("--diff-aside-width--", compactAsideWidth) + } + if (numberCol) { + numberCol.style.width = gutterWidth + } + + entries.forEach(({ gutter, label }) => { + gutter.style.width = gutterWidth + gutter.style.minWidth = gutterWidth + gutter.style.maxWidth = gutterWidth + label.style.width = "auto" + label.style.maxWidth = "none" + }) + + hunkGutters.forEach((gutter) => { + gutter.style.width = gutterWidth + gutter.style.minWidth = gutterWidth + gutter.style.maxWidth = gutterWidth + gutter.style.paddingLeft = "0" + gutter.style.paddingRight = "0" + }) +} + +function applyCompactSplitGutter(container: HTMLElement) { + const oldWrapper = container.querySelector(".old-diff-table-wrapper") + const newWrapper = container.querySelector(".new-diff-table-wrapper") + const numberCells = Array.from(container.querySelectorAll(".diff-line-old-num, .diff-line-new-num")) + const hunkActions = Array.from(container.querySelectorAll(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper")) + const numberSpans = numberCells + .map((cell) => ({ cell, span: cell.querySelector("[data-line-num]") })) + .filter((entry): entry is { cell: HTMLElement; span: HTMLElement } => Boolean(entry.span)) + + const gutterWidthPx = computeCompactWidth( + container, + numberSpans.map(({ span }) => ({ text: span.textContent?.trim() ?? "", source: span })), + 64, + ) + const gutterWidth = `${gutterWidthPx}px` + + ;[oldWrapper, newWrapper].forEach((wrapper) => { + if (wrapper) { + wrapper.style.setProperty("--diff-aside-width", gutterWidth) + } + }) + + numberCells.forEach((cell) => { + cell.style.width = gutterWidth + cell.style.minWidth = gutterWidth + cell.style.maxWidth = gutterWidth + cell.style.paddingLeft = "2px" + cell.style.paddingRight = "2px" + cell.style.textAlign = "left" + cell.style.whiteSpace = "nowrap" + cell.style.overflowWrap = "normal" + cell.style.wordBreak = "normal" + }) + + numberSpans.forEach(({ span }) => { + span.style.whiteSpace = "nowrap" + span.style.overflowWrap = "normal" + span.style.wordBreak = "normal" + }) + + hunkActions.forEach((cell) => { + cell.style.width = gutterWidth + cell.style.minWidth = gutterWidth + cell.style.maxWidth = gutterWidth + cell.style.paddingLeft = "0" + cell.style.paddingRight = "0" + }) +} + +function applyCompactDiffLayout(container: HTMLElement, mode: DiffViewMode, wrap = false) { + if (mode === "unified") { + applyCompactUnifiedGutter(container, wrap) + return + } + if (mode === "split") { + applyCompactSplitGutter(container) + } } export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) { @@ -67,12 +240,15 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) { const contextKey = createMemo(() => { const data = diffData() if (!data) return "" - return `${props.theme}|${props.mode}|${props.diffText}` + return `${props.theme}|${props.mode}|${props.wrap ? "wrap" : "nowrap"}|${props.diffText}` }) createEffect(() => { const cachedHtml = props.cachedHtml if (cachedHtml) { + if (diffContainerRef) { + applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap)) + } // When we are given cached HTML, we rely on the caller's cache // and simply notify once rendered. props.onRendered?.() @@ -83,9 +259,10 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) { if (!key) return if (!diffContainerRef) return if (lastCapturedKey === key) return - + requestAnimationFrame(() => { if (!diffContainerRef) return + applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap)) const markup = diffContainerRef.innerHTML if (!markup) return lastCapturedKey = key @@ -95,6 +272,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) { html: markup, theme: props.theme, mode: props.mode, + wrap: props.wrap, }) } props.onRendered?.() @@ -122,7 +300,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) { diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified} diffViewTheme={props.theme} diffViewHighlight - diffViewWrap={false} + diffViewWrap={Boolean(props.wrap)} diffViewFontSize={13} /> @@ -131,7 +309,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) { } > -
+
) diff --git a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx index ace89d5f..6856fb24 100644 --- a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx +++ b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx @@ -1,15 +1,17 @@ -import { createEffect, createSignal, onCleanup, onMount } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { loadMonaco } from "../../lib/monaco/setup" import { getOrCreateTextModel } from "../../lib/monaco/model-cache" import { inferMonacoLanguageId } from "../../lib/monaco/language" import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup" import { useTheme } from "../../lib/theme" +import { parsePatchToBeforeAfter } from "../../lib/diff-utils" interface MonacoDiffViewerProps { scopeKey: string path: string - before: string - after: string + patch?: string + before?: string + after?: string viewMode?: "split" | "unified" contextMode?: "expanded" | "collapsed" wordWrap?: "on" | "off" @@ -23,6 +25,16 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { let monaco: any = null const [ready, setReady] = createSignal(false) + const resolvedContent = createMemo(() => { + if (props.patch !== undefined && props.patch !== null) { + return parsePatchToBeforeAfter(props.patch) + } + return { + before: props.before ?? "", + after: props.after ?? "", + } + }) + const disposeEditor = () => { try { diffEditor?.setModel(null as any) @@ -115,11 +127,12 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { createEffect(() => { if (!ready() || !monaco || !diffEditor) return const languageId = inferMonacoLanguageId(monaco, props.path) + const { before, after } = resolvedContent() const beforeKey = `${props.scopeKey}:diff:${props.path}:before` const afterKey = `${props.scopeKey}:diff:${props.path}:after` - const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId }) - const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId }) + const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: before, languageId }) + const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: after, languageId }) diffEditor.setModel({ original, modified }) void ensureMonacoLanguageLoaded(languageId).then(() => { diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 2dcc4c1a..90459ada 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -1,6 +1,7 @@ +import { Dialog } from "@kobalte/core/dialog" import { Select } from "@kobalte/core/select" import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" -import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid" +import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2 } from "lucide-solid" import { useConfig } from "../stores/preferences" import DirectoryBrowserDialog from "./directory-browser-dialog" import Kbd from "./kbd" @@ -14,25 +15,48 @@ import { useI18n, type Locale } from "../lib/i18n" import { showAlertDialog } from "../stores/alerts" import { openSettings, settingsOpen } from "../stores/settings-screen" import { openExternalUrl } from "../lib/external-url" +import { serverApi } from "../lib/api-client" +import { openRemoteServerWindow } from "../lib/native/remote-window" const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad" const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945" +type HomeTab = "local" | "servers" + interface FolderSelectionViewProps { onSelectFolder: (folder: string, binaryPath?: string) => void + onOpenSidecar?: () => void isLoading?: boolean onClose?: () => void } const FolderSelectionView: Component = (props) => { - const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig() + const { + recentFolders, + removeRecentFolder, + preferences, + updatePreferences, + serverSettings, + remoteServers, + saveRemoteServerProfile, + markRemoteServerConnected, + removeRemoteServerProfile, + } = useConfig() const { t, locale } = useI18n() const [selectedIndex, setSelectedIndex] = createSignal(0) const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false) + const [activeTab, setActiveTab] = createSignal("local") + const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false) + const [serverName, setServerName] = createSignal("") + const [serverUrl, setServerUrl] = createSignal("") + const [skipTlsVerify, setSkipTlsVerify] = createSignal(false) + const [serverDialogError, setServerDialogError] = createSignal(null) + const [isSavingServer, setIsSavingServer] = createSignal(false) + const [connectingServerId, setConnectingServerId] = createSignal(null) const nativeDialogsAvailable = supportsNativeDialogs() let recentListRef: HTMLDivElement | undefined @@ -49,10 +73,15 @@ const FolderSelectionView: Component = (props) => { ] const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0] - + const folders = () => recentFolders() + const serverList = () => remoteServers() const isLoading = () => Boolean(props.isLoading) + function getActiveListLength() { + return activeTab() === "local" ? folders().length : serverList().length + } + // Update selected binary when preferences change createEffect(() => { const lastUsed = serverSettings().opencodeBinary @@ -64,7 +93,7 @@ const FolderSelectionView: Component = (props) => { function scrollToIndex(index: number) { const container = recentListRef if (!container) return - const element = container.querySelector(`[data-folder-index="${index}"]`) as HTMLElement | null + const element = container.querySelector(`[data-list-index="${index}"]`) as HTMLElement | null if (!element) return const containerRect = container.getBoundingClientRect() @@ -113,19 +142,18 @@ const FolderSelectionView: Component = (props) => { return } - const folderList = folders() - if (isBrowseShortcut) { e.preventDefault() void handleBrowse() return } - if (folderList.length === 0) return + const listLength = getActiveListLength() + if (listLength === 0) return if (e.key === "ArrowDown") { e.preventDefault() - const newIndex = Math.min(selectedIndex() + 1, folderList.length - 1) + const newIndex = Math.min(selectedIndex() + 1, listLength - 1) setSelectedIndex(newIndex) setFocusMode("recent") scrollToIndex(newIndex) @@ -138,7 +166,7 @@ const FolderSelectionView: Component = (props) => { } else if (e.key === "PageDown") { e.preventDefault() const pageSize = 5 - const newIndex = Math.min(selectedIndex() + pageSize, folderList.length - 1) + const newIndex = Math.min(selectedIndex() + pageSize, listLength - 1) setSelectedIndex(newIndex) setFocusMode("recent") scrollToIndex(newIndex) @@ -156,7 +184,7 @@ const FolderSelectionView: Component = (props) => { scrollToIndex(0) } else if (e.key === "End") { e.preventDefault() - const newIndex = folderList.length - 1 + const newIndex = listLength - 1 setSelectedIndex(newIndex) setFocusMode("recent") scrollToIndex(newIndex) @@ -165,10 +193,17 @@ const FolderSelectionView: Component = (props) => { handleEnterKey() } else if (e.key === "Backspace" || e.key === "Delete") { e.preventDefault() - if (folderList.length > 0 && focusMode() === "recent") { - const folder = folderList[selectedIndex()] - if (folder) { - handleRemove(folder.path) + if (listLength > 0 && focusMode() === "recent") { + if (activeTab() === "local") { + const folder = folders()[selectedIndex()] + if (folder) { + handleRemove(folder.path) + } + } else { + const server = serverList()[selectedIndex()] + if (server) { + removeRemoteServerProfile(server.id) + } } } } @@ -177,15 +212,40 @@ const FolderSelectionView: Component = (props) => { function handleEnterKey() { if (isLoading()) return - const folderList = folders() const index = selectedIndex() - const folder = folderList[index] - if (folder) { - handleFolderSelect(folder.path) + if (activeTab() === "local") { + const folder = folders()[index] + if (folder) { + handleFolderSelect(folder.path) + } + return + } + + const server = serverList()[index] + if (server) { + void handleConnectSavedServer(server.id) } } + createEffect(() => { + activeTab() + setSelectedIndex(0) + setFocusMode("recent") + }) + + createEffect(() => { + const length = getActiveListLength() + if (length === 0) { + setSelectedIndex(0) + return + } + + if (selectedIndex() >= length) { + setSelectedIndex(length - 1) + } + }) + onMount(() => { window.addEventListener("keydown", handleKeyDown) @@ -236,6 +296,87 @@ const FolderSelectionView: Component = (props) => { props.onSelectFolder(path, selectedBinary()) } + function resetServerDialog() { + setServerName("") + setServerUrl("") + setSkipTlsVerify(false) + setServerDialogError(null) + } + + function openServerDialog() { + resetServerDialog() + setIsServerDialogOpen(true) + } + + async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) { + const trimmedName = input.name.trim() + const trimmedUrl = input.baseUrl.trim() + if (!trimmedName || !trimmedUrl) { + throw new Error(t("folderSelection.servers.dialog.errorRequired")) + } + + const probe = await serverApi.probeRemoteServer({ + baseUrl: trimmedUrl, + skipTlsVerify: input.skipTlsVerify, + }) + + if (!probe.ok) { + throw new Error(probe.error || t("folderSelection.servers.dialog.errorConnect")) + } + + const profile = await saveRemoteServerProfile({ + id: input.id, + name: trimmedName, + baseUrl: probe.normalizedUrl, + skipTlsVerify: input.skipTlsVerify, + }) + + if (openWindow) { + await openRemoteServerWindow(profile) + await markRemoteServerConnected(profile.id) + } + + return profile + } + + async function handleSaveServer(openWindow: boolean) { + if (isSavingServer()) return + setIsSavingServer(true) + setServerDialogError(null) + try { + await probeAndOpenServer( + { + name: serverName(), + baseUrl: serverUrl(), + skipTlsVerify: skipTlsVerify(), + }, + openWindow, + ) + setIsServerDialogOpen(false) + resetServerDialog() + } catch (error) { + setServerDialogError(error instanceof Error ? error.message : String(error)) + } finally { + setIsSavingServer(false) + } + } + + async function handleConnectSavedServer(id: string) { + const target = remoteServers().find((entry) => entry.id === id) + if (!target || connectingServerId()) return + setConnectingServerId(id) + try { + await probeAndOpenServer(target, true) + } catch (error) { + showAlertDialog(error instanceof Error ? error.message : String(error), { + title: t("folderSelection.servers.errorTitle"), + variant: "warning", + }) + } finally { + setConnectingServerId(null) + } + } + async function handleBrowse() { if (isLoading()) return setFocusMode("new") @@ -476,90 +617,223 @@ const FolderSelectionView: Component = (props) => {
{/* Right column: recent folders */}
- 0} - fallback={ -
-
- -
-

{t("folderSelection.empty.title")}

-

{t("folderSelection.empty.description")}

-
- } - >
-
-

{t("folderSelection.recent.title")}

-

- {t( - folders().length === 1 - ? "folderSelection.recent.subtitle.one" - : "folderSelection.recent.subtitle.other", - { count: folders().length }, - )} -

-
-
(recentListRef = el)} - > - - {(folder, index) => ( +
+
+ + +
+
+ + 0} + fallback={ +
+
+ +
+

{t("folderSelection.servers.empty.title")}

+

{t("folderSelection.servers.empty.description")}

-
+ } + > +
(recentListRef = el)} + > + + {(server, index) => ( +
+
+ + +
+
+ )} +
- )} -
-
+ + } + > + 0} + fallback={ +
+
+ +
+

{t("folderSelection.empty.title")}

+

{t("folderSelection.empty.description")}

+
+ } + > +
(recentListRef = el)} + > + + {(folder, index) => ( +
+
+ + +
+
+ )} +
+
+
+
-
@@ -567,11 +841,11 @@ const FolderSelectionView: Component = (props) => {
-
+
+ + + +
{/* OpenCode settings section */} @@ -663,6 +958,82 @@ const FolderSelectionView: Component = (props) => { onClose={() => setIsFolderBrowserOpen(false)} onSelect={handleBrowserSelect} /> + + !open && setIsServerDialogOpen(false)}> + + +
+ +
+ + {t("folderSelection.servers.dialog.title")} + + + {t("folderSelection.servers.dialog.description")} + +
+ + + + + + + + + {(message) =>

{message()}

} +
+ +
+ + + +
+
+
+
+
) } diff --git a/packages/ui/src/components/instance-tabs.tsx b/packages/ui/src/components/instance-tabs.tsx index 195d3f4b..86e2c498 100644 --- a/packages/ui/src/components/instance-tabs.tsx +++ b/packages/ui/src/components/instance-tabs.tsx @@ -1,6 +1,5 @@ import { Component, For, Show, createMemo } from "solid-js" import { Dynamic } from "solid-js/web" -import type { Instance } from "../types/instance" import InstanceTab from "./instance-tab" import KeyboardHint from "./keyboard-hint" import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid" @@ -9,12 +8,13 @@ import { useI18n } from "../lib/i18n" import { isOsNotificationSupportedSync } from "../lib/os-notifications" import { useConfig } from "../stores/preferences" import { openSettings } from "../stores/settings-screen" +import type { AppTabRecord } from "../stores/app-tabs" interface InstanceTabsProps { - instances: Map - activeInstanceId: string | null - onSelect: (instanceId: string) => void - onClose: (instanceId: string) => void + tabs: AppTabRecord[] + activeTabId: string | null + onSelect: (tabId: string) => void + onClose: (tabId: string) => void onNew: () => void } @@ -42,15 +42,25 @@ const InstanceTabs: Component = (props) => {
- - {([id, instance]) => ( - props.onSelect(id)} - onClose={() => props.onClose(id)} - /> - )} + + {(tab) => + tab.kind === "instance" ? ( + props.onSelect(tab.id)} + onClose={() => props.onClose(tab.id)} + /> + ) : ( +
+ + +
+ )}
- 1}> + 1}>
= (props) => { } > {(file) => ( - - {props.t("instanceInfo.loading")} -
- } - > - - + + {props.t("instanceInfo.loading")} +
+ } + > + + )}
diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index 74741ae5..f618bf03 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -4,7 +4,7 @@ import { Accordion } from "@kobalte/core" import { Tooltip } from "@kobalte/core/tooltip" import Switch from "@suid/material/Switch" -import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid" +import { BellRing, ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import type { Instance } from "../../../../../types/instance" import type { BackgroundProcess } from "../../../../../../../server/src/api-types" @@ -187,6 +187,24 @@ const StatusTab: Component = (props) => {
{process.title}
+ + + {props.t("instanceShell.backgroundProcesses.status", { status: process.status })} diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index 0020333d..352a6dba 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -1,21 +1,22 @@ -import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js" +import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js" import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid" import MessageItem from "./message-item" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { ClientPart, MessageInfo } from "../types/message" -import { partHasRenderableText } from "../types/message" +import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message" import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache" import type { MessageRecord } from "../stores/message-v2/types" import { messageStoreBus } from "../stores/message-v2/bus" import { formatTokenTotal } from "../lib/formatters" import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" -import { setActiveInstanceId } from "../stores/instances" +import { selectInstanceTab } from "../stores/app-tabs" import { showAlertDialog } from "../stores/alerts" import { deleteMessage } from "../stores/session-actions" import { useI18n } from "../lib/i18n" import type { DeleteHoverState } from "../types/delete-hover" import { useSpeech } from "../lib/hooks/use-speech" import SpeechActionButton from "./speech-action-button" +import { createFollowScroll } from "../lib/follow-scroll" function DeleteUpToIcon() { return ( @@ -29,6 +30,7 @@ const TOOL_ICON = "🔧" const USER_BORDER_COLOR = "var(--message-user-border)" const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)" const TOOL_BORDER_COLOR = "var(--message-tool-border)" +const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48 const LazyToolCall = lazy(() => import("./tool-call")) @@ -130,7 +132,7 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string } function navigateToTaskSession(location: TaskSessionLocation) { - setActiveInstanceId(location.instanceId) + selectInstanceTab(location.instanceId) const parentToActivate = location.parentId ?? location.sessionId setActiveParentSession(location.instanceId, parentToActivate) if (location.parentId) { @@ -229,6 +231,12 @@ function isContentPartType(type: unknown): boolean { return type === "text" || type === "file" } +function isVisibleContentPart(part: ClientPart): boolean { + if (!part || !isContentPartType((part as any).type)) return false + if (isHiddenSyntheticTextPart(part)) return false + return partHasRenderableText(part) +} + function MessageContentItem(props: MessageContentItemProps) { const record = createMemo(() => props.store().getMessage(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) @@ -262,13 +270,15 @@ function MessageContentItem(props: MessageContentItemProps) { return resolved }) + const visibleParts = createMemo(() => parts().filter((part) => isVisibleContentPart(part))) + const showAgentMeta = createMemo(() => { const current = record() if (!current) return false if (current.role !== "assistant") return false const currentParts = parts() - if (!currentParts.some((part) => partHasRenderableText(part))) { + if (visibleParts().length === 0) { return false } @@ -284,10 +294,10 @@ function MessageContentItem(props: MessageContentItemProps) { if (!isSupportedPartType(part)) continue if (!isContentPartType((part as any).type)) continue - if (partHasRenderableText(part)) { - return false + if (isVisibleContentPart(part)) { + return false + } } - } return true }) @@ -298,7 +308,7 @@ function MessageContentItem(props: MessageContentItemProps) { lastAssistantIdx) - // Intentionally untracked: messageInfoVersion updates should not trigger - // a full message block rebuild; record revision is the invalidation key. - const info = untrack(messageInfo) + const messageInfoVersion = props.store().state.messageInfoVersion[current.id] ?? 0 const cacheSignature = [ current.id, current.revision, + messageInfoVersion, isQueued ? 1 : 0, props.showThinking() ? 1 : 0, props.thinkingDefaultExpanded() ? 1 : 0, @@ -637,6 +646,9 @@ export default function MessageBlock(props: MessageBlockProps) { return cachedBlock.block } + // Only capture info after cache check fails - ensures fresh data on version bump + const info = untrack(messageInfo) + const { orderedParts } = buildRecordDisplayData(props.instanceId, current) const items: MessageBlockItem[] = [] const blockContentKeys: string[] = [] @@ -803,19 +815,19 @@ export default function MessageBlock(props: MessageBlockProps) { data-message-id={resolvedBlock().record.id} data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined} > - + {(item, index) => ( - + - + {(() => { - const toolItem = item as ToolDisplayItem + const toolItem = item() as ToolDisplayItem return (
- - + - + - + - + )} - +
)}
@@ -1098,17 +1110,23 @@ function StepCard(props: StepCardProps) { return null } const info = props.messageInfo - if (!info || info.role !== "assistant" || !info.tokens) { + const part = props.part as any + + // step-finish parts have tokens embedded; also check messageInfo + const partTokens = part?.tokens + const infoTokens = info && info.role === "assistant" ? info.tokens : undefined + const tokens = partTokens ?? infoTokens + if (!tokens) { return null } - const tokens = info.tokens + return { input: tokens.input ?? 0, output: tokens.output ?? 0, reasoning: tokens.reasoning ?? 0, cacheRead: tokens.cache?.read ?? 0, cacheWrite: tokens.cache?.write ?? 0, - cost: info.cost ?? 0, + cost: (part?.cost ?? (info && info.role === "assistant" ? info.cost : 0)) ?? 0, } } @@ -1293,14 +1311,23 @@ interface ReasoningCardProps { onContentRendered?: () => void } -function ReasoningCard(props: ReasoningCardProps) { - const { t } = useI18n() - const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded)) - const [deletingMessage, setDeletingMessage] = createSignal(false) - const [deletingUpTo, setDeletingUpTo] = createSignal(false) - const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) +function ReasoningStreamOutput(props: { + text: Accessor + scrollTopSnapshot: Accessor + setScrollTopSnapshot: (next: number) => void + onContentRendered?: () => void + ariaLabel: string +}) { + let preRef: HTMLPreElement | undefined let pendingRenderNotificationFrame: number | null = null + const followScroll = createFollowScroll({ + getScrollTopSnapshot: props.scrollTopSnapshot, + setScrollTopSnapshot: props.setScrollTopSnapshot, + sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX, + sentinelClassName: "reasoning-scroll-sentinel", + }) + const notifyContentRendered = () => { if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return if (pendingRenderNotificationFrame !== null) { @@ -1312,6 +1339,15 @@ function ReasoningCard(props: ReasoningCardProps) { }) } + createEffect(() => { + const nextText = props.text() + if (preRef && preRef.textContent !== nextText) { + preRef.textContent = nextText + } + followScroll.restoreAfterRender() + notifyContentRendered() + }) + onCleanup(() => { if (pendingRenderNotificationFrame !== null) { cancelAnimationFrame(pendingRenderNotificationFrame) @@ -1319,6 +1355,37 @@ function ReasoningCard(props: ReasoningCardProps) { } }) + return ( +
+
 {
+          preRef = element || undefined
+          if (preRef) {
+            preRef.textContent = props.text() || ""
+          }
+        }}
+        class="message-reasoning-text"
+        dir="auto"
+      />
+      {followScroll.renderSentinel()}
+    
+ ) +} + +function ReasoningCard(props: ReasoningCardProps) { + const { t } = useI18n() + const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded)) + const [deletingMessage, setDeletingMessage] = createSignal(false) + const [deletingUpTo, setDeletingUpTo] = createSignal(false) + const [scrollTopSnapshot, setScrollTopSnapshot] = createSignal(0) + const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) + createEffect(() => { setExpanded(Boolean(props.defaultExpanded)) }) @@ -1393,12 +1460,6 @@ function ReasoningCard(props: ReasoningCardProps) { const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech() - createEffect(() => { - if (!expanded()) return - reasoningText() - notifyContentRendered() - }) - const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage() const handleDeleteMessage = async (event: MouseEvent) => { @@ -1553,9 +1614,13 @@ function ReasoningCard(props: ReasoningCardProps) {
-
-
{reasoningText() || ""}
-
+
diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 03b041f1..1a666851 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -2,7 +2,7 @@ import { For, Show, createEffect, createSignal, onCleanup } from "solid-js" import { Portal } from "solid-js/web" import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid" import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message" -import { partHasRenderableText } from "../types/message" +import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message" import type { MessageRecord } from "../stores/message-v2/types" import MessagePart from "./message-part" import { copyToClipboard } from "../lib/clipboard" @@ -290,9 +290,9 @@ export default function MessageItem(props: MessageItemProps) { const getRawContent = () => { return props.parts - .filter(part => part.type === "text") - .map(part => (part as { text?: string }).text || "") - .filter(text => text.trim().length > 0) + .filter((part) => part.type === "text" && !isHiddenSyntheticTextPart(part)) + .map((part) => (part as { text?: string }).text || "") + .filter((text) => text.trim().length > 0) .join("\n\n") } @@ -338,7 +338,7 @@ export default function MessageItem(props: MessageItemProps) { } } - if (!isUser() && !hasContent() && !isGenerating()) { + if (!hasContent() && !isGenerating()) { return null } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index c0bd07cf..b51a5282 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -33,19 +33,7 @@ export default function MessagePart(props: MessagePartProps) { const shouldHideTextPart = () => { const part = props.part if (!part || part.type !== "text") return false - - const isSynthetic = Boolean((part as any).synthetic) - if (!isSynthetic) return false - - // Keep optimistic user prompts visible; hide other synthetic user helper parts. - if (props.messageType === "user") { - const primaryId = props.primaryUserTextPartId - if (!primaryId) return false - return part.id !== primaryId - } - - // Hide synthetic assistant text. - return true + return Boolean((part as any).synthetic) } diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 62827fba..3fbdd6c9 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -1,5 +1,5 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js" -import { MoreHorizontal, Trash, X } from "lucide-solid" +import { MoreHorizontal, Pause, Trash, X } from "lucide-solid" import Kbd from "./kbd" import MessageBlock from "./message-block" import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors" @@ -16,12 +16,14 @@ import { showAlertDialog } from "../stores/alerts" import { deleteMessage, deleteMessagePart } from "../stores/session-actions" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { DeleteHoverState } from "../types/delete-hover" +import { partHasRenderableText } from "../types/message" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { getPartCharCount } from "../lib/token-utils" const SCROLL_SENTINEL_MARGIN_PX = 8 const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" const QUOTE_SELECTION_MAX_LENGTH = 2000 +const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8 const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href export interface MessageSectionProps { @@ -40,12 +42,40 @@ export interface MessageSectionProps { } export default function MessageSection(props: MessageSectionProps) { - const { preferences } = useConfig() + const { preferences, updatePreferences } = useConfig() const { t } = useI18n() const showUsagePreference = () => preferences().showUsageMetrics ?? true const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true + const holdLongAssistantRepliesEnabled = () => preferences().holdLongAssistantReplies ?? true const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId)) + const visibleMessageIds = createMemo(() => { + const resolvedStore = store() + return messageIds().filter((messageId) => { + const record = resolvedStore.getMessage(messageId) + if (!record) return false + + if (buildTimelineSegments(props.instanceId, record, t).length > 0) { + return true + } + + if (record.role !== "assistant") { + return false + } + + const info = resolvedStore.getMessageInfo(messageId) + if (!info || info.role !== "assistant") { + return false + } + + if (info.error) { + return true + } + + const timeInfo = info.time as { created: number; end?: number } | undefined + return Boolean(timeInfo && (timeInfo.end === undefined || timeInfo.end === 0)) + }) + }) const scrollCache = useScrollCache({ instanceId: props.instanceId, @@ -129,6 +159,8 @@ export default function MessageSection(props: MessageSectionProps) { return map }) + const lastAssistantMessageId = createMemo(() => store().getLastAssistantMessageId(props.sessionId)) + const lastCompactionIndex = createMemo(() => { // Depend on a single session revision signal (not every message/part read) // to keep reactive overhead small. @@ -315,15 +347,9 @@ export default function MessageSection(props: MessageSectionProps) { } const lastAssistantIndex = createMemo(() => { - const ids = messageIds() - const resolvedStore = store() - for (let index = ids.length - 1; index >= 0; index--) { - const record = resolvedStore.getMessage(ids[index]) - if (record?.role === "assistant") { - return index - } - } - return -1 + const messageId = lastAssistantMessageId() + if (!messageId) return -1 + return messageIndexById().get(messageId) ?? -1 }) const [timelineSegments, setTimelineSegments] = createSignal([]) @@ -571,7 +597,10 @@ export default function MessageSection(props: MessageSectionProps) { const [streamElement, setStreamElement] = createSignal() const [streamShellElement, setStreamShellElement] = createSignal() - const followToken = createMemo(() => `${sessionRevision()}|${preferenceSignature()}`) + // Only preferences should force a follow-token re-anchor. Message/session + // revision churn at the end of a turn (message.updated, session.idle, etc.) + // should not trigger an immediate scroll-to-bottom. + const followToken = createMemo(() => preferenceSignature()) const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE)) const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true) @@ -601,6 +630,35 @@ export default function MessageSection(props: MessageSectionProps) { const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null) + const lastVisibleMessageId = createMemo(() => { + const ids = visibleMessageIds() + return ids[ids.length - 1] ?? null + }) + + const autoPinHoldTargetKey = createMemo(() => { + if (!holdLongAssistantRepliesEnabled()) return null + const messageId = lastVisibleMessageId() + return isAssistantTextMessage(messageId) ? messageId : null + }) + + function toggleHoldLongAssistantReplies() { + updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() }) + } + + function isAssistantTextMessage(messageId: string | null | undefined) { + if (!messageId) return false + const resolvedStore = store() + const record = resolvedStore.getMessage(messageId) + if (!record || record.role !== "assistant") return false + + const { orderedParts } = buildRecordDisplayData(props.instanceId, record) + return orderedParts.some((part) => { + if ((part as any)?.type !== "text") return false + if (partHasRenderableText(part)) return true + return typeof (part as { text?: unknown }).text === "string" + }) + } + createEffect(() => { const api = listApi() if (!api) return @@ -615,7 +673,7 @@ export default function MessageSection(props: MessageSectionProps) { const api = listApi() if (!element || !api) return if (props.loading) return - if (messageIds().length === 0) return + if (visibleMessageIds().length === 0) return if (didRestoreScroll()) return scrollCache.restore(element, { @@ -734,88 +792,93 @@ export default function MessageSection(props: MessageSectionProps) { const loading = Boolean(props.loading) const ids = messageIds() - if (loading) { - handleClearTimelineSelection() - previousTimelineIds = [] - setTimelineSegments([]) - seenTimelineMessageIds.clear() - seenTimelineSegmentKeys.clear() - timelinePartCountsByMessageId.clear() - pendingTimelineMessagePartUpdates.clear() - if (pendingTimelinePartUpdateFrame !== null) { - cancelAnimationFrame(pendingTimelinePartUpdateFrame) - pendingTimelinePartUpdateFrame = null - } - return - } - - if (previousTimelineIds.length === 0 && ids.length > 0) { - seedTimeline() - previousTimelineIds = ids.slice() - return - } - - if (ids.length < previousTimelineIds.length) { - seedTimeline() - previousTimelineIds = ids.slice() - return - } - - if (ids.length === previousTimelineIds.length) { - let changedIndex = -1 - let changeCount = 0 - for (let index = 0; index < ids.length; index++) { - if (ids[index] !== previousTimelineIds[index]) { - changedIndex = index - changeCount += 1 - if (changeCount > 1) break + // Wrap all iteration of the store-proxied `ids` array in untrack() + // to prevent O(n) per-element reactive subscriptions. The effect + // only needs to re-run when `messageIds` (memo) changes. + untrack(() => { + if (loading) { + handleClearTimelineSelection() + previousTimelineIds = [] + setTimelineSegments([]) + seenTimelineMessageIds.clear() + seenTimelineSegmentKeys.clear() + timelinePartCountsByMessageId.clear() + pendingTimelineMessagePartUpdates.clear() + if (pendingTimelinePartUpdateFrame !== null) { + cancelAnimationFrame(pendingTimelinePartUpdateFrame) + pendingTimelinePartUpdateFrame = null } + return } - if (changeCount === 1 && changedIndex >= 0) { - const oldId = previousTimelineIds[changedIndex] - const newId = ids[changedIndex] - if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) { - seenTimelineMessageIds.delete(oldId) - seenTimelineMessageIds.add(newId) - setTimelineSegments((prev) => { - const next = prev.map((segment) => { - if (segment.messageId !== oldId) return segment - const updatedId = segment.id.replace(oldId, newId) - return { ...segment, messageId: newId, id: updatedId } - }) - seenTimelineSegmentKeys.clear() - next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment))) - return next - }) - // Keep part count tracking in sync with id replacement. - const existingPartCount = timelinePartCountsByMessageId.get(oldId) - if (existingPartCount !== undefined) { - timelinePartCountsByMessageId.delete(oldId) - timelinePartCountsByMessageId.set(newId, existingPartCount) + if (previousTimelineIds.length === 0 && ids.length > 0) { + seedTimeline() + previousTimelineIds = [...ids] + return + } + + if (ids.length < previousTimelineIds.length) { + seedTimeline() + previousTimelineIds = [...ids] + return + } + + if (ids.length === previousTimelineIds.length) { + let changedIndex = -1 + let changeCount = 0 + for (let index = 0; index < ids.length; index++) { + if (ids[index] !== previousTimelineIds[index]) { + changedIndex = index + changeCount += 1 + if (changeCount > 1) break } + } + if (changeCount === 1 && changedIndex >= 0) { + const oldId = previousTimelineIds[changedIndex] + const newId = ids[changedIndex] + if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) { + seenTimelineMessageIds.delete(oldId) + seenTimelineMessageIds.add(newId) + setTimelineSegments((prev) => { + const next = prev.map((segment) => { + if (segment.messageId !== oldId) return segment + const updatedId = segment.id.replace(oldId, newId) + return { ...segment, messageId: newId, id: updatedId } + }) + seenTimelineSegmentKeys.clear() + next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment))) + return next + }) - previousTimelineIds = ids.slice() - return + // Keep part count tracking in sync with id replacement. + const existingPartCount = timelinePartCountsByMessageId.get(oldId) + if (existingPartCount !== undefined) { + timelinePartCountsByMessageId.delete(oldId) + timelinePartCountsByMessageId.set(newId, existingPartCount) + } + + previousTimelineIds = [...ids] + return + } } } - } - const newIds: string[] = [] - ids.forEach((id) => { - if (!seenTimelineMessageIds.has(id)) { - newIds.push(id) - } - }) - - if (newIds.length > 0) { - newIds.forEach((id) => { - seenTimelineMessageIds.add(id) - appendTimelineForMessage(id) + const newIds: string[] = [] + ids.forEach((id) => { + if (!seenTimelineMessageIds.has(id)) { + newIds.push(id) + } }) - } - previousTimelineIds = ids.slice() + if (newIds.length > 0) { + newIds.forEach((id) => { + seenTimelineMessageIds.add(id) + appendTimelineForMessage(id) + }) + } + + previousTimelineIds = [...ids] + }) }) function clearPendingTimelinePartUpdateFrame() { @@ -886,36 +949,49 @@ export default function MessageSection(props: MessageSectionProps) { createEffect(() => { if (props.loading) return const ids = messageIds() - const resolvedStore = store() + // Also re-run when sessionRevision bumps (covers part additions within + // existing messages) but read individual records inside untrack() to + // avoid creating O(n) fine-grained subscriptions. + sessionRevision() - let hasChanges = false - for (const messageId of ids) { - const record = resolvedStore.getMessage(messageId) - const partCount = record?.partIds.length ?? 0 - const previousCount = timelinePartCountsByMessageId.get(messageId) + // Wrap the iteration in untrack() so that accessing individual elements + // of the store-proxied `ids` array does not create O(n) per-element + // reactive subscriptions. We only need to re-run when the memo + // (messageIds) or sessionRevision changes — not per-element. + untrack(() => { + const resolvedStore = store() + const idsSet = new Set(ids) + let hasChanges = false - if (previousCount === undefined) { - timelinePartCountsByMessageId.set(messageId, partCount) - continue + for (const messageId of ids) { + const record = resolvedStore.getMessage(messageId) + const partCount = record?.partIds.length ?? 0 + const previousCount = timelinePartCountsByMessageId.get(messageId) + + if (previousCount === undefined) { + timelinePartCountsByMessageId.set(messageId, partCount) + continue + } + + if (previousCount !== partCount) { + timelinePartCountsByMessageId.set(messageId, partCount) + pendingTimelineMessagePartUpdates.add(messageId) + hasChanges = true + } } - if (previousCount !== partCount) { - timelinePartCountsByMessageId.set(messageId, partCount) - pendingTimelineMessagePartUpdates.add(messageId) - hasChanges = true + // Drop tracking for ids that are no longer present. + // Use the Set for O(1) lookups instead of ids.includes() which is O(n). + for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) { + if (!idsSet.has(trackedId)) { + timelinePartCountsByMessageId.delete(trackedId) + } } - } - // Drop tracking for ids that are no longer present. - for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) { - if (!ids.includes(trackedId)) { - timelinePartCountsByMessageId.delete(trackedId) + if (hasChanges) { + scheduleTimelinePartUpdateFlush() } - } - - if (hasChanges) { - scheduleTimelinePartUpdateFlush() - } + }) }) createEffect(() => { @@ -989,7 +1065,7 @@ export default function MessageSection(props: MessageSectionProps) { data-scroll-buttons={scrollButtonsCount()} > messageId} getAnchorId={getMessageAnchorId} getKeyFromAnchorId={getMessageIdFromAnchorId} @@ -1003,6 +1079,12 @@ export default function MessageSection(props: MessageSectionProps) { initialAutoScroll={initialAutoScroll} resetKey={() => props.sessionId} followToken={followToken} + autoPinHoldTargetKey={autoPinHoldTargetKey} + autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX} + resolveAutoPinHoldElement={(itemWrapper, key) => { + const candidates = Array.from(itemWrapper.querySelectorAll(`.message-item-base[data-message-id="${key}"][data-message-role="assistant"]`)) + return candidates[candidates.length - 1] ?? null + }} onScroll={() => { clearQuoteSelection() scrollCache.persist(streamElement()) @@ -1033,9 +1115,55 @@ export default function MessageSection(props: MessageSectionProps) { scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")} registerApi={(api) => setListApi(api)} registerState={(state) => setListState(state)} + renderControls={(state, api) => ( +
+ + + + + + + +
+ )} renderBeforeItems={() => ( <> - +
diff --git a/packages/ui/src/components/message-timeline.tsx b/packages/ui/src/components/message-timeline.tsx index 7df1eba0..17cd7d10 100644 --- a/packages/ui/src/components/message-timeline.tsx +++ b/packages/ui/src/components/message-timeline.tsx @@ -1,7 +1,10 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js" +import { Virtualizer, type VirtualizerHandle } from "virtua/solid" +import { Portal } from "solid-js/web" import MessagePreview from "./message-preview" import { messageStoreBus } from "../stores/message-v2/bus" import type { ClientPart } from "../types/message" +import { isHiddenSyntheticTextPart } from "../types/message" import type { MessageRecord } from "../stores/message-v2/types" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { getPartCharCount } from "../lib/token-utils" @@ -53,6 +56,7 @@ const MAX_TOOLTIP_LENGTH = 220 const LONG_PRESS_MS = 500 const JITTER_THRESHOLD = 10 const ABSOLUTE_TOKEN_CAP = 10000 +const TIMELINE_VIRTUALIZER_BUFFER_PX = 240 type ToolCallPart = Extract @@ -65,6 +69,13 @@ interface PendingSegment { hasPrimaryText: boolean } +interface TimelineSegmentState { + deleteHovered: boolean + deleteSelected: boolean + hasActivePermission: boolean + hidden: boolean +} + function truncateText(value: string): string { if (value.length <= MAX_TOOLTIP_LENGTH) { return value @@ -105,6 +116,7 @@ function collectReasoningText(part: ClientPart): string { function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record) => string): string { if (!part) return "" + if (isHiddenSyntheticTextPart(part)) return "" if (typeof (part as any).text === "string") { return (part as any).text as string } @@ -349,6 +361,13 @@ const MessageTimeline: Component = (props) => { } } + const clearHoverPreview = () => { + clearHoverTimer() + clearCloseTimer() + setHoveredSegment(null) + setHoverAnchorRect(null) + } + const scheduleClose = () => { if (typeof window === "undefined") return clearHoverTimer() @@ -356,8 +375,7 @@ const MessageTimeline: Component = (props) => { // Small delay so the pointer can travel from the segment to the tooltip. closeTimer = window.setTimeout(() => { closeTimer = null - setHoveredSegment(null) - setHoverAnchorRect(null) + clearHoverPreview() }, 160) } @@ -397,8 +415,7 @@ const MessageTimeline: Component = (props) => { }) onCleanup(() => { - clearHoverTimer() - clearCloseTimer() + clearHoverPreview() }) // --- Selection & histogram rib state --- @@ -416,6 +433,8 @@ const MessageTimeline: Component = (props) => { // on activation, resize, or expansion — NOT on every scroll frame. const [badgeOffsets, setBadgeOffsets] = createSignal>({}) const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200) + const [scrollElement, setScrollElement] = createSignal() + const [virtualizerHandle, setVirtualizerHandle] = createSignal() let scrollContainerRef: HTMLDivElement | undefined let xrayOverlayRef: HTMLDivElement | undefined @@ -447,6 +466,12 @@ const MessageTimeline: Component = (props) => { } const handleScroll = () => { + if (renderVirtualizedTimeline()) { + if (hoveredSegment()) { + clearHoverPreview() + } + return + } if (!isSelectionActive()) return if (!scrollContainerRef || !xrayOverlayRef) return xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`) @@ -475,6 +500,12 @@ const MessageTimeline: Component = (props) => { } }) + const renderVirtualizedTimeline = createMemo(() => !isSelectionActive()) + + createEffect(on(renderVirtualizedTimeline, () => { + clearHoverPreview() + })) + const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5)) // Compute fresh char counts from the store. segment.totalChars can be stale for @@ -577,7 +608,7 @@ const MessageTimeline: Component = (props) => { wasLongPress = true // Scroll anchoring: preserve visual position of the pressed badge. - const btn = buttonRefs.get(segment.id) + const btn = renderVirtualizedTimeline() ? null : buttonRefs.get(segment.id) let anchorOffset: number | null = null if (btn && scrollContainerRef) { anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop @@ -629,9 +660,17 @@ const MessageTimeline: Component = (props) => { createEffect(on(() => props.activeSegmentId, (activeId) => { if (!activeId) return - const element = buttonRefs.get(activeId) - if (!element) return const timer = typeof window !== "undefined" ? window.setTimeout(() => { + if (renderVirtualizedTimeline()) { + const index = segmentIndexById().get(activeId) + if (index !== undefined) { + virtualizerHandle()?.scrollToIndex(index, { align: "nearest", smooth: true }) + } + return + } + + const element = buttonRefs.get(activeId) + if (!element) return element.scrollIntoView({ block: "nearest", behavior: "smooth" }) }, 120) : null onCleanup(() => { @@ -682,60 +721,239 @@ const MessageTimeline: Component = (props) => { return map }) + const segmentIndexById = createMemo(() => { + const map = new Map() + for (let i = 0; i < props.segments.length; i++) map.set(props.segments[i].id, i) + return map + }) + + const segmentStates = createMemo(() => { + const hover = deleteHover() + const selectedMessages = props.selectedMessageIds?.() + const expandedMessages = props.expandedMessageIds?.() + const resolvedStore = store() + const indexMap = messageIdToSessionIndex() + const selectionActive = isSelectionActive() + const result = new Map() + + for (const segment of props.segments) { + let deleteHovered = false + if (hover.kind === "message") { + deleteHovered = hover.messageId === segment.messageId + } else if (hover.kind === "deleteUpTo") { + const targetIndex = indexMap.get(hover.messageId) + const segmentIndex = indexMap.get(segment.messageId) + deleteHovered = targetIndex !== undefined && segmentIndex !== undefined && segmentIndex >= targetIndex + } + + const deleteSelected = selectedMessages?.has(segment.messageId) ?? false + + let hasActivePermission = false + if (segment.type === "tool") { + const partIds = segment.toolPartIds ?? [] + for (const partId of partIds) { + const permissionState = resolvedStore.getPermissionState(segment.messageId, partId) + if (permissionState?.active) { + hasActivePermission = true + break + } + } + } + + const hidden = segment.type === "tool" && !( + showTools() + || expandedMessages?.has(segment.messageId) + || selectionActive + || props.activeSegmentId === segment.id + || hasActivePermission + || deleteHovered + || deleteSelected + ) + + result.set(segment.id, { + deleteHovered, + deleteSelected, + hasActivePermission, + hidden, + }) + } + + return result + }) + + const segmentStateFor = (segmentId: string): TimelineSegmentState => { + return segmentStates().get(segmentId) ?? { + deleteHovered: false, + deleteSelected: false, + hasActivePermission: false, + hidden: false, + } + } + + const segmentSpacerHeights = createMemo(() => { + const states = segmentStates() + const result = new Map() + let previousVisible: TimelineSegment | null = null + + for (let index = 0; index < props.segments.length; index += 1) { + const segment = props.segments[index] + const state = states.get(segment.id) + + if (state?.hidden) { + result.set(segment.id, "0") + continue + } + + if (!previousVisible) { + result.set(segment.id, "0") + previousVisible = segment + continue + } + + const previousRaw = index > 0 ? props.segments[index - 1] : null + const startsVisibleToolGroup = segment.type === "tool" + && (previousVisible.type !== "tool" || previousVisible.messageId !== segment.messageId) + const startsCollapsedToolGroup = segment.type === "assistant" + && previousVisible.messageId !== segment.messageId + && messagesWithTools().has(segment.messageId) + && previousRaw?.type === "tool" + && previousRaw.messageId === segment.messageId + const followsVisibleGroupParent = (segment.type === "user" || segment.type === "compaction") + && previousVisible.type === "assistant" + && messagesWithTools().has(previousVisible.messageId) + + const gapUnits = 1 + (startsVisibleToolGroup || startsCollapsedToolGroup || followsVisibleGroupParent ? 1 : 0) + result.set( + segment.id, + gapUnits === 1 + ? "var(--message-timeline-segment-gap)" + : "calc(var(--message-timeline-segment-gap) * 2)", + ) + + previousVisible = segment + } + + return result + }) + return (
{ + scrollContainerRef = element + setScrollElement(element) + }} class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`} role="navigation" aria-label={t("messageTimeline.ariaLabel")} onScroll={handleScroll} > - - {(segment, segIndex) => { - onCleanup(() => buttonRefs.delete(segment.id)) + + {(segment, segIndex) => { + onCleanup(() => buttonRefs.delete(segment.id)) + const isActive = () => props.activeSegmentId === segment.id + const isSelected = () => props.selectedIds?.().has(segment.id) + const state = () => segmentStateFor(segment.id) + const isDeleteHovered = () => state().deleteHovered + const isDeleteSelected = () => state().deleteSelected + const hasActivePermission = () => state().hasActivePermission + const isHidden = () => state().hidden + + const groupRole = (): "child" | "parent" | "none" => { + if (segment.type === "tool") return "child" + if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent" + return "none" + } + + const shortLabelContent = () => { + if (segment.type === "tool") { + if (hasActivePermission()) { + return {(data) => { onCleanup(() => setTooltipElement(null)) return ( -
setTooltipElement(element)} - class="message-timeline-tooltip" - style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }} - onMouseEnter={() => clearCloseTimer()} - onMouseLeave={() => scheduleClose()} - > - -
+ +
setTooltipElement(element)} + class="message-timeline-tooltip" + style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }} + onMouseEnter={() => clearCloseTimer()} + onMouseLeave={() => scheduleClose()} + > + +
+
) }}
diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index daffa104..22601f02 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -540,6 +540,10 @@ export default function PromptInput(props: PromptInputProps) { mode={pickerMode()} onClose={handlePickerClose} onSelect={handlePickerSelect} + onSubmitWithoutSelection={() => { + handlePickerClose() + void handleSend() + }} agents={instanceAgents()} commands={getCommands(props.instanceId)} instanceClient={instance()!.client} diff --git a/packages/ui/src/components/prompt-input/usePromptPicker.ts b/packages/ui/src/components/prompt-input/usePromptPicker.ts index b0056bc9..b267b130 100644 --- a/packages/ui/src/components/prompt-input/usePromptPicker.ts +++ b/packages/ui/src/components/prompt-input/usePromptPicker.ts @@ -324,28 +324,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr const pos = atPosition() if (pickerMode() === "mention" && pos !== null) { setIgnoredAtPositions((prev) => new Set(prev).add(pos)) - - // Remove the partial @mention text from the textarea when ESC is pressed - const textarea = options.getTextarea() - if (textarea) { - const currentPrompt = options.prompt() - const cursorPos = textarea.selectionStart - // Remove text from @ position to cursor position - const before = currentPrompt.substring(0, pos) - const after = currentPrompt.substring(cursorPos) - options.setPrompt(before + after) - - // Restore cursor position to where @ was - setTimeout(() => { - const nextTextarea = options.getTextarea() - if (nextTextarea) { - nextTextarea.setSelectionRange(pos, pos) - } - }, 0) - - // Clear ignoredAtPositions so typing @ again will work - setIgnoredAtPositions(new Set()) - } } setShowPicker(false) setAtPosition(null) diff --git a/packages/ui/src/components/prompt-input/usePromptVoiceInput.ts b/packages/ui/src/components/prompt-input/usePromptVoiceInput.ts index 0b8f93f8..0ff87d07 100644 --- a/packages/ui/src/components/prompt-input/usePromptVoiceInput.ts +++ b/packages/ui/src/components/prompt-input/usePromptVoiceInput.ts @@ -169,18 +169,25 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) { const textarea = options.getTextarea() const start = textarea ? textarea.selectionStart : current.length const end = textarea ? textarea.selectionEnd : current.length + const wasCursorAtEnd = end === current.length + const wasScrolledToBottom = textarea + ? textarea.scrollHeight - (textarea.scrollTop + textarea.clientHeight) <= 4 + : false const before = current.slice(0, start) const after = current.slice(end) - const prefix = before.length > 0 && !/\s$/.test(before) ? " " : "" - const suffix = after.length > 0 && !/^\s/.test(after) ? " " : "" + const prefix = "" + const suffix = after.length > 0 ? (/^\s/.test(after) ? "" : " ") : " " const nextValue = `${before}${prefix}${text}${suffix}${after}` - const cursor = before.length + prefix.length + text.length + const cursor = before.length + prefix.length + text.length + suffix.length options.setPrompt(nextValue) if (textarea) { setTimeout(() => { textarea.focus() textarea.setSelectionRange(cursor, cursor) + if (wasCursorAtEnd || wasScrolledToBottom) { + textarea.scrollTop = textarea.scrollHeight + } }, 0) } } diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 04a32613..bcc2b9cc 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -79,11 +79,17 @@ export const SessionView: Component = (props) => { requestAnimationFrame(() => scrollToBottomHandle?.()) }) } - createEffect(() => { - if (!props.isActive) return - if (!shouldScrollToBottomOnActivate()) return - scheduleScrollToBottom() - }) + createEffect( + on( + () => props.isActive, + (isActive, wasActive) => { + if (!isActive) return + if (wasActive === true) return + if (!shouldScrollToBottomOnActivate()) return + scheduleScrollToBottom() + }, + ), + ) createEffect( on( @@ -332,16 +338,11 @@ export const SessionView: Component = (props) => { loading={messagesLoading()} onRevert={handleRevert} onDeleteMessagesUpTo={handleDeleteMessagesUpTo} - onFork={handleFork} - isActive={props.isActive} - registerScrollToBottom={(fn) => { - scrollToBottomHandle = fn - if (props.isActive) { - if (shouldScrollToBottomOnActivate()) { - scheduleScrollToBottom() - } - } - }} + onFork={handleFork} + isActive={props.isActive} + registerScrollToBottom={(fn) => { + scrollToBottomHandle = fn + }} diff --git a/packages/ui/src/components/settings-screen.tsx b/packages/ui/src/components/settings-screen.tsx index b82462c9..42cf490e 100644 --- a/packages/ui/src/components/settings-screen.tsx +++ b/packages/ui/src/components/settings-screen.tsx @@ -1,5 +1,5 @@ import { Dialog } from "@kobalte/core/dialog" -import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, X } from "lucide-solid" +import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, Globe, X } from "lucide-solid" import { createMemo, For, type Component } from "solid-js" import { useI18n } from "../lib/i18n" import { @@ -14,6 +14,7 @@ import { NotificationsSettingsSection } from "./settings/notifications-settings- import { OpenCodeSettingsSection } from "./settings/opencode-settings-section" import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section" import { SpeechSettingsSection } from "./settings/speech-settings-section" +import { SideCarsSettingsSection } from "./settings/sidecars-settings-section" export const SettingsScreen: Component = () => { const { t } = useI18n() @@ -23,6 +24,7 @@ export const SettingsScreen: Component = () => { { id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") }, { id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") }, { id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") }, + { id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") }, { id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") }, ]) @@ -34,6 +36,8 @@ export const SettingsScreen: Component = () => { return case "speech": return + case "sidecars": + return case "opencode": return case "appearance": diff --git a/packages/ui/src/components/settings/opencode-settings-section.tsx b/packages/ui/src/components/settings/opencode-settings-section.tsx index 8af940eb..8c0bc01b 100644 --- a/packages/ui/src/components/settings/opencode-settings-section.tsx +++ b/packages/ui/src/components/settings/opencode-settings-section.tsx @@ -1,14 +1,30 @@ -import { createEffect, createSignal, type Component } from "solid-js" -import { Terminal } from "lucide-solid" +import { Select } from "@kobalte/core/select" +import { createEffect, createMemo, createSignal, type Component } from "solid-js" +import { ChevronDown, Terminal } from "lucide-solid" import OpenCodeBinarySelector from "../opencode-binary-selector" import EnvironmentVariablesEditor from "../environment-variables-editor" import { useConfig } from "../../stores/preferences" +import type { ServerLogLevel } from "../../stores/preferences" import { useI18n } from "../../lib/i18n" +type LogLevelOption = { + value: ServerLogLevel + label: string +} + export const OpenCodeSettingsSection: Component = () => { const { t } = useI18n() - const { serverSettings, updateLastUsedBinary } = useConfig() + const { serverSettings, updateLastUsedBinary, updateLogLevel } = useConfig() const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") + const logLevelOptions = createMemo(() => [ + { value: "DEBUG", label: t("settings.opencode.logLevel.option.debug") }, + { value: "INFO", label: t("settings.opencode.logLevel.option.info") }, + { value: "WARN", label: t("settings.opencode.logLevel.option.warn") }, + { value: "ERROR", label: t("settings.opencode.logLevel.option.error") }, + ]) + const selectedLogLevel = createMemo( + () => logLevelOptions().find((option) => option.value === serverSettings().logLevel) ?? logLevelOptions()[0], + ) createEffect(() => { const binary = serverSettings().opencodeBinary || "opencode" @@ -37,6 +53,60 @@ export const OpenCodeSettingsSection: Component = () => {
+
+
+
+

{t("settings.opencode.logLevel.title")}

+

{t("settings.opencode.logLevel.subtitle")}

+
+ {t("settings.scope.server")} +
+
+
+
+
{t("settings.opencode.logLevel.selector.title")}
+
{t("settings.opencode.logLevel.selector.subtitle")}
+
+ + value={selectedLogLevel()} + onChange={(option) => { + if (!option) return + updateLogLevel(option.value) + }} + options={logLevelOptions()} + optionValue="value" + optionTextValue="label" + itemComponent={(itemProps) => ( + + {itemProps.item.rawValue.label} + + )} + > + +
+ > + {(state) => ( + + {state.selectedOption()?.label} + + )} + +
+ + + +
+ + + + + + + +
+
+
+
diff --git a/packages/ui/src/components/settings/sidecars-settings-section.tsx b/packages/ui/src/components/settings/sidecars-settings-section.tsx new file mode 100644 index 00000000..c70d72b6 --- /dev/null +++ b/packages/ui/src/components/settings/sidecars-settings-section.tsx @@ -0,0 +1,201 @@ +import { createMemo, createSignal, For, Show, onMount, type Component } from "solid-js" +import { Globe, Loader2, Plus, Trash2 } from "lucide-solid" +import { useI18n } from "../../lib/i18n" +import { serverApi } from "../../lib/api-client" +import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../../stores/sidecars" + +function deriveSidecarId(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-|-$/g, "") +} + +export const SideCarsSettingsSection: Component = () => { + const { t } = useI18n() + const [name, setName] = createSignal("") + const [port, setPort] = createSignal("3000") + const [insecure, setInsecure] = createSignal(false) + const [prefixMode, setPrefixMode] = createSignal<"strip" | "preserve">("strip") + const [busyId, setBusyId] = createSignal(null) + const [creating, setCreating] = createSignal(false) + const [formError, setFormError] = createSignal(null) + const [actionError, setActionError] = createSignal(null) + + onMount(() => { + void ensureSidecarsLoaded() + }) + + const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name))) + const derivedId = createMemo(() => deriveSidecarId(name()) || "your-sidecar") + + async function handleCreate() { + const trimmedName = name().trim() + const nextPort = Number(port()) + if (!trimmedName || !Number.isInteger(nextPort) || nextPort <= 0 || nextPort > 65535) { + setFormError(t("sidecars.form.validation")) + return + } + + setCreating(true) + setFormError(null) + try { + await serverApi.createSidecar({ + kind: "port", + name: trimmedName, + port: nextPort, + insecure: insecure(), + prefixMode: prefixMode(), + }) + setName("") + setPort("3000") + setInsecure(false) + setPrefixMode("strip") + } catch (error) { + setFormError(error instanceof Error ? error.message : String(error)) + } finally { + setCreating(false) + } + } + + async function handleDelete(id: string) { + setBusyId(id) + setActionError(null) + try { + await serverApi.deleteSidecar(id) + } catch (error) { + setActionError(error instanceof Error ? error.message : String(error)) + } finally { + setBusyId(null) + } + } + + return ( +
+
+
+
+ +
+

{t("settings.section.sidecars.title")}

+

{t("settings.section.sidecars.subtitle")}

+
+
+ {t("settings.scope.server")} +
+ +
+
+
+
{t("sidecars.form.name")}
+
{t("sidecars.basePath")}: /sidecars/{derivedId()}
+
+ { + setFormError(null) + setName(event.currentTarget.value) + }} + /> +
+ +
+
+
{t("sidecars.form.port")}
+
127.0.0.1
+
+ { + setFormError(null) + setPort(event.currentTarget.value) + }} + inputMode="numeric" + /> +
+ +
+
+
{t("sidecars.form.protocol")}
+
{t("sidecars.form.protocol.help")}
+
+ +
+ +
+
+
{t("sidecars.form.prefixMode")}
+
{t("sidecars.form.prefixMode.help")}
+
+ +
+ + +
{formError()}
+
+ +
+ +
+
+
+ +
+
+
+

{t("sidecars.settings.listTitle")}

+

{t("sidecars.settings.listSubtitle")}

+
+
+ +
+ +
{actionError()}
+
+ + {t("sidecars.picker.loading")}
}> + 0} fallback={
{t("sidecars.settings.empty")}
}> + + {(sidecar) => ( +
+
+
{sidecar.name}
+
+ {t("sidecars.kind.port")} · {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port} +
+
+ {t("sidecars.basePath")}: /sidecars/{sidecar.id} · {t(`sidecars.form.prefixMode.${sidecar.prefixMode}`)} +
+
+ +
+ {t(`sidecars.status.${sidecar.status}`)} + +
+
+ )} +
+
+ +
+
+
+ ) +} diff --git a/packages/ui/src/components/settings/speech-settings-card.tsx b/packages/ui/src/components/settings/speech-settings-card.tsx index e847113d..6a196315 100644 --- a/packages/ui/src/components/settings/speech-settings-card.tsx +++ b/packages/ui/src/components/settings/speech-settings-card.tsx @@ -334,7 +334,7 @@ const Field: Component<{
{props.label}
{props.caption}
-
+
{props.icon} {props.label}
{props.caption}
-
+
diff --git a/packages/ui/src/components/sidecar-picker-dialog.tsx b/packages/ui/src/components/sidecar-picker-dialog.tsx new file mode 100644 index 00000000..be2ce57f --- /dev/null +++ b/packages/ui/src/components/sidecar-picker-dialog.tsx @@ -0,0 +1,82 @@ +import { Dialog } from "@kobalte/core/dialog" +import { For, Show, createEffect, createMemo, type Component } from "solid-js" +import { Globe, Square } from "lucide-solid" +import { useI18n } from "../lib/i18n" +import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../stores/sidecars" + +interface SideCarPickerDialogProps { + open: boolean + onClose: () => void + onOpenSidecar: (sidecarId: string) => void | Promise +} + +export const SideCarPickerDialog: Component = (props) => { + const { t } = useI18n() + const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name))) + + createEffect(() => { + if (props.open) { + void ensureSidecarsLoaded() + } + }) + + return ( + !open && props.onClose()}> + + +
+ +
+ {t("sidecars.picker.title")} + + {t("sidecars.picker.subtitle")} + +
+ +
+ {t("sidecars.picker.loading")}
}> + 0} fallback={
{t("sidecars.picker.empty")}
}> + + {(sidecar) => ( + + )} + +
+ +
+ +
+ +
+ +
+ + + ) +} diff --git a/packages/ui/src/components/sidecar-view.tsx b/packages/ui/src/components/sidecar-view.tsx new file mode 100644 index 00000000..f326e5f4 --- /dev/null +++ b/packages/ui/src/components/sidecar-view.tsx @@ -0,0 +1,197 @@ +import { ArrowLeft, ArrowRight, RefreshCw } from "lucide-solid" +import { createEffect, createMemo, createSignal, type Component } from "solid-js" +import type { SideCarTabRecord } from "../stores/sidecars" +import { useI18n } from "../lib/i18n" + +interface SideCarViewProps { + tab: SideCarTabRecord +} + +export const SideCarView: Component = (props) => { + const { t } = useI18n() + const [frameSrc, setFrameSrc] = createSignal(props.tab.shellUrl) + const [pathInput, setPathInput] = createSignal("/") + let iframeRef: HTMLIFrameElement | undefined + + const lockedBaseLabel = createMemo(() => { + const hostLabel = props.tab.port ? `${props.tab.name}:${props.tab.port}` : props.tab.name + if (props.tab.prefixMode === "preserve") { + return `${hostLabel}${props.tab.proxyBasePath}` + } + return hostLabel + }) + + const getEditablePathFromUrl = (url: string): string => { + try { + const parsed = new URL(url, window.location.origin) + const basePath = props.tab.proxyBasePath + let pathname = parsed.pathname + + if (basePath && pathname.startsWith(basePath)) { + pathname = pathname.slice(basePath.length) || "/" + } + + if (!pathname.startsWith("/")) { + pathname = `/${pathname}` + } + + return `${pathname}${parsed.search}${parsed.hash}` + } catch { + return "/" + } + } + + const buildNormalizedTargetUrl = (rawInput: string): string => { + const trimmed = rawInput.trim() + const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}` + const parsed = new URL(withLeadingSlash || "/", window.location.origin) + + const safeSegments: string[] = [] + for (const segment of parsed.pathname.split("/")) { + if (!segment || segment === ".") { + continue + } + if (segment === "..") { + if (safeSegments.length > 0) { + safeSegments.pop() + } + continue + } + safeSegments.push(segment) + } + + const normalizedPath = `/${safeSegments.join("/")}` || "/" + const basePath = props.tab.proxyBasePath + return `${basePath}${normalizedPath}${parsed.search}${parsed.hash}` + } + + const syncPathInputFromFrame = () => { + try { + const currentHref = iframeRef?.contentWindow?.location.href + if (!currentHref) { + return + } + setPathInput(getEditablePathFromUrl(currentHref)) + } catch { + setPathInput(getEditablePathFromUrl(frameSrc())) + } + } + + createEffect(() => { + setFrameSrc(props.tab.shellUrl) + setPathInput(getEditablePathFromUrl(props.tab.shellUrl)) + }) + + const handleBack = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + + try { + const frameWindow = iframeRef?.contentWindow + if (!frameWindow) { + return + } + + if (frameWindow.history.length <= 1) { + return + } + + frameWindow.focus() + frameWindow.history.go(-1) + } catch { + // Ignore navigation errors from pages that do not expose history access. + } + } + + const handleRefresh = () => { + try { + iframeRef?.contentWindow?.location.reload() + return + } catch { + // Fall back to resetting the iframe source if the frame cannot be reloaded directly. + } + + setFrameSrc("about:blank") + requestAnimationFrame(() => setFrameSrc(props.tab.shellUrl)) + } + + const handleGo = (event?: Event) => { + event?.preventDefault() + + const nextUrl = buildNormalizedTargetUrl(pathInput()) + setFrameSrc(nextUrl) + setPathInput(getEditablePathFromUrl(nextUrl)) + } + + return ( +
+
+ + +
+ {lockedBaseLabel()} +
+
handleGo(event)}> + setPathInput(event.currentTarget.value)} + spellcheck={false} + autocomplete="off" + autocorrect="off" + autocapitalize="off" + aria-label={t("sidecars.path")} + /> + +
+
+