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.  -_Manage multiple OpenCode sessions side-by-side._ - -📸 More Screenshots +--- - -_Global command palette for keyboard-first control._ +## Features - -_Rich media previews for images and assets._ +- **🚀 Multi-Instance Workspace** +- **🌐 Remote Access** +- **🧠 Session Management** +- **🎙️ Voice Input & Speech** +- **🌳 Git Worktrees** +- **💬 Rich Message Experience** +- **⌨️ Command Palette** +- **📁 File System Browser** +- **🔐 Authentication & Security** +- **🔔 Notifications** +- **🎨 Theming** +- **🌍 Internationalization** - -_Browser support via CodeNomad Server._ - - +--- ## Getting Started -Choose the way that fits your workflow: +### 🖥️ Desktop App -### 🖥️ Desktop App (Recommended) -The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window. +Available as both Electron and Tauri builds — choose based on your preference. -- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases). -- **Run**: Install and launch like any other app. +Download the latest installer for your platform from [Releases](https://github.com/shantur/CodeNomad/releases). -### 🦀 Tauri App (Experimental) -We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development. - -- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases). -- **Source**: Check out `packages/tauri-app` if you're interested in contributing. +| Platform | Formats | +|----------|---------| +| macOS | DMG, ZIP (Universal: Intel + Apple Silicon) | +| Windows | NSIS Installer, ZIP (x64, ARM64) | +| Linux | AppImage, deb, tar.gz (x64, ARM64) | ### 💻 CodeNomad Server -Run CodeNomad as a local server and access it via your web browser. Perfect for remote development (SSH/VPN) or running as a service. + +Run as a local server and access via browser. Perfect for remote development. ```bash npx @neuralnomads/codenomad --launch ``` -Full server/CLI documentation (flags + env vars, TLS, auth, remote access): -- [packages/server/README.md](packages/server/README.md) - -To see all available options: - -```bash -npx @neuralnomads/codenomad --help -``` +See [Server Documentation](packages/server/README.md) for flags, TLS, auth, and remote access. ### 🧪 Dev Releases -Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch. + +Bleeding-edge builds from the `dev` branch: ```bash npx @neuralnomads/codenomad-dev --launch ``` -## Highlights - -- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs. -- **Long-Session Native**: Scroll through massive transcripts without hitches. -- **Command Palette**: A single global palette to jump tabs, launch tools, and control everything. -- **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow. +--- ## Requirements -- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`. -- **Node.js 18+**: Required if running the CLI server or building from source. +- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH` +- **Node.js 18+** — for server mode or building from source -## Troubleshooting +--- -### macOS says the app is damaged -If macOS reports that "CodeNomad.app is damaged and can't be opened," Gatekeeper flagged the download because the app is not yet notarized. You can clear the quarantine flag after moving CodeNomad into `/Applications`: +## Development -```bash -xattr -l /Applications/CodeNomad.app -xattr -dr com.apple.quarantine /Applications/CodeNomad.app -``` - -After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it. - -### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately -On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away. - -Try running with one of these environment variables: - -```bash -# Most reliable workaround (can reduce rendering performance) -WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad - -# Alternative for some Wayland setups -__NV_DISABLE_EXPLICIT_SYNC=1 codenomad -``` - -If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`: - -```bash -#!/bin/bash -export WEBKIT_DISABLE_DMABUF_RENDERER=1 -exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@" -``` - -Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702 - -## Architecture & Development - -CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation: +CodeNomad is a monorepo built with: | Package | Description | |---------|-------------| -| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. | -| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. | -| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. | +| **[packages/server](packages/server/README.md)** | Core logic & CLI — workspaces, OpenCode proxy, API, auth, speech | +| **[packages/ui](packages/ui/README.md)** | SolidJS frontend — reactive, fast, beautiful | +| **[packages/electron-app](packages/electron-app/README.md)** | Desktop shell — process management, IPC, native dialogs | +| **[packages/tauri-app](packages/tauri-app)** | Tauri desktop shell (experimental) | -### Quick Build -To build the Desktop App from source: +### Quick Start -1. Clone the repo. -2. Run `npm install` (requires pnpm or npm 7+ for workspaces). -3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`. +```bash +git clone https://github.com/NeuralNomadsAI/CodeNomad.git +cd CodeNomad +npm install +npm run dev +``` -[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date) +--- +## Troubleshooting + + +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 + +[](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) => ( + + + setActiveTab("local")} + > - + {t("folderSelection.recent.title")} + + + {t( + folders().length === 1 + ? "folderSelection.recent.subtitle.one" + : "folderSelection.recent.subtitle.other", + { count: folders().length }, + )} + + + setActiveTab("servers")} + > + + {t("folderSelection.tabs.servers")} + + + {t("folderSelection.servers.count", { count: remoteServers().length })} + + + + + + 0} + fallback={ + + + + + {t("folderSelection.servers.empty.title")} + {t("folderSelection.servers.empty.description")} handleFolderSelect(folder.path)} - onMouseEnter={() => { - if (isLoading()) return - setFocusMode("recent") - setSelectedIndex(index()) - }} + type="button" + class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4" + onClick={openServerDialog} > - - - - - - {splitFolderPath(folder.path).baseName} - - - - - {getDisplayPath(folder.path)} - - {formatRelativeTime(folder.lastAccessed)} - - - - ↵ - - - - handleRemove(folder.path, e)} - disabled={isLoading()} - class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded" - title={t("folderSelection.recent.remove")} - > - + + {t("folderSelection.actions.connectButton")} + } + > + (recentListRef = el)} + > + + {(server, index) => ( + + + void handleConnectSavedServer(server.id)} + onMouseEnter={() => { + setFocusMode("recent") + setSelectedIndex(index()) + }} + > + + + + + {server.name} + + + {server.baseUrl} + + + ↵}> + + + + + removeRemoteServerProfile(server.id)} + class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded" + title={t("folderSelection.servers.remove")} + > + + + + + )} + - )} - - + + } + > + 0} + fallback={ + + + + + {t("folderSelection.empty.title")} + {t("folderSelection.empty.description")} + + } + > + (recentListRef = el)} + > + + {(folder, index) => ( + + + handleFolderSelect(folder.path)} + onMouseEnter={() => { + if (isLoading()) return + setFocusMode("recent") + setSelectedIndex(index()) + }} + > + + + + + + {splitFolderPath(folder.path).baseName} + + + + + {getDisplayPath(folder.path)} + + {formatRelativeTime(folder.lastAccessed)} + + + + ↵ + + + + handleRemove(folder.path, e)} + disabled={isLoading()} + class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded" + title={t("folderSelection.recent.remove")} + > + + + + + )} + + + + - @@ -567,11 +841,11 @@ const FolderSelectionView: Component = (props) => { - {t("folderSelection.browse.title")} - {t("folderSelection.browse.subtitle")} + {t("folderSelection.actions.title")} + {t("folderSelection.actions.subtitle")} - + void handleBrowse()} disabled={props.isLoading} @@ -588,6 +862,27 @@ const FolderSelectionView: Component = (props) => { + + props.onOpenSidecar?.()} + class="button-primary mt-3 w-full flex items-center justify-center text-sm" + > + + + {t("folderSelection.sidecars.button")} + + + + + + + {t("folderSelection.actions.connectButton")} + + {/* 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")} + + + + + {t("folderSelection.servers.dialog.name")} + setServerName(event.currentTarget.value)} + placeholder={t("folderSelection.servers.dialog.namePlaceholder")} + /> + + + + {t("folderSelection.servers.dialog.url")} + setServerUrl(event.currentTarget.value)} + placeholder={t("folderSelection.servers.dialog.urlPlaceholder")} + /> + + + + setSkipTlsVerify(event.currentTarget.checked)} + /> + {t("folderSelection.servers.dialog.skipTls")} + + + + {(message) => {message()}} + + + + setIsServerDialogOpen(false)}> + {t("folderSelection.servers.dialog.cancel")} + + void handleSaveServer(false)} + > + {t("folderSelection.servers.dialog.save")} + + void handleSaveServer(true)} + > + {t("folderSelection.servers.dialog.connect")}}> + + + {t("folderSelection.servers.dialog.connecting")} + + + + + + + + > ) } 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)} + /> + ) : ( + + props.onSelect(tab.id)}> + {tab.sidecarTab.name} + + props.onClose(tab.id)} aria-label={tab.sidecarTab.name}> + × + + + )} = (props) => { - 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) => ( + + + + + + api.scrollToTop()} + aria-label={t("messageSection.scroll.toFirstAriaLabel")} + > + + ↑ + + + + + api.scrollToBottom()} + aria-label={t("messageSection.scroll.toLatestAriaLabel")} + > + + ↓ + + + + + )} 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 + } + return segment.shortLabel ?? getToolIcon("tool") + } + if (segment.type === "compaction") { + return + } + if (segment.type === "user") { + return + } + return + } + + return ( + + + registerButtonRef(segment.id, el)} + type="button" + data-variant={segment.variant} + class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`} + data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined} + aria-current={isActive() ? "true" : undefined} + aria-hidden={isHidden() ? "true" : undefined} + onClick={(event) => { + if (wasLongPress) { + wasLongPress = false + return + } + + const btn = buttonRefs.get(segment.id) + const stableBtn = renderVirtualizedTimeline() ? null : btn + let anchorOffset: number | null = null + if (stableBtn && scrollContainerRef) { + anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop + } + + const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0 + + if (event.shiftKey) { + props.onSelectRange?.(segment.id) + } else if (event.ctrlKey || event.metaKey) { + props.onToggleSelection?.(segment.id) + } else if (isMultiSelectActive) { + props.onSegmentClick?.(segment) + } else { + props.onSegmentClick?.(segment) + } + + if (anchorOffset !== null && stableBtn && scrollContainerRef) { + const desired = stableBtn.offsetTop - anchorOffset + if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) { + scrollContainerRef.scrollTop = desired + } + } + }} + onPointerDown={(e) => handlePointerDown(segment, e)} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} + onPointerMove={handlePointerMove} + onContextMenu={handleContextMenu} + onMouseEnter={(event) => handleMouseEnter(segment, event)} + onMouseLeave={handleMouseLeave} + > + {segment.label} + {shortLabelContent()} + + + ) + }} + + )} + > + + {(segment, index) => { + const segIndex = () => index() const isActive = () => props.activeSegmentId === segment.id const isSelected = () => props.selectedIds?.().has(segment.id) - - const isDeleteHovered = () => { - const hover = deleteHover() as DeleteHoverState - if (hover.kind === "message") { - return hover.messageId === segment.messageId - } - - if (hover.kind === "deleteUpTo") { - const indexMap = messageIdToSessionIndex() - const targetIndex = indexMap.get(hover.messageId) - if (targetIndex === undefined) return false - const segmentIndex = indexMap.get(segment.messageId) - if (segmentIndex === undefined) return false - return segmentIndex >= targetIndex - } - - return false - } - - const isDeleteSelected = () => { - const selected = props.selectedMessageIds?.() - if (!selected) return false - return selected.has(segment.messageId) - } - - const hasActivePermission = () => { - if (segment.type !== "tool") return false - const partIds = segment.toolPartIds ?? [] - if (partIds.length === 0) return false - for (const partId of partIds) { - const permissionState = store().getPermissionState(segment.messageId, partId) - if (permissionState?.active) return true - } - return false - } - - const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false - const isHidden = () => - segment.type === "tool" && - !(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered() || isDeleteSelected()) + const state = () => segmentStateFor(segment.id) + const isDeleteHovered = () => state().deleteHovered + const isDeleteSelected = () => state().deleteSelected + const hasActivePermission = () => state().hasActivePermission + const isHidden = () => state().hidden // Group visual indicators: tools belong to the same message as their // assistant. Uses messageId for correctness (not positional adjacency). @@ -744,18 +962,10 @@ const MessageTimeline: Component = (props) => { if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent" return "none" } - const isGroupStart = () => { - if (segment.type !== "tool") return false - const idx = segIndex() - const prev = idx > 0 ? props.segments[idx - 1] : null - // First tool in the message's run: either nothing before, or previous - // segment is from a different message or is not a tool. - return !prev || prev.type !== "tool" || prev.messageId !== segment.messageId - } const shortLabelContent = () => { if (segment.type === "tool") { - if (hasActivePermission()) { + if (hasActivePermission()) { return } return segment.shortLabel ?? getToolIcon("tool") @@ -765,95 +975,92 @@ const MessageTimeline: Component = (props) => { } if (segment.type === "user") { return - } - return - } + } + return + } - return ( - registerButtonRef(segment.id, el)} - type="button" - data-variant={segment.variant} - class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""} ${isGroupStart() ? "message-timeline-group-start" : ""}`} - - data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined} - - aria-current={isActive() ? "true" : undefined} - aria-hidden={isHidden() ? "true" : undefined} - onClick={(event) => { - if (wasLongPress) { - wasLongPress = false - return - } - - // Capture scroll anchor before selection changes may toggle - // tool segment visibility, which shifts timeline layout. - const btn = buttonRefs.get(segment.id) - let anchorOffset: number | null = null - if (btn && scrollContainerRef) { - anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop - } - - const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0 - - if (event.shiftKey) { - props.onSelectRange?.(segment.id) - } else if (event.ctrlKey || event.metaKey) { - props.onToggleSelection?.(segment.id) - } else if (isMultiSelectActive) { - // In selection mode, plain click scrolls to the message - // instead of clearing. Selection is cleared by clicking - // anywhere inside the chat container or pressing Esc. - props.onSegmentClick?.(segment) - } else { - props.onSegmentClick?.(segment) - } - - // Restore scroll anchor: keep the clicked badge at the same - // visual position after hidden tools appear or disappear. - if (anchorOffset !== null && btn && scrollContainerRef) { - const desired = btn.offsetTop - anchorOffset - if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) { - scrollContainerRef.scrollTop = desired + return ( + + + { + if (wasLongPress) { + wasLongPress = false + return } - } - }} - onPointerDown={(e) => handlePointerDown(segment, e)} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerUp} - onPointerMove={handlePointerMove} - onContextMenu={handleContextMenu} - onMouseEnter={(event) => handleMouseEnter(segment, event)} - onMouseLeave={handleMouseLeave} - > - {segment.label} - {shortLabelContent()} - - ) - }} - + + const btn = buttonRefs.get(segment.id) + const stableBtn = renderVirtualizedTimeline() ? null : btn + let anchorOffset: number | null = null + if (stableBtn && scrollContainerRef) { + anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop + } + + const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0 + + if (event.shiftKey) { + props.onSelectRange?.(segment.id) + } else if (event.ctrlKey || event.metaKey) { + props.onToggleSelection?.(segment.id) + } else if (isMultiSelectActive) { + props.onSegmentClick?.(segment) + } else { + props.onSegmentClick?.(segment) + } + + if (anchorOffset !== null && stableBtn && scrollContainerRef) { + const desired = stableBtn.offsetTop - anchorOffset + if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) { + scrollContainerRef.scrollTop = desired + } + } + }} + onPointerDown={(e) => handlePointerDown(segment, e)} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} + onPointerMove={handlePointerMove} + onContextMenu={handleContextMenu} + onMouseEnter={(event) => handleMouseEnter(segment, event)} + onMouseLeave={handleMouseLeave} + > + {segment.label} + {shortLabelContent()} + + + ) + }} + + {(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")} + + setInsecure(event.currentTarget.value === "http") }> + {t("sidecars.form.protocol.https")} + {t("sidecars.form.protocol.http")} + + + + + + {t("sidecars.form.prefixMode")} + {t("sidecars.form.prefixMode.help")} + + setPrefixMode(event.currentTarget.value as "strip" | "preserve") }> + {t("sidecars.form.prefixMode.strip")} + {t("sidecars.form.prefixMode.preserve")} + + + + + {formError()} + + + + void handleCreate()}> + }> + + + {t("sidecars.form.add")} + + + + + + + + + {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}`)} + void handleDelete(sidecar.id)}> + + + + + )} + + + + + + + ) +} 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} - + props.onInput(event.currentTarget.value)} class="selector-input w-full"> {(option) => {option.label}} 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) => ( + void props.onOpenSidecar(sidecar.id)} + > + + + + + + + {sidecar.name} + + {t("sidecars.kind.port")} - {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port} + + {t("sidecars.basePath")}: /sidecars/{sidecar.id} + + + + + {t(`sidecars.status.${sidecar.status}`)} + + + + )} + + + + + + + + {t("sidecars.picker.close")} + + + + + + + ) +} 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")} + /> + + + + + + + + ) +} diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 572d178c..21f2c608 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -1,4 +1,4 @@ -import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js" +import { createSignal, Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js" import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid" import { stringify as stringifyYaml } from "yaml" import { messageStoreBus } from "../stores/message-v2/bus" @@ -44,6 +44,7 @@ import { resolveTitleForTool } from "./tool-call/tool-title" import { getLogger } from "../lib/logger" import { useSpeech } from "../lib/hooks/use-speech" import SpeechActionButton from "./speech-action-button" +import { createFollowScroll } from "../lib/follow-scroll" const log = getLogger("session") @@ -51,8 +52,6 @@ type ToolState = import("@opencode-ai/sdk/v2").ToolState const TOOL_CALL_CACHE_SCOPE = "tool-call" const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48 -const TOOL_SCROLL_INTENT_WINDOW_MS = 600 -const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"]) function makeRenderCacheKey( toolCallId?: string | null, @@ -82,6 +81,27 @@ interface ToolCallProps { forceCollapsed?: boolean } +function ToolStatusIndicator(props: { status: Accessor }) { + const isVisible = (value: string) => props.status() === value + + return ( + + + + + + + + + + + + + + + ) +} + function ToolCallDetails(props: { toolCallMemo: () => ToolCallPart toolState: () => ToolState | undefined @@ -166,179 +186,25 @@ function ToolCallDetails(props: { const [permissionSubmitting, setPermissionSubmitting] = createSignal(false) const [permissionError, setPermissionError] = createSignal(null) - const [scrollContainer, setScrollContainer] = createSignal() - const [bottomSentinel, setBottomSentinel] = createSignal(null) - const [autoScroll, setAutoScroll] = createSignal(true) - const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true) - - let scrollContainerRef: HTMLDivElement | undefined - let detachScrollIntentListeners: (() => void) | undefined - - let pendingScrollFrame: number | null = null - let pendingAnchorScroll: number | null = null - let userScrollIntentUntil = 0 - let lastKnownScrollTop = props.scrollTopSnapshot() - - function restoreScrollPosition(forceBottom = false) { - const container = scrollContainerRef - if (!container) return - if (forceBottom) { - container.scrollTop = container.scrollHeight - lastKnownScrollTop = container.scrollTop - props.setScrollTopSnapshot(lastKnownScrollTop) - } else { - container.scrollTop = lastKnownScrollTop - } - } - - const persistScrollSnapshot = (element?: HTMLElement | null) => { - if (!element) return - lastKnownScrollTop = element.scrollTop - props.setScrollTopSnapshot(lastKnownScrollTop) - } - - function markUserScrollIntent() { - const now = typeof performance !== "undefined" ? performance.now() : Date.now() - userScrollIntentUntil = now + TOOL_SCROLL_INTENT_WINDOW_MS - } - - function hasUserScrollIntent() { - const now = typeof performance !== "undefined" ? performance.now() : Date.now() - return now <= userScrollIntentUntil - } - - function attachScrollIntentListeners(element: HTMLDivElement) { - if (detachScrollIntentListeners) { - detachScrollIntentListeners() - detachScrollIntentListeners = undefined - } - const handlePointerIntent = () => markUserScrollIntent() - const handleKeyIntent = (event: KeyboardEvent) => { - if (TOOL_SCROLL_INTENT_KEYS.has(event.key)) { - markUserScrollIntent() - } - } - element.addEventListener("wheel", handlePointerIntent, { passive: true }) - element.addEventListener("pointerdown", handlePointerIntent) - element.addEventListener("touchstart", handlePointerIntent, { passive: true }) - element.addEventListener("keydown", handleKeyIntent) - detachScrollIntentListeners = () => { - element.removeEventListener("wheel", handlePointerIntent) - element.removeEventListener("pointerdown", handlePointerIntent) - element.removeEventListener("touchstart", handlePointerIntent) - element.removeEventListener("keydown", handleKeyIntent) - } - } - - function scheduleAnchorScroll(immediate = false) { - if (!autoScroll()) return - const sentinel = bottomSentinel() - const container = scrollContainerRef - if (!sentinel || !container) return - if (pendingAnchorScroll !== null) { - cancelAnimationFrame(pendingAnchorScroll) - pendingAnchorScroll = null - } - pendingAnchorScroll = requestAnimationFrame(() => { - pendingAnchorScroll = null - const containerRect = container.getBoundingClientRect() - const sentinelRect = sentinel.getBoundingClientRect() - const delta = sentinelRect.bottom - containerRect.bottom + TOOL_SCROLL_SENTINEL_MARGIN_PX - if (Math.abs(delta) > 1) { - container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" }) - } - lastKnownScrollTop = container.scrollTop - props.setScrollTopSnapshot(lastKnownScrollTop) - }) - } - - function handleScroll() { - const container = scrollContainer() - if (!container) return - if (pendingScrollFrame !== null) { - cancelAnimationFrame(pendingScrollFrame) - } - const isUserScroll = hasUserScrollIntent() - pendingScrollFrame = requestAnimationFrame(() => { - pendingScrollFrame = null - const atBottom = bottomSentinelVisible() - if (isUserScroll) { - if (atBottom) { - if (!autoScroll()) setAutoScroll(true) - } else if (autoScroll()) { - setAutoScroll(false) - } - } - }) - } - - const handleScrollEvent = (event: Event & { currentTarget: HTMLDivElement }) => { - handleScroll() - persistScrollSnapshot(event.currentTarget) - } - - const handleScrollRendered = () => { - requestAnimationFrame(() => { - restoreScrollPosition(autoScroll()) - scheduleAnchorScroll(true) - }) - } - - const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => { - const next = element || undefined - if (next === scrollContainerRef) { - return - } - scrollContainerRef = next - setScrollContainer(scrollContainerRef) - if (scrollContainerRef) { - // Refresh our snapshot on mount (e.g. when remounting after collapse) - lastKnownScrollTop = props.scrollTopSnapshot() - restoreScrollPosition(autoScroll()) - } - } + const followScroll = createFollowScroll({ + getScrollTopSnapshot: props.scrollTopSnapshot, + setScrollTopSnapshot: props.setScrollTopSnapshot, + sentinelMarginPx: TOOL_SCROLL_SENTINEL_MARGIN_PX, + sentinelClassName: "tool-call-scroll-sentinel", + }) const scrollHelpers: ToolScrollHelpers = { registerContainer: (element, options) => { - if (options?.disableTracking) return - initializeScrollContainer(element) - }, - handleScroll: handleScrollEvent, - renderSentinel: (options) => { - if (options?.disableTracking) return null - return + followScroll.registerContainer(element, options) }, + handleScroll: followScroll.handleScroll, + renderSentinel: followScroll.renderSentinel, + restoreAfterRender: followScroll.restoreAfterRender, } - createEffect(() => { - const container = scrollContainer() - if (!container) return - attachScrollIntentListeners(container) - onCleanup(() => { - if (detachScrollIntentListeners) { - detachScrollIntentListeners() - detachScrollIntentListeners = undefined - } - }) - }) - - createEffect(() => { - const container = scrollContainer() - const sentinel = bottomSentinel() - if (!container || !sentinel) return - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.target === sentinel) { - setBottomSentinelVisible(entry.isIntersecting) - } - }) - }, - { root: container, threshold: 0, rootMargin: `0px 0px ${TOOL_SCROLL_SENTINEL_MARGIN_PX}px 0px` }, - ) - observer.observe(sentinel) - onCleanup(() => observer.disconnect()) - }) + const handleScrollRendered = () => { + scrollHelpers.restoreAfterRender() + } createEffect(() => { const permission = permissionDetails() @@ -564,11 +430,13 @@ function ToolCallDetails(props: { partVersion={options.partVersion} instanceId={props.instanceId} sessionId={options.sessionId} + onContentRendered={props.onContentRendered} forceCollapsed={options.forceCollapsed} /> ) }, scrollHelpers, + onContentRendered: props.onContentRendered, } let previousPartVersion: number | undefined @@ -581,12 +449,12 @@ function ToolCallDetails(props: { return } previousPartVersion = version - scheduleAnchorScroll(true) + scrollHelpers.restoreAfterRender() }) createEffect(() => { - if (autoScroll()) { - scheduleAnchorScroll(true) + if (followScroll.autoScroll()) { + scrollHelpers.restoreAfterRender() } }) @@ -634,21 +502,6 @@ function ToolCallDetails(props: { /> ) - onCleanup(() => { - if (pendingScrollFrame !== null) { - cancelAnimationFrame(pendingScrollFrame) - pendingScrollFrame = null - } - if (pendingAnchorScroll !== null) { - cancelAnimationFrame(pendingAnchorScroll) - pendingAnchorScroll = null - } - if (detachScrollIntentListeners) { - detachScrollIntentListeners() - detachScrollIntentListeners = undefined - } - }) - return ( { - const status = toolState()?.status || "" - switch (status) { - case "pending": - return - case "running": - return - case "completed": - return - case "error": - return - default: - return "" - } - } - const statusClass = () => { const status = toolState()?.status || "pending" return `tool-call-status-${status}` @@ -1051,9 +886,7 @@ export default function ToolCall(props: ToolCallProps) { /> - - {statusIcon()} - + diff --git a/packages/ui/src/components/tool-call/ansi-render.tsx b/packages/ui/src/components/tool-call/ansi-render.tsx index 229728af..d7eb2e7d 100644 --- a/packages/ui/src/components/tool-call/ansi-render.tsx +++ b/packages/ui/src/components/tool-call/ansi-render.tsx @@ -1,4 +1,4 @@ -import type { Accessor, JSXElement } from "solid-js" +import { createEffect, onCleanup, type Accessor, type JSXElement } from "solid-js" import type { RenderCache } from "../../types/message" import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi" import { escapeHtml } from "../../lib/text-render-utils" @@ -11,6 +11,97 @@ type CacheHandle = { set(value: unknown): void } +export interface StableAnsiStreamUpdater { + update: (element: HTMLElement, content: string) => void + reset: () => void +} + +export function createStableAnsiStreamUpdater(): StableAnsiStreamUpdater { + const renderer = createAnsiStreamRenderer() + let previousContent = "" + let ansiActive = false + + return { + update(element: HTMLElement, content: string) { + const resetStreaming = !previousContent || !content.startsWith(previousContent) + + if (resetStreaming) { + ansiActive = hasAnsi(content) + renderer.reset() + element.innerHTML = ansiActive ? renderer.render(content) : escapeHtml(content) + previousContent = content + return + } + + const delta = content.slice(previousContent.length) + if (delta.length === 0) { + return + } + + if (!ansiActive && hasAnsi(delta)) { + ansiActive = true + renderer.reset() + element.innerHTML = renderer.render(content) + previousContent = content + return + } + + if (ansiActive) { + const htmlChunk = renderer.render(delta) + if (htmlChunk.length > 0) { + element.insertAdjacentHTML("beforeend", htmlChunk) + } + } else { + const escapedDelta = escapeHtml(delta) + if (escapedDelta.length > 0) { + element.insertAdjacentHTML("beforeend", escapedDelta) + } + } + + previousContent = content + }, + reset() { + previousContent = "" + ansiActive = false + renderer.reset() + }, + } +} + +function StreamingAnsiContent(props: { + html: string + htmlChunk?: string + updateMode: "replace" | "append" | "noop" +}) { + let preRef: HTMLPreElement | undefined + + createEffect(() => { + const element = preRef + if (!element) return + if (props.updateMode === "noop") return + if (props.updateMode === "append") { + if (element.innerHTML.length === 0) { + element.innerHTML = props.html + return + } + const chunk = props.htmlChunk ?? "" + if (chunk.length > 0) { + element.insertAdjacentHTML("beforeend", chunk) + } + return + } + if (element.innerHTML !== props.html) { + element.innerHTML = props.html + } + }) + + onCleanup(() => { + preRef = undefined + }) + + return +} + export function createAnsiContentRenderer(params: { ansiRunningCache: CacheHandle ansiFinalCache: CacheHandle @@ -46,6 +137,8 @@ export function createAnsiContentRenderer(params: { const isRunningVariant = options.variant === "running" const disableScrollTracking = !isRunningVariant const registerRef = disableScrollTracking ? registerUntracked : registerTracked + let updateMode: "replace" | "append" | "noop" = "replace" + let htmlChunk = "" let nextCache: AnsiRenderCache @@ -54,6 +147,7 @@ export function createAnsiContentRenderer(params: { const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource if (resetStreaming) { + updateMode = "replace" const detectedAnsi = hasAnsi(content) if (detectedAnsi) { runningAnsiRenderer.reset() @@ -66,15 +160,21 @@ export function createAnsiContentRenderer(params: { } else { const delta = content.slice(cached.text.length) if (delta.length === 0) { + updateMode = "noop" nextCache = { ...cached, mode } } else if (!cached.hasAnsi && hasAnsi(delta)) { + updateMode = "replace" runningAnsiRenderer.reset() const html = runningAnsiRenderer.render(content) nextCache = { text: content, html, mode, hasAnsi: true } } else if (cached.hasAnsi) { - const htmlChunk = runningAnsiRenderer.render(delta) - nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true } + const appendedHtml = runningAnsiRenderer.render(delta) + updateMode = "append" + htmlChunk = appendedHtml + nextCache = { text: content, html: `${cached.html}${appendedHtml}`, mode, hasAnsi: true } } else { + updateMode = "append" + htmlChunk = escapeHtml(delta) nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false } } } @@ -98,7 +198,7 @@ export function createAnsiContentRenderer(params: { return ( - + {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })} ) diff --git a/packages/ui/src/components/tool-call/diff-render.tsx b/packages/ui/src/components/tool-call/diff-render.tsx index 47bf3880..563fe9d6 100644 --- a/packages/ui/src/components/tool-call/diff-render.tsx +++ b/packages/ui/src/components/tool-call/diff-render.tsx @@ -1,10 +1,13 @@ -import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js" +import { Suspense, createEffect, createMemo, createSignal, lazy, onMount, type Accessor, type JSXElement } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" +import useMediaQuery from "@suid/material/useMediaQuery" +import { AlignJustify, Copy, Split, WrapText } from "lucide-solid" import type { RenderCache } from "../../types/message" import type { DiffViewMode } from "../../stores/preferences" import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types" import { getRelativePath } from "./utils" import { getCacheEntry } from "../../lib/global-cache" +import { copyToClipboard } from "../../lib/clipboard" const LazyToolCallDiffViewer = lazy(() => import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })), @@ -43,6 +46,16 @@ export function createDiffContentRenderer(params: { handleScrollRendered: () => void onContentRendered?: () => void }) { + const compactDiffQuery = useMediaQuery("(max-width: 640px)") + const [mobileModeOverride, setMobileModeOverride] = createSignal(undefined) + const [wordWrapEnabled, setWordWrapEnabled] = createSignal(true) + + createEffect(() => { + if (!compactDiffQuery()) { + setMobileModeOverride(undefined) + } + }) + const registerTracked = (element: HTMLDivElement | null) => { params.scrollHelpers.registerContainer(element) } @@ -58,7 +71,12 @@ export function createDiffContentRenderer(params: { : params.t("toolCall.diff.label")) const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff" const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache - const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode + const preferredMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode + const effectiveMode = () => { + if (!compactDiffQuery()) return preferredMode() + return mobileModeOverride() || "unified" + } + const shouldWrap = () => wordWrapEnabled() const themeKey = params.isDark() ? "dark" : "light" const state = params.toolState() const disableScrollTracking = Boolean( @@ -76,60 +94,94 @@ export function createDiffContentRenderer(params: { } })() - let cachedHtml: string | undefined - const cached = getCacheEntry(cacheEntryParams) - const currentMode = diffMode() - if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) { - cachedHtml = cached.html - } + const currentMode = createMemo(() => effectiveMode()) + const currentWrap = createMemo(() => shouldWrap()) + const cachedHtml = createMemo(() => { + const cached = getCacheEntry(cacheEntryParams) + if ( + cached + && cached.text === payload.diffText + && cached.theme === themeKey + && cached.mode === currentMode() + && cached.wrap === currentWrap() + ) { + return cached.html + } + return undefined + }) const handleModeChange = (mode: DiffViewMode) => { + if (compactDiffQuery()) { + setMobileModeOverride(mode) + } params.setDiffViewMode(mode) } + const nextViewMode = (): DiffViewMode => (currentMode() === "split" ? "unified" : "split") + const viewModeTitle = () => + nextViewMode() === "split" + ? params.t("toolCall.diff.switchToSplit") + : params.t("toolCall.diff.switchToUnified") + const wordWrapTitle = () => + wordWrapEnabled() + ? params.t("toolCall.diff.disableWordWrap") + : params.t("toolCall.diff.enableWordWrap") + const copyPatchTitle = () => params.t("toolCall.diff.copyPatch") + const handleDiffRendered = () => { - if (!disableScrollTracking) { - params.handleScrollRendered() - } + params.handleScrollRendered() params.onContentRendered?.() } return ( - + {toolbarLabel} - + handleModeChange("split")} + class="file-viewer-toolbar-icon-button" + onClick={() => void copyToClipboard(payload.diffText)} + aria-label={copyPatchTitle()} + title={copyPatchTitle()} > - {params.t("toolCall.diff.viewMode.split")} + handleModeChange("unified")} + class="file-viewer-toolbar-icon-button" + onClick={() => handleModeChange(nextViewMode())} + aria-label={viewModeTitle()} + title={viewModeTitle()} > - {params.t("toolCall.diff.viewMode.unified")} + {nextViewMode() === "split" ? : } + + setWordWrapEnabled((enabled) => !enabled)} + aria-label={wordWrapTitle()} + title={wordWrapTitle()} + > + - {cachedHtml ? ( - + {cachedHtml() ? ( + ) : ( {payload.diffText}}> diff --git a/packages/ui/src/components/tool-call/renderers/bash.tsx b/packages/ui/src/components/tool-call/renderers/bash.tsx index 731c912a..e417e582 100644 --- a/packages/ui/src/components/tool-call/renderers/bash.tsx +++ b/packages/ui/src/components/tool-call/renderers/bash.tsx @@ -1,6 +1,107 @@ -import type { ToolRenderer } from "../types" +import { Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js" +import type { ToolState } from "@opencode-ai/sdk/v2" +import type { ToolRenderer, ToolScrollHelpers } from "../types" import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" +import { createStableAnsiStreamUpdater } from "../ansi-render" +import { ansiToHtml, hasAnsi } from "../../../lib/ansi" + +function RunningBashOutput(props: { + content: Accessor + scrollHelpers?: ToolScrollHelpers +}) { + let preRef: HTMLPreElement | undefined + const updater = createStableAnsiStreamUpdater() + + createEffect(() => { + const element = preRef + if (!element) return + updater.update(element, props.content()) + }) + + onCleanup(() => { + preRef = undefined + updater.reset() + }) + + return ( + props.scrollHelpers!.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined} + > + + {props.scrollHelpers?.renderSentinel?.()} + + ) +} + +function BashToolBody(props: { + toolState: Accessor + renderMarkdown: (options: { content: string }) => ReturnType + scrollHelpers?: ToolScrollHelpers +}) { + const state = createMemo(() => props.toolState()) + + const joinedContent = createMemo(() => { + const current = state() + if (!current || current.status === "pending") return "" + + const { input, metadata } = readToolStatePayload(current) + const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : "" + const outputResult = formatUnknown( + isToolStateCompleted(current) + ? current.output + : (isToolStateRunning(current) || isToolStateError(current)) && metadata.output + ? metadata.output + : undefined, + ) + return [command, outputResult?.text].filter(Boolean).join("\n") + }) + + const finalMarkdown = createMemo(() => { + const current = state() + const content = joinedContent() + if (!current || current.status === "pending" || current.status === "running" || content.length === 0) { + return null + } + if (hasAnsi(content)) { + return null + } + return ensureMarkdownContent(content, "bash", true) + }) + + const finalAnsiHtml = createMemo(() => { + const current = state() + const content = joinedContent() + if (!current || current.status === "pending" || current.status === "running" || content.length === 0) { + return null + } + if (!hasAnsi(content)) { + return null + } + return ansiToHtml(content) + }) + + return ( + 0}> + + {(html) => ( + + + + )} + + } + > + + + + ) +} export const bashRenderer: ToolRenderer = { tools: ["bash"], @@ -21,35 +122,7 @@ export const bashRenderer: ToolRenderer = { const timeoutLabel = `${timeout}ms` return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}` }, - renderBody({ toolState, renderMarkdown, renderAnsi }) { - const state = toolState() - if (!state || state.status === "pending") return null - - const { input, metadata } = readToolStatePayload(state) - const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : "" - const outputResult = formatUnknown( - isToolStateCompleted(state) - ? state.output - : (isToolStateRunning(state) || isToolStateError(state)) && metadata.output - ? metadata.output - : undefined, - ) - const parts = [command, outputResult?.text].filter(Boolean) - if (parts.length === 0) return null - - const joined = parts.join("\n") - if (state.status === "running") { - return renderAnsi({ content: joined, variant: "running" }) - } - - const ansiBody = renderAnsi({ content: joined, requireAnsi: true, variant: "final" }) - if (ansiBody) { - return ansiBody - } - - const content = ensureMarkdownContent(joined, "bash", true) - if (!content) return null - - return renderMarkdown({ content }) + renderBody({ toolState, renderMarkdown, scrollHelpers }) { + return }, } diff --git a/packages/ui/src/components/tool-call/renderers/task.tsx b/packages/ui/src/components/tool-call/renderers/task.tsx index e36ed286..0d5a8724 100644 --- a/packages/ui/src/components/tool-call/renderers/task.tsx +++ b/packages/ui/src/components/tool-call/renderers/task.tsx @@ -1,4 +1,4 @@ -import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js" +import { For, Index, Show, createEffect, createMemo, createSignal, untrack } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolRenderer } from "../types" import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils" @@ -145,7 +145,7 @@ export const taskRenderer: ToolRenderer = { const { input } = readToolStatePayload(state) return describeTaskTitle(input) }, - renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) { + renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) { const store = messageStoreBus.getOrCreate(instanceId) const [requestedChildLoad, setRequestedChildLoad] = createSignal(false) @@ -360,6 +360,14 @@ export const taskRenderer: ToolRenderer = { }) }) + createEffect(() => { + const childCount = childToolKeys().length + const legacyCount = legacyItems().length + if (childCount === 0 && legacyCount === 0) return + scrollHelpers?.restoreAfterRender() + onContentRendered?.() + }) + return ( @@ -443,12 +451,12 @@ export const taskRenderer: ToolRenderer = { } > - + {(key) => ( {(render) => ( )} - + {scrollHelpers?.renderSentinel?.()} diff --git a/packages/ui/src/components/tool-call/types.ts b/packages/ui/src/components/tool-call/types.ts index e0d994de..a92d7504 100644 --- a/packages/ui/src/components/tool-call/types.ts +++ b/packages/ui/src/components/tool-call/types.ts @@ -47,6 +47,7 @@ export interface ToolScrollHelpers { registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void handleScroll(event: Event & { currentTarget: HTMLDivElement }): void renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null + restoreAfterRender(): void } export interface ToolRendererContext { @@ -74,6 +75,7 @@ export interface ToolRendererContext { forceCollapsed?: boolean }) => JSXElement | null scrollHelpers?: ToolScrollHelpers + onContentRendered?: () => void } export interface ToolRenderer { diff --git a/packages/ui/src/components/unified-picker.tsx b/packages/ui/src/components/unified-picker.tsx index 76d7cb7a..b06e54a7 100644 --- a/packages/ui/src/components/unified-picker.tsx +++ b/packages/ui/src/components/unified-picker.tsx @@ -79,6 +79,7 @@ interface UnifiedPickerProps { mode?: "mention" | "command" onSelect: (item: PickerItem, action: PickerSelectAction) => void onClose: () => void + onSubmitWithoutSelection?: () => void agents: Agent[] commands?: SDKCommand[] instanceClient: OpencodeClient | null @@ -404,6 +405,8 @@ const UnifiedPicker: Component = (props) => { if (selected) { const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter" props.onSelect(selected, action) + } else if (e.key === "Enter" && mode() === "mention") { + props.onSubmitWithoutSelection?.() } } else if (e.key === "Escape") { e.preventDefault() diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index a0dce708..c26ad97d 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -2,6 +2,8 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, import { Virtualizer, type VirtualizerHandle } from "virtua/solid" const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48 +const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8 +const DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX = 128 const USER_SCROLL_INTENT_WINDOW_MS = 600 const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"]) @@ -85,6 +87,28 @@ export interface VirtualFollowListProps { */ followToken?: Accessor + /** + * Optional item key whose geometry can temporarily hold auto-follow when the + * rendered item grows taller than the viewport and reaches the top edge. + */ + autoPinHoldTargetKey?: Accessor + + /** + * Optional resolver for the specific element inside an item wrapper that + * should be measured for hold-target geometry. + */ + resolveAutoPinHoldElement?: (itemWrapper: HTMLDivElement, key: string) => HTMLElement | null | undefined + + /** + * Top-edge threshold for the hold target in pixels. + */ + autoPinHoldTopThresholdPx?: number + + /** + * Temporarily suppress automatic bottom pinning while keeping follow mode enabled. + */ + suspendAutoPinToBottom?: Accessor + /** * Optional hooks to render content inside the scroll container. * Useful for empty/loading states that should scroll with the list. @@ -130,13 +154,19 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true) const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true) const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true) + const externalSuspendAutoPinToBottom = () => (props.suspendAutoPinToBottom ? props.suspendAutoPinToBottom() : false) + const holdTargetKey = () => (props.autoPinHoldTargetKey ? props.autoPinHoldTargetKey() : null) + const holdTargetTopThresholdPx = () => props.autoPinHoldTopThresholdPx ?? DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll())) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [activeKey, setActiveKey] = createSignal(null) + const [heldItemCount, setHeldItemCount] = createSignal(null) + const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || heldItemCount() !== null const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0)) + const itemElements = new Map() let userScrollIntentUntil = 0 let lastUserScrollIntentDirection: "up" | "down" | null = null @@ -220,6 +250,9 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { // Sync autoScroll state based on scroll position if it was a user scroll if (hasUserScrollIntent()) { + if (atBottom && heldItemCount() !== null) { + setHeldItemCount(null) + } if (atBottom && !autoScroll()) { setAutoScroll(true) } else if (!atBottom && autoScroll()) { @@ -253,6 +286,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { } } updateScrollButtons() + updateAutoPinHold() props.onScroll?.() // Find active key (roughly the first visible item) @@ -270,6 +304,68 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { } } + function registerItemElement(key: string, element: HTMLDivElement | null | undefined) { + if (!element) { + itemElements.delete(key) + return + } + itemElements.set(key, element) + } + + function getAnchorIdForKey(key: string) { + return props.getAnchorId ? props.getAnchorId(key) : key + } + + function updateAutoPinHold() { + const element = scrollElement() + const itemCount = props.items().length + const heldCount = heldItemCount() + if (!element) return + + if (heldCount !== null) { + if (itemCount > heldCount) { + setHeldItemCount(null) + if (autoScroll()) { + requestAnimationFrame(() => { + if (!autoScroll()) return + scrollToBottom(false) + }) + } + return + } + + if (itemCount < heldCount) { + setHeldItemCount(null) + return + } + + return + } + + if (!autoScroll()) return + if (externalSuspendAutoPinToBottom()) return + + const targetKey = holdTargetKey() + if (!targetKey) return + + const itemWrapper = itemElements.get(targetKey) + if (!itemWrapper) return + const target = props.resolveAutoPinHoldElement?.(itemWrapper, targetKey) ?? itemWrapper + + const containerRect = element.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const relativeTop = targetRect.top - containerRect.top + const exceedsViewport = targetRect.height > element.clientHeight + + if ( + exceedsViewport && + relativeTop <= holdTargetTopThresholdPx() && + relativeTop >= holdTargetTopThresholdPx() - DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX + ) { + setHeldItemCount(itemCount) + } + } + const api: VirtualFollowListApi = { scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true), scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }), @@ -281,7 +377,9 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" }) }, notifyContentRendered: () => { - if (autoScroll()) { + updateAutoPinHold() + if (heldItemCount() !== null) return + if (autoScroll() && !effectiveSuspendAutoPinToBottom()) { scrollToBottom(true) } }, @@ -294,9 +392,15 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { createEffect(() => props.registerApi?.(api)) createEffect(() => props.registerState?.(state)) + createEffect(on(() => props.resetKey?.(), () => { + itemElements.clear() + setHeldItemCount(null) + })) + // Handle autoScroll (Follow) on items change createEffect(on(() => props.items().length, (len, prevLen) => { - if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) { + updateAutoPinHold() + if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) { requestAnimationFrame(() => scrollToBottom(true)) } suppressAutoScrollOnce = false @@ -304,11 +408,16 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { // Handle followToken change createEffect(on(() => props.followToken?.(), () => { - if (autoScroll()) { + updateAutoPinHold() + if (autoScroll() && !effectiveSuspendAutoPinToBottom()) { scrollToBottom(true) } }, { defer: true })) + createEffect(on(() => holdTargetKey(), () => { + updateAutoPinHold() + }, { defer: true })) + // Reset state on resetKey change createEffect(on(() => props.resetKey?.(), (nextKey) => { if (nextKey === lastResetKey) return @@ -331,6 +440,13 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { } }) + createEffect(() => { + if (typeof window === "undefined") return + const handleResize = () => updateAutoPinHold() + window.addEventListener("resize", handleResize) + onCleanup(() => window.removeEventListener("resize", handleResize)) + }) + return ( { setShellElement(shellElement) @@ -356,7 +472,15 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { bufferSize={props.overscanPx ?? 400} onScroll={handleScroll} > - {(item, index) => props.renderItem(item, index())} + {(item, index) => { + const key = props.getKey(item, index()) + const anchorId = getAnchorIdForKey(key) + return ( + registerItemElement(key, element)}> + {props.renderItem(item, index())} + + ) + }} diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 10bb29ea..20d0f74a 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -10,7 +10,10 @@ import type { SpeechCapabilitiesResponse, SpeechSynthesisResponse, SpeechTranscriptionResponse, + SideCar, ServerMeta, + RemoteServerProbeRequest, + RemoteServerProbeResponse, VoiceModeStateResponse, WorkspaceCreateRequest, WorkspaceDescriptor, @@ -191,9 +194,42 @@ export const serverApi = { body: JSON.stringify(payload), }) }, + fetchSidecars(): Promise<{ sidecars: SideCar[] }> { + return request<{ sidecars: SideCar[] }>("/api/sidecars") + }, + createSidecar(payload: { + kind: "port" + name: string + port: number + insecure: boolean + prefixMode: "strip" | "preserve" + }): Promise { + return request("/api/sidecars", { + method: "POST", + body: JSON.stringify(payload), + }) + }, + updateSidecar( + id: string, + payload: Partial<{ name: string; port: number; insecure: boolean; prefixMode: "strip" | "preserve" }>, + ): Promise { + return request(`/api/sidecars/${encodeURIComponent(id)}`, { + method: "PUT", + body: JSON.stringify(payload), + }) + }, + deleteSidecar(id: string): Promise { + return request(`/api/sidecars/${encodeURIComponent(id)}`, { method: "DELETE" }) + }, fetchServerMeta(): Promise { return request("/api/meta") }, + probeRemoteServer(payload: RemoteServerProbeRequest): Promise { + return request("/api/remote-servers/probe", { + method: "POST", + body: JSON.stringify(payload), + }) + }, fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> { return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status") }, @@ -430,4 +466,4 @@ function buildClientEventsUrl(identity: { clientId: string; connectionId: string return `${url.pathname}${url.search}` } -export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType } +export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType, SideCar } diff --git a/packages/ui/src/lib/diff-utils.ts b/packages/ui/src/lib/diff-utils.ts index cca3dc9d..e2fc6954 100644 --- a/packages/ui/src/lib/diff-utils.ts +++ b/packages/ui/src/lib/diff-utils.ts @@ -2,6 +2,7 @@ const HUNK_PATTERN = /(^|\n)@@/m const FILE_MARKER_PATTERN = /(^|\n)(diff --git |--- |\+\+\+)/ const BEGIN_PATCH_PATTERN = /^\*\*\* (Begin|End) Patch/ const UPDATE_FILE_PATTERN = /^\*\*\* Update File: (.+)$/ +const HUNK_HEADER_PATTERN = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/ function stripCodeFence(value: string): string { const trimmed = value.trim() @@ -48,3 +49,48 @@ export function isRenderableDiffText(raw?: string | null): raw is string { if (!normalized) return false return HUNK_PATTERN.test(normalized) } + +export function parsePatchToBeforeAfter(patch: string): { before: string; after: string } { + if (!patch || patch.trim().length === 0) { + return { before: "", after: "" } + } + + const lines = patch.replace(/\r\n/g, "\n").split("\n") + const beforeLines: string[] = [] + const afterLines: string[] = [] + + for (const line of lines) { + if (line.startsWith("---") || line.startsWith("+++") || line.startsWith("diff --git")) { + continue + } + if (HUNK_HEADER_PATTERN.test(line)) { + continue + } + if (line.startsWith("-") && !line.startsWith("---")) { + beforeLines.push(line.slice(1)) + } else if (line.startsWith("+") && !line.startsWith("+++")) { + afterLines.push(line.slice(1)) + } else if (line.startsWith(" ")) { + beforeLines.push(line.slice(1)) + afterLines.push(line.slice(1)) + } else if (line === "") { + beforeLines.push("") + afterLines.push("") + } else { + beforeLines.push(line) + afterLines.push(line) + } + } + + while (beforeLines.length > 0 && beforeLines[beforeLines.length - 1] === "") { + beforeLines.pop() + } + while (afterLines.length > 0 && afterLines[afterLines.length - 1] === "") { + afterLines.pop() + } + + return { + before: beforeLines.join("\n"), + after: afterLines.join("\n"), + } +} diff --git a/packages/ui/src/lib/follow-scroll.tsx b/packages/ui/src/lib/follow-scroll.tsx new file mode 100644 index 00000000..89b489a9 --- /dev/null +++ b/packages/ui/src/lib/follow-scroll.tsx @@ -0,0 +1,262 @@ +import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js" + +const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600 +const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"]) + +interface FollowScrollOptions { + getScrollTopSnapshot: Accessor + setScrollTopSnapshot: (next: number) => void + sentinelMarginPx: number + sentinelClassName: string + intentWindowMs?: number + intentKeys?: ReadonlySet +} + +export interface FollowScrollHelpers { + registerContainer: (element: HTMLDivElement | null | undefined, options?: { disableTracking?: boolean }) => void + handleScroll: (event: Event & { currentTarget: HTMLDivElement }) => void + renderSentinel: (options?: { disableTracking?: boolean }) => JSXElement | null + restoreAfterRender: () => void + autoScroll: Accessor +} + +export function createFollowScroll(options: FollowScrollOptions): FollowScrollHelpers { + const [scrollContainer, setScrollContainer] = createSignal() + const [bottomSentinel, setBottomSentinel] = createSignal(null) + const [autoScroll, setAutoScroll] = createSignal(true) + const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true) + + let scrollContainerRef: HTMLDivElement | undefined + let detachScrollIntentListeners: (() => void) | undefined + + let pendingScrollFrame: number | null = null + let pendingAnchorScroll: number | null = null + let userScrollIntentUntil = 0 + let lastKnownScrollTop = options.getScrollTopSnapshot() + let pointerInteractionActive = false + let suppressNextScrollHandling = false + + function restoreScrollPosition(forceBottom = false) { + const container = scrollContainerRef + if (!container) return + suppressNextScrollHandling = true + if (forceBottom) { + container.scrollTop = container.scrollHeight + lastKnownScrollTop = container.scrollTop + options.setScrollTopSnapshot(lastKnownScrollTop) + } else { + container.scrollTop = lastKnownScrollTop + } + } + + function persistScrollSnapshot(element?: HTMLElement | null) { + if (!element) return + lastKnownScrollTop = element.scrollTop + options.setScrollTopSnapshot(lastKnownScrollTop) + } + + function markUserScrollIntent() { + const now = typeof performance !== "undefined" ? performance.now() : Date.now() + userScrollIntentUntil = now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS) + } + + function hasUserScrollIntent() { + if (pointerInteractionActive) { + return true + } + const now = typeof performance !== "undefined" ? performance.now() : Date.now() + return now <= userScrollIntentUntil + } + + function attachScrollIntentListeners(element: HTMLDivElement) { + if (detachScrollIntentListeners) { + detachScrollIntentListeners() + detachScrollIntentListeners = undefined + } + const intentKeys = options.intentKeys ?? DEFAULT_SCROLL_INTENT_KEYS + const handlePointerIntent = () => { + pointerInteractionActive = true + markUserScrollIntent() + } + const clearPointerIntent = () => { + pointerInteractionActive = false + } + const handleKeyIntent = (event: KeyboardEvent) => { + if (intentKeys.has(event.key)) { + markUserScrollIntent() + } + } + element.addEventListener("wheel", handlePointerIntent, { passive: true }) + element.addEventListener("pointerdown", handlePointerIntent) + element.addEventListener("touchstart", handlePointerIntent, { passive: true }) + element.addEventListener("keydown", handleKeyIntent) + if (typeof window !== "undefined") { + window.addEventListener("pointerup", clearPointerIntent) + window.addEventListener("pointercancel", clearPointerIntent) + window.addEventListener("mouseup", clearPointerIntent) + window.addEventListener("touchend", clearPointerIntent) + window.addEventListener("touchcancel", clearPointerIntent) + } + detachScrollIntentListeners = () => { + element.removeEventListener("wheel", handlePointerIntent) + element.removeEventListener("pointerdown", handlePointerIntent) + element.removeEventListener("touchstart", handlePointerIntent) + element.removeEventListener("keydown", handleKeyIntent) + if (typeof window !== "undefined") { + window.removeEventListener("pointerup", clearPointerIntent) + window.removeEventListener("pointercancel", clearPointerIntent) + window.removeEventListener("mouseup", clearPointerIntent) + window.removeEventListener("touchend", clearPointerIntent) + window.removeEventListener("touchcancel", clearPointerIntent) + } + pointerInteractionActive = false + } + } + + function scheduleAnchorScroll(immediate = false) { + if (!autoScroll()) return + const sentinel = bottomSentinel() + const container = scrollContainerRef + if (!sentinel || !container) return + if (pendingAnchorScroll !== null) { + cancelAnimationFrame(pendingAnchorScroll) + pendingAnchorScroll = null + } + pendingAnchorScroll = requestAnimationFrame(() => { + pendingAnchorScroll = null + const containerRect = container.getBoundingClientRect() + const sentinelRect = sentinel.getBoundingClientRect() + const delta = sentinelRect.bottom - containerRect.bottom + options.sentinelMarginPx + if (Math.abs(delta) > 1) { + suppressNextScrollHandling = true + container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" }) + } + lastKnownScrollTop = container.scrollTop + options.setScrollTopSnapshot(lastKnownScrollTop) + }) + } + + function isAtBottom(container: HTMLDivElement) { + return container.scrollHeight - (container.scrollTop + container.clientHeight) <= options.sentinelMarginPx + } + + function updateFollowModeFromScroll(containerOverride?: HTMLDivElement) { + const container = containerOverride ?? scrollContainer() + if (!container) return + if (suppressNextScrollHandling) { + suppressNextScrollHandling = false + return + } + const isUserScroll = hasUserScrollIntent() + const atBottomFromScroll = isAtBottom(container) + const atBottom = atBottomFromScroll || bottomSentinelVisible() + + if (isUserScroll || !atBottom) { + if (atBottom) { + if (!autoScroll()) setAutoScroll(true) + } else if (autoScroll()) { + setAutoScroll(false) + } + } + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + updateFollowModeFromScroll(event.currentTarget) + persistScrollSnapshot(event.currentTarget) + } + + const registerContainer = (element: HTMLDivElement | null | undefined, config?: { disableTracking?: boolean }) => { + const next = element || undefined + if (next === scrollContainerRef) { + return + } + scrollContainerRef = next + setScrollContainer(scrollContainerRef) + if (scrollContainerRef) { + lastKnownScrollTop = options.getScrollTopSnapshot() + restoreScrollPosition(autoScroll()) + } + } + + const renderSentinel = (config?: { disableTracking?: boolean }) => { + if (config?.disableTracking) return null + return + } + + const restoreAfterRender = () => { + const container = scrollContainerRef + if (container && hasUserScrollIntent() && !isAtBottom(container)) { + if (autoScroll()) { + setAutoScroll(false) + } + requestAnimationFrame(() => { + restoreScrollPosition(false) + }) + return + } + + // Never let a render-time caller force follow mode back on after the user + // has already escaped it. Staying pinned should depend on the current + // follow state, not on a caller opting into forceBottom. + const shouldFollow = autoScroll() + requestAnimationFrame(() => { + restoreScrollPosition(shouldFollow) + if (shouldFollow) { + scheduleAnchorScroll(true) + } + }) + } + + createEffect(() => { + const container = scrollContainer() + if (!container) return + attachScrollIntentListeners(container) + onCleanup(() => { + if (detachScrollIntentListeners) { + detachScrollIntentListeners() + detachScrollIntentListeners = undefined + } + }) + }) + + createEffect(() => { + const container = scrollContainer() + const sentinel = bottomSentinel() + if (!container || !sentinel) return + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.target === sentinel) { + setBottomSentinelVisible(entry.isIntersecting) + } + }) + }, + { root: container, threshold: 0, rootMargin: `0px 0px ${options.sentinelMarginPx}px 0px` }, + ) + observer.observe(sentinel) + onCleanup(() => observer.disconnect()) + }) + + onCleanup(() => { + if (pendingScrollFrame !== null) { + cancelAnimationFrame(pendingScrollFrame) + pendingScrollFrame = null + } + if (pendingAnchorScroll !== null) { + cancelAnimationFrame(pendingAnchorScroll) + pendingAnchorScroll = null + } + if (detachScrollIntentListeners) { + detachScrollIntentListeners() + detachScrollIntentListeners = undefined + } + }) + + return { + registerContainer, + handleScroll, + renderSentinel, + restoreAfterRender, + autoScroll, + } +} diff --git a/packages/ui/src/lib/hooks/use-app-lifecycle.ts b/packages/ui/src/lib/hooks/use-app-lifecycle.ts index 0721b6dc..062963c3 100644 --- a/packages/ui/src/lib/hooks/use-app-lifecycle.ts +++ b/packages/ui/src/lib/hooks/use-app-lifecycle.ts @@ -16,6 +16,7 @@ const log = getLogger("actions") interface UseAppLifecycleOptions { setEscapeInDebounce: (value: boolean) => void handleNewInstanceRequest: () => void + handleCloseActiveTab: () => Promise handleCloseInstance: (instanceId: string) => Promise handleNewSession: (instanceId: string) => Promise handleCloseSession: (instanceId: string, sessionId: string) => Promise @@ -31,7 +32,7 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) { setupTabKeyboardShortcuts( options.handleNewInstanceRequest, - options.handleCloseInstance, + options.handleCloseActiveTab, options.handleNewSession, options.handleCloseSession, () => { diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index d73a47fe..d6a7d670 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -2,7 +2,8 @@ import { createSignal, onMount } from "solid-js" import type { Accessor } from "solid-js" import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences" import { createCommandRegistry, type Command } from "../commands" -import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances" +import { activeInstanceId } from "../../stores/instances" +import { selectNextAppTab, selectPreviousAppTab } from "../../stores/app-tabs" import type { ClientPart, MessageInfo } from "../../types/message" import { getSessions, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions" import { showAlertDialog } from "../../stores/alerts" @@ -41,6 +42,7 @@ export interface UseCommandsOptions { setThinkingBlocksExpansion: (mode: ExpansionPreference) => void setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void handleNewInstanceRequest: () => void + handleCloseActiveTab: () => Promise handleCloseInstance: (instanceId: string) => Promise handleNewSession: (instanceId: string) => Promise handleCloseSession: (instanceId: string, sessionId: string) => Promise @@ -90,9 +92,7 @@ export function useCommands(options: UseCommandsOptions) { keywords: () => splitKeywords("commands.closeInstance.keywords"), shortcut: { key: "W", meta: true }, action: async () => { - const instance = activeInstance() - if (!instance) return - await options.handleCloseInstance(instance.id) + await options.handleCloseActiveTab() }, }) @@ -103,13 +103,7 @@ export function useCommands(options: UseCommandsOptions) { category: "Instance", keywords: () => splitKeywords("commands.nextInstance.keywords"), shortcut: { key: "]", meta: true }, - action: () => { - const ids = Array.from(instances().keys()) - if (ids.length <= 1) return - const current = ids.indexOf(activeInstanceId() || "") - const next = (current + 1) % ids.length - if (ids[next]) setActiveInstanceId(ids[next]) - }, + action: () => selectNextAppTab(), }) commandRegistry.register({ @@ -119,13 +113,7 @@ export function useCommands(options: UseCommandsOptions) { category: "Instance", keywords: () => splitKeywords("commands.previousInstance.keywords"), shortcut: { key: "[", meta: true }, - action: () => { - const ids = Array.from(instances().keys()) - if (ids.length <= 1) return - const current = ids.indexOf(activeInstanceId() || "") - const prev = current <= 0 ? ids.length - 1 : current - 1 - if (ids[prev]) setActiveInstanceId(ids[prev]) - }, + action: () => selectPreviousAppTab(), }) commandRegistry.register({ diff --git a/packages/ui/src/lib/i18n/messages/en/commands.ts b/packages/ui/src/lib/i18n/messages/en/commands.ts index b396200c..a61bddd2 100644 --- a/packages/ui/src/lib/i18n/messages/en/commands.ts +++ b/packages/ui/src/lib/i18n/messages/en/commands.ts @@ -15,17 +15,17 @@ export const commandMessages = { "commands.newInstance.description": "Open folder picker to create new instance", "commands.newInstance.keywords": "folder, project, workspace", - "commands.closeInstance.label": "Close Instance", - "commands.closeInstance.description": "Stop current instance's server", - "commands.closeInstance.keywords": "stop, quit, close", + "commands.closeInstance.label": "Close Tab", + "commands.closeInstance.description": "Close the current top-level tab", + "commands.closeInstance.keywords": "stop, quit, close, tab", - "commands.nextInstance.label": "Next Instance", - "commands.nextInstance.description": "Cycle to next instance tab", - "commands.nextInstance.keywords": "switch, navigate", + "commands.nextInstance.label": "Next Tab", + "commands.nextInstance.description": "Cycle to the next top-level tab", + "commands.nextInstance.keywords": "switch, navigate, tab", - "commands.previousInstance.label": "Previous Instance", - "commands.previousInstance.description": "Cycle to previous instance tab", - "commands.previousInstance.keywords": "switch, navigate", + "commands.previousInstance.label": "Previous Tab", + "commands.previousInstance.description": "Cycle to the previous top-level tab", + "commands.previousInstance.keywords": "switch, navigate, tab", "commands.newSession.label": "New Session", "commands.newSession.description": "Create a new parent session", diff --git a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts index e548f92c..16eac13e 100644 --- a/packages/ui/src/lib/i18n/messages/en/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/en/folderSelection.ts @@ -20,6 +20,9 @@ export const folderSelectionMessages = { "folderSelection.browse.subtitle": "Select any folder on your computer", "folderSelection.browse.button": "Browse Folders", "folderSelection.browse.buttonOpening": "Opening...", + "folderSelection.actions.title": "Open Folder or Connect Server", + "folderSelection.actions.subtitle": "Open local folder or connect to a CodeNomad server", + "folderSelection.actions.connectButton": "Connect CodeNomad Server", "folderSelection.advancedSettings": "Advanced Settings", "folderSelection.opencode": "OpenCode", @@ -39,4 +42,32 @@ export const folderSelectionMessages = { "folderSelection.dialog.title": "Select Workspace", "folderSelection.dialog.description": "Select workspace to start coding.", + + "folderSelection.tabs.local": "Local Folders", + "folderSelection.tabs.servers": "Servers", + "folderSelection.servers.title": "Saved Servers", + "folderSelection.servers.subtitle": "Open a saved remote CodeNomad server in a new window", + "folderSelection.servers.count": "{count} Servers", + "folderSelection.servers.empty.title": "No Saved Servers", + "folderSelection.servers.empty.description": "Add a remote server to reconnect quickly from this device", + "folderSelection.servers.connectTitle": "Connect to Server", + "folderSelection.servers.connectSubtitle": "Save a remote CodeNomad server and open it in a new window", + "folderSelection.servers.connectButton": "Connect to Server", + "folderSelection.servers.remove": "Remove saved server", + "folderSelection.servers.skipTls": "Self-signed TLS", + "folderSelection.servers.errorTitle": "Remote Connection Failed", + "folderSelection.servers.dialog.title": "Connect to Server", + "folderSelection.servers.dialog.description": "Add a remote CodeNomad server and optionally open it right away.", + "folderSelection.servers.dialog.name": "Server name", + "folderSelection.servers.dialog.namePlaceholder": "Production Server", + "folderSelection.servers.dialog.url": "Server URL", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "Skip TLS verification for self-signed certificates.", + "folderSelection.servers.dialog.cancel": "Cancel", + "folderSelection.servers.dialog.save": "Save", + "folderSelection.servers.dialog.connect": "Connect", + "folderSelection.servers.dialog.connecting": "Connecting...", + "folderSelection.servers.dialog.errorRequired": "Server name and URL are required.", + "folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.", + "folderSelection.sidecars.button": "Open SideCar", } as const diff --git a/packages/ui/src/lib/i18n/messages/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts index a7b3baba..d56f481c 100644 --- a/packages/ui/src/lib/i18n/messages/en/instance.ts +++ b/packages/ui/src/lib/i18n/messages/en/instance.ts @@ -160,6 +160,8 @@ export const instanceMessages = { "instanceShell.backgroundProcesses.empty": "No background processes.", "instanceShell.backgroundProcesses.status": "Status: {status}", "instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB", + "instanceShell.backgroundProcesses.notify.enabled": "Completion notification enabled", + "instanceShell.backgroundProcesses.notify.disabled": "Completion notification disabled", "instanceShell.backgroundProcesses.actions.output": "Output", "instanceShell.backgroundProcesses.actions.stop": "Stop", "instanceShell.backgroundProcesses.actions.terminate": "Terminate", diff --git a/packages/ui/src/lib/i18n/messages/en/messaging.ts b/packages/ui/src/lib/i18n/messages/en/messaging.ts index 93e07cb4..58422334 100644 --- a/packages/ui/src/lib/i18n/messages/en/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/en/messaging.ts @@ -18,6 +18,8 @@ export const messagingMessages = { "messageSection.loading.messages": "Loading messages...", "messageSection.scroll.toFirstAriaLabel": "Scroll to first message", "messageSection.scroll.toLatestAriaLabel": "Scroll to latest message", + "messageSection.scroll.enableHoldAriaLabel": "Enable hold for long assistant replies", + "messageSection.scroll.disableHoldAriaLabel": "Disable hold for long assistant replies", "messageSection.quote.addAsQuote": "Add as quote", "messageSection.quote.addAsCode": "Add as code", "messageSection.quote.copy": "Copy", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index bdd4a710..f77b7b48 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -113,6 +113,15 @@ export const settingsMessages = { "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + "settings.opencode.logLevel.title": "OpenCode Log Level", + "settings.opencode.logLevel.subtitle": "Control the log verbosity used when launching new OpenCode instances.", + "settings.opencode.logLevel.selector.title": "Default log level", + "settings.opencode.logLevel.selector.subtitle": "Choose how verbose new OpenCode instances should be.", + "settings.opencode.logLevel.option.debug": "Debug", + "settings.opencode.logLevel.option.info": "Info", + "settings.opencode.logLevel.option.warn": "Warn", + "settings.opencode.logLevel.option.error": "Error", + "settings.appearance.behavior.title": "Interaction", "settings.appearance.behavior.subtitle": "Message, diff, and input defaults.", @@ -186,4 +195,40 @@ export const settingsMessages = { "settings.speech.save.saved": "Saved", "settings.speech.save.unsaved": "Unsaved changes", "settings.speech.save.error": "Save failed", + "settings.nav.sidecars": "SideCars", + "settings.section.sidecars.eyebrow": "Server services", + "settings.section.sidecars.title": "SideCars", + "settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.", + "sidecars.form.name": "Name", + "sidecars.form.validation": "Enter a valid SideCar name and port.", + "sidecars.form.port": "Port", + "sidecars.form.insecure": "Use HTTP", + "sidecars.form.protocol": "Protocol", + "sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.", + "sidecars.form.protocol.https": "HTTPS", + "sidecars.form.protocol.http": "HTTP", + "sidecars.form.prefixMode": "Prefix mode", + "sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.", + "sidecars.form.prefixMode.strip": "Strip prefix", + "sidecars.form.prefixMode.preserve": "Preserve prefix", + "sidecars.form.add": "Add SideCar", + "sidecars.kind.port": "Port", + "sidecars.status.running": "Running", + "sidecars.status.stopped": "Stopped", + "sidecars.basePath": "Base path", + "sidecars.settings.listTitle": "Configured SideCars", + "sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.", + "sidecars.settings.empty": "No SideCars configured yet.", + "sidecars.picker.title": "Open SideCar", + "sidecars.picker.loading": "Loading SideCars...", + "sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.", + "sidecars.picker.empty": "No port-based SideCars are available yet.", + "sidecars.picker.close": "Close", + "sidecars.open.errorTitle": "Unable to open SideCar", + "sidecars.open.notFound": "SideCar not found.", + "sidecars.open.notRunning": "SideCar is not reachable on its configured port.", + "sidecars.back": "Back", + "sidecars.refresh": "Refresh", + "sidecars.path": "Path", + "sidecars.go": "Go", } as const diff --git a/packages/ui/src/lib/i18n/messages/en/toolCall.ts b/packages/ui/src/lib/i18n/messages/en/toolCall.ts index 7b380522..6d1736f5 100644 --- a/packages/ui/src/lib/i18n/messages/en/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/en/toolCall.ts @@ -18,6 +18,11 @@ export const toolCallMessages = { "toolCall.diff.viewMode.ariaLabel": "Diff view mode", "toolCall.diff.viewMode.split": "Split", "toolCall.diff.viewMode.unified": "Unified", + "toolCall.diff.switchToSplit": "Switch to split view", + "toolCall.diff.switchToUnified": "Switch to unified view", + "toolCall.diff.enableWordWrap": "Enable word wrap", + "toolCall.diff.disableWordWrap": "Disable word wrap", + "toolCall.diff.copyPatch": "Copy patch", "toolCall.diagnostics.title": "Diagnostics", "toolCall.diagnostics.ariaLabel": "Diagnostics", diff --git a/packages/ui/src/lib/i18n/messages/es/commands.ts b/packages/ui/src/lib/i18n/messages/es/commands.ts index 43e1fbb1..ba077f05 100644 --- a/packages/ui/src/lib/i18n/messages/es/commands.ts +++ b/packages/ui/src/lib/i18n/messages/es/commands.ts @@ -15,17 +15,17 @@ export const commandMessages = { "commands.newInstance.description": "Abrir el selector de carpetas para crear una nueva instancia", "commands.newInstance.keywords": "carpeta, proyecto, workspace", - "commands.closeInstance.label": "Cerrar instancia", - "commands.closeInstance.description": "Detener el servidor de la instancia actual", - "commands.closeInstance.keywords": "detener, salir, cerrar", + "commands.closeInstance.label": "Cerrar pestaña", + "commands.closeInstance.description": "Cerrar la pestaña superior actual", + "commands.closeInstance.keywords": "detener, salir, cerrar, pestaña", - "commands.nextInstance.label": "Siguiente instancia", - "commands.nextInstance.description": "Cambiar a la siguiente pestaña de instancia", - "commands.nextInstance.keywords": "cambiar, navegar", + "commands.nextInstance.label": "Siguiente pestaña", + "commands.nextInstance.description": "Cambiar a la siguiente pestaña superior", + "commands.nextInstance.keywords": "cambiar, navegar, pestaña", - "commands.previousInstance.label": "Instancia anterior", - "commands.previousInstance.description": "Cambiar a la pestaña de instancia anterior", - "commands.previousInstance.keywords": "cambiar, navegar", + "commands.previousInstance.label": "Pestaña anterior", + "commands.previousInstance.description": "Cambiar a la pestaña superior anterior", + "commands.previousInstance.keywords": "cambiar, navegar, pestaña", "commands.newSession.label": "Nueva sesión", "commands.newSession.description": "Crear una nueva sesión principal", diff --git a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts index 56948be4..ad618a94 100644 --- a/packages/ui/src/lib/i18n/messages/es/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/es/folderSelection.ts @@ -2,35 +2,38 @@ export const folderSelectionMessages = { "folderSelection.language.ariaLabel": "Idioma", "folderSelection.logoAlt": "Logo de CodeNomad", - "folderSelection.tagline": "Selecciona una carpeta para empezar a programar con IA", + "folderSelection.tagline": "Selecciona una carpeta para empezar a programar con AI", "folderSelection.links.github": "GitHub de CodeNomad", - "folderSelection.links.githubStars": "Estrellas de CodeNomad en GitHub", + "folderSelection.links.githubStars": "Estrellas de GitHub de CodeNomad", "folderSelection.links.discord": "Discord de CodeNomad", "folderSelection.empty.title": "No hay carpetas recientes", - "folderSelection.empty.description": "Explora una carpeta para comenzar", + "folderSelection.empty.description": "Busca una carpeta para comenzar", "folderSelection.recent.title": "Carpetas recientes", "folderSelection.recent.subtitle.one": "{count} carpeta disponible", "folderSelection.recent.subtitle.other": "{count} carpetas disponibles", - "folderSelection.recent.remove": "Quitar de recientes", + "folderSelection.recent.remove": "Eliminar de recientes", - "folderSelection.browse.title": "Explorar carpetas", + "folderSelection.browse.title": "Buscar carpeta", "folderSelection.browse.subtitle": "Selecciona cualquier carpeta en tu ordenador", - "folderSelection.browse.button": "Explorar carpetas", + "folderSelection.browse.button": "Buscar carpetas", "folderSelection.browse.buttonOpening": "Abriendo...", + "folderSelection.actions.title": "Abrir carpeta o conectar servidor", + "folderSelection.actions.subtitle": "Abre una carpeta local o conéctate a un servidor de CodeNomad", + "folderSelection.actions.connectButton": "Conectar servidor CodeNomad", "folderSelection.advancedSettings": "Configuración avanzada", "folderSelection.opencode": "OpenCode", "folderSelection.hints.navigate": "Navegar", "folderSelection.hints.select": "Seleccionar", - "folderSelection.hints.remove": "Quitar", - "folderSelection.hints.browse": "Explorar", + "folderSelection.hints.remove": "Eliminar", + "folderSelection.hints.browse": "Buscar", "folderSelection.loading.title": "Iniciando instancia...", - "folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.", + "folderSelection.loading.subtitle": "Espera mientras preparamos tu espacio de trabajo.", "folderSelection.drop.title": "Suelta una carpeta para abrirla", "folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.", @@ -39,4 +42,32 @@ export const folderSelectionMessages = { "folderSelection.dialog.title": "Seleccionar workspace", "folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.", + + "folderSelection.tabs.local": "Carpetas locales", + "folderSelection.tabs.servers": "Servidores", + "folderSelection.servers.title": "Servidores guardados", + "folderSelection.servers.subtitle": "Abre un servidor remoto de CodeNomad guardado en una ventana nueva", + "folderSelection.servers.count": "{count} servidores", + "folderSelection.servers.empty.title": "No hay servidores guardados", + "folderSelection.servers.empty.description": "Añade un servidor remoto para volver a conectarte rápidamente desde este dispositivo", + "folderSelection.servers.connectTitle": "Conectar a un servidor", + "folderSelection.servers.connectSubtitle": "Guarda un servidor remoto de CodeNomad y ábrelo en una ventana nueva", + "folderSelection.servers.connectButton": "Conectar a un servidor", + "folderSelection.servers.remove": "Eliminar servidor guardado", + "folderSelection.servers.skipTls": "TLS autofirmado", + "folderSelection.servers.errorTitle": "Falló la conexión remota", + "folderSelection.servers.dialog.title": "Conectar a un servidor", + "folderSelection.servers.dialog.description": "Añade un servidor remoto de CodeNomad y ábrelo ahora si quieres.", + "folderSelection.servers.dialog.name": "Nombre del servidor", + "folderSelection.servers.dialog.namePlaceholder": "Servidor de producción", + "folderSelection.servers.dialog.url": "URL del servidor", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "Omitir la verificación TLS para certificados autofirmados.", + "folderSelection.servers.dialog.cancel": "Cancelar", + "folderSelection.servers.dialog.save": "Guardar", + "folderSelection.servers.dialog.connect": "Conectar", + "folderSelection.servers.dialog.connecting": "Conectando...", + "folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.", + "folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.", + "folderSelection.sidecars.button": "Open SideCar", } as const diff --git a/packages/ui/src/lib/i18n/messages/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts index 2eaa5532..e0293f1f 100644 --- a/packages/ui/src/lib/i18n/messages/es/instance.ts +++ b/packages/ui/src/lib/i18n/messages/es/instance.ts @@ -150,6 +150,8 @@ export const instanceMessages = { "instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.", "instanceShell.backgroundProcesses.status": "Estado: {status}", "instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB", + "instanceShell.backgroundProcesses.notify.enabled": "Notificacion de finalizacion activada", + "instanceShell.backgroundProcesses.notify.disabled": "Notificacion de finalizacion desactivada", "instanceShell.backgroundProcesses.actions.output": "Salida", "instanceShell.backgroundProcesses.actions.stop": "Detener", "instanceShell.backgroundProcesses.actions.terminate": "Terminar", diff --git a/packages/ui/src/lib/i18n/messages/es/messaging.ts b/packages/ui/src/lib/i18n/messages/es/messaging.ts index 2fbedea7..2e4173cf 100644 --- a/packages/ui/src/lib/i18n/messages/es/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/es/messaging.ts @@ -18,6 +18,8 @@ export const messagingMessages = { "messageSection.loading.messages": "Cargando mensajes...", "messageSection.scroll.toFirstAriaLabel": "Desplazarse al primer mensaje", "messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje", + "messageSection.scroll.enableHoldAriaLabel": "Activar pausa para respuestas largas del asistente", + "messageSection.scroll.disableHoldAriaLabel": "Desactivar pausa para respuestas largas del asistente", "messageSection.quote.addAsQuote": "Añadir como cita", "messageSection.quote.addAsCode": "Añadir como código", "messageSection.quote.copy": "Copiar", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index ea17bb48..37bca7ae 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -113,6 +113,14 @@ export const settingsMessages = { "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + "settings.opencode.logLevel.title": "Nivel de logs de OpenCode", + "settings.opencode.logLevel.subtitle": "Define el nivel de logs usado al iniciar nuevas instancias de OpenCode.", + "settings.opencode.logLevel.selector.title": "Verbosidad de logs", + "settings.opencode.logLevel.selector.subtitle": "Elige cuanta informacion deben registrar las nuevas instancias de OpenCode.", + "settings.opencode.logLevel.option.debug": "Depuracion", + "settings.opencode.logLevel.option.info": "Informacion", + "settings.opencode.logLevel.option.warn": "Advertencia", + "settings.opencode.logLevel.option.error": "Error", "settings.appearance.behavior.title": "Interaccion", "settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.", @@ -186,4 +194,40 @@ export const settingsMessages = { "settings.speech.save.saved": "Guardado", "settings.speech.save.unsaved": "Cambios sin guardar", "settings.speech.save.error": "Error al guardar", + "settings.nav.sidecars": "SideCars", + "settings.section.sidecars.eyebrow": "Server services", + "settings.section.sidecars.title": "SideCars", + "settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.", + "sidecars.form.name": "Name", + "sidecars.form.validation": "Enter a valid SideCar name and port.", + "sidecars.form.port": "Port", + "sidecars.form.insecure": "Use HTTP", + "sidecars.form.protocol": "Protocol", + "sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.", + "sidecars.form.protocol.https": "HTTPS", + "sidecars.form.protocol.http": "HTTP", + "sidecars.form.prefixMode": "Prefix mode", + "sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.", + "sidecars.form.prefixMode.strip": "Strip prefix", + "sidecars.form.prefixMode.preserve": "Preserve prefix", + "sidecars.form.add": "Add SideCar", + "sidecars.kind.port": "Port", + "sidecars.status.running": "Running", + "sidecars.status.stopped": "Stopped", + "sidecars.basePath": "Base path", + "sidecars.settings.listTitle": "Configured SideCars", + "sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.", + "sidecars.settings.empty": "No SideCars configured yet.", + "sidecars.picker.title": "Open SideCar", + "sidecars.picker.loading": "Loading SideCars...", + "sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.", + "sidecars.picker.empty": "No port-based SideCars are available yet.", + "sidecars.picker.close": "Close", + "sidecars.open.errorTitle": "Unable to open SideCar", + "sidecars.open.notFound": "SideCar not found.", + "sidecars.open.notRunning": "SideCar is not reachable on its configured port.", + "sidecars.back": "Back", + "sidecars.refresh": "Refresh", + "sidecars.path": "Path", + "sidecars.go": "Go", } as const diff --git a/packages/ui/src/lib/i18n/messages/es/toolCall.ts b/packages/ui/src/lib/i18n/messages/es/toolCall.ts index f0453187..52422359 100644 --- a/packages/ui/src/lib/i18n/messages/es/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/es/toolCall.ts @@ -18,6 +18,11 @@ export const toolCallMessages = { "toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff", "toolCall.diff.viewMode.split": "Dividida", "toolCall.diff.viewMode.unified": "Unificada", + "toolCall.diff.switchToSplit": "Cambiar a vista dividida", + "toolCall.diff.switchToUnified": "Cambiar a vista unificada", + "toolCall.diff.enableWordWrap": "Activar ajuste de línea", + "toolCall.diff.disableWordWrap": "Desactivar ajuste de línea", + "toolCall.diff.copyPatch": "Copiar patch", "toolCall.diagnostics.title": "Diagnósticos", "toolCall.diagnostics.ariaLabel": "Diagnósticos", diff --git a/packages/ui/src/lib/i18n/messages/fr/commands.ts b/packages/ui/src/lib/i18n/messages/fr/commands.ts index 505baa19..6543f9e3 100644 --- a/packages/ui/src/lib/i18n/messages/fr/commands.ts +++ b/packages/ui/src/lib/i18n/messages/fr/commands.ts @@ -15,17 +15,17 @@ export const commandMessages = { "commands.newInstance.description": "Ouvrir le sélecteur de dossiers pour créer une nouvelle instance", "commands.newInstance.keywords": "dossier, projet, espace de travail", - "commands.closeInstance.label": "Fermer l'instance", - "commands.closeInstance.description": "Arrêter le serveur de l'instance actuelle", - "commands.closeInstance.keywords": "arrêter, quitter, fermer", + "commands.closeInstance.label": "Fermer l'onglet", + "commands.closeInstance.description": "Fermer l'onglet de premier niveau actuel", + "commands.closeInstance.keywords": "arrêter, quitter, fermer, onglet", - "commands.nextInstance.label": "Instance suivante", - "commands.nextInstance.description": "Passer à l'onglet d'instance suivant", - "commands.nextInstance.keywords": "changer, naviguer, suivant", + "commands.nextInstance.label": "Onglet suivant", + "commands.nextInstance.description": "Passer à l'onglet de premier niveau suivant", + "commands.nextInstance.keywords": "changer, naviguer, suivant, onglet", - "commands.previousInstance.label": "Instance précédente", - "commands.previousInstance.description": "Passer à l'onglet d'instance précédent", - "commands.previousInstance.keywords": "changer, naviguer, précédent", + "commands.previousInstance.label": "Onglet précédent", + "commands.previousInstance.description": "Passer à l'onglet de premier niveau précédent", + "commands.previousInstance.keywords": "changer, naviguer, précédent, onglet", "commands.newSession.label": "Nouvelle session", "commands.newSession.description": "Créer une nouvelle session parente", diff --git a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts index cd1f2cdc..1372a78e 100644 --- a/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/fr/folderSelection.ts @@ -5,7 +5,7 @@ export const folderSelectionMessages = { "folderSelection.tagline": "Sélectionnez un dossier pour commencer à coder avec l'IA", "folderSelection.links.github": "GitHub de CodeNomad", - "folderSelection.links.githubStars": "Stars GitHub de CodeNomad", + "folderSelection.links.githubStars": "Étoiles GitHub de CodeNomad", "folderSelection.links.discord": "Discord de CodeNomad", "folderSelection.empty.title": "Aucun dossier récent", @@ -16,10 +16,13 @@ export const folderSelectionMessages = { "folderSelection.recent.subtitle.other": "{count} dossiers disponibles", "folderSelection.recent.remove": "Retirer des récents", - "folderSelection.browse.title": "Parcourir les dossiers", + "folderSelection.browse.title": "Parcourir un dossier", "folderSelection.browse.subtitle": "Sélectionnez n'importe quel dossier sur votre ordinateur", "folderSelection.browse.button": "Parcourir les dossiers", "folderSelection.browse.buttonOpening": "Ouverture...", + "folderSelection.actions.title": "Ouvrir un dossier ou se connecter à un serveur", + "folderSelection.actions.subtitle": "Ouvrez un dossier local ou connectez-vous à un serveur CodeNomad", + "folderSelection.actions.connectButton": "Se connecter au serveur CodeNomad", "folderSelection.advancedSettings": "Paramètres avancés", "folderSelection.opencode": "OpenCode", @@ -39,4 +42,32 @@ export const folderSelectionMessages = { "folderSelection.dialog.title": "Sélectionner l'espace de travail", "folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.", + + "folderSelection.tabs.local": "Dossiers locaux", + "folderSelection.tabs.servers": "Serveurs", + "folderSelection.servers.title": "Serveurs enregistrés", + "folderSelection.servers.subtitle": "Ouvrez un serveur CodeNomad distant enregistré dans une nouvelle fenêtre", + "folderSelection.servers.count": "{count} serveurs", + "folderSelection.servers.empty.title": "Aucun serveur enregistré", + "folderSelection.servers.empty.description": "Ajoutez un serveur distant pour vous reconnecter rapidement depuis cet appareil", + "folderSelection.servers.connectTitle": "Se connecter à un serveur", + "folderSelection.servers.connectSubtitle": "Enregistrez un serveur CodeNomad distant et ouvrez-le dans une nouvelle fenêtre", + "folderSelection.servers.connectButton": "Se connecter à un serveur", + "folderSelection.servers.remove": "Supprimer le serveur enregistré", + "folderSelection.servers.skipTls": "TLS auto-signé", + "folderSelection.servers.errorTitle": "Échec de la connexion distante", + "folderSelection.servers.dialog.title": "Se connecter à un serveur", + "folderSelection.servers.dialog.description": "Ajoutez un serveur CodeNomad distant et ouvrez-le immédiatement si vous le souhaitez.", + "folderSelection.servers.dialog.name": "Nom du serveur", + "folderSelection.servers.dialog.namePlaceholder": "Serveur de production", + "folderSelection.servers.dialog.url": "URL du serveur", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "Ignorer la vérification TLS pour les certificats auto-signés.", + "folderSelection.servers.dialog.cancel": "Annuler", + "folderSelection.servers.dialog.save": "Enregistrer", + "folderSelection.servers.dialog.connect": "Se connecter", + "folderSelection.servers.dialog.connecting": "Connexion...", + "folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.", + "folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.", + "folderSelection.sidecars.button": "Open SideCar", } as const diff --git a/packages/ui/src/lib/i18n/messages/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts index dfaa3fe3..26c56844 100644 --- a/packages/ui/src/lib/i18n/messages/fr/instance.ts +++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts @@ -150,6 +150,8 @@ export const instanceMessages = { "instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.", "instanceShell.backgroundProcesses.status": "Statut : {status}", "instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB", + "instanceShell.backgroundProcesses.notify.enabled": "Notification de fin activee", + "instanceShell.backgroundProcesses.notify.disabled": "Notification de fin desactivee", "instanceShell.backgroundProcesses.actions.output": "Sortie", "instanceShell.backgroundProcesses.actions.stop": "Arrêter", "instanceShell.backgroundProcesses.actions.terminate": "Terminer", diff --git a/packages/ui/src/lib/i18n/messages/fr/messaging.ts b/packages/ui/src/lib/i18n/messages/fr/messaging.ts index 6c3a8751..32bb8a9a 100644 --- a/packages/ui/src/lib/i18n/messages/fr/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/fr/messaging.ts @@ -18,6 +18,8 @@ export const messagingMessages = { "messageSection.loading.messages": "Chargement des messages...", "messageSection.scroll.toFirstAriaLabel": "Aller au premier message", "messageSection.scroll.toLatestAriaLabel": "Aller au dernier message", + "messageSection.scroll.enableHoldAriaLabel": "Activer le maintien pour les longues réponses de l'assistant", + "messageSection.scroll.disableHoldAriaLabel": "Désactiver le maintien pour les longues réponses de l'assistant", "messageSection.quote.addAsQuote": "Ajouter en citation", "messageSection.quote.addAsCode": "Ajouter en code", "messageSection.quote.copy": "Copier", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 5a543305..bd44a3fa 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -113,6 +113,14 @@ export const settingsMessages = { "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + "settings.opencode.logLevel.title": "Niveau de logs OpenCode", + "settings.opencode.logLevel.subtitle": "Definir le niveau de logs utilise au lancement des nouvelles instances OpenCode.", + "settings.opencode.logLevel.selector.title": "Verbosite des logs", + "settings.opencode.logLevel.selector.subtitle": "Choisir la quantite de journaux emise par les nouvelles instances OpenCode.", + "settings.opencode.logLevel.option.debug": "Debogage", + "settings.opencode.logLevel.option.info": "Info", + "settings.opencode.logLevel.option.warn": "Avertissement", + "settings.opencode.logLevel.option.error": "Erreur", "settings.appearance.behavior.title": "Interaction", "settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.", @@ -186,4 +194,40 @@ export const settingsMessages = { "settings.speech.save.saved": "Enregistré", "settings.speech.save.unsaved": "Modifications non enregistrées", "settings.speech.save.error": "Échec de l'enregistrement", + "settings.nav.sidecars": "SideCars", + "settings.section.sidecars.eyebrow": "Server services", + "settings.section.sidecars.title": "SideCars", + "settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.", + "sidecars.form.name": "Name", + "sidecars.form.validation": "Enter a valid SideCar name and port.", + "sidecars.form.port": "Port", + "sidecars.form.insecure": "Use HTTP", + "sidecars.form.protocol": "Protocol", + "sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.", + "sidecars.form.protocol.https": "HTTPS", + "sidecars.form.protocol.http": "HTTP", + "sidecars.form.prefixMode": "Prefix mode", + "sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.", + "sidecars.form.prefixMode.strip": "Strip prefix", + "sidecars.form.prefixMode.preserve": "Preserve prefix", + "sidecars.form.add": "Add SideCar", + "sidecars.kind.port": "Port", + "sidecars.status.running": "Running", + "sidecars.status.stopped": "Stopped", + "sidecars.basePath": "Base path", + "sidecars.settings.listTitle": "Configured SideCars", + "sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.", + "sidecars.settings.empty": "No SideCars configured yet.", + "sidecars.picker.title": "Open SideCar", + "sidecars.picker.loading": "Loading SideCars...", + "sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.", + "sidecars.picker.empty": "No port-based SideCars are available yet.", + "sidecars.picker.close": "Close", + "sidecars.open.errorTitle": "Unable to open SideCar", + "sidecars.open.notFound": "SideCar not found.", + "sidecars.open.notRunning": "SideCar is not reachable on its configured port.", + "sidecars.back": "Back", + "sidecars.refresh": "Refresh", + "sidecars.path": "Path", + "sidecars.go": "Go", } as const diff --git a/packages/ui/src/lib/i18n/messages/fr/toolCall.ts b/packages/ui/src/lib/i18n/messages/fr/toolCall.ts index 75685849..6cab1b0a 100644 --- a/packages/ui/src/lib/i18n/messages/fr/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/fr/toolCall.ts @@ -18,6 +18,11 @@ export const toolCallMessages = { "toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff", "toolCall.diff.viewMode.split": "Côte à côte", "toolCall.diff.viewMode.unified": "Unifié", + "toolCall.diff.switchToSplit": "Passer à la vue côte à côte", + "toolCall.diff.switchToUnified": "Passer à la vue unifiée", + "toolCall.diff.enableWordWrap": "Activer le retour à la ligne", + "toolCall.diff.disableWordWrap": "Désactiver le retour à la ligne", + "toolCall.diff.copyPatch": "Copier le patch", "toolCall.diagnostics.title": "Diagnostics", "toolCall.diagnostics.ariaLabel": "Diagnostics", diff --git a/packages/ui/src/lib/i18n/messages/he/commands.ts b/packages/ui/src/lib/i18n/messages/he/commands.ts index 09d246ac..1969ebf4 100644 --- a/packages/ui/src/lib/i18n/messages/he/commands.ts +++ b/packages/ui/src/lib/i18n/messages/he/commands.ts @@ -15,17 +15,17 @@ export const commandMessages = { "commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש", "commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה", - "commands.closeInstance.label": "סגור מופע", - "commands.closeInstance.description": "עצור את השרת של המופע הנוכחי", - "commands.closeInstance.keywords": "עצור, סגור", + "commands.closeInstance.label": "סגור לשונית", + "commands.closeInstance.description": "סגור את הלשונית העליונה הנוכחית", + "commands.closeInstance.keywords": "עצור, סגור, לשונית", - "commands.nextInstance.label": "מופע הבא", - "commands.nextInstance.description": "עבור למופע הבא", - "commands.nextInstance.keywords": "החלף, נווט", + "commands.nextInstance.label": "הלשונית הבאה", + "commands.nextInstance.description": "עבור ללשונית העליונה הבאה", + "commands.nextInstance.keywords": "החלף, נווט, לשונית", - "commands.previousInstance.label": "מופע קודם", - "commands.previousInstance.description": "עבור למופע הקודם", - "commands.previousInstance.keywords": "החלף, נווט", + "commands.previousInstance.label": "הלשונית הקודמת", + "commands.previousInstance.description": "עבור ללשונית העליונה הקודמת", + "commands.previousInstance.keywords": "החלף, נווט, לשונית", "commands.newSession.label": "סשן חדש", "commands.newSession.description": "צור סשן הורה חדש", diff --git a/packages/ui/src/lib/i18n/messages/he/folderSelection.ts b/packages/ui/src/lib/i18n/messages/he/folderSelection.ts index 489430a9..6e772cd1 100644 --- a/packages/ui/src/lib/i18n/messages/he/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/he/folderSelection.ts @@ -20,6 +20,9 @@ export const folderSelectionMessages = { "folderSelection.browse.subtitle": "בחר כל תיקייה במחשב שלך", "folderSelection.browse.button": "עיון בתיקיות", "folderSelection.browse.buttonOpening": "פותח...", + "folderSelection.actions.title": "פתח תיקייה או התחבר לשרת", + "folderSelection.actions.subtitle": "פתח תיקייה מקומית או התחבר לשרת CodeNomad", + "folderSelection.actions.connectButton": "התחבר לשרת CodeNomad", "folderSelection.advancedSettings": "הגדרות מתקדמות", "folderSelection.opencode": "OpenCode", @@ -39,4 +42,32 @@ export const folderSelectionMessages = { "folderSelection.dialog.title": "בחר סביבת עבודה", "folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.", + + "folderSelection.tabs.local": "תיקיות מקומיות", + "folderSelection.tabs.servers": "שרתים", + "folderSelection.servers.title": "שרתים שמורים", + "folderSelection.servers.subtitle": "פתח שרת CodeNomad מרוחק שמור בחלון חדש", + "folderSelection.servers.count": "{count} שרתים", + "folderSelection.servers.empty.title": "אין שרתים שמורים", + "folderSelection.servers.empty.description": "הוסף שרת מרוחק כדי להתחבר אליו במהירות מהמכשיר הזה", + "folderSelection.servers.connectTitle": "התחבר לשרת", + "folderSelection.servers.connectSubtitle": "שמור שרת CodeNomad מרוחק ופתח אותו בחלון חדש", + "folderSelection.servers.connectButton": "התחבר לשרת", + "folderSelection.servers.remove": "הסר שרת שמור", + "folderSelection.servers.skipTls": "TLS בחתימה עצמית", + "folderSelection.servers.errorTitle": "החיבור המרוחק נכשל", + "folderSelection.servers.dialog.title": "התחבר לשרת", + "folderSelection.servers.dialog.description": "הוסף שרת CodeNomad מרוחק ופתח אותו מיד אם תרצה.", + "folderSelection.servers.dialog.name": "שם השרת", + "folderSelection.servers.dialog.namePlaceholder": "שרת ייצור", + "folderSelection.servers.dialog.url": "כתובת השרת", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "דלג על אימות TLS עבור תעודות בחתימה עצמית.", + "folderSelection.servers.dialog.cancel": "ביטול", + "folderSelection.servers.dialog.save": "שמור", + "folderSelection.servers.dialog.connect": "התחבר", + "folderSelection.servers.dialog.connecting": "מתחבר...", + "folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.", + "folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.", + "folderSelection.sidecars.button": "Open SideCar", } as const diff --git a/packages/ui/src/lib/i18n/messages/he/instance.ts b/packages/ui/src/lib/i18n/messages/he/instance.ts index 483ca767..1db1b29c 100644 --- a/packages/ui/src/lib/i18n/messages/he/instance.ts +++ b/packages/ui/src/lib/i18n/messages/he/instance.ts @@ -158,6 +158,8 @@ export const instanceMessages = { "instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.", "instanceShell.backgroundProcesses.status": "סטטוס: {status}", "instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB", + "instanceShell.backgroundProcesses.notify.enabled": "התראת סיום פעילה", + "instanceShell.backgroundProcesses.notify.disabled": "התראת סיום כבויה", "instanceShell.backgroundProcesses.actions.output": "פלט", "instanceShell.backgroundProcesses.actions.stop": "עצור", "instanceShell.backgroundProcesses.actions.terminate": "סיים", diff --git a/packages/ui/src/lib/i18n/messages/he/messaging.ts b/packages/ui/src/lib/i18n/messages/he/messaging.ts index 54777f9a..b661e012 100644 --- a/packages/ui/src/lib/i18n/messages/he/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/he/messaging.ts @@ -18,6 +18,8 @@ export const messagingMessages = { "messageSection.loading.messages": "טוען הודעות...", "messageSection.scroll.toFirstAriaLabel": "גלול להודעה הראשונה", "messageSection.scroll.toLatestAriaLabel": "גלול להודעה האחרונה", + "messageSection.scroll.enableHoldAriaLabel": "הפעל עצירה לתגובות עוזר ארוכות", + "messageSection.scroll.disableHoldAriaLabel": "כבה עצירה לתגובות עוזר ארוכות", "messageSection.quote.addAsQuote": "הוסף כציטוט", "messageSection.quote.addAsCode": "הוסף כקוד", "messageSection.quote.copy": "העתק", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index 534a6d30..b59f4490 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -112,6 +112,14 @@ export const settingsMessages = { "settings.section.opencode.subtitle": "בחר את הקובץ הבינארי של OpenCode והסביבה לשימוש במופעים חדשים.", "settings.opencode.runtime.title": "סביבת ריצה", "settings.opencode.runtime.subtitle": "הגדר עם איזה קובץ בינארי של OpenCode מופעים חדשים יופעלו.", + "settings.opencode.logLevel.title": "רמת הלוגים של OpenCode", + "settings.opencode.logLevel.subtitle": "הגדר את רמת הלוגים שבה ייעשה שימוש בעת הפעלת מופעי OpenCode חדשים.", + "settings.opencode.logLevel.selector.title": "פירוט לוגים", + "settings.opencode.logLevel.selector.subtitle": "בחר כמה לוגים מופעי OpenCode חדשים צריכים להפיק.", + "settings.opencode.logLevel.option.debug": "ניפוי שגיאות", + "settings.opencode.logLevel.option.info": "מידע", + "settings.opencode.logLevel.option.warn": "אזהרה", + "settings.opencode.logLevel.option.error": "שגיאה", "settings.appearance.behavior.title": "אינטראקציה", "settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.", @@ -185,4 +193,40 @@ export const settingsMessages = { "settings.speech.save.saved": "נשמר", "settings.speech.save.unsaved": "יש שינויים שלא נשמרו", "settings.speech.save.error": "השמירה נכשלה", + "settings.nav.sidecars": "SideCars", + "settings.section.sidecars.eyebrow": "Server services", + "settings.section.sidecars.title": "SideCars", + "settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.", + "sidecars.form.name": "Name", + "sidecars.form.validation": "Enter a valid SideCar name and port.", + "sidecars.form.port": "Port", + "sidecars.form.insecure": "Use HTTP", + "sidecars.form.protocol": "Protocol", + "sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.", + "sidecars.form.protocol.https": "HTTPS", + "sidecars.form.protocol.http": "HTTP", + "sidecars.form.prefixMode": "Prefix mode", + "sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.", + "sidecars.form.prefixMode.strip": "Strip prefix", + "sidecars.form.prefixMode.preserve": "Preserve prefix", + "sidecars.form.add": "Add SideCar", + "sidecars.kind.port": "Port", + "sidecars.status.running": "Running", + "sidecars.status.stopped": "Stopped", + "sidecars.basePath": "Base path", + "sidecars.settings.listTitle": "Configured SideCars", + "sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.", + "sidecars.settings.empty": "No SideCars configured yet.", + "sidecars.picker.title": "Open SideCar", + "sidecars.picker.loading": "Loading SideCars...", + "sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.", + "sidecars.picker.empty": "No port-based SideCars are available yet.", + "sidecars.picker.close": "Close", + "sidecars.open.errorTitle": "Unable to open SideCar", + "sidecars.open.notFound": "SideCar not found.", + "sidecars.open.notRunning": "SideCar is not reachable on its configured port.", + "sidecars.back": "Back", + "sidecars.refresh": "Refresh", + "sidecars.path": "Path", + "sidecars.go": "Go", } as const diff --git a/packages/ui/src/lib/i18n/messages/he/toolCall.ts b/packages/ui/src/lib/i18n/messages/he/toolCall.ts index cf7e3e74..578dd244 100644 --- a/packages/ui/src/lib/i18n/messages/he/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/he/toolCall.ts @@ -18,6 +18,11 @@ export const toolCallMessages = { "toolCall.diff.viewMode.ariaLabel": "מצב תצוגת diff", "toolCall.diff.viewMode.split": "מפוצל", "toolCall.diff.viewMode.unified": "מאוחד", + "toolCall.diff.switchToSplit": "עבור לתצוגה מפוצלת", + "toolCall.diff.switchToUnified": "עבור לתצוגה מאוחדת", + "toolCall.diff.enableWordWrap": "הפעל גלישת מילים", + "toolCall.diff.disableWordWrap": "כבה גלישת מילים", + "toolCall.diff.copyPatch": "העתק patch", "toolCall.diagnostics.title": "אבחון", "toolCall.diagnostics.ariaLabel": "אבחון", diff --git a/packages/ui/src/lib/i18n/messages/ja/commands.ts b/packages/ui/src/lib/i18n/messages/ja/commands.ts index de21f94c..cfed1c8d 100644 --- a/packages/ui/src/lib/i18n/messages/ja/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ja/commands.ts @@ -15,17 +15,17 @@ export const commandMessages = { "commands.newInstance.description": "フォルダ選択を開いて新しいインスタンスを作成", "commands.newInstance.keywords": "フォルダ, プロジェクト, ワークスペース, folder, project, workspace", - "commands.closeInstance.label": "インスタンスを閉じる", - "commands.closeInstance.description": "現在のインスタンスのサーバーを停止", - "commands.closeInstance.keywords": "停止, 終了, 閉じる, stop, quit, close", + "commands.closeInstance.label": "タブを閉じる", + "commands.closeInstance.description": "現在のトップレベルタブを閉じる", + "commands.closeInstance.keywords": "閉じる, タブ, stop, quit, close", - "commands.nextInstance.label": "次のインスタンス", - "commands.nextInstance.description": "次のインスタンスタブへ切り替え", - "commands.nextInstance.keywords": "切り替え, 移動, switch, navigate", + "commands.nextInstance.label": "次のタブ", + "commands.nextInstance.description": "次のトップレベルタブへ切り替え", + "commands.nextInstance.keywords": "切り替え, 移動, タブ, switch, navigate", - "commands.previousInstance.label": "前のインスタンス", - "commands.previousInstance.description": "前のインスタンスタブへ切り替え", - "commands.previousInstance.keywords": "切り替え, 移動, switch, navigate", + "commands.previousInstance.label": "前のタブ", + "commands.previousInstance.description": "前のトップレベルタブへ切り替え", + "commands.previousInstance.keywords": "切り替え, 移動, タブ, switch, navigate", "commands.newSession.label": "新しいセッション", "commands.newSession.description": "新しい親セッションを作成", diff --git a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts index 4c05e401..82edb63f 100644 --- a/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/ja/folderSelection.ts @@ -20,6 +20,9 @@ export const folderSelectionMessages = { "folderSelection.browse.subtitle": "コンピュータ上の任意のフォルダを選択", "folderSelection.browse.button": "フォルダを参照", "folderSelection.browse.buttonOpening": "開いています...", + "folderSelection.actions.title": "フォルダを開くかサーバーに接続", + "folderSelection.actions.subtitle": "ローカルフォルダを開くか CodeNomad サーバーに接続します", + "folderSelection.actions.connectButton": "CodeNomad サーバーに接続", "folderSelection.advancedSettings": "詳細設定", "folderSelection.opencode": "OpenCode", @@ -39,4 +42,32 @@ export const folderSelectionMessages = { "folderSelection.dialog.title": "ワークスペースを選択", "folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。", + + "folderSelection.tabs.local": "ローカルフォルダ", + "folderSelection.tabs.servers": "サーバー", + "folderSelection.servers.title": "保存済みサーバー", + "folderSelection.servers.subtitle": "保存したリモート CodeNomad サーバーを新しいウィンドウで開きます", + "folderSelection.servers.count": "{count} サーバー", + "folderSelection.servers.empty.title": "保存済みサーバーはありません", + "folderSelection.servers.empty.description": "この端末からすばやく再接続できるように、リモートサーバーを追加してください", + "folderSelection.servers.connectTitle": "サーバーに接続", + "folderSelection.servers.connectSubtitle": "リモート CodeNomad サーバーを保存して新しいウィンドウで開きます", + "folderSelection.servers.connectButton": "サーバーに接続", + "folderSelection.servers.remove": "保存したサーバーを削除", + "folderSelection.servers.skipTls": "自己署名 TLS", + "folderSelection.servers.errorTitle": "リモート接続に失敗しました", + "folderSelection.servers.dialog.title": "サーバーに接続", + "folderSelection.servers.dialog.description": "リモート CodeNomad サーバーを追加し、必要に応じてすぐに開きます。", + "folderSelection.servers.dialog.name": "サーバー名", + "folderSelection.servers.dialog.namePlaceholder": "本番サーバー", + "folderSelection.servers.dialog.url": "サーバー URL", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "自己署名証明書の TLS 検証をスキップします。", + "folderSelection.servers.dialog.cancel": "キャンセル", + "folderSelection.servers.dialog.save": "保存", + "folderSelection.servers.dialog.connect": "接続", + "folderSelection.servers.dialog.connecting": "接続中...", + "folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。", + "folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。", + "folderSelection.sidecars.button": "Open SideCar", } as const diff --git a/packages/ui/src/lib/i18n/messages/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts index b3fe6ded..546a22ef 100644 --- a/packages/ui/src/lib/i18n/messages/ja/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts @@ -150,6 +150,8 @@ export const instanceMessages = { "instanceShell.backgroundProcesses.empty": "バックグラウンドプロセスはありません。", "instanceShell.backgroundProcesses.status": "状態: {status}", "instanceShell.backgroundProcesses.output": "出力: {sizeKb}KB", + "instanceShell.backgroundProcesses.notify.enabled": "完了通知が有効", + "instanceShell.backgroundProcesses.notify.disabled": "完了通知が無効", "instanceShell.backgroundProcesses.actions.output": "出力", "instanceShell.backgroundProcesses.actions.stop": "停止", "instanceShell.backgroundProcesses.actions.terminate": "終了", diff --git a/packages/ui/src/lib/i18n/messages/ja/messaging.ts b/packages/ui/src/lib/i18n/messages/ja/messaging.ts index 2c3cb6c3..893581db 100644 --- a/packages/ui/src/lib/i18n/messages/ja/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ja/messaging.ts @@ -18,6 +18,8 @@ export const messagingMessages = { "messageSection.loading.messages": "メッセージを読み込み中...", "messageSection.scroll.toFirstAriaLabel": "最初のメッセージへスクロール", "messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール", + "messageSection.scroll.enableHoldAriaLabel": "長いアシスタント返信の保持を有効にする", + "messageSection.scroll.disableHoldAriaLabel": "長いアシスタント返信の保持を無効にする", "messageSection.quote.addAsQuote": "引用として追加", "messageSection.quote.addAsCode": "コードとして追加", "messageSection.quote.copy": "コピー", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 9373a8ca..756cbf81 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -113,6 +113,14 @@ export const settingsMessages = { "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + "settings.opencode.logLevel.title": "OpenCode のログレベル", + "settings.opencode.logLevel.subtitle": "新しい OpenCode インスタンスの起動時に使うログレベルを設定します。", + "settings.opencode.logLevel.selector.title": "ログ出力の詳細度", + "settings.opencode.logLevel.selector.subtitle": "新しい OpenCode インスタンスがどの程度ログを出力するかを選択します。", + "settings.opencode.logLevel.option.debug": "デバッグ", + "settings.opencode.logLevel.option.info": "情報", + "settings.opencode.logLevel.option.warn": "警告", + "settings.opencode.logLevel.option.error": "エラー", "settings.appearance.behavior.title": "操作", "settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。", @@ -186,4 +194,40 @@ export const settingsMessages = { "settings.speech.save.saved": "保存済み", "settings.speech.save.unsaved": "未保存の変更", "settings.speech.save.error": "保存に失敗しました", + "settings.nav.sidecars": "SideCars", + "settings.section.sidecars.eyebrow": "Server services", + "settings.section.sidecars.title": "SideCars", + "settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.", + "sidecars.form.name": "Name", + "sidecars.form.validation": "Enter a valid SideCar name and port.", + "sidecars.form.port": "Port", + "sidecars.form.insecure": "Use HTTP", + "sidecars.form.protocol": "Protocol", + "sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.", + "sidecars.form.protocol.https": "HTTPS", + "sidecars.form.protocol.http": "HTTP", + "sidecars.form.prefixMode": "Prefix mode", + "sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.", + "sidecars.form.prefixMode.strip": "Strip prefix", + "sidecars.form.prefixMode.preserve": "Preserve prefix", + "sidecars.form.add": "Add SideCar", + "sidecars.kind.port": "Port", + "sidecars.status.running": "Running", + "sidecars.status.stopped": "Stopped", + "sidecars.basePath": "Base path", + "sidecars.settings.listTitle": "Configured SideCars", + "sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.", + "sidecars.settings.empty": "No SideCars configured yet.", + "sidecars.picker.title": "Open SideCar", + "sidecars.picker.loading": "Loading SideCars...", + "sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.", + "sidecars.picker.empty": "No port-based SideCars are available yet.", + "sidecars.picker.close": "Close", + "sidecars.open.errorTitle": "Unable to open SideCar", + "sidecars.open.notFound": "SideCar not found.", + "sidecars.open.notRunning": "SideCar is not reachable on its configured port.", + "sidecars.back": "Back", + "sidecars.refresh": "Refresh", + "sidecars.path": "Path", + "sidecars.go": "Go", } as const diff --git a/packages/ui/src/lib/i18n/messages/ja/toolCall.ts b/packages/ui/src/lib/i18n/messages/ja/toolCall.ts index 9251c719..58e6ad1f 100644 --- a/packages/ui/src/lib/i18n/messages/ja/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ja/toolCall.ts @@ -18,6 +18,11 @@ export const toolCallMessages = { "toolCall.diff.viewMode.ariaLabel": "diff 表示モード", "toolCall.diff.viewMode.split": "分割", "toolCall.diff.viewMode.unified": "ユニファイド", + "toolCall.diff.switchToSplit": "分割表示に切り替え", + "toolCall.diff.switchToUnified": "ユニファイド表示に切り替え", + "toolCall.diff.enableWordWrap": "折り返しを有効化", + "toolCall.diff.disableWordWrap": "折り返しを無効化", + "toolCall.diff.copyPatch": "パッチをコピー", "toolCall.diagnostics.title": "診断", "toolCall.diagnostics.ariaLabel": "診断", diff --git a/packages/ui/src/lib/i18n/messages/ru/commands.ts b/packages/ui/src/lib/i18n/messages/ru/commands.ts index 55d2a791..27e96100 100644 --- a/packages/ui/src/lib/i18n/messages/ru/commands.ts +++ b/packages/ui/src/lib/i18n/messages/ru/commands.ts @@ -15,17 +15,17 @@ export const commandMessages = { "commands.newInstance.description": "Открыть выбор папки для создания нового экземпляра", "commands.newInstance.keywords": "папка, проект, рабочее пространство", - "commands.closeInstance.label": "Закрыть экземпляр", - "commands.closeInstance.description": "Остановить сервер текущего экземпляра", - "commands.closeInstance.keywords": "остановить, выйти, закрыть", + "commands.closeInstance.label": "Закрыть вкладку", + "commands.closeInstance.description": "Закрыть текущую верхнеуровневую вкладку", + "commands.closeInstance.keywords": "остановить, выйти, закрыть, вкладка", - "commands.nextInstance.label": "Следующий экземпляр", - "commands.nextInstance.description": "Переключиться на следующую вкладку экземпляра", - "commands.nextInstance.keywords": "переключить, навигация", + "commands.nextInstance.label": "Следующая вкладка", + "commands.nextInstance.description": "Переключиться на следующую верхнеуровневую вкладку", + "commands.nextInstance.keywords": "переключить, навигация, вкладка", - "commands.previousInstance.label": "Предыдущий экземпляр", - "commands.previousInstance.description": "Переключиться на предыдущую вкладку экземпляра", - "commands.previousInstance.keywords": "переключить, навигация", + "commands.previousInstance.label": "Предыдущая вкладка", + "commands.previousInstance.description": "Переключиться на предыдущую верхнеуровневую вкладку", + "commands.previousInstance.keywords": "переключить, навигация, вкладка", "commands.newSession.label": "Новая сессия", "commands.newSession.description": "Создать новую родительскую сессию", diff --git a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts index 4a005938..dd293097 100644 --- a/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/ru/folderSelection.ts @@ -20,6 +20,9 @@ export const folderSelectionMessages = { "folderSelection.browse.subtitle": "Выберите любую папку на компьютере", "folderSelection.browse.button": "Обзор папок", "folderSelection.browse.buttonOpening": "Открытие…", + "folderSelection.actions.title": "Открыть папку или подключить сервер", + "folderSelection.actions.subtitle": "Откройте локальную папку или подключитесь к серверу CodeNomad", + "folderSelection.actions.connectButton": "Подключить сервер CodeNomad", "folderSelection.advancedSettings": "Расширенные настройки", "folderSelection.opencode": "OpenCode", @@ -39,4 +42,32 @@ export const folderSelectionMessages = { "folderSelection.dialog.title": "Выберите рабочее пространство", "folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.", + + "folderSelection.tabs.local": "Локальные папки", + "folderSelection.tabs.servers": "Серверы", + "folderSelection.servers.title": "Сохраненные серверы", + "folderSelection.servers.subtitle": "Откройте сохраненный удаленный сервер CodeNomad в новом окне", + "folderSelection.servers.count": "{count} серверов", + "folderSelection.servers.empty.title": "Нет сохраненных серверов", + "folderSelection.servers.empty.description": "Добавьте удаленный сервер, чтобы быстро подключаться к нему с этого устройства", + "folderSelection.servers.connectTitle": "Подключиться к серверу", + "folderSelection.servers.connectSubtitle": "Сохраните удаленный сервер CodeNomad и откройте его в новом окне", + "folderSelection.servers.connectButton": "Подключиться к серверу", + "folderSelection.servers.remove": "Удалить сохраненный сервер", + "folderSelection.servers.skipTls": "Самоподписанный TLS", + "folderSelection.servers.errorTitle": "Ошибка удаленного подключения", + "folderSelection.servers.dialog.title": "Подключиться к серверу", + "folderSelection.servers.dialog.description": "Добавьте удаленный сервер CodeNomad и при желании сразу откройте его.", + "folderSelection.servers.dialog.name": "Имя сервера", + "folderSelection.servers.dialog.namePlaceholder": "Продакшн сервер", + "folderSelection.servers.dialog.url": "URL сервера", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "Пропустить проверку TLS для самоподписанных сертификатов.", + "folderSelection.servers.dialog.cancel": "Отмена", + "folderSelection.servers.dialog.save": "Сохранить", + "folderSelection.servers.dialog.connect": "Подключиться", + "folderSelection.servers.dialog.connecting": "Подключение...", + "folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.", + "folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.", + "folderSelection.sidecars.button": "Open SideCar", } as const diff --git a/packages/ui/src/lib/i18n/messages/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts index 042c7ddd..8a4b6a89 100644 --- a/packages/ui/src/lib/i18n/messages/ru/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts @@ -150,6 +150,8 @@ export const instanceMessages = { "instanceShell.backgroundProcesses.empty": "Нет фоновых процессов.", "instanceShell.backgroundProcesses.status": "Статус: {status}", "instanceShell.backgroundProcesses.output": "Вывод: {sizeKb}KB", + "instanceShell.backgroundProcesses.notify.enabled": "Уведомление о завершении включено", + "instanceShell.backgroundProcesses.notify.disabled": "Уведомление о завершении выключено", "instanceShell.backgroundProcesses.actions.output": "Вывод", "instanceShell.backgroundProcesses.actions.stop": "Остановить", "instanceShell.backgroundProcesses.actions.terminate": "Завершить", diff --git a/packages/ui/src/lib/i18n/messages/ru/messaging.ts b/packages/ui/src/lib/i18n/messages/ru/messaging.ts index 0635a95a..b4b7cbf5 100644 --- a/packages/ui/src/lib/i18n/messages/ru/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ru/messaging.ts @@ -18,6 +18,8 @@ export const messagingMessages = { "messageSection.loading.messages": "Загрузка сообщений…", "messageSection.scroll.toFirstAriaLabel": "Прокрутить к первому сообщению", "messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению", + "messageSection.scroll.enableHoldAriaLabel": "Включить удержание для длинных ответов ассистента", + "messageSection.scroll.disableHoldAriaLabel": "Выключить удержание для длинных ответов ассистента", "messageSection.quote.addAsQuote": "Добавить как цитату", "messageSection.quote.addAsCode": "Добавить как код", "messageSection.quote.copy": "Копировать", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 2b3e0fa1..117a758b 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -113,6 +113,14 @@ export const settingsMessages = { "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + "settings.opencode.logLevel.title": "Уровень логирования OpenCode", + "settings.opencode.logLevel.subtitle": "Задайте уровень логирования, используемый при запуске новых экземпляров OpenCode.", + "settings.opencode.logLevel.selector.title": "Подробность логов", + "settings.opencode.logLevel.selector.subtitle": "Выберите, сколько логов должны выводить новые экземпляры OpenCode.", + "settings.opencode.logLevel.option.debug": "Отладка", + "settings.opencode.logLevel.option.info": "Информация", + "settings.opencode.logLevel.option.warn": "Предупреждение", + "settings.opencode.logLevel.option.error": "Ошибка", "settings.appearance.behavior.title": "Взаимодействие", "settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.", @@ -186,4 +194,40 @@ export const settingsMessages = { "settings.speech.save.saved": "Сохранено", "settings.speech.save.unsaved": "Есть несохранённые изменения", "settings.speech.save.error": "Не удалось сохранить", + "settings.nav.sidecars": "SideCars", + "settings.section.sidecars.eyebrow": "Server services", + "settings.section.sidecars.title": "SideCars", + "settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.", + "sidecars.form.name": "Name", + "sidecars.form.validation": "Enter a valid SideCar name and port.", + "sidecars.form.port": "Port", + "sidecars.form.insecure": "Use HTTP", + "sidecars.form.protocol": "Protocol", + "sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.", + "sidecars.form.protocol.https": "HTTPS", + "sidecars.form.protocol.http": "HTTP", + "sidecars.form.prefixMode": "Prefix mode", + "sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.", + "sidecars.form.prefixMode.strip": "Strip prefix", + "sidecars.form.prefixMode.preserve": "Preserve prefix", + "sidecars.form.add": "Add SideCar", + "sidecars.kind.port": "Port", + "sidecars.status.running": "Running", + "sidecars.status.stopped": "Stopped", + "sidecars.basePath": "Base path", + "sidecars.settings.listTitle": "Configured SideCars", + "sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.", + "sidecars.settings.empty": "No SideCars configured yet.", + "sidecars.picker.title": "Open SideCar", + "sidecars.picker.loading": "Loading SideCars...", + "sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.", + "sidecars.picker.empty": "No port-based SideCars are available yet.", + "sidecars.picker.close": "Close", + "sidecars.open.errorTitle": "Unable to open SideCar", + "sidecars.open.notFound": "SideCar not found.", + "sidecars.open.notRunning": "SideCar is not reachable on its configured port.", + "sidecars.back": "Back", + "sidecars.refresh": "Refresh", + "sidecars.path": "Path", + "sidecars.go": "Go", } as const diff --git a/packages/ui/src/lib/i18n/messages/ru/toolCall.ts b/packages/ui/src/lib/i18n/messages/ru/toolCall.ts index 6ca953df..54f24020 100644 --- a/packages/ui/src/lib/i18n/messages/ru/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ru/toolCall.ts @@ -18,6 +18,11 @@ export const toolCallMessages = { "toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff", "toolCall.diff.viewMode.split": "Раздельный", "toolCall.diff.viewMode.unified": "Единый", + "toolCall.diff.switchToSplit": "Переключить на раздельный вид", + "toolCall.diff.switchToUnified": "Переключить на единый вид", + "toolCall.diff.enableWordWrap": "Включить перенос слов", + "toolCall.diff.disableWordWrap": "Выключить перенос слов", + "toolCall.diff.copyPatch": "Скопировать patch", "toolCall.diagnostics.title": "Диагностика", "toolCall.diagnostics.ariaLabel": "Диагностика", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts index 69eba72f..a2d5541c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/commands.ts @@ -15,17 +15,17 @@ export const commandMessages = { "commands.newInstance.description": "打开文件夹选择器以创建新实例", "commands.newInstance.keywords": "folder, project, workspace, 文件夹, 项目, 工作区", - "commands.closeInstance.label": "关闭实例", - "commands.closeInstance.description": "停止当前实例的服务器", - "commands.closeInstance.keywords": "stop, quit, close, 停止, 退出, 关闭", + "commands.closeInstance.label": "关闭标签页", + "commands.closeInstance.description": "关闭当前顶层标签页", + "commands.closeInstance.keywords": "stop, quit, close, 停止, 退出, 关闭, 标签", - "commands.nextInstance.label": "下一个实例", - "commands.nextInstance.description": "切换到下一个实例标签页", - "commands.nextInstance.keywords": "switch, navigate, 切换, 导航", + "commands.nextInstance.label": "下一个标签页", + "commands.nextInstance.description": "切换到下一个顶层标签页", + "commands.nextInstance.keywords": "switch, navigate, 切换, 导航, 标签", - "commands.previousInstance.label": "上一个实例", - "commands.previousInstance.description": "切换到上一个实例标签页", - "commands.previousInstance.keywords": "switch, navigate, 切换, 导航", + "commands.previousInstance.label": "上一个标签页", + "commands.previousInstance.description": "切换到上一个顶层标签页", + "commands.previousInstance.keywords": "switch, navigate, 切换, 导航, 标签", "commands.newSession.label": "新建会话", "commands.newSession.description": "创建新的父会话", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts index 1c765fe9..e3ea8727 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/folderSelection.ts @@ -20,6 +20,9 @@ export const folderSelectionMessages = { "folderSelection.browse.subtitle": "选择你电脑上的任意文件夹", "folderSelection.browse.button": "浏览文件夹", "folderSelection.browse.buttonOpening": "正在打开...", + "folderSelection.actions.title": "打开文件夹或连接服务器", + "folderSelection.actions.subtitle": "打开本地文件夹或连接到 CodeNomad 服务器", + "folderSelection.actions.connectButton": "连接 CodeNomad 服务器", "folderSelection.advancedSettings": "高级设置", "folderSelection.opencode": "OpenCode", @@ -39,4 +42,32 @@ export const folderSelectionMessages = { "folderSelection.dialog.title": "选择工作区", "folderSelection.dialog.description": "选择工作区以开始编码。", + + "folderSelection.tabs.local": "本地文件夹", + "folderSelection.tabs.servers": "服务器", + "folderSelection.servers.title": "已保存的服务器", + "folderSelection.servers.subtitle": "在新窗口中打开已保存的远程 CodeNomad 服务器", + "folderSelection.servers.count": "{count} 个服务器", + "folderSelection.servers.empty.title": "没有已保存的服务器", + "folderSelection.servers.empty.description": "添加远程服务器,以便在此设备上快速重新连接", + "folderSelection.servers.connectTitle": "连接到服务器", + "folderSelection.servers.connectSubtitle": "保存远程 CodeNomad 服务器并在新窗口中打开它", + "folderSelection.servers.connectButton": "连接到服务器", + "folderSelection.servers.remove": "删除已保存服务器", + "folderSelection.servers.skipTls": "自签名 TLS", + "folderSelection.servers.errorTitle": "远程连接失败", + "folderSelection.servers.dialog.title": "连接到服务器", + "folderSelection.servers.dialog.description": "添加远程 CodeNomad 服务器,并可选择立即打开。", + "folderSelection.servers.dialog.name": "服务器名称", + "folderSelection.servers.dialog.namePlaceholder": "生产服务器", + "folderSelection.servers.dialog.url": "服务器 URL", + "folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com", + "folderSelection.servers.dialog.skipTls": "为自签名证书跳过 TLS 验证。", + "folderSelection.servers.dialog.cancel": "取消", + "folderSelection.servers.dialog.save": "保存", + "folderSelection.servers.dialog.connect": "连接", + "folderSelection.servers.dialog.connecting": "连接中...", + "folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。", + "folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。", + "folderSelection.sidecars.button": "Open SideCar", } as const diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts index f06f344c..10675d0a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts @@ -150,6 +150,8 @@ export const instanceMessages = { "instanceShell.backgroundProcesses.empty": "没有后台进程。", "instanceShell.backgroundProcesses.status": "状态:{status}", "instanceShell.backgroundProcesses.output": "输出:{sizeKb}KB", + "instanceShell.backgroundProcesses.notify.enabled": "已启用完成通知", + "instanceShell.backgroundProcesses.notify.disabled": "已禁用完成通知", "instanceShell.backgroundProcesses.actions.output": "输出", "instanceShell.backgroundProcesses.actions.stop": "停止", "instanceShell.backgroundProcesses.actions.terminate": "终止", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts index 0f653b57..202a8693 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts @@ -18,6 +18,8 @@ export const messagingMessages = { "messageSection.loading.messages": "正在加载消息...", "messageSection.scroll.toFirstAriaLabel": "滚动到第一条消息", "messageSection.scroll.toLatestAriaLabel": "滚动到最新消息", + "messageSection.scroll.enableHoldAriaLabel": "启用长助手回复保持", + "messageSection.scroll.disableHoldAriaLabel": "禁用长助手回复保持", "messageSection.quote.addAsQuote": "作为引用添加", "messageSection.quote.addAsCode": "作为代码添加", "messageSection.quote.copy": "复制", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 75303a7b..f8102f71 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -113,6 +113,14 @@ export const settingsMessages = { "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", + "settings.opencode.logLevel.title": "OpenCode 日志级别", + "settings.opencode.logLevel.subtitle": "设置启动新的 OpenCode 实例时使用的日志级别。", + "settings.opencode.logLevel.selector.title": "日志详细程度", + "settings.opencode.logLevel.selector.subtitle": "选择新的 OpenCode 实例应输出多少日志信息。", + "settings.opencode.logLevel.option.debug": "调试", + "settings.opencode.logLevel.option.info": "信息", + "settings.opencode.logLevel.option.warn": "警告", + "settings.opencode.logLevel.option.error": "错误", "settings.appearance.behavior.title": "交互", "settings.appearance.behavior.subtitle": "消息、差异与输入的默认值。", @@ -186,4 +194,40 @@ export const settingsMessages = { "settings.speech.save.saved": "已保存", "settings.speech.save.unsaved": "有未保存的更改", "settings.speech.save.error": "保存失败", + "settings.nav.sidecars": "SideCars", + "settings.section.sidecars.eyebrow": "Server services", + "settings.section.sidecars.title": "SideCars", + "settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.", + "sidecars.form.name": "Name", + "sidecars.form.validation": "Enter a valid SideCar name and port.", + "sidecars.form.port": "Port", + "sidecars.form.insecure": "Use HTTP", + "sidecars.form.protocol": "Protocol", + "sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.", + "sidecars.form.protocol.https": "HTTPS", + "sidecars.form.protocol.http": "HTTP", + "sidecars.form.prefixMode": "Prefix mode", + "sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.", + "sidecars.form.prefixMode.strip": "Strip prefix", + "sidecars.form.prefixMode.preserve": "Preserve prefix", + "sidecars.form.add": "Add SideCar", + "sidecars.kind.port": "Port", + "sidecars.status.running": "Running", + "sidecars.status.stopped": "Stopped", + "sidecars.basePath": "Base path", + "sidecars.settings.listTitle": "Configured SideCars", + "sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.", + "sidecars.settings.empty": "No SideCars configured yet.", + "sidecars.picker.title": "Open SideCar", + "sidecars.picker.loading": "Loading SideCars...", + "sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.", + "sidecars.picker.empty": "No port-based SideCars are available yet.", + "sidecars.picker.close": "Close", + "sidecars.open.errorTitle": "Unable to open SideCar", + "sidecars.open.notFound": "SideCar not found.", + "sidecars.open.notRunning": "SideCar is not reachable on its configured port.", + "sidecars.back": "Back", + "sidecars.refresh": "Refresh", + "sidecars.path": "Path", + "sidecars.go": "Go", } as const diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts index a0f5d30c..eae5a29b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts @@ -18,6 +18,11 @@ export const toolCallMessages = { "toolCall.diff.viewMode.ariaLabel": "Diff 视图模式", "toolCall.diff.viewMode.split": "分栏", "toolCall.diff.viewMode.unified": "统一", + "toolCall.diff.switchToSplit": "切换到分栏视图", + "toolCall.diff.switchToUnified": "切换到统一视图", + "toolCall.diff.enableWordWrap": "启用自动换行", + "toolCall.diff.disableWordWrap": "禁用自动换行", + "toolCall.diff.copyPatch": "复制补丁", "toolCall.diagnostics.title": "诊断", "toolCall.diagnostics.ariaLabel": "诊断", diff --git a/packages/ui/src/lib/keyboard.ts b/packages/ui/src/lib/keyboard.ts index be00976b..d4f1ddba 100644 --- a/packages/ui/src/lib/keyboard.ts +++ b/packages/ui/src/lib/keyboard.ts @@ -1,11 +1,12 @@ -import { instances, activeInstanceId, setActiveInstanceId } from "../stores/instances" +import { activeInstanceId } from "../stores/instances" +import { selectAppTabByIndex } from "../stores/app-tabs" import { activeSessionId, setActiveSession, getSessions, activeParentSessionId } from "../stores/sessions" import { keyboardRegistry } from "./keyboard-registry" import { isMac } from "./keyboard-utils" export function setupTabKeyboardShortcuts( handleNewInstance: () => void, - handleCloseInstance: (instanceId: string) => void, + handleCloseActiveTab: () => Promise, handleNewSession: (instanceId: string) => void, handleCloseSession: (instanceId: string, sessionId: string) => void, handleCommandPalette: () => void, @@ -35,11 +36,7 @@ export function setupTabKeyboardShortcuts( if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key >= "1" && e.key <= "9") { e.preventDefault() - const index = parseInt(e.key) - 1 - const instanceIds = Array.from(instances().keys()) - if (instanceIds[index]) { - setActiveInstanceId(instanceIds[index]) - } + selectAppTabByIndex(parseInt(e.key) - 1) } if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key >= "1" && e.key <= "9") { @@ -67,10 +64,7 @@ export function setupTabKeyboardShortcuts( if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "w") { e.preventDefault() - const instanceId = activeInstanceId() - if (instanceId) { - handleCloseInstance(instanceId) - } + void handleCloseActiveTab() } if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "w") { diff --git a/packages/ui/src/lib/native/remote-window.ts b/packages/ui/src/lib/native/remote-window.ts new file mode 100644 index 00000000..96b36cd5 --- /dev/null +++ b/packages/ui/src/lib/native/remote-window.ts @@ -0,0 +1,34 @@ +import { invoke } from "@tauri-apps/api/core" +import type { RemoteServerProfile } from "../../../../server/src/api-types" +import { runtimeEnv } from "../runtime-env" + +export interface RemoteWindowOpenPayload { + id: string + name: string + baseUrl: string + skipTlsVerify: boolean +} + +export async function openRemoteServerWindow(profile: Pick): Promise { + const payload: RemoteWindowOpenPayload = { + id: profile.id, + name: profile.name, + baseUrl: profile.baseUrl, + skipTlsVerify: profile.skipTlsVerify, + } + + if (runtimeEnv.host === "electron") { + const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI + if (typeof api?.openRemoteWindow === "function") { + await api.openRemoteWindow(payload) + return + } + } + + if (runtimeEnv.host === "tauri") { + await invoke("open_remote_window", { payload }) + return + } + + window.open(profile.baseUrl, "_blank", "noopener,noreferrer") +} diff --git a/packages/ui/src/lib/shortcuts/navigation.ts b/packages/ui/src/lib/shortcuts/navigation.ts index 8dcea0f7..be4bf0ae 100644 --- a/packages/ui/src/lib/shortcuts/navigation.ts +++ b/packages/ui/src/lib/shortcuts/navigation.ts @@ -1,5 +1,6 @@ import { keyboardRegistry } from "../keyboard-registry" -import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances" +import { activeInstanceId } from "../../stores/instances" +import { selectNextAppTab, selectPreviousAppTab } from "../../stores/app-tabs" import { activeSessionId, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions" export function registerNavigationShortcuts() { @@ -11,14 +12,8 @@ export function registerNavigationShortcuts() { id: "instance-prev", key: "[", modifiers: { ctrl: !isMac(), meta: isMac() }, - handler: () => { - const ids = Array.from(instances().keys()) - if (ids.length <= 1) return - const current = ids.indexOf(activeInstanceId() || "") - const prev = current <= 0 ? ids.length - 1 : current - 1 - if (ids[prev]) setActiveInstanceId(ids[prev]) - }, - description: "previous instance", + handler: () => selectPreviousAppTab(), + description: "previous tab", context: "global", }) @@ -26,14 +21,8 @@ export function registerNavigationShortcuts() { id: "instance-next", key: "]", modifiers: { ctrl: !isMac(), meta: isMac() }, - handler: () => { - const ids = Array.from(instances().keys()) - if (ids.length <= 1) return - const current = ids.indexOf(activeInstanceId() || "") - const next = (current + 1) % ids.length - if (ids[next]) setActiveInstanceId(ids[next]) - }, - description: "next instance", + handler: () => selectNextAppTab(), + description: "next tab", context: "global", }) diff --git a/packages/ui/src/stores/app-tabs.ts b/packages/ui/src/stores/app-tabs.ts new file mode 100644 index 00000000..3ead0ccf --- /dev/null +++ b/packages/ui/src/stores/app-tabs.ts @@ -0,0 +1,172 @@ +import { createMemo, createSignal } from "solid-js" +import type { Instance } from "../types/instance" +import { activeInstanceId, instances, setActiveInstanceId } from "./instances" +import { activeSidecarToken, setActiveSidecarToken, sidecarTabs, type SideCarTabRecord } from "./sidecars" + +export interface InstanceAppTab { + id: string + kind: "instance" + instance: Instance +} + +export interface SideCarAppTab { + id: string + kind: "sidecar" + sidecarTab: SideCarTabRecord +} + +export type AppTabRecord = InstanceAppTab | SideCarAppTab + +function getInstanceAppTabId(instanceId: string): string { + return `instance:${instanceId}` +} + +function getSidecarAppTabId(token: string): string { + return `sidecar:${token}` +} + +function getAdjacentAppTabId(tabId: string): string | null { + const tabs = appTabs() + const index = tabs.findIndex((tab) => tab.id === tabId) + if (index < 0) return activeAppTabId() + return tabs[index - 1]?.id ?? tabs[index + 1]?.id ?? null +} + +function getPreferredTabId(): string | null { + const sidecarToken = activeSidecarToken() + if (sidecarToken) { + return getSidecarAppTabId(sidecarToken) + } + + const instanceId = activeInstanceId() + if (instanceId) { + return getInstanceAppTabId(instanceId) + } + + return null +} + +const [activeAppTabId, setActiveAppTabId] = createSignal(null) +const [tabOrder, setTabOrder] = createSignal([]) + +function rememberTabOrder(tabId: string) { + setTabOrder((prev) => (prev.includes(tabId) ? prev : [...prev, tabId])) +} + +const appTabs = createMemo(() => { + const currentTabs = [ + ...Array.from(instances().values()).map((instance) => ({ + id: getInstanceAppTabId(instance.id), + kind: "instance" as const, + instance, + })), + ...sidecarTabs().map((sidecarTab) => ({ + id: getSidecarAppTabId(sidecarTab.token), + kind: "sidecar" as const, + sidecarTab, + })), + ] + + const tabsById = new Map(currentTabs.map((tab) => [tab.id, tab])) + const orderedIds = tabOrder().filter((tabId) => tabsById.has(tabId)) + const missingIds = currentTabs.map((tab) => tab.id).filter((tabId) => !orderedIds.includes(tabId)) + + return [...orderedIds, ...missingIds].map((tabId) => tabsById.get(tabId)!).filter(Boolean) +}) + +const activeAppTab = createMemo(() => appTabs().find((tab) => tab.id === activeAppTabId()) ?? null) + +function getAppTabById(tabId: string | null): AppTabRecord | null { + if (!tabId) return null + return appTabs().find((tab) => tab.id === tabId) ?? null +} + +function selectAppTab(tabId: string | null) { + if (!tabId) { + setActiveAppTabId(null) + setActiveSidecarToken(null) + return + } + + const tab = appTabs().find((entry) => entry.id === tabId) + if (!tab) return + + rememberTabOrder(tab.id) + setActiveAppTabId(tab.id) + + if (tab.kind === "instance") { + setActiveSidecarToken(null) + setActiveInstanceId(tab.instance.id) + return + } + + setActiveInstanceId(null) + setActiveSidecarToken(tab.sidecarTab.token) +} + +function selectInstanceTab(instanceId: string) { + selectAppTab(getInstanceAppTabId(instanceId)) +} + +function selectSidecarTab(token: string) { + selectAppTab(getSidecarAppTabId(token)) +} + +function selectNextAppTab() { + const tabs = appTabs() + if (tabs.length <= 1) return + + const current = tabs.findIndex((tab) => tab.id === activeAppTabId()) + const nextIndex = current < 0 ? 0 : (current + 1) % tabs.length + const nextTab = tabs[nextIndex] + if (nextTab) selectAppTab(nextTab.id) +} + +function selectPreviousAppTab() { + const tabs = appTabs() + if (tabs.length <= 1) return + + const current = tabs.findIndex((tab) => tab.id === activeAppTabId()) + const previousIndex = current <= 0 ? tabs.length - 1 : current - 1 + const previousTab = tabs[previousIndex] + if (previousTab) selectAppTab(previousTab.id) +} + +function selectAppTabByIndex(index: number) { + const tab = appTabs()[index] + if (tab) selectAppTab(tab.id) +} + +function ensureActiveAppTab(preferredTabId?: string | null) { + const tabs = appTabs() + const current = activeAppTabId() + + if (current && tabs.some((tab) => tab.id === current)) { + return + } + + const candidateId = preferredTabId ?? getPreferredTabId() + if (candidateId && tabs.some((tab) => tab.id === candidateId)) { + selectAppTab(candidateId) + return + } + + selectAppTab(tabs[0]?.id ?? null) +} + +export { + activeAppTabId, + activeAppTab, + appTabs, + ensureActiveAppTab, + getAdjacentAppTabId, + getAppTabById, + getInstanceAppTabId, + getSidecarAppTabId, + selectAppTab, + selectAppTabByIndex, + selectInstanceTab, + selectNextAppTab, + selectPreviousAppTab, + selectSidecarTab, +} diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 2d4bb6ec..c6b49482 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -36,6 +36,7 @@ import { clearCacheForInstance } from "../lib/global-cache" import { getLogger } from "../lib/logger" import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata" import { showWorkspaceLaunchError } from "./launch-errors" +import { activeSidecarToken } from "./sidecars" const log = getLogger("api") @@ -109,6 +110,8 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc } function ensureActiveInstanceSelected(): void { + if (activeSidecarToken()) return + const current = activeInstanceId() const instanceMap = instances() if (current && instanceMap.has(current)) return diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 12f70bc3..ae2a3b3c 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -33,6 +33,7 @@ function createInitialState(instanceId: string): InstanceMessageState { sessions: {}, sessionOrder: [], messages: {}, + lastAssistantMessageIds: {}, messageInfoVersion: {}, pendingParts: {}, sessionRevisions: {}, @@ -218,6 +219,7 @@ export interface InstanceMessageStore { getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined getSessionRevision: (sessionId: string) => number getSessionMessageIds: (sessionId: string) => string[] + getLastAssistantMessageId: (sessionId: string) => string | undefined // Index of the most recent message in the session that contains a compaction part. // Returns -1 if there has been no compaction. getLastCompactionMessageIndex: (sessionId: string) => number @@ -234,6 +236,21 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt const messageInfoCache = new Map() + function findLastAssistantMessageId(messageIds: readonly string[]): string | undefined { + for (let index = messageIds.length - 1; index >= 0; index -= 1) { + const messageId = messageIds[index] + if (state.messages[messageId]?.role === "assistant") { + return messageId + } + } + return undefined + } + + function recomputeLastAssistantMessageId(sessionId: string, messageIds?: readonly string[]) { + if (!sessionId) return + setState("lastAssistantMessageIds", sessionId, findLastAssistantMessageId(messageIds ?? state.sessions[sessionId]?.messageIds ?? [])) + } + function getLastCompactionMessageIndex(sessionId: string): number { if (!sessionId) return -1 const ids = state.sessions[sessionId]?.messageIds ?? [] @@ -306,6 +323,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return state.sessionRevisions[sessionId] ?? 0 } + function getLastAssistantMessageIdValue(sessionId: string) { + return state.lastAssistantMessageIds[sessionId] + } + function withUsageState(sessionId: string, updater: (draft: SessionUsageState) => void) { setState("usage", sessionId, (current) => { const draft = current @@ -375,6 +396,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) if (Array.isArray(input.messageIds) && !areMessageIdListsEqual(previousIds, nextMessageIds)) { + recomputeLastAssistantMessageId(input.id, nextMessageIds) bumpSessionRevision(input.id) } } @@ -445,6 +467,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt messageIds: incomingIds, updatedAt: Date.now(), })) + recomputeLastAssistantMessageId(sessionId, incomingIds) Object.values(normalizedRecords).forEach((record) => { maybeUpdateLatestTodoFromRecord(record) @@ -516,6 +539,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt insertMessageIntoSession(input.sessionId, input.id) flushPendingParts(input.id) + recomputeLastAssistantMessageId(input.sessionId) bumpSessionRevision(input.sessionId) } @@ -730,6 +754,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt if (state.latestTodos[sessionId]?.messageId === messageId) { clearLatestTodoSnapshot(sessionId) } + recomputeLastAssistantMessageId(sessionId) bumpSessionRevision(sessionId) }) }) @@ -816,7 +841,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt affectedSessions.add(session.id) }) - affectedSessions.forEach((sessionId) => bumpSessionRevision(sessionId)) + affectedSessions.forEach((sessionId) => { + recomputeLastAssistantMessageId(sessionId) + bumpSessionRevision(sessionId) + }) const infoEntry = messageInfoCache.get(options.oldId) if (infoEntry) { @@ -1037,6 +1065,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt removedIds.forEach((id) => removeUsageEntry(draft, id)) }) + recomputeLastAssistantMessageId(sessionId, keptIds) bumpSessionRevision(sessionId) } @@ -1128,6 +1157,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return next }) + setState("lastAssistantMessageIds", (prev) => { + const next = { ...prev } + delete next[sessionId] + return next + }) + setState("scrollState", (prev) => { const next = { ...prev } const prefix = `${sessionId}:` @@ -1190,16 +1225,17 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt setSessionRevert, getSessionRevert, - rebuildUsage, - getSessionUsage, - setScrollSnapshot, - getScrollSnapshot, - getSessionRevision: getSessionRevisionValue, - getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [], - getLastCompactionMessageIndex, - getMessage: (messageId: string) => state.messages[messageId], - getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId], - clearSession, - clearInstance, - } - } + rebuildUsage, + getSessionUsage, + setScrollSnapshot, + getScrollSnapshot, + getSessionRevision: getSessionRevisionValue, + getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [], + getLastAssistantMessageId: getLastAssistantMessageIdValue, + getLastCompactionMessageIndex, + getMessage: (messageId: string) => state.messages[messageId], + getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId], + clearSession, + clearInstance, + } + } diff --git a/packages/ui/src/stores/message-v2/session-info.ts b/packages/ui/src/stores/message-v2/session-info.ts index a0970ebf..50bbf697 100644 --- a/packages/ui/src/stores/message-v2/session-info.ts +++ b/packages/ui/src/stores/message-v2/session-info.ts @@ -116,18 +116,11 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void { // Prefer explicit input limits when provided by the API. // This is used by the UI "Avail" chip. contextAvailableTokens = modelInputLimit - } - - if (!contextAvailableFromPrevious && contextAvailableTokens === null) { - if (contextWindow > 0) { - if (latestHasContextUsage && actualUsageTokens > 0) { - contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0) - } else { - contextAvailableTokens = contextWindow - } - } else { - contextAvailableTokens = null - } + } else if (contextWindow > 0) { + // When no explicit input limit, show full context window capacity. + contextAvailableTokens = contextWindow + } else { + contextAvailableTokens = null } setSessionInfoByInstance((prev) => { diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index 986990a3..581a896e 100644 --- a/packages/ui/src/stores/message-v2/types.ts +++ b/packages/ui/src/stores/message-v2/types.ts @@ -113,6 +113,7 @@ export interface InstanceMessageState { sessions: Record sessionOrder: string[] messages: Record + lastAssistantMessageIds: Record messageInfoVersion: Record pendingParts: Record sessionRevisions: Record diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 0d4cff6e..39b76e5a 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -1,6 +1,7 @@ import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js" import type { Accessor, ParentComponent } from "solid-js" import { storage, type OwnerBucket } from "../lib/storage" +import type { RemoteServerProfile } from "../../../server/src/api-types" import { ensureInstanceConfigLoaded, getInstanceConfig, @@ -28,6 +29,7 @@ export type DiffViewMode = "split" | "unified" export type ExpansionPreference = "expanded" | "collapsed" export type ToolInputsVisibilityPreference = "hidden" | "collapsed" | "expanded" export type ListeningMode = "local" | "all" +export type ServerLogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" export type SpeechProviderPreference = "openai-compatible" export type SpeechPlaybackMode = "streaming" | "buffered" export type SpeechTtsFormat = "mp3" | "wav" | "opus" | "aac" @@ -53,6 +55,7 @@ export interface UiSettings { showKeyboardShortcutHints: boolean thinkingBlocksExpansion: ExpansionPreference showTimelineTools: boolean + holdLongAssistantReplies: boolean promptSubmitOnEnter: boolean showPromptVoiceInput: boolean locale?: string @@ -94,6 +97,7 @@ interface UiConfigBucket { interface ServerConfigBucket { listeningMode?: ListeningMode + logLevel?: ServerLogLevel environmentVariables?: Record opencodeBinary?: string speech?: Partial @@ -102,6 +106,7 @@ interface ServerConfigBucket { interface UiStateBucket { recentFolders?: RecentFolder[] opencodeBinaries?: OpenCodeBinary[] + remoteServers?: RemoteServerProfile[] models?: { recents?: ModelPreference[] favorites?: ModelPreference[] @@ -112,6 +117,7 @@ interface UiStateBucket { interface NormalizedUiState { recentFolders: RecentFolder[] opencodeBinaries: OpenCodeBinary[] + remoteServers: RemoteServerProfile[] models: { recents: ModelPreference[] favorites: ModelPreference[] @@ -128,6 +134,7 @@ const defaultUiSettings: UiSettings = { showKeyboardShortcutHints: true, thinkingBlocksExpansion: "expanded", showTimelineTools: true, + holdLongAssistantReplies: true, promptSubmitOnEnter: false, showPromptVoiceInput: true, diffViewMode: "split", @@ -161,6 +168,7 @@ function normalizeUiSettings(input?: Partial | null): UiSettings { sanitized.showKeyboardShortcutHints ?? defaultUiSettings.showKeyboardShortcutHints, thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion, showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools, + holdLongAssistantReplies: sanitized.holdLongAssistantReplies ?? defaultUiSettings.holdLongAssistantReplies, promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter, showPromptVoiceInput: sanitized.showPromptVoiceInput ?? defaultUiSettings.showPromptVoiceInput, locale: sanitized.locale ?? defaultUiSettings.locale, @@ -250,6 +258,29 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState { const label = typeof (b as any).label === "string" ? (b as any).label : undefined return { path: p, version, label, lastUsed } }), + remoteServers: cloneArray(source.remoteServers, (server) => { + if (!server || typeof server !== "object") return null + const id = typeof (server as any).id === "string" ? (server as any).id.trim() : "" + const name = typeof (server as any).name === "string" ? (server as any).name.trim() : "" + const baseUrl = typeof (server as any).baseUrl === "string" ? (server as any).baseUrl.trim() : "" + if (!id || !name || !baseUrl) return null + const createdAt = typeof (server as any).createdAt === "string" ? (server as any).createdAt : new Date().toISOString() + const updatedAt = typeof (server as any).updatedAt === "string" ? (server as any).updatedAt : createdAt + const lastConnectedAt = typeof (server as any).lastConnectedAt === "string" ? (server as any).lastConnectedAt : undefined + return { + id, + name, + baseUrl, + skipTlsVerify: Boolean((server as any).skipTlsVerify), + createdAt, + updatedAt, + lastConnectedAt, + } + }).sort((a, b) => { + const left = a.lastConnectedAt ?? a.updatedAt + const right = b.lastConnectedAt ?? b.updatedAt + return right.localeCompare(left) + }), models: { recents: cloneArray((source.models as any)?.recents, (m) => { if (!m || typeof m !== "object") return null @@ -272,13 +303,17 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState { function normalizeServerConfig( input?: ServerConfigBucket | null, -): Required> & { speech: SpeechSettings } { +): Required> & { speech: SpeechSettings } { const source = input ?? {} const listeningMode = source.listeningMode === "all" ? "all" : "local" + const logLevel = + source.logLevel === "INFO" || source.logLevel === "WARN" || source.logLevel === "ERROR" || source.logLevel === "DEBUG" + ? source.logLevel + : "DEBUG" const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode" const environmentVariables = normalizeRecord(source.environmentVariables) const speech = normalizeSpeechSettings(source.speech) - return { listeningMode, opencodeBinary, environmentVariables, speech } + return { listeningMode, logLevel, opencodeBinary, environmentVariables, speech } } function getModelKey(model: { providerId: string; modelId: string }): string { @@ -305,6 +340,43 @@ function buildBinaryList(binaryPath: string, version: string | undefined, source return [nextEntry, ...source].slice(0, 10) } +interface RemoteServerProfileInput { + id?: string + name: string + baseUrl: string + skipTlsVerify: boolean +} + +function buildRemoteServerProfile(input: RemoteServerProfileInput, source: RemoteServerProfile[]): RemoteServerProfile { + const existing = input.id ? source.find((entry) => entry.id === input.id) : undefined + const now = new Date().toISOString() + return { + id: existing?.id ?? input.id ?? createRandomId(), + name: input.name.trim(), + baseUrl: input.baseUrl.trim(), + skipTlsVerify: Boolean(input.skipTlsVerify), + createdAt: existing?.createdAt ?? now, + updatedAt: now, + lastConnectedAt: existing?.lastConnectedAt, + } +} + +function buildRemoteServerList(profile: RemoteServerProfile, source: RemoteServerProfile[]): RemoteServerProfile[] { + const remaining = source.filter((entry) => entry.id !== profile.id) + return [profile, ...remaining].sort((a, b) => { + const left = a.lastConnectedAt ?? a.updatedAt + const right = b.lastConnectedAt ?? b.updatedAt + return right.localeCompare(left) + }) +} + +function createRandomId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID() + } + return `remote-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + const [uiConfigBucket, setUiConfigBucket] = createSignal({}) const [serverConfigBucket, setServerConfigBucket] = createSignal({}) const [uiStateBucket, setUiStateBucket] = createSignal({}) @@ -318,6 +390,7 @@ const uiState = createMemo(() => normalizeUiState(uiStateBucket())) const preferences = uiSettings const recentFolders = createMemo(() => uiState().recentFolders) const opencodeBinaries = createMemo(() => uiState().opencodeBinaries) +const remoteServers = createMemo(() => uiState().remoteServers) let loadPromise: Promise | null = null @@ -409,6 +482,11 @@ function updateLastUsedBinary(path: string): void { void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error)) } +function updateLogLevel(level: ServerLogLevel): void { + const target = level ?? "DEBUG" + void patchConfigOwner("server", { logLevel: target }).catch((error) => log.error("Failed to set log level", error)) +} + async function updateSpeechSettings(updates: SpeechSettingsUpdate): Promise { const apiKeyPatch = updates.apiKey const { apiKey: _apiKey, ...restUpdates } = updates @@ -456,6 +534,29 @@ function removeRecentFolder(folderPath: string): void { void patchStateOwner("ui", { recentFolders: next }).catch((error) => log.error("Failed to remove recent folder", error)) } +async function saveRemoteServerProfile(input: RemoteServerProfileInput): Promise { + const profile = buildRemoteServerProfile(input, remoteServers()) + await patchStateOwner("ui", { remoteServers: buildRemoteServerList(profile, remoteServers()) }) + return profile +} + +async function markRemoteServerConnected(id: string): Promise { + const current = remoteServers().find((entry) => entry.id === id) + if (!current) return + const now = new Date().toISOString() + const updated: RemoteServerProfile = { + ...current, + updatedAt: now, + lastConnectedAt: now, + } + await patchStateOwner("ui", { remoteServers: buildRemoteServerList(updated, remoteServers()) }) +} + +function removeRemoteServerProfile(id: string): void { + const next = remoteServers().filter((entry) => entry.id !== id) + void patchStateOwner("ui", { remoteServers: next }).catch((error) => log.error("Failed to remove remote server", error)) +} + function recordWorkspaceLaunch(folderPath: string, binaryPath?: string): void { const targetBinary = binaryPath && binaryPath.trim().length > 0 ? binaryPath : serverSettings().opencodeBinary const nextFolders = buildRecentFolderList(folderPath, recentFolders()) @@ -612,17 +713,22 @@ interface ConfigContextValue { updateEnvironmentVariables: typeof updateEnvironmentVariables addEnvironmentVariable: typeof addEnvironmentVariable removeEnvironmentVariable: typeof removeEnvironmentVariable - updateLastUsedBinary: typeof updateLastUsedBinary - updateSpeechSettings: typeof updateSpeechSettings + updateLastUsedBinary: typeof updateLastUsedBinary + updateLogLevel: typeof updateLogLevel + updateSpeechSettings: typeof updateSpeechSettings // ui-owned state recentFolders: typeof recentFolders opencodeBinaries: typeof opencodeBinaries + remoteServers: typeof remoteServers uiState: typeof uiState addRecentFolder: typeof addRecentFolder removeRecentFolder: typeof removeRecentFolder addOpenCodeBinary: typeof addOpenCodeBinary removeOpenCodeBinary: typeof removeOpenCodeBinary + saveRemoteServerProfile: typeof saveRemoteServerProfile + markRemoteServerConnected: typeof markRemoteServerConnected + removeRemoteServerProfile: typeof removeRemoteServerProfile recordWorkspaceLaunch: typeof recordWorkspaceLaunch addRecentModelPreference: typeof addRecentModelPreference isFavoriteModelPreference: typeof isFavoriteModelPreference @@ -663,14 +769,19 @@ const configContextValue: ConfigContextValue = { addEnvironmentVariable, removeEnvironmentVariable, updateLastUsedBinary, + updateLogLevel, updateSpeechSettings, recentFolders, opencodeBinaries, + remoteServers, uiState, addRecentFolder, removeRecentFolder, addOpenCodeBinary, removeOpenCodeBinary, + saveRemoteServerProfile, + markRemoteServerConnected, + removeRemoteServerProfile, recordWorkspaceLaunch, addRecentModelPreference, isFavoriteModelPreference, @@ -746,6 +857,7 @@ export { addEnvironmentVariable, removeEnvironmentVariable, updateLastUsedBinary, + updateLogLevel, updateSpeechSettings, addRecentFolder, removeRecentFolder, diff --git a/packages/ui/src/stores/settings-screen.ts b/packages/ui/src/stores/settings-screen.ts index f411f073..e8ae4bf4 100644 --- a/packages/ui/src/stores/settings-screen.ts +++ b/packages/ui/src/stores/settings-screen.ts @@ -1,6 +1,6 @@ import { createSignal } from "solid-js" -export type SettingsSectionId = "appearance" | "notifications" | "remote" | "speech" | "opencode" +export type SettingsSectionId = "appearance" | "notifications" | "remote" | "speech" | "opencode" | "sidecars" const [settingsOpen, setSettingsOpen] = createSignal(false) const [activeSettingsSection, setActiveSettingsSection] = createSignal("appearance") diff --git a/packages/ui/src/stores/sidecars.ts b/packages/ui/src/stores/sidecars.ts new file mode 100644 index 00000000..e5df4d10 --- /dev/null +++ b/packages/ui/src/stores/sidecars.ts @@ -0,0 +1,149 @@ +import { createMemo, createSignal } from "solid-js" +import { serverApi } from "../lib/api-client" +import { tGlobal } from "../lib/i18n" +import { serverEvents } from "../lib/server-events" +import { getLogger } from "../lib/logger" +import type { SideCar } from "../../../server/src/api-types" + +const log = getLogger("api") + +export interface SideCarTabRecord { + token: string + sidecarId: string + name: string + port?: number + prefixMode: SideCar["prefixMode"] + proxyBasePath: string + shellUrl: string +} + +function buildSidecarShellUrl(sidecarId: string): string { + return `/sidecars/${encodeURIComponent(sidecarId)}/` +} + +const [sidecars, setSidecars] = createSignal>(new Map()) +const [sidecarTabs, setSidecarTabs] = createSignal([]) +const [activeSidecarToken, setActiveSidecarToken] = createSignal(null) +const [sidecarsLoading, setSidecarsLoading] = createSignal(false) + +let loadPromise: Promise | null = null + +async function ensureSidecarsLoaded() { + if (loadPromise) return loadPromise + setSidecarsLoading(true) + loadPromise = serverApi.fetchSidecars() + .then((result) => { + setSidecars(new Map(result.sidecars.map((sidecar) => [sidecar.id, sidecar]))) + }) + .catch((error) => { + log.error("Failed to load SideCars", error) + }) + .finally(() => { + setSidecarsLoading(false) + loadPromise = null + }) + return loadPromise +} + +function upsertSidecar(sidecar: SideCar) { + setSidecars((prev) => { + const next = new Map(prev) + next.set(sidecar.id, sidecar) + return next + }) + + setSidecarTabs((prev) => + prev.map((tab) => + tab.sidecarId === sidecar.id + ? { + ...tab, + name: sidecar.name, + port: sidecar.port, + prefixMode: sidecar.prefixMode, + proxyBasePath: buildSidecarShellUrl(sidecar.id).replace(/\/$/, ""), + shellUrl: buildSidecarShellUrl(sidecar.id), + } + : tab, + ), + ) +} + +function removeSidecar(sidecarId: string) { + setSidecars((prev) => { + const next = new Map(prev) + next.delete(sidecarId) + return next + }) + + setSidecarTabs((prev) => { + const next = prev.filter((tab) => tab.sidecarId !== sidecarId) + if (!next.some((tab) => tab.token === activeSidecarToken())) { + setActiveSidecarToken(next[0]?.token ?? null) + } + return next + }) +} + +serverEvents.on("sidecar.updated", (event) => { + if (event.type !== "sidecar.updated") return + upsertSidecar(event.sidecar) +}) + +serverEvents.on("sidecar.removed", (event) => { + if (event.type !== "sidecar.removed") return + removeSidecar(event.sidecarId) +}) + +async function openSidecarTab(sidecarId: string) { + await ensureSidecarsLoaded() + + const sidecar = sidecars().get(sidecarId) + if (!sidecar) { + throw new Error(tGlobal("sidecars.open.notFound")) + } + if (sidecar.status !== "running") { + throw new Error(tGlobal("sidecars.open.notRunning")) + } + + const token = `${sidecarId}:${Date.now().toString(36)}` + const nextTab: SideCarTabRecord = { + token, + sidecarId, + name: sidecar.name, + port: sidecar.port, + prefixMode: sidecar.prefixMode, + proxyBasePath: buildSidecarShellUrl(sidecarId).replace(/\/$/, ""), + shellUrl: buildSidecarShellUrl(sidecarId), + } + + setSidecarTabs((prev) => [...prev, nextTab]) + setActiveSidecarToken(nextTab.token) + return nextTab +} + +function closeSidecarTab(token: string) { + setSidecarTabs((prev) => { + const index = prev.findIndex((tab) => tab.token === token) + if (index < 0) return prev + const next = prev.filter((tab) => tab.token !== token) + if (activeSidecarToken() === token) { + const fallback = next[index - 1] ?? next[index] ?? null + setActiveSidecarToken(fallback?.token ?? null) + } + return next + }) +} + +const activeSidecarTab = createMemo(() => sidecarTabs().find((tab) => tab.token === activeSidecarToken()) ?? null) + +export { + sidecars, + sidecarTabs, + activeSidecarToken, + activeSidecarTab, + sidecarsLoading, + setActiveSidecarToken, + ensureSidecarsLoaded, + openSidecarTab, + closeSidecarTab, +} diff --git a/packages/ui/src/styles/components/settings-screen.css b/packages/ui/src/styles/components/settings-screen.css index 15ad55d9..0827c9a5 100644 --- a/packages/ui/src/styles/components/settings-screen.css +++ b/packages/ui/src/styles/components/settings-screen.css @@ -526,14 +526,49 @@ @media (max-width: 640px) { .settings-screen-frame { padding: 0; + overflow: hidden; } .modal-surface.settings-screen-shell { width: 100%; + max-width: 100%; height: 100%; max-height: none; min-height: 100%; border-radius: 0; + overflow-x: hidden; + } + + .modal-surface.settings-screen-shell .settings-screen-nav, + .modal-surface.settings-screen-shell .settings-screen-nav-list, + .modal-surface.settings-screen-shell .settings-screen-content, + .modal-surface.settings-screen-shell .settings-screen-scroll, + .modal-surface.settings-screen-shell .settings-section-stack, + .modal-surface.settings-screen-shell .settings-stack, + .modal-surface.settings-screen-shell .settings-card, + .modal-surface.settings-screen-shell .settings-card-content, + .modal-surface.settings-screen-shell .settings-toggle-row, + .modal-surface.settings-screen-shell .settings-toggle-row > * { + min-width: 0; + } + + .modal-surface.settings-screen-shell .selector-trigger, + .modal-surface.settings-screen-shell .selector-input, + .modal-surface.settings-screen-shell .selector-button { + min-width: 0; + max-width: 100%; + } + + .modal-surface.settings-screen-shell .settings-toggle-caption, + .modal-surface.settings-screen-shell .settings-inline-note, + .modal-surface.settings-screen-shell .remote-address-url, + .modal-surface.settings-screen-shell code { + overflow-wrap: anywhere; + word-break: break-word; + } + + .modal-surface.settings-screen-shell .whitespace-nowrap { + white-space: normal; } .settings-screen-content-header, diff --git a/packages/ui/src/styles/messaging/message-section.css b/packages/ui/src/styles/messaging/message-section.css index 7e6f2475..9582b48d 100644 --- a/packages/ui/src/styles/messaging/message-section.css +++ b/packages/ui/src/styles/messaging/message-section.css @@ -242,6 +242,10 @@ color: var(--accent-primary); } +.message-scroll-button[data-active="false"] .message-scroll-icon--toggle { + color: var(--text-secondary); +} + .message-quote-popover { position: absolute; z-index: 5; diff --git a/packages/ui/src/styles/messaging/message-timeline.css b/packages/ui/src/styles/messaging/message-timeline.css index 78aeb927..308ae92c 100644 --- a/packages/ui/src/styles/messaging/message-timeline.css +++ b/packages/ui/src/styles/messaging/message-timeline.css @@ -66,10 +66,11 @@ } .message-timeline { + --message-timeline-segment-gap: 0.35rem; flex: 1 1 auto; display: flex; flex-direction: column; - gap: 0.35rem; + gap: 0; padding: 0.25rem; overflow-y: auto; overflow-x: visible; @@ -114,6 +115,17 @@ -webkit-touch-callout: none; } +.message-timeline-item { + display: flex; + flex-direction: column; + width: 100%; +} + +.message-timeline-item-spacer { + flex: none; + width: 100%; +} + .message-timeline-segment[data-delete-hover="true"]::before { content: ""; position: absolute; @@ -319,18 +331,7 @@ border-inline-start: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent); } -/* Extra spacing before the first tool in a group to separate from the - preceding user/assistant badge. */ -.message-timeline-group-start { - margin-top: 0.35rem; -} - -/* Subtle extra spacing after the group parent (assistant) to separate - from the next user badge below. Uses adjacent sibling targeting. */ -.message-timeline-group-parent + .message-timeline-user, -.message-timeline-group-parent + .message-timeline-compaction { - margin-top: 0.35rem; -} +/* Spacing is rendered by the measured item wrapper so virtua can account for it. */ .message-timeline-container { position: relative; diff --git a/packages/ui/src/styles/messaging/tool-call.css b/packages/ui/src/styles/messaging/tool-call.css index 55aa2448..f75feaae 100644 --- a/packages/ui/src/styles/messaging/tool-call.css +++ b/packages/ui/src/styles/messaging/tool-call.css @@ -321,6 +321,7 @@ .tool-call-diff-shell { padding: 0; + scrollbar-gutter: auto; } .tool-call-diff-viewer { @@ -343,6 +344,8 @@ .tool-call-diff-shell .tool-call-diff-viewer { max-height: none; overflow: visible; + width: 100%; + min-width: 100%; } .tool-call-diff-toolbar-label { @@ -513,6 +516,84 @@ font-size: var(--font-size-xs); } +.tool-call-diff-shell .tool-call-diff-viewer .diff-line-content, +.tool-call-diff-shell .tool-call-diff-viewer .diff-line-hunk-content, +.tool-call-diff-shell .tool-call-diff-viewer .diff-line-old-content, +.tool-call-diff-shell .tool-call-diff-viewer .diff-line-new-content { + padding-right: 0 !important; +} + +.tool-call-diff-shell[data-diff-mode="unified"] .tool-call-diff-viewer .diff-line-num { + padding-left: 1px !important; + padding-right: 1px !important; + text-align: left !important; +} + +.tool-call-diff-shell[data-diff-mode="unified"] .tool-call-diff-viewer .unified-diff-table { + table-layout: fixed; +} + +.tool-call-diff-shell[data-diff-mode="unified"] .tool-call-diff-viewer .unified-diff-table-num-col { + width: auto !important; +} + +.tool-call-diff-shell[data-diff-mode="unified"] .tool-call-diff-viewer .tool-call-diff-compact-line-number { + display: block; + width: 100%; + overflow: hidden; + text-align: left; + white-space: nowrap; +} + +.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-old-num, +.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-new-num, +.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-hunk-action { + padding-left: 2px !important; + padding-right: 2px !important; + text-align: left !important; +} + +.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-old-num, +.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-new-num, +.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-old-num [data-line-num], +.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-new-num [data-line-num] { + white-space: nowrap !important; + word-break: normal !important; + overflow-wrap: normal !important; +} + +.tool-call-diff-shell[data-diff-mode="split"] .tool-call-diff-viewer .diff-line-hunk-action { + padding-top: 1px !important; + padding-bottom: 1px !important; +} + +.tool-call-diff-shell .tool-call-diff-viewer .diff-line-content-item { + padding-left: 1.1em !important; +} + +.tool-call-diff-shell .tool-call-diff-viewer .diff-line-content-operator { + margin-left: -1.1em !important; + width: 0.9em !important; + min-width: 0.9em !important; + text-indent: 0 !important; +} + +@media (max-width: 640px) { + .tool-call-diff-shell[data-diff-mode="unified"] .tool-call-diff-viewer .unified-diff-table-wrapper { + --diff-aside-width: 18px; + } + + .tool-call-diff-shell .tool-call-diff-viewer .diff-line-content-item { + padding-left: 1.5em !important; + } + + .tool-call-diff-shell .tool-call-diff-viewer .diff-line-content-operator { + margin-left: -1.5em !important; + width: 1.1em !important; + min-width: 1.1em !important; + } +} + .tool-call-markdown .markdown-code-block { margin: 0; border-radius: 0; diff --git a/packages/ui/src/styles/panels/tabs.css b/packages/ui/src/styles/panels/tabs.css index 13fa65b9..3d6c251f 100644 --- a/packages/ui/src/styles/panels/tabs.css +++ b/packages/ui/src/styles/panels/tabs.css @@ -154,6 +154,31 @@ ring-offset-color: var(--surface-base); } +.tab-pill { + @apply inline-flex items-center gap-1 px-3 py-2 rounded-t-md max-w-[220px] text-sm; + background-color: var(--tab-inactive-bg); + color: var(--tab-inactive-text); + border-bottom: 2px solid var(--tab-active-bg); +} + +.tab-pill-active { + background-color: var(--tab-active-bg); + color: var(--tab-active-text); + border-bottom-color: var(--accent-primary); +} + +.tab-pill-button { + @apply truncate; +} + +.tab-pill-close { + @apply inline-flex items-center justify-center rounded w-5 h-5 text-xs; +} + +.tab-pill-close:hover { + background-color: var(--new-tab-hover-bg); +} + /* Session tabs */ .session-tab-base { @apply inline-flex items-center gap-2 px-3 py-1.5 rounded-t-md max-w-[150px] transition-colors text-sm; diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts index 258f85fe..8e6a475e 100644 --- a/packages/ui/src/types/global.d.ts +++ b/packages/ui/src/types/global.d.ts @@ -33,6 +33,12 @@ declare global { setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }> showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }> + openRemoteWindow?: (payload: { + id: string + name: string + baseUrl: string + skipTlsVerify: boolean + }) => Promise<{ ok: boolean }> } interface File { diff --git a/packages/ui/src/types/message.ts b/packages/ui/src/types/message.ts index 6bca8238..95fec10d 100644 --- a/packages/ui/src/types/message.ts +++ b/packages/ui/src/types/message.ts @@ -40,6 +40,7 @@ export interface RenderCache { html: string theme?: string mode?: string + wrap?: boolean } export interface PendingPermissionState { @@ -77,6 +78,10 @@ export interface TextPart { export type MessageInfo = SDKMessage +export function isHiddenSyntheticTextPart(part: ClientPart): boolean { + return Boolean(part && part.type === "text" && part.synthetic) +} + function hasTextSegment(segment: string | { text?: string }): boolean { if (typeof segment === "string") { return segment.trim().length > 0 @@ -94,6 +99,10 @@ export function partHasRenderableText(part: ClientPart): boolean { return false } + if (isHiddenSyntheticTextPart(part)) { + return false + } + const typedPart = part as SDKPart if (typedPart.type === "text" && hasTextSegment(typedPart.text)) { diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index 179b712a..de1a6e8d 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -4,8 +4,7 @@ import type { Provider as SDKProvider, Model as SDKModel, } from "@opencode-ai/sdk" -import type { SessionStatus as SDKSessionStatus } from "@opencode-ai/sdk/v2/client" -import type { FileDiff } from "@opencode-ai/sdk/v2/client" +import type { SessionStatus as SDKSessionStatus, FileDiff } from "@opencode-ai/sdk/v2/client" // Export SDK types for external use export type {
Could not connect to the remote server.
${escapedMessage}
${escapedUrl}
{t("folderSelection.empty.title")}
{t("folderSelection.empty.description")}
- {t( - folders().length === 1 - ? "folderSelection.recent.subtitle.one" - : "folderSelection.recent.subtitle.other", - { count: folders().length }, - )} -
+ {t( + folders().length === 1 + ? "folderSelection.recent.subtitle.one" + : "folderSelection.recent.subtitle.other", + { count: folders().length }, + )} +
+ {t("folderSelection.servers.count", { count: remoteServers().length })} +
{t("folderSelection.servers.empty.title")}
{t("folderSelection.servers.empty.description")}
{t("folderSelection.browse.subtitle")}
{t("folderSelection.actions.subtitle")}
{message()}
{ + preRef = element || undefined + if (preRef) { + preRef.textContent = props.text() || "" + } + }} + class="message-reasoning-text" + dir="auto" + /> + {followScroll.renderSentinel()} +
{reasoningText() || ""}
{t("settings.opencode.logLevel.subtitle")}
{t("settings.section.sidecars.subtitle")}
/sidecars/{derivedId()}
{t("sidecars.settings.listSubtitle")}
/sidecars/{sidecar.id}