Compare commits

..

6 Commits

Author SHA1 Message Date
Shantur Rathore
2236350f1b fix(server): prefer LAN remote addresses over public IPs 2026-04-01 22:09:23 +01:00
VooDisss
5f3e9317ca refactor(ui): align password action with status summary
Keep the Change password control on the same row as the username and password-status copy in the settings card, so the summary reads as one horizontal unit instead of stacked text followed by a separate action row.
2026-04-01 17:32:27 +03:00
VooDisss
750e73f540 refactor(ui): let settings modal use full viewport frame
Make the settings dialog fill the height inside a frame that explicitly reserves about five percent of the screen above and below. This removes the old fixed height cap and reduces unnecessary inner scrolling on taller displays.
2026-04-01 17:17:42 +03:00
VooDisss
61e06ef883 refactor(ui): group extra remote addresses behind disclosure
Replace the standalone show/hide button with a full-width collapsible disclosure container in both remote access surfaces. This keeps the hidden address list visually grouped, makes the header easier to scan and click, and centers the label so it reads as structure rather than an unrelated action button.

Keep the first recommended remote address visible by default while preserving access to the full list behind the compact disclosure header.
2026-04-01 17:04:20 +03:00
VooDisss
b0b0a55e14 refactor(server): centralize remote address selection
Unify remote address resolution so startup logging, primary remote URL selection, and /api/meta all consume the same server-side policy. Keep all external addresses user-visible, preserve stable interface ordering, and only de-prioritize link-local addresses when choosing the primary recommended remote URL.

On the UI side, introduce a shared helper that keeps the first remote address visible by default and collapses the remaining addresses behind a reveal action, with i18n coverage and targeted tests for the new selection behavior.
2026-04-01 17:03:41 +03:00
VooDisss
984743f3c7 fix(server): show sane remote URLs for 0.0.0.0 binds
When CodeNomad was started with --host 0.0.0.0, the CLI picked the first external IPv4 address it discovered and advertised only that one as the remote URL. On Windows machines with WSL, Hyper-V, Docker, or other virtual adapters, this often surfaced a virtual 172.x address even though a normal LAN address such as 192.168.x.x was also reachable and usable from other devices.

Tighten remote URL presentation so the startup log reuses the resolved address list, prefers an advertisable external address for the primary Remote Connection URL, and prints additional accessible URLs instead of hiding them. Keep address ordering stable across private RFC1918 ranges instead of hard-coding one subnet family as universally better, while filtering out link-local addresses from the user-facing advertised list to avoid noisy or misleading output.

Add targeted tests around address ordering and advertisability so link-local-first interface enumeration does not regress the primary LAN URL selection again.

# Conflicts:
#	packages/server/src/index.ts
2026-03-31 02:58:42 +03:00
212 changed files with 1863 additions and 17258 deletions

View File

@@ -212,7 +212,7 @@ jobs:
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
for file in packages/electron-app/release/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
@@ -313,7 +313,7 @@ jobs:
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
for file in packages/electron-app/release/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
@@ -324,9 +324,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
path: |
packages/electron-app/release/*.zip
packages/electron-app/release/*.AppImage
path: packages/electron-app/release/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error

View File

@@ -4,7 +4,6 @@ on:
pull_request_target:
types:
- opened
- edited
- synchronize
- reopened
- ready_for_review
@@ -20,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
ACTOR: ${{ github.actor }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
PR_NUMBER: ${{ github.event.pull_request.number }}
@@ -38,7 +37,7 @@ jobs:
fi
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
if [[ "$normalized" == *",${ACTOR},"* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
else
echo "allowed=false" >> "$GITHUB_OUTPUT"

View File

@@ -4,7 +4,6 @@ on:
pull_request:
types:
- opened
- edited
- synchronize
- reopened
- ready_for_review
@@ -24,7 +23,7 @@ jobs:
allowed: ${{ steps.auth.outputs.allowed }}
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
ACTOR: ${{ github.actor }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
steps:
- name: Check PR authorization
@@ -38,11 +37,11 @@ jobs:
fi
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
if [[ "$normalized" == *",${ACTOR},"* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
else
echo "allowed=false" >> "$GITHUB_OUTPUT"
echo "Skipping builds for PR by unauthorized author targeting $BASE_REF" >&2
echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2
fi
build:

View File

@@ -4,7 +4,6 @@ on:
pull_request_target:
types:
- opened
- edited
- reopened
- synchronize
@@ -18,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
ACTOR: ${{ github.actor }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
steps:
@@ -28,7 +27,7 @@ jobs:
run: |
set -euo pipefail
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
if [[ "$normalized" == *",${ACTOR},"* ]]; then
echo "authorized=true" >> "$GITHUB_OUTPUT"
else
echo "authorized=false" >> "$GITHUB_OUTPUT"
@@ -51,5 +50,5 @@ jobs:
- name: Fail unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}
run: |
echo "PR author $PR_AUTHOR is not allowed to open PRs targeting $BASE_REF" >&2
echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
exit 1

204
README.md
View File

@@ -1,182 +1,128 @@
# CodeNomad
## The AI Coding Cockpit for OpenCode
## A fast, multi-instance workspace for running OpenCode sessions.
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.
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.
![Multi-instance workspace](docs/screenshots/newSession.png)
_Manage multiple OpenCode sessions side-by-side._
---
<details>
<summary>📸 More Screenshots</summary>
## Features
![Command palette overlay](docs/screenshots/command-palette.png)
_Global command palette for keyboard-first control._
- **🚀 Multi-Instance Workspace**
- **🌐 Remote Access**
- **🧠 Session Management**
- **🎙️ Voice Input & Speech**
- **🌳 Git Worktrees**
- **💬 Rich Message Experience**
- **🧩 SideCars**
- **⌨️ Command Palette**
- **📁 File System Browser**
- **🔐 Authentication & Security**
- **🔔 Notifications**
- **🎨 Theming**
- **🌍 Internationalization**
![Image Previews](docs/screenshots/image-previews.png)
_Rich media previews for images and assets._
---
![Browser Support](docs/screenshots/browser-support.png)
_Browser support via CodeNomad Server._
</details>
## Getting Started
### 🖥️ Desktop App
Choose the way that fits your workflow:
Available as both Electron and Tauri builds — choose based on your preference.
### 🖥️ Desktop App (Recommended)
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
Download the latest installer for your platform from [Releases](https://github.com/shantur/CodeNomad/releases).
- **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.
| Platform | Formats |
|----------|---------|
| macOS | DMG, ZIP (Universal: Intel + Apple Silicon) |
| Windows | NSIS Installer, ZIP (x64, ARM64) |
| Linux | AppImage, deb, tar.gz (x64, ARM64) |
### 🦀 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.
### 💻 CodeNomad Server
Run as a local server and access via browser. Perfect for remote development.
Run CodeNomad as a local server and access it via your web browser. Perfect for remote development (SSH/VPN) or running as a service.
```bash
npx @neuralnomads/codenomad --launch
```
See [Server Documentation](packages/server/README.md) for flags, TLS, auth, and remote access.
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
```
### 🧪 Dev Releases
Bleeding-edge builds from the `dev` branch:
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
```bash
npx @neuralnomads/codenomad-dev --launch
```
---
## Highlights
## SideCars
SideCars let you open local web tools inside CodeNomad as tabs.
<details>
<summary><strong>Configuration</strong></summary>
- **Name**: Display name used in CodeNomad
- **Port**: Local HTTP or HTTPS service running on `127.0.0.1:<port>`
- **Base path**: Mounted under `/sidecars/:id`
- **Prefix mode**:
- **Preserve prefix** forwards the full `/sidecars/:id/...` path upstream
- **Strip prefix** removes `/sidecars/:id` before forwarding the request upstream
</details>
<details>
<summary><strong>VSCode (OpenVSCode Server)</strong></summary>
Run with Docker:
```bash
docker run -it --init -p 8000:3000 -v "${HOME}:${HOME}:cached" -e HOME=${HOME} gitpod/openvscode-server --server-base-path /sidecars/vscode
```
Add SideCar as:
- **Name**: `VSCode`
- **Port**: `http://127.0.0.1:8000`
- **Base path**: `/sidecars/vscode`
- **Prefix mode**: `Preserve prefix`
</details>
<details>
<summary><strong>Terminal (ttyd)</strong></summary>
Run with:
```bash
ttyd --writable zsh
```
Add SideCar as:
- **Name**: `Terminal`
- **Port**: `http://127.0.0.1:7681`
- **Base path**: `/sidecars/terminal`
- **Prefix mode**: `Strip prefix`
</details>
---
- **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 in your `PATH`
- **Node.js 18+** — for server mode or building from source
---
## Development
CodeNomad is a monorepo built with:
| Package | Description |
|---------|-------------|
| **[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 Start
```bash
git clone https://github.com/NeuralNomadsAI/CodeNomad.git
cd CodeNomad
npm install
npm run dev
```
---
- **[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.
## Troubleshooting
<details>
<summary><strong>macOS: "CodeNomad.app is damaged and can't be opened"</strong></summary>
Gatekeeper flag due to missing notarization. Clear the quarantine attribute:
### 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`:
```bash
xattr -l /Applications/CodeNomad.app
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
```
On Intel Macs, also check **System Settings → Privacy & Security** on first launch.
</details>
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.
<details>
<summary><strong>Linux (Wayland + NVIDIA): Tauri App closes immediately</strong></summary>
### 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.
WebKitGTK DMA-BUF/GBM issue. Run with:
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
```
See full workaround in the original README.
</details>
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 "$@"
```
## Community
Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702
[![Star History](https://api.star-history.com/svg?repos=NeuralNomadsAI/CodeNomad&type=Date)](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
## 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:
| 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. |
### Quick Build
To build the Desktop App from source:
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`.
[![Star History Chart](https://api.star-history.com/svg?repos=NeuralNomadsAI/CodeNomad&type=Date)](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
**Built with ♥ by [Neural Nomads](https://github.com/NeuralNomadsAI)** · [MIT License](LICENSE)

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 966 KiB

1257
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.14.0",
"version": "0.13.1",
"private": true,
"description": "CodeNomad monorepo workspace",
"license": "MIT",
@@ -22,15 +22,13 @@
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
"bumpVersion": "node ./scripts/bump-version.js"
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
},
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
},
"devDependencies": {
"@esbuild/darwin-arm64": "^0.28.0",
"@rollup/rollup-darwin-arm64": "^4.60.2",
"baseline-browser-mapping": "^2.9.11"
}
}

View File

@@ -1,4 +1,4 @@
{
"minServerVersion": "0.14.0",
"minServerVersion": "0.13.1",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
}

View File

@@ -4,23 +4,6 @@ export interface Env {
export default {
async fetch(request: Request, env: Env): Promise<Response> {
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)
},
}

View File

@@ -2,4 +2,3 @@ node_modules/
dist/
release/
.vite/
electron/resources/server/

View File

@@ -1,6 +1,5 @@
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
import fs from "fs"
import { requestMicrophoneAccess } from "./permissions"
import type { CliProcessManager, CliStatus } from "./process-manager"
let wakeLockId: number | null = null
@@ -112,33 +111,6 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return { enabled: false }
})
ipcMain.handle(
"media:requestMicrophoneAccess",
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<void>
}).__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 }> => {

View File

@@ -1,12 +1,11 @@
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
import http from "node:http"
import https from "node:https"
import { existsSync, mkdirSync } from "fs"
import { existsSync } from "fs"
import { dirname, join } from "path"
import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu"
import { setupCliIPC } from "./ipc"
import { configureMediaPermissionHandlers } from "./permissions"
import { CliProcessManager } from "./process-manager"
const mainFilename = fileURLToPath(import.meta.url)
@@ -14,31 +13,6 @@ 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
@@ -46,8 +20,6 @@ let pendingCliUrl: string | null = null
let pendingBootstrapToken: string | null = null
let showingLoadingScreen = false
let preloadingView: BrowserView | null = null
const remoteWindowOrigins = new Map<number, Set<string>>()
const insecureWindowOrigins = new Map<number, Set<string>>()
if (isMac) {
app.commandLine.appendSwitch("disable-spell-checking")
@@ -120,13 +92,8 @@ function loadLoadingScreen(window: BrowserWindow) {
})
}
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
function getAllowedRendererOrigins(): string[] {
const origins = new Set<string>()
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) {
@@ -141,13 +108,13 @@ function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
return Array.from(origins)
}
function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean {
function shouldOpenExternally(url: string): boolean {
try {
const parsed = new URL(url)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return true
}
const allowedOrigins = getAllowedRendererOrigins(window)
const allowedOrigins = getAllowedRendererOrigins()
return !allowedOrigins.includes(parsed.origin)
} catch {
return false
@@ -160,7 +127,7 @@ function setupNavigationGuards(window: BrowserWindow) {
}
window.webContents.setWindowOpenHandler(({ url }) => {
if (shouldOpenExternally(url, window)) {
if (shouldOpenExternally(url)) {
handleExternal(url)
return { action: "deny" }
}
@@ -168,54 +135,13 @@ function setupNavigationGuards(window: BrowserWindow) {
})
window.webContents.on("will-navigate", (event, url) => {
if (shouldOpenExternally(url, window)) {
if (shouldOpenExternally(url)) {
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)) {
@@ -280,30 +206,25 @@ function createWindow() {
},
})
const window = mainWindow
setupNavigationGuards(window)
setupNavigationGuards(mainWindow)
if (isMac) {
window.webContents.session.setSpellCheckerEnabled(false)
mainWindow.webContents.session.setSpellCheckerEnabled(false)
}
showingLoadingScreen = true
currentCliUrl = null
clearWindowAllowedOrigin(window)
loadLoadingScreen(window)
loadLoadingScreen(mainWindow)
if (process.env.NODE_ENV === "development") {
window.webContents.openDevTools({ mode: "detach" })
mainWindow.webContents.openDevTools({ mode: "detach" })
}
createApplicationMenu(window)
setupCliIPC(window, cliManager)
createApplicationMenu(mainWindow)
setupCliIPC(mainWindow, cliManager)
window.on("closed", () => {
mainWindow.on("closed", () => {
destroyPreloadingView()
clearWindowAllowedOrigin(window)
clearWindowInsecureOrigin(window)
mainWindow = null
currentCliUrl = null
pendingCliUrl = null
@@ -400,66 +321,10 @@ function finalizeCliSwap(url: string) {
return
}
const window = mainWindow
showingLoadingScreen = false
currentCliUrl = url
setWindowAllowedOrigin(window, url)
pendingCliUrl = null
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
return `<!doctype html><html><head><meta charset="utf-8" /><title>${escapedName}</title><style>body{margin:0;background:#111827;color:#f9fafb;font-family:Inter,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}main{max-width:560px;width:100%;background:rgba(17,24,39,.88);border:1px solid rgba(255,255,255,.08);border-radius:20px;padding:28px;box-shadow:0 25px 60px rgba(0,0,0,.45)}h1{margin:0 0 10px;font-size:1.5rem}p{margin:0 0 10px;color:#cbd5e1;line-height:1.5}code{display:block;margin-top:16px;padding:12px 14px;border-radius:12px;background:#0f172a;color:#bfdbfe;overflow:auto}</style></head><body><main><h1>${escapedName}</h1><p>Could not connect to the remote server.</p><p>${escapedMessage}</p><code>${escapedUrl}</code></main></body></html>`
}
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))}`)
}
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
}
let bootstrapExchangeInFlight = false
@@ -624,7 +489,6 @@ app.whenReady().then(() => {
if (isMac) {
session.defaultSession.setSpellCheckerEnabled(false)
configureMediaPermissionHandlers(getAllowedRendererOrigins)
app.on("browser-window-created", (_, window) => {
window.webContents.session.setSpellCheckerEnabled(false)
})
@@ -638,17 +502,6 @@ 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) {

View File

@@ -1,58 +0,0 @@
import { session, systemPreferences } from "electron"
const isMac = process.platform === "darwin"
export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean {
if (!origin) {
return false
}
try {
const normalized = new URL(origin).origin
return allowedOrigins.includes(normalized)
} catch {
return false
}
}
export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) {
const isAudioMediaRequest = (permission: string, details?: unknown) => {
if (permission !== "media") {
return false
}
const mediaTypes = (details as { mediaTypes?: string[] } | undefined)?.mediaTypes ?? []
return mediaTypes.length === 0 || mediaTypes.includes("audio")
}
session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => {
if (!isAudioMediaRequest(permission, details)) {
return false
}
return isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins())
})
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
if (!isAudioMediaRequest(permission, details)) {
callback(false)
return
}
const requestingOrigin = (details as { requestingOrigin?: string } | undefined)?.requestingOrigin || webContents.getURL()
callback(isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins()))
})
}
export async function requestMicrophoneAccess(): Promise<boolean> {
if (!isMac) {
return true
}
const status = systemPreferences.getMediaAccessStatus("microphone")
if (status === "granted") {
return true
}
return systemPreferences.askForMediaAccess("microphone")
}

View File

@@ -1,17 +1,14 @@
import { spawn, spawnSync, type ChildProcess } from "child_process"
import { app, utilityProcess, type UtilityProcess } from "electron"
import { app } from "electron"
import { createRequire } from "module"
import { EventEmitter } from "events"
import { existsSync, readFileSync } from "fs"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { parse as parseYaml } from "yaml"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
const nodeRequire = createRequire(import.meta.url)
const mainFilename = fileURLToPath(import.meta.url)
const mainDirname = path.dirname(mainFilename)
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
@@ -42,9 +39,6 @@ interface CliEntryResolution {
runnerPath?: string
}
type ManagedChild = ChildProcess | UtilityProcess
type ChildLaunchMode = "spawn" | "utility"
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function isYamlPath(filePath: string): boolean {
@@ -124,8 +118,7 @@ export declare interface CliProcessManager {
}
export class CliProcessManager extends EventEmitter {
private child?: ManagedChild
private childLaunchMode: ChildLaunchMode = "spawn"
private child?: ChildProcess
private status: CliStatus = { state: "stopped" }
private stdoutBuffer = ""
private stderrBuffer = ""
@@ -145,63 +138,33 @@ export class CliProcessManager extends EventEmitter {
this.requestedStop = false
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
const cliEntry = this.resolveCliEntry(options)
const listeningMode = this.resolveListeningMode()
const host = resolveHostForMode(listeningMode)
const args = this.buildCliArgs(options, host)
let child: ManagedChild
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
if (this.shouldUsePackagedShellSupervisor(options)) {
const runtimePath = this.resolveShellNodeCommand()
const entryPath = this.resolveBundledProdEntry()
const supervisorPath = this.resolveCliSupervisorPath()
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
const supervisorPayload = JSON.stringify({
command: shellCommand.command,
args: shellCommand.args,
cwd: process.cwd(),
})
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
env.ELECTRON_RUN_AS_NODE = "1"
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
)
console.info(`[cli] utility supervisor: ${supervisorPath}`)
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
const spawnDetails = supportsUserShell()
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args)
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
env: shellEnv,
stdio: "pipe",
serviceName: "CodeNomad CLI Supervisor",
})
this.childLaunchMode = "utility"
} else {
const cliEntry = this.resolveCliEntry(options)
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
const detached = process.platform !== "win32"
const child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
detached,
})
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
env.ELECTRON_RUN_AS_NODE = "1"
const spawnDetails = supportsUserShell()
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args)
const detached = process.platform !== "win32"
child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
detached,
})
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
this.childLaunchMode = "spawn"
}
if (this.childLaunchMode === "spawn" && !child.pid) {
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
if (!child.pid) {
console.error("[cli] spawn failed: no pid")
}
@@ -216,48 +179,23 @@ export class CliProcessManager extends EventEmitter {
this.handleStream(data.toString(), "stderr")
})
if (this.childLaunchMode === "utility") {
const utilityChild = child as UtilityProcess
child.on("error", (error) => {
console.error("[cli] failed to start CLI:", error)
this.updateStatus({ state: "error", error: error.message })
this.emit("error", error)
})
utilityChild.on("error", (error) => {
const message = this.describeUtilityProcessError(error)
console.error("[cli] utility supervisor failed:", error)
this.updateStatus({ state: "error", error: message })
this.emit("error", new Error(message))
})
utilityChild.on("exit", (code) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined
console.info(`[cli] exit (code=${code ?? ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
} else {
const spawnedChild = child as ChildProcess
spawnedChild.on("error", (error) => {
console.error("[cli] failed to start CLI:", error)
this.updateStatus({ state: "error", error: error.message })
this.emit("error", error)
})
spawnedChild.on("exit", (code, signal) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
}
child.on("exit", (code, signal) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
return new Promise<CliStatus>((resolve, reject) => {
const timeout = setTimeout(() => {
@@ -284,22 +222,16 @@ export class CliProcessManager extends EventEmitter {
return
}
if (this.childLaunchMode === "utility") {
return this.stopUtilityChild(child as UtilityProcess)
}
const spawnedChild = child as ChildProcess
this.requestedStop = true
const pid = spawnedChild.pid
const pid = child.pid
if (!pid) {
this.child = undefined
this.updateStatus({ state: "stopped" })
return
}
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
try {
@@ -375,7 +307,7 @@ export class CliProcessManager extends EventEmitter {
sendStopSignal("SIGKILL")
}, 30000)
spawnedChild.on("exit", () => {
child.on("exit", () => {
clearTimeout(killTimeout)
this.child = undefined
console.info("[cli] CLI process exited")
@@ -395,46 +327,6 @@ export class CliProcessManager extends EventEmitter {
})
}
private stopUtilityChild(child: UtilityProcess): Promise<void> {
this.requestedStop = true
const pid = child.pid
if (!pid) {
this.child = undefined
this.updateStatus({ state: "stopped" })
return Promise.resolve()
}
return new Promise((resolve) => {
const killTimeout = setTimeout(() => {
console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`)
try {
process.kill(pid, "SIGKILL")
} catch {
// no-op
}
}, 30000)
child.once("exit", () => {
clearTimeout(killTimeout)
this.child = undefined
console.info("[cli] CLI process exited")
this.updateStatus({ state: "stopped" })
resolve()
})
if (child.pid === undefined) {
clearTimeout(killTimeout)
this.child = undefined
this.updateStatus({ state: "stopped" })
resolve()
return
}
child.kill()
})
}
getStatus(): CliStatus {
return { ...this.status }
}
@@ -450,22 +342,14 @@ export class CliProcessManager extends EventEmitter {
private handleTimeout() {
if (this.child) {
const pid = this.child.pid
if (this.childLaunchMode === "utility") {
if (pid) {
try {
process.kill(pid, "SIGKILL")
} catch {
// no-op
}
}
} else if (pid && process.platform !== "win32") {
if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGKILL")
} catch {
;(this.child as ChildProcess).kill("SIGKILL")
this.child.kill("SIGKILL")
}
} else {
;(this.child as ChildProcess).kill("SIGKILL")
this.child.kill("SIGKILL")
}
this.child = undefined
}
@@ -539,7 +423,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, "--unrestricted-root"]
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName]
if (options.dev) {
// Dev: run plain HTTP + Vite dev server proxy.
@@ -572,10 +456,6 @@ export class CliProcessManager extends EventEmitter {
return parts.join(" ")
}
private buildExecutableCommand(command: string, args: string[]): string {
return [JSON.stringify(command), ...args.map((arg) => JSON.stringify(arg))].join(" ")
}
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
if (cliEntry.runner === "tsx") {
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
@@ -646,58 +526,4 @@ export class CliProcessManager extends EventEmitter {
}
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
}
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
return !options.dev && app.isPackaged && process.platform === "darwin"
}
private resolveCliSupervisorPath(): string {
const candidates = [
path.join(process.resourcesPath, "cli-supervisor.cjs"),
path.join(mainDirname, "../resources/cli-supervisor.cjs"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
}
private resolveShellNodeCommand(): string {
const configured = process.env.NODE_BINARY?.trim()
return configured && configured.length > 0 ? configured : "node"
}
private resolveBundledProdEntry(): string {
const candidates = [
path.join(process.resourcesPath, "server", "dist", "bin.js"),
path.join(mainDirname, "../resources/server/dist/bin.js"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
}
private describeUtilityProcessError(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message
}
if (error && typeof error === "object") {
const typed = error as { type?: unknown; location?: unknown }
if (typeof typed.type === "string") {
return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type
}
}
return String(error)
}
}

View File

@@ -20,10 +20,8 @@ const electronAPI = {
return null
}
},
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)

View File

@@ -1,131 +0,0 @@
#!/usr/bin/env node
const { spawn } = require("child_process")
const SHUTDOWN_GRACE_MS = 30_000
let child = null
let shutdownTimer = null
function log(message, error) {
if (error) {
console.error(`[cli-supervisor] ${message}`, error)
return
}
console.log(`[cli-supervisor] ${message}`)
}
function clearShutdownTimer() {
if (shutdownTimer) {
clearTimeout(shutdownTimer)
shutdownTimer = null
}
}
function forwardStream(stream, target) {
if (!stream) return
stream.on("data", (chunk) => {
target.write(chunk)
})
}
function terminateChild(force) {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return
}
try {
child.kill(force ? "SIGKILL" : "SIGTERM")
} catch {
// no-op
}
}
function requestShutdown(force = false) {
if (!child) {
process.exit(force ? 1 : 0)
return
}
terminateChild(force)
if (force) {
process.exit(1)
return
}
clearShutdownTimer()
shutdownTimer = setTimeout(() => {
log(`shutdown timed out after ${SHUTDOWN_GRACE_MS}ms; forcing child termination`)
terminateChild(true)
}, SHUTDOWN_GRACE_MS)
shutdownTimer.unref()
}
function installShutdownHandlers() {
process.on("SIGTERM", () => requestShutdown(false))
process.on("SIGINT", () => requestShutdown(false))
process.on("disconnect", () => requestShutdown(false))
process.on("uncaughtException", (error) => {
log("uncaught exception", error)
requestShutdown(true)
})
process.on("unhandledRejection", (error) => {
log("unhandled rejection", error)
requestShutdown(true)
})
}
function parsePayload() {
const raw = process.argv[2]
if (!raw) {
throw new Error("Supervisor payload is required")
}
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object") {
throw new Error("Supervisor payload must be an object")
}
if (typeof parsed.command !== "string" || parsed.command.trim().length === 0) {
throw new Error("Supervisor payload command is required")
}
if (!Array.isArray(parsed.args) || !parsed.args.every((value) => typeof value === "string")) {
throw new Error("Supervisor payload args must be a string array")
}
return {
command: parsed.command,
args: parsed.args,
cwd: typeof parsed.cwd === "string" && parsed.cwd.trim().length > 0 ? parsed.cwd : process.cwd(),
}
}
function main() {
installShutdownHandlers()
const payload = parsePayload()
log(`launching shell command: ${payload.command} ${payload.args.join(" ")}`)
child = spawn(payload.command, payload.args, {
cwd: payload.cwd,
env: process.env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
})
forwardStream(child.stdout, process.stdout)
forwardStream(child.stderr, process.stderr)
child.on("error", (error) => {
log("failed to spawn shell command", error)
process.exit(1)
})
child.on("exit", (code, signal) => {
clearShutdownTimer()
log(`child exited code=${code ?? ""} signal=${signal ?? ""}`)
process.exitCode = typeof code === "number" ? code : signal ? 1 : 0
process.exit()
})
}
main()

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.14.0",
"version": "0.13.1",
"description": "CodeNomad - AI coding assistant",
"license": "MIT",
"author": {
@@ -20,8 +20,6 @@
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
"prepare:resources": "node scripts/prepare-resources.js",
"prebuild": "npm run prepare:resources",
"build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json",
"preview": "electron-vite preview",
@@ -35,11 +33,8 @@
"build:linux-arm64": "node scripts/build.js linux-arm64",
"build:linux-rpm": "node scripts/build.js linux-rpm",
"build:all": "node scripts/build.js all",
"prepackage:mac": "npm run prepare:resources",
"package:mac": "electron-builder --mac",
"prepackage:win": "npm run prepare:resources",
"package:win": "electron-builder --win",
"prepackage:linux": "npm run prepare:resources",
"package:linux": "electron-builder --linux"
},
"dependencies": {
@@ -87,12 +82,6 @@
}
],
"mac": {
"entitlements": "electron/resources/entitlements.mac.plist",
"entitlementsInherit": "electron/resources/entitlements.mac.plist",
"extendInfo": {
"NSMicrophoneUsageDescription": "CodeNomad needs microphone access for speech-to-text prompt input.",
"NSLocalNetworkUsageDescription": "CodeNomad needs local network access to connect to locally hosted AI and speech services."
},
"category": "public.app-category.developer-tools",
"target": [
{
@@ -147,13 +136,6 @@
"x64",
"arm64"
]
},
{
"target": "AppImage",
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",

View File

@@ -111,12 +111,6 @@ async function build(platform) {
env: { NODE_PATH: workspaceNodeModulesPath },
})
console.log("\n📦 Step 1.5/3: Preparing packaged server resources...\n")
await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], {
cwd: workspaceRoot,
env: { NODE_PATH: workspaceNodeModulesPath },
})
console.log("\n📦 Step 2/3: Building Electron app...\n")
await run(npmCmd, ["run", "build"])

View File

@@ -1,132 +0,0 @@
#!/usr/bin/env node
import fs from "fs"
import path, { join } from "path"
import { spawnSync } from "child_process"
import { fileURLToPath } from "url"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
const appDir = join(__dirname, "..")
const workspaceRoot = join(appDir, "..", "..")
const serverRoot = join(appDir, "..", "server")
const resourcesRoot = join(appDir, "electron", "resources")
const serverDest = join(resourcesRoot, "server")
const npmExecPath = process.env.npm_execpath
const npmNodeExecPath = process.env.npm_node_execpath
const serverSources = ["dist", "public", "node_modules", "package.json"]
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
function log(message) {
console.log(`[prepare-resources] ${message}`)
}
function ensureServerBuild() {
const distPath = join(serverRoot, "dist")
const publicPath = join(serverRoot, "public")
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
throw new Error("Server build artifacts are missing. Run the server build before packaging Electron.")
}
}
function ensureServerDependencies() {
if (fs.existsSync(serverDepsMarker)) {
return
}
log("installing production server dependencies")
const npmArgs = [
"install",
"--omit=dev",
"--ignore-scripts",
"--workspaces=false",
"--package-lock=false",
"--install-strategy=shallow",
"--fund=false",
"--audit=false",
]
const env = {
...process.env,
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
npm_config_workspaces: "false",
}
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
const result = npmCli
? spawnSync(npmCli[0], npmCli[1], { cwd: serverRoot, stdio: "inherit", env })
: spawnSync("npm", npmArgs, { cwd: serverRoot, stdio: "inherit", env, shell: process.platform === "win32" })
if (result.status !== 0) {
if (result.error) {
throw result.error
}
throw new Error(`npm install exited with code ${result.status ?? 1}`)
}
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
for (const name of serverSources) {
const from = join(serverRoot, name)
const to = join(serverDest, name)
if (!fs.existsSync(from)) {
throw new Error(`Missing required server artifact: ${from}`)
}
fs.cpSync(from, to, { recursive: true, dereference: true })
log(`copied ${name} to Electron resources`)
}
}
function stripNodeModuleBins() {
const root = join(serverDest, "node_modules")
if (!fs.existsSync(root)) {
return
}
const stack = [root]
let removed = 0
while (stack.length > 0) {
const current = stack.pop()
if (!current) break
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = join(current, entry.name)
if (entry.name === ".bin") {
fs.rmSync(full, { recursive: true, force: true })
removed += 1
continue
}
if (entry.isDirectory()) {
stack.push(full)
}
}
}
if (removed > 0) {
log(`removed ${removed} node_modules/.bin directories`)
}
}
async function main() {
ensureServerBuild()
ensureServerDependencies()
copyServerArtifacts()
stripNodeModuleBins()
}
main().catch((error) => {
console.error("[prepare-resources] failed:", error)
process.exit(1)
})

View File

@@ -14,5 +14,5 @@
"noEmit": true
},
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
"exclude": ["node_modules", "dist", "electron/resources/server"]
"exclude": ["node_modules", "dist"]
}

View File

@@ -4,6 +4,6 @@
"private": true,
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.3.7"
"@opencode-ai/plugin": "1.3.2"
}
}

View File

@@ -2,8 +2,6 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
import { createBackgroundProcessTools } from "./lib/background-process"
let voiceModeEnabled = false
export async function CodeNomadPlugin(input: PluginInput) {
const config = getCodeNomadConfig()
const client = createCodeNomadClient(config)
@@ -18,11 +16,6 @@ export async function CodeNomadPlugin(input: PluginInput) {
pingTs: (event.properties as any)?.ts,
},
}).catch(() => {})
return
}
if (event.type === "codenomad.voiceMode") {
voiceModeEnabled = Boolean((event.properties as { enabled?: unknown } | undefined)?.enabled)
}
})
@@ -30,13 +23,6 @@ export async function CodeNomadPlugin(input: PluginInput) {
tool: {
...backgroundProcessTools,
},
async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) {
if (!voiceModeEnabled) {
return
}
output.message.system = [output.message.system, buildVoiceModePrompt()].filter(Boolean).join("\n\n")
},
async event(input: { event: any }) {
const opencodeEvent = input?.event
if (!opencodeEvent || typeof opencodeEvent !== "object") return
@@ -44,19 +30,3 @@ export async function CodeNomadPlugin(input: PluginInput) {
},
}
}
function buildVoiceModePrompt(): string {
return [
"Voice conversation mode is enabled.",
"Prepend your reply with a fenced code block using language `spoken`.",
"The `spoken` block should be the natural conversational reply you would say out loud to the user. It should be a concise spoken gist of the full response in 2 to 4 natural sentences.",
"In the spoken block, summarize the main outcome, recommendation, or next step. Sound conversational and natural, not like a document summary.",
"Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.",
"Do not add generic phrases about whether the user should read more.",
"Only mention additional written detail when there is something specific that may matter for the user's next response, such as a tradeoff, caveat, risk, open question, exact diff, or test result.",
"When referring to that written detail, say `below` or `in the message` rather than `detailed section`.",
"After the `spoken` block, continue with your normal detailed response.",
"Example:",
"```spoken\nI implemented the relay-based voice-mode flow and it works with the current plugin bridge. The reconnect caveat is explained below.\n```",
].join("\n\n")
}

View File

@@ -13,11 +13,6 @@ type BackgroundProcess = {
outputSizeBytes?: number
}
type BackgroundProcessNotificationRequest = {
sessionID: string
directory: string
}
type BackgroundProcessOptions = {
baseDir: string
}
@@ -41,19 +36,12 @@ 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, context) {
async execute(args) {
assertCommandWithinBase(args.command, options.baseDir)
const notification: BackgroundProcessNotificationRequest | undefined = args.notify
? {
sessionID: context.sessionID,
directory: context.directory,
}
: undefined
const process = await request<BackgroundProcess>("", {
method: "POST",
body: JSON.stringify({ title: args.title, command: args.command, notify: args.notify, notification }),
body: JSON.stringify({ title: args.title, command: args.command }),
})
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.14.0",
"version": "0.13.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.14.0",
"version": "0.13.1",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.14.0",
"version": "0.13.1",
"description": "CodeNomad Server",
"license": "MIT",
"author": {

View File

@@ -81,55 +81,6 @@ export interface WorktreeMap {
parentSessionWorktreeSlug: Record<string, string>
}
export type GitChangeKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unmerged"
export interface WorktreeGitStatusEntry {
path: string
originalPath?: string | null
stagedStatus: GitChangeKind | null
stagedAdditions: number
stagedDeletions: number
unstagedStatus: GitChangeKind | null
unstagedAdditions: number
unstagedDeletions: number
}
export type WorktreeGitStatusResponse = WorktreeGitStatusEntry[]
export type WorktreeGitDiffScope = "staged" | "unstaged"
export interface WorktreeGitPathsRequest {
paths: string[]
}
export interface WorktreeGitMutationResponse {
ok: true
}
export interface WorktreeGitCommitRequest {
message: string
}
export interface WorktreeGitCommitResponse {
ok: true
commitSha?: string
}
export interface WorktreeGitDiffResponse {
path: string
originalPath?: string | null
scope: WorktreeGitDiffScope
before: string
after: string
isBinary?: boolean
}
export interface WorktreeGitDiffRequest {
path: string
originalPath?: string | null
scope: WorktreeGitDiffScope
}
export type LogLevel = "debug" | "info" | "warn" | "error"
export interface WorkspaceLogEntry {
@@ -219,24 +170,6 @@ 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
@@ -307,53 +240,12 @@ export interface SpeechSynthesisResponse {
mimeType: string
}
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 interface RemoteProxySessionCreateRequest {
baseUrl: string
skipTlsVerify?: boolean
}
export interface RemoteProxySessionCreateResponse {
windowUrl: string
}
export type WorkspaceEventType =
| "workspace.created"
| "workspace.started"
| "workspace.error"
| "workspace.stopped"
| "workspace.log"
| "sidecar.updated"
| "sidecar.removed"
| "storage.configChanged"
| "storage.stateChanged"
| "instance.dataChanged"
@@ -366,8 +258,6 @@ 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 }
@@ -434,8 +324,6 @@ 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
@@ -448,8 +336,6 @@ export interface BackgroundProcess {
stoppedAt?: string
exitCode?: number
outputSizeBytes?: number
terminalReason?: BackgroundProcessTerminalReason
notifyEnabled?: boolean
}
export interface BackgroundProcessListResponse {

View File

@@ -104,18 +104,13 @@ 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 cookieHeader = Array.isArray(headers.cookie) ? headers.cookie.join("; ") : headers.cookie
const cookies = parseCookies(cookieHeader)
const cookies = parseCookies(request.headers.cookie)
const sessionId = cookies[this.cookieName]
const session = this.sessionManager.getSession(sessionId)
if (!session) return null

View File

@@ -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, BackgroundProcessTerminalReason } from "../api-types"
import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types"
const ROOT_DIR = ".codenomad/background_processes"
const INDEX_FILE = "index.json"
@@ -27,31 +27,6 @@ interface RunningProcess {
outputPath: string
exitPromise: Promise<void>
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 {
@@ -66,14 +41,14 @@ export class BackgroundProcessManager {
const records = await this.readIndex(workspaceId)
const enriched = await Promise.all(
records.map(async (record) => ({
...this.toPublicProcess(record),
...record,
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
})),
)
return enriched
}
async start(workspaceId: string, title: string, command: string, options: StartOptions = {}): Promise<BackgroundProcess> {
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
@@ -98,7 +73,8 @@ export class BackgroundProcessManager {
this.killProcessTree(child, "SIGTERM")
})
const record: PersistedBackgroundProcess = {
const record: BackgroundProcess = {
id,
workspaceId,
title,
@@ -108,20 +84,6 @@ 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<void>((resolve) => {
@@ -129,21 +91,18 @@ export class BackgroundProcessManager {
await new Promise<void>((resolve) => outputStream.end(resolve))
this.running.delete(id)
const completion = runningState.completion ?? this.completionFromExit(code)
record.terminalReason = completion.reason
record.status = this.statusFromReason(completion.reason)
record.status = this.statusFromExit(code)
record.exitCode = code === null ? undefined : code
record.stoppedAt = new Date().toISOString()
await this.finalizeRecord(workspaceId, record, completion)
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
resolve()
})
})
runningState.exitPromise = exitPromise
this.running.set(id, runningState)
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
let lastPublishAt = 0
const maybePublishSize = () => {
@@ -169,7 +128,7 @@ export class BackgroundProcessManager {
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
return this.toPublicProcess(record)
return record
}
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
@@ -180,21 +139,19 @@ 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.finalizeRecord(workspaceId, record, { reason: "user_stopped", endContext: "normal" })
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
}
return this.toPublicProcess(record)
return record
}
async terminate(workspaceId: string, processId: string): Promise<void> {
@@ -203,19 +160,17 @@ 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
}
record.status = "stopped"
record.terminalReason = "user_terminated"
record.stoppedAt = new Date().toISOString()
await this.finalizeRecord(workspaceId, record, {
reason: "user_terminated",
endContext: "normal",
removeAfterFinalize: true,
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 } },
})
}
@@ -311,11 +266,6 @@ 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)
}
@@ -406,17 +356,10 @@ export class BackgroundProcessManager {
return args
}
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 statusFromExit(code: number | null): BackgroundProcessStatus {
if (code === null) return "stopped"
if (code === 0) return "stopped"
return "error"
}
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
@@ -480,25 +423,25 @@ export class BackgroundProcessManager {
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
}
private async findProcess(workspaceId: string, processId: string): Promise<PersistedBackgroundProcess | null> {
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
const records = await this.readIndex(workspaceId)
return records.find((entry) => entry.id === processId) ?? null
}
private async readIndex(workspaceId: string): Promise<PersistedBackgroundProcess[]> {
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> {
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 PersistedBackgroundProcess[]) : []
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : []
} catch {
return []
}
}
private async upsertIndex(workspaceId: string, record: PersistedBackgroundProcess) {
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
const records = await this.readIndex(workspaceId)
const index = records.findIndex((entry) => entry.id === record.id)
if (index >= 0) {
@@ -515,7 +458,7 @@ export class BackgroundProcessManager {
await this.writeIndex(workspaceId, next)
}
private async writeIndex(workspaceId: string, records: PersistedBackgroundProcess[]) {
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) {
const indexPath = await this.getIndexPath(workspaceId)
await fs.mkdir(path.dirname(indexPath), { recursive: true })
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
@@ -560,139 +503,14 @@ export class BackgroundProcessManager {
}
}
private publishUpdate(workspaceId: string, record: PersistedBackgroundProcess) {
private publishUpdate(workspaceId: string, record: BackgroundProcess) {
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.updated", properties: { process: this.toPublicProcess(record) } },
event: { type: "background.process.updated", properties: { process: 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<string, string> = {
"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 `<system-message>${this.escapeTaggedText(this.buildCompletionPrompt(record))}</system-message>`
}
private escapeTaggedText(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
private generateId(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
const random = randomBytes(3).toString("hex")

View File

@@ -1,128 +0,0 @@
import type { Logger } from "../logger"
const STALE_CONNECTION_TIMEOUT_MS = 45000
const STALE_SWEEP_INTERVAL_MS = 5000
export interface ClientConnectionRef {
clientId: string
connectionId: string
}
export interface ClientConnectionRecord extends ClientConnectionRef {
key: string
connectedAt: number
lastSeenAt: number
}
type ConnectionChangeEvent = {
type: "connected" | "disconnected"
connection: ClientConnectionRecord
reason?: string
}
interface RegisteredConnection extends ClientConnectionRecord {
close: () => void
}
export class ClientConnectionManager {
private readonly connections = new Map<string, RegisteredConnection>()
private readonly subscribers = new Set<(event: ConnectionChangeEvent) => void>()
private readonly sweepTimer: NodeJS.Timeout
constructor(private readonly logger: Logger) {
this.sweepTimer = setInterval(() => this.sweepStaleConnections(), STALE_SWEEP_INTERVAL_MS)
this.sweepTimer.unref?.()
}
shutdown(): void {
clearInterval(this.sweepTimer)
for (const connection of Array.from(this.connections.values())) {
this.disconnect(connection.key, "shutdown", false)
}
}
subscribe(listener: (event: ConnectionChangeEvent) => void): () => void {
this.subscribers.add(listener)
return () => this.subscribers.delete(listener)
}
register(input: ClientConnectionRef & { close: () => void }): () => void {
const key = getConnectionKey(input)
const now = Date.now()
const existing = this.connections.get(key)
if (existing) {
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Replacing existing client connection")
this.disconnect(key, "replaced")
}
const connection: RegisteredConnection = {
key,
clientId: input.clientId,
connectionId: input.connectionId,
connectedAt: now,
lastSeenAt: now,
close: input.close,
}
this.connections.set(key, connection)
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Client connected")
this.notify({ type: "connected", connection })
return () => this.disconnect(key, "closed")
}
pong(input: ClientConnectionRef): boolean {
const key = getConnectionKey(input)
const connection = this.connections.get(key)
if (!connection) {
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Ignoring pong for unknown client connection")
return false
}
connection.lastSeenAt = Date.now()
return true
}
isConnected(input: ClientConnectionRef): boolean {
return this.connections.has(getConnectionKey(input))
}
private sweepStaleConnections(): void {
const cutoff = Date.now() - STALE_CONNECTION_TIMEOUT_MS
for (const connection of Array.from(this.connections.values())) {
if (connection.lastSeenAt > cutoff) continue
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId }, "Client connection timed out")
this.disconnect(connection.key, "timeout")
}
}
private disconnect(key: string, reason: string, invokeClose = true): void {
const connection = this.connections.get(key)
if (!connection) return
this.connections.delete(key)
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId, reason }, "Client disconnected")
if (invokeClose) {
try {
connection.close()
} catch (error) {
this.logger.warn({ err: error, clientId: connection.clientId, connectionId: connection.connectionId }, "Failed to close stale client connection")
}
}
this.notify({ type: "disconnected", connection, reason })
}
private notify(event: ConnectionChangeEvent): void {
for (const subscriber of this.subscribers) {
try {
subscriber(event)
} catch (error) {
this.logger.warn({ err: error, eventType: event.type }, "Client connection subscriber failed")
}
}
}
}
function getConnectionKey(input: ClientConnectionRef): string {
return `${input.clientId}:${input.connectionId}`
}

View File

@@ -26,7 +26,6 @@ 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),

View File

@@ -24,8 +24,6 @@ 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)
@@ -37,8 +35,6 @@ 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)

View File

@@ -81,14 +81,6 @@ export class FileSystemBrowser {
return { path: relativePath, absolutePath }
}
writeFile(relativePath: string, contents: string): void {
if (this.unrestricted) {
throw new Error("writeFile is not available in unrestricted mode")
}
const resolved = this.toRestrictedAbsolute(relativePath)
fs.writeFileSync(resolved, contents, "utf-8")
}
readFile(relativePath: string): string {
if (this.unrestricted) {
throw new Error("readFile is not available in unrestricted mode")

View File

@@ -21,14 +21,9 @@ import { launchInBrowser } from "./launcher"
import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls"
import { RemoteProxySessionManager } from "./server/remote-proxy"
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)
@@ -320,11 +315,6 @@ 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,
@@ -382,19 +372,6 @@ 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 remoteProxySessionManager = new RemoteProxySessionManager({
authManager,
logger: logger.child({ component: "remote-proxy" }),
httpsOptions: tlsResolution?.httpsOptions,
})
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)
@@ -423,12 +400,7 @@ async function main() {
serverMeta,
instanceStore,
speechService,
sidecarManager,
authManager,
clientConnectionManager,
pluginChannel,
voiceModeManager,
remoteProxySessionManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
@@ -449,12 +421,7 @@ async function main() {
serverMeta,
instanceStore,
speechService,
sidecarManager,
authManager,
clientConnectionManager,
pluginChannel,
voiceModeManager,
remoteProxySessionManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined,
logger,
@@ -553,18 +520,6 @@ 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")

View File

@@ -1,100 +0,0 @@
import type { Logger } from "../logger"
import type { ClientConnectionManager, ClientConnectionRef } from "../clients/connection-manager"
import type { PluginChannelManager } from "./channel"
interface VoiceModeManagerOptions {
connections: ClientConnectionManager
channel: PluginChannelManager
logger: Logger
}
export class VoiceModeManager {
private readonly enabledConnectionsByInstance = new Map<string, Set<string>>()
private readonly aggregateByInstance = new Map<string, boolean>()
constructor(private readonly options: VoiceModeManagerOptions) {
this.options.connections.subscribe((event) => {
if (event.type !== "disconnected") return
this.clearConnection(event.connection)
})
}
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 false
}
const key = getConnectionKey(connection)
const current = this.enabledConnectionsByInstance.get(instanceId) ?? new Set<string>()
if (enabled) {
current.add(key)
this.enabledConnectionsByInstance.set(instanceId, current)
} else if (current.delete(key)) {
if (current.size === 0) {
this.enabledConnectionsByInstance.delete(instanceId)
} else {
this.enabledConnectionsByInstance.set(instanceId, current)
}
}
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 {
this.options.channel.send(instanceId, buildVoiceModeEvent(this.isEnabled(instanceId)))
}
isEnabled(instanceId: string): boolean {
return this.aggregateByInstance.get(instanceId) === true
}
private clearConnection(connection: ClientConnectionRef): void {
const key = getConnectionKey(connection)
for (const [instanceId, enabledConnections] of Array.from(this.enabledConnectionsByInstance.entries())) {
if (!enabledConnections.delete(key)) continue
if (enabledConnections.size === 0) {
this.enabledConnectionsByInstance.delete(instanceId)
}
this.publishIfChanged(instanceId)
}
}
private publishIfChanged(instanceId: string): void {
const enabled = (this.enabledConnectionsByInstance.get(instanceId)?.size ?? 0) > 0
const previous = this.aggregateByInstance.get(instanceId) === true
if (enabled === previous) return
if (enabled) {
this.aggregateByInstance.set(instanceId, true)
} else {
this.aggregateByInstance.delete(instanceId)
}
this.options.logger.debug(
{ instanceId, enabled },
"Broadcasting aggregate voice mode",
)
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
}
}
function buildVoiceModeEvent(enabled: boolean) {
return {
type: "codenomad.voiceMode",
properties: {
enabled,
formatVersion: "v1",
},
}
}
function getConnectionKey(connection: ClientConnectionRef): string {
return `${connection.clientId}:${connection.connectionId}`
}

View File

@@ -3,14 +3,11 @@ 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"
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
import { resolveWorktreeDirectory } from "../workspaces/worktree-directory"
import type { SettingsService } from "../settings/service"
import { FileSystemBrowser } from "../filesystem/browser"
@@ -25,9 +22,6 @@ 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 { registerRemoteProxyRoutes } from "./routes/remote-proxy"
import { registerSideCarRoutes } from "./routes/sidecars"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager"
@@ -35,11 +29,6 @@ import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
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"
import type { RemoteProxySessionManager } from "./remote-proxy"
interface HttpServerDeps {
bindHost: string
@@ -55,12 +44,7 @@ interface HttpServerDeps {
serverMeta: ServerMeta
instanceStore: InstanceStore
speechService: SpeechService
sidecarManager: SideCarManager
authManager: AuthManager
clientConnectionManager: ClientConnectionManager
pluginChannel: PluginChannelManager
voiceModeManager: VoiceModeManager
remoteProxySessionManager: RemoteProxySessionManager
uiStaticDir: string
uiDevServerUrl?: string
logger: Logger
@@ -209,7 +193,7 @@ export function createHttpServer(deps: HttpServerDeps) {
const session = deps.authManager.getSessionFromRequest(request)
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/")
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
if (requiresAuthForApi && !session) {
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
@@ -264,35 +248,15 @@ export function createHttpServer(deps: HttpServerDeps) {
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, {
eventBus: deps.eventBus,
registerClient: registerSseClient,
logger: sseLogger,
connectionManager: deps.clientConnectionManager,
})
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
registerStorageRoutes(app, {
instanceStore: deps.instanceStore,
eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager,
})
registerRemoteServerRoutes(app, { logger: apiLogger })
registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager })
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: deps.pluginChannel,
voiceModeManager: deps.voiceModeManager,
})
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
@@ -367,68 +331,6 @@ 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()
@@ -765,6 +667,52 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
return trimmed.length === 0 ? "/" : `/${trimmed}`
}
type WorktreeCacheEntry = {
expiresAt: number
repoRoot: string
worktrees: Array<{ slug: string; directory: string }>
}
const WORKTREE_CACHE_TTL_MS = 2000
const worktreeCache = new Map<string, WorktreeCacheEntry>()
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) {
const cached = worktreeCache.get(params.workspaceId)
const now = Date.now()
if (cached && cached.expiresAt > now) {
return cached
}
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
const entry: WorktreeCacheEntry = {
expiresAt: now + WORKTREE_CACHE_TTL_MS,
repoRoot,
worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
}
worktreeCache.set(params.workspaceId, entry)
return entry
}
async function resolveWorktreeDirectory(params: {
workspaceId: string
workspacePath: string
worktreeSlug: string
logger: Logger
}): Promise<string | null> {
const { worktreeSlug } = params
const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug)
if (match) {
return match.directory
}
// If the slug is new (e.g., created moments ago), refresh once.
worktreeCache.delete(params.workspaceId)
const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null
}
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
if (!uiDir) {
app.log.warn("UI static directory not provided; API endpoints only")
@@ -867,281 +815,3 @@ function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, s
}
return result
}
async function proxySideCarRequest(args: {
request: FastifyRequest
reply: FastifyReply
sidecarManager: SideCarManager
logger: Logger
pathSuffix?: string
}) {
const sidecarId = (args.request.params as { id?: string }).id ?? ""
const sidecar = await args.sidecarManager.get(sidecarId)
if (!sidecar) {
args.reply.code(404).send({ error: "SideCar not found" })
return
}
const pathname = (args.request.raw.url ?? args.request.url ?? "").split("?")[0] ?? ""
const queryIndex = (args.request.raw.url ?? args.request.url ?? "").indexOf("?")
const search = queryIndex >= 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<string, string | string[] | undefined>, 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<string, string | string[] | undefined>,
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<string, string | string[] | undefined>,
targetOrigin: string,
): Record<string, string | string[] | undefined> {
const blockedHeaders = getBlockedSideCarRequestHeaders()
const next: Record<string, string | string[] | undefined> = {}
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<string> {
return new Set([
"host",
"authorization",
"proxy-authorization",
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-port",
"x-forwarded-proto",
])
}

View File

@@ -1,533 +0,0 @@
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
import { randomBytes, randomUUID } from "crypto"
import { Readable } from "stream"
import { Agent, fetch } from "undici"
import type { AuthManager } from "../auth/manager"
import type { Logger } from "../logger"
const LOOPBACK_HOST = "127.0.0.1"
const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token"
const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token"
const SESSION_IDLE_TTL_MS = 30 * 60_000
interface RemoteProxySession {
id: string
targetBaseUrl: URL
skipTlsVerify: boolean
localBaseUrl: URL
entryUrl: URL
bootstrapUrl: string
activated: boolean
cookiePrefix: string
app: FastifyInstance
dispatcher?: Agent
createdAt: number
lastAccessAt: number
}
export interface RemoteProxySessionManagerOptions {
authManager: AuthManager
logger: Logger
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
}
export class RemoteProxySessionManager {
private readonly sessions = new Map<string, RemoteProxySession>()
private readonly cleanupTimer: NodeJS.Timeout
constructor(private readonly options: RemoteProxySessionManagerOptions) {
this.cleanupTimer = setInterval(() => {
void this.cleanupExpiredSessions()
}, 60_000)
this.cleanupTimer.unref()
}
async createSession(baseUrl: string, skipTlsVerify: boolean): Promise<string> {
if (!this.options.httpsOptions) {
throw new Error("Local HTTPS is required for remote proxy sessions")
}
const targetBaseUrl = normalizeBaseUrl(baseUrl)
const token = this.options.authManager.issueBootstrapToken()
if (!token) {
throw new Error("Bootstrap token generation is unavailable")
}
const sessionId = randomUUID()
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
const app = Fastify({ logger: false, https: this.options.httpsOptions })
let session: RemoteProxySession | null = null
app.removeAllContentTypeParsers()
app.addContentTypeParser("*", (req, body, done) => done(null, body))
app.get(BOOTSTRAP_PAGE_PATH, async (request, reply) => {
if (!this.options.authManager.isLoopbackRequest(request)) {
reply.code(404).send({ error: "Not found" })
return
}
reply.header("Cache-Control", "no-store")
reply.header("Pragma", "no-cache")
reply.header("Expires", "0")
reply.type("text/html").send(buildBootstrapPageHtml())
})
app.post(BOOTSTRAP_EXCHANGE_PATH, async (request, reply) => {
if (!this.options.authManager.isLoopbackRequest(request)) {
reply.code(404).send({ error: "Not found" })
return
}
const body = parseTokenBody(request.body)
if (!this.options.authManager.consumeBootstrapToken(body.token)) {
reply.code(401).send({ error: "Invalid token" })
return
}
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
session.activated = true
session.lastAccessAt = Date.now()
reply.send({ ok: true })
})
app.all("/*", async (request, reply) => {
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
if (!session.activated) {
reply.code(403).send({ error: "Remote proxy session is not activated" })
return
}
session.lastAccessAt = Date.now()
await proxyRequest({ request, reply, session, logger: this.options.logger })
})
app.setNotFoundHandler(async (request, reply) => {
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
if (!session.activated) {
reply.code(403).send({ error: "Remote proxy session is not activated" })
return
}
session.lastAccessAt = Date.now()
await proxyRequest({ request, reply, session, logger: this.options.logger })
})
const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 })
const address = new URL(addressInfo)
const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`)
const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl)
const returnTo = buildReturnToTarget(entryUrl)
session = {
id: sessionId,
targetBaseUrl,
skipTlsVerify,
localBaseUrl,
entryUrl,
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(token)}`,
activated: false,
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
app,
dispatcher,
createdAt: Date.now(),
lastAccessAt: Date.now(),
}
this.sessions.set(sessionId, session)
this.options.logger.info(
{ sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() },
"Created remote proxy session",
)
return session.bootstrapUrl
}
private async cleanupExpiredSessions() {
const now = Date.now()
for (const session of Array.from(this.sessions.values())) {
if (now - session.lastAccessAt <= SESSION_IDLE_TTL_MS) {
continue
}
await this.disposeSession(session.id)
}
}
private async disposeSession(sessionId: string) {
const session = this.sessions.get(sessionId)
if (!session) {
return
}
this.sessions.delete(sessionId)
session.dispatcher?.close().catch(() => {})
await session.app.close().catch(() => {})
this.options.logger.info({ sessionId }, "Disposed remote proxy session")
}
}
function normalizeBaseUrl(input: string): URL {
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(/\/+$/, "") || "/"
return parsed
}
function buildReturnToTarget(entryUrl: URL): string {
const query = entryUrl.search ? entryUrl.search : ""
return `${entryUrl.pathname || "/"}${query}`
}
function buildBootstrapPageHtml(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodeNomad</title>
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background: #0b0b0f; color: #fff; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { width: 420px; max-width: calc(100vw - 32px); background: #14141c; border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 24px; }
h1 { font-size: 18px; margin: 0 0 12px; }
p { margin: 0; color: rgba(255,255,255,0.7); font-size: 13px; line-height: 1.4; }
.error { margin-top: 12px; color: #ff6b6b; font-size: 13px; display: none; }
</style>
</head>
<body>
<div class="card">
<h1>Connecting...</h1>
<p>Finalizing local authentication.</p>
<div id="error" class="error"></div>
</div>
<script>
const token = decodeURIComponent((location.hash || "").replace(/^#/, "").trim())
const params = new URLSearchParams(location.search)
const returnTo = sanitizeReturnTo(params.get("returnTo"))
const errorEl = document.getElementById("error")
function sanitizeReturnTo(value) {
if (!value || typeof value !== "string") return "/"
if (!value.startsWith("/")) return "/"
if (value.startsWith("//")) return "/"
return value
}
function showError(message) {
errorEl.textContent = message
errorEl.style.display = "block"
}
async function run() {
if (!token) {
showError("Missing bootstrap token.")
return
}
try {
const res = await fetch("${BOOTSTRAP_EXCHANGE_PATH}", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
credentials: "include",
})
if (!res.ok) {
let message = ""
try {
const json = await res.json()
message = json && json.error ? String(json.error) : ""
} catch {
message = ""
}
showError(message || "Token exchange failed (" + res.status + ")")
return
}
window.location.replace(returnTo)
} catch (error) {
showError(error && error.message ? error.message : String(error))
}
}
run()
</script>
</body>
</html>`
}
function parseTokenBody(body: unknown): { token: string } {
const value = normalizeJsonBody(body) as { token?: unknown } | null | undefined
const token = typeof value?.token === "string" ? value.token.trim() : ""
if (!token) {
throw new Error("Missing bootstrap token")
}
return { token }
}
function normalizeJsonBody(body: unknown): unknown {
if (Buffer.isBuffer(body)) {
return JSON.parse(body.toString("utf-8"))
}
if (typeof body === "string") {
return JSON.parse(body)
}
return body
}
function toRequestBody(body: unknown): any {
if (body == null) {
return undefined
}
if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) {
return body
}
return JSON.stringify(body)
}
async function proxyRequest(args: {
request: FastifyRequest
reply: FastifyReply
session: RemoteProxySession
logger: Logger
}) {
const { request, reply, session, logger } = args
const upstreamUrl = buildUpstreamUrl(session.targetBaseUrl, request.raw.url ?? request.url)
const headers = filterRequestHeaders(request.headers, session)
const init: any = {
method: request.method,
headers,
dispatcher: session.dispatcher,
redirect: "manual",
}
if (request.method !== "GET" && request.method !== "HEAD") {
const body = toRequestBody(request.body)
if (body !== undefined) {
init.body = body
init.duplex = "half"
}
}
try {
const response = await fetch(upstreamUrl, init as any)
reply.code(response.status)
applyResponseHeaders(reply, response, session)
if (!response.body || request.method === "HEAD") {
reply.send()
return
}
reply.send(Readable.fromWeb(response.body as any))
} catch (error) {
logger.error({ err: error, upstreamUrl }, "Failed to proxy remote session request")
if (!reply.sent) {
reply.code(502).send({ error: "Remote proxy request failed" })
}
}
}
function buildUpstreamUrl(baseUrl: URL, rawUrl: string): string {
const parsed = new URL(rawUrl, "https://localhost")
const url = new URL(baseUrl.toString())
url.pathname = rewriteRequestPath(baseUrl, parsed.pathname)
url.search = stripInternalQuery(parsed.search)
url.hash = ""
return url.toString()
}
function rewriteRequestPath(baseUrl: URL, requestPath: string): string {
const basePath = normalizedBasePath(baseUrl)
if (basePath === "/") {
return requestPath
}
if (requestPath === "/") {
return basePath
}
if (pathHasBasePrefix(basePath, requestPath)) {
return requestPath
}
return `${basePath}${requestPath}`
}
function normalizedBasePath(baseUrl: URL): string {
return baseUrl.pathname || "/"
}
function pathHasBasePrefix(basePath: string, requestPath: string): boolean {
return requestPath === basePath || requestPath.startsWith(`${basePath}/`)
}
function stripInternalQuery(search: string): string {
if (!search || search === "?") {
return ""
}
return search
}
function filterRequestHeaders(
headers: FastifyRequest["headers"],
session: RemoteProxySession,
): Record<string, string> {
const next: Record<string, string> = {}
for (const [key, value] of Object.entries(headers ?? {})) {
if (!value) continue
const lower = key.toLowerCase()
if (isHopByHopHeader(lower) || lower === "host" || lower === "content-length") {
continue
}
if (lower === "origin") {
next[key] = session.targetBaseUrl.origin
continue
}
if (lower === "referer") {
const rewritten = rewriteRefererHeader(Array.isArray(value) ? value[0] : value, session.targetBaseUrl)
if (rewritten) {
next[key] = rewritten
}
continue
}
if (lower === "cookie") {
const rewritten = rewriteRequestCookieHeader(Array.isArray(value) ? value.join("; ") : value, session.cookiePrefix)
if (rewritten) {
next[key] = rewritten
}
continue
}
next[key] = Array.isArray(value) ? value.join(",") : value
}
next.host = session.targetBaseUrl.port ? `${session.targetBaseUrl.hostname}:${session.targetBaseUrl.port}` : session.targetBaseUrl.hostname
if (!next.origin) {
next.origin = session.targetBaseUrl.origin
}
return next
}
function rewriteRefererHeader(referer: string | undefined, targetBaseUrl: URL): string | null {
if (!referer) {
return null
}
try {
const parsed = new URL(referer)
const rewritten = new URL(targetBaseUrl.toString())
rewritten.pathname = rewriteRequestPath(targetBaseUrl, parsed.pathname)
rewritten.search = parsed.search
rewritten.hash = parsed.hash
return rewritten.toString()
} catch {
return null
}
}
function applyResponseHeaders(reply: FastifyReply, response: any, session: RemoteProxySession) {
const setCookie = (response.headers as any).getSetCookie?.() as string[] | undefined
if (Array.isArray(setCookie)) {
for (const cookie of setCookie) {
reply.header("set-cookie", rewriteSetCookie(cookie, session.cookiePrefix))
}
}
response.headers.forEach((value: string, key: string) => {
const lower = key.toLowerCase()
if (isHopByHopHeader(lower) || lower === "set-cookie") {
return
}
if (lower === "location") {
reply.header(key, rewriteLocation(value, session.targetBaseUrl, session.localBaseUrl))
return
}
reply.header(key, value)
})
}
function rewriteSetCookie(cookie: string, cookiePrefix: string): string {
const parts = cookie.split(";").map((part) => part.trim())
const first = parts.shift() ?? ""
const separator = first.indexOf("=")
if (separator <= 0) {
return cookie
}
const name = first.slice(0, separator).trim()
const value = first.slice(separator + 1)
const rewritten = [`${cookiePrefix}${name}=${value}`]
for (const part of parts) {
if (part.slice(0, 7).toLowerCase().startsWith("domain=")) {
continue
}
rewritten.push(part)
}
return rewritten.join("; ")
}
function rewriteRequestCookieHeader(cookieHeader: string, cookiePrefix: string): string {
const next: string[] = []
for (const rawPart of cookieHeader.split(";")) {
const part = rawPart.trim()
if (!part) continue
const separator = part.indexOf("=")
if (separator <= 0) continue
const name = part.slice(0, separator).trim()
const value = part.slice(separator + 1)
if (!name.startsWith(cookiePrefix)) {
continue
}
next.push(`${name.slice(cookiePrefix.length)}=${value}`)
}
return next.join("; ")
}
function rewriteLocation(location: string, targetBaseUrl: URL, localBaseUrl: URL): string {
try {
const parsed = new URL(location, targetBaseUrl)
if (parsed.origin !== targetBaseUrl.origin) {
return location
}
const rewritten = new URL(localBaseUrl.toString())
rewritten.pathname = parsed.pathname
rewritten.search = parsed.search
rewritten.hash = parsed.hash
return rewritten.toString()
} catch {
return location
}
}
function isHopByHopHeader(name: string): boolean {
return new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]).has(name)
}

View File

@@ -9,21 +9,6 @@ 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({
@@ -42,10 +27,7 @@ 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, {
notify: payload.notify,
notification: payload.notification,
})
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command)
reply.code(201)
return process
})

View File

@@ -1,32 +1,19 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { EventBus } from "../../events/bus"
import { WorkspaceEventPayload } from "../../api-types"
import type { ClientConnectionManager } from "../../clients/connection-manager"
import { Logger } from "../../logger"
interface RouteDeps {
eventBus: EventBus
registerClient: (cleanup: () => void) => () => void
logger: Logger
connectionManager: ClientConnectionManager
}
let nextClientId = 0
const ConnectionQuerySchema = z.object({
clientId: z.string().trim().min(1),
connectionId: z.string().trim().min(1),
})
const PongBodySchema = ConnectionQuerySchema.extend({
pingTs: z.number().optional(),
})
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/events", (request, reply) => {
const clientId = ++nextClientId
const connection = ConnectionQuerySchema.parse(request.query ?? {})
deps.logger.debug({ clientId }, "SSE client connected")
const origin = request.headers.origin ?? "*"
@@ -48,8 +35,7 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
const unsubscribe = deps.eventBus.onEvent(send)
const heartbeat = setInterval(() => {
const ping = { ts: Date.now() }
reply.raw.write(`event: codenomad.client.ping\ndata: ${JSON.stringify(ping)}\n\n`)
reply.raw.write(`:hb ${Date.now()}\n\n`)
}, 15000)
let closed = false
@@ -63,27 +49,13 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
}
const unregister = deps.registerClient(close)
const unregisterConnection = deps.connectionManager.register({
...connection,
close,
})
const handleClose = () => {
close()
unregister()
unregisterConnection()
}
request.raw.on("close", handleClose)
request.raw.on("error", handleClose)
})
app.post("/api/client-connections/pong", (request, reply) => {
const body = PongBodySchema.parse(request.body ?? {})
if (!deps.connectionManager.pong(body)) {
reply.code(404).send({ error: "Client connection not found" })
return
}
reply.code(204).send()
})
}

View File

@@ -1,19 +1,15 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import type { VoiceModeStateResponse } from "../../api-types"
import type { WorkspaceManager } from "../../workspaces/manager"
import type { EventBus } from "../../events/bus"
import type { Logger } from "../../logger"
import { PluginChannelManager } from "../../plugins/channel"
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
import { VoiceModeManager } from "../../plugins/voice-mode"
interface RouteDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
channel: PluginChannelManager
voiceModeManager: VoiceModeManager
}
const PluginEventSchema = z.object({
@@ -21,13 +17,9 @@ const PluginEventSchema = z.object({
properties: z.record(z.unknown()).optional(),
})
const VoiceModeStateSchema = z.object({
enabled: z.boolean(),
clientId: z.string().trim().min(1),
connectionId: z.string().trim().min(1),
})
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
@@ -41,11 +33,10 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
reply.raw.flushHeaders?.()
reply.hijack()
const registration = deps.channel.register(request.params.id, reply)
deps.voiceModeManager.syncInstance(request.params.id)
const registration = channel.register(request.params.id, reply)
const heartbeat = setInterval(() => {
deps.channel.send(request.params.id, buildPingEvent())
channel.send(request.params.id, buildPingEvent())
}, 15000)
const close = () => {
@@ -58,28 +49,6 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
request.raw.on("error", close)
})
app.post<{ Params: { id: string }; Body: VoiceModeStateResponse }>("/workspaces/:id/plugin/voice-mode", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
}
const payload = VoiceModeStateSchema.parse(request.body ?? {})
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 }
})
const handleWildcard = async (request: any, reply: any) => {
const workspaceId = request.params.id as string
const workspace = deps.workspaceManager.get(workspaceId)

View File

@@ -1,29 +0,0 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { RemoteProxySessionCreateResponse } from "../../api-types"
import type { Logger } from "../../logger"
import type { RemoteProxySessionManager } from "../remote-proxy"
interface RouteDeps {
logger: Logger
sessionManager: RemoteProxySessionManager
}
const CreateSessionSchema = z.object({
baseUrl: z.string().min(1),
skipTlsVerify: z.boolean().optional(),
})
export function registerRemoteProxyRoutes(app: FastifyInstance, deps: RouteDeps) {
app.post("/api/remote-proxy/sessions", async (request, reply): Promise<RemoteProxySessionCreateResponse | { error: string }> => {
try {
const body = CreateSessionSchema.parse(request.body ?? {})
const windowUrl = await deps.sessionManager.createSession(body.baseUrl, Boolean(body.skipTlsVerify))
return { windowUrl }
} catch (error) {
deps.logger.warn({ err: error }, "Failed to create remote proxy session")
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to create remote proxy session" }
}
})
}

View File

@@ -1,166 +0,0 @@
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<RemoteServerProbeResponse> {
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<unknown>()
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
}

View File

@@ -1,56 +0,0 @@
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)
})
}

View File

@@ -1,10 +1,6 @@
import { FastifyInstance, FastifyReply } from "fastify"
import { z } from "zod"
import { WorkspaceManager } from "../../workspaces/manager"
import { getWorktreeGitDiff, getWorktreeGitStatus } from "../../workspaces/git-status"
import { commitWorktreeChanges, isGitMutationError, stageWorktreePaths, unstageWorktreePaths } from "../../workspaces/git-mutations"
import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees"
import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory"
interface RouteDeps {
workspaceManager: WorkspaceManager
@@ -23,24 +19,6 @@ const WorkspaceFileContentQuerySchema = z.object({
path: z.string(),
})
const WorkspaceFileContentBodySchema = z.object({
contents: z.string(),
})
const WorktreeGitDiffQuerySchema = z.object({
path: z.string().trim().min(1, "Path is required"),
originalPath: z.string().trim().optional(),
scope: z.enum(["staged", "unstaged"]),
})
const WorktreeGitPathsBodySchema = z.object({
paths: z.array(z.string().trim().min(1, "Path is required")).min(1, "At least one path is required"),
})
const WorktreeGitCommitBodySchema = z.object({
message: z.string().trim().min(1, "Commit message is required"),
})
const WorkspaceFileSearchQuerySchema = z.object({
q: z.string().trim().min(1, "Query is required"),
limit: z.coerce.number().int().positive().max(200).optional(),
@@ -122,152 +100,10 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
return handleWorkspaceError(error, reply)
}
})
app.put<{
Params: { id: string }
Querystring: { path?: string }
}>("/api/workspaces/:id/files/content", async (request, reply) => {
try {
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
const body = WorkspaceFileContentBodySchema.parse(request.body ?? {})
deps.workspaceManager.writeFile(request.params.id, query.path, body.contents)
reply.code(204)
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.get<{
Params: { id: string; slug: string }
}>("/api/workspaces/:id/worktrees/:slug/git-status", async (request, reply) => {
try {
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
return await getWorktreeGitStatus({ workspaceFolder: directory, logger: request.log })
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.get<{
Params: { id: string; slug: string }
Querystring: { path: string; originalPath?: string; scope: "staged" | "unstaged" }
}>("/api/workspaces/:id/worktrees/:slug/git-diff", async (request, reply) => {
try {
const query = WorktreeGitDiffQuerySchema.parse(request.query ?? {})
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
return await getWorktreeGitDiff({
workspaceFolder: directory,
path: query.path,
originalPath: query.originalPath,
scope: query.scope,
})
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.post<{
Params: { id: string; slug: string }
Body: { paths: string[] }
}>("/api/workspaces/:id/worktrees/:slug/git-stage", async (request, reply) => {
try {
const body = WorktreeGitPathsBodySchema.parse(request.body ?? {})
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
await stageWorktreePaths({ workspaceFolder: directory, paths: body.paths })
return { ok: true as const }
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.post<{
Params: { id: string; slug: string }
Body: { paths: string[] }
}>("/api/workspaces/:id/worktrees/:slug/git-unstage", async (request, reply) => {
try {
const body = WorktreeGitPathsBodySchema.parse(request.body ?? {})
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
await unstageWorktreePaths({ workspaceFolder: directory, paths: body.paths })
return { ok: true as const }
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.post<{
Params: { id: string; slug: string }
Body: { message: string }
}>("/api/workspaces/:id/worktrees/:slug/git-commit", async (request, reply) => {
try {
const body = WorktreeGitCommitBodySchema.parse(request.body ?? {})
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
const result = await commitWorktreeChanges({ workspaceFolder: directory, message: body.message })
return { ok: true as const, ...result }
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
}
async function resolveGitWorktreeDirectory(
workspaceManager: WorkspaceManager,
workspaceId: string,
worktreeSlug: string,
logger: { debug?: (obj: any, msg?: string) => void; warn?: (obj: any, msg?: string) => void },
reply: FastifyReply,
): Promise<string | null> {
const workspace = workspaceManager.get(workspaceId)
if (!workspace) {
reply.code(404)
reply.send({ error: "Workspace not found" })
return null
}
const gitAvailable = await isGitAvailable(workspace.path)
if (!gitAvailable) {
reply.code(503)
reply.send({ error: "Git is not installed or not available in PATH" })
return null
}
const { isGitRepo } = await resolveRepoRoot(workspace.path, logger)
if (!isGitRepo) {
reply.code(400)
reply.send({ error: "Workspace is not a Git repository" })
return null
}
const directory = await resolveWorktreeDirectory({
workspaceId: workspace.id,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404)
reply.send({ error: "Worktree not found" })
return null
}
return directory
}
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
if (isGitMutationError(error)) {
reply.code(error.statusCode)
return { error: error.message }
}
if (error instanceof Error && error.message === "Workspace not found") {
reply.code(404)
return { error: "Workspace not found" }

View File

@@ -107,10 +107,6 @@ 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
@@ -139,7 +135,6 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
const moved = new Set([
"environmentVariables",
"listeningMode",
"logLevel",
"lastUsedBinary",
"modelRecents",
"modelFavorites",

View File

@@ -1,7 +1,6 @@
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"
@@ -9,54 +8,6 @@ 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<string, unknown> {
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
@@ -72,44 +23,22 @@ export class SettingsService {
}
getDoc(kind: DocKind): SettingsDoc {
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
return kind === "config" ? this.configStore.get() : this.stateStore.get()
}
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
const updated =
kind === "config"
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
: this.stateStore.mergePatch(patch)
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
this.publish(kind, "*")
return updated
}
getOwner(kind: DocKind, owner: string): SettingsDoc {
if (kind !== "config") {
return this.stateStore.getOwner(owner)
}
return owner === "server"
? normalizeServerConfigOwner(this.getDoc("config").server as SettingsDoc)
: this.getDoc("config")[owner] as SettingsDoc
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
}
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
const updated =
kind === "config"
? owner === "server"
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
: this.configStore.mergePatchOwner(owner, patch)
: this.stateStore.mergePatchOwner(owner, patch)
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch)
this.publish(kind, owner, updated)
return updated
}

View File

@@ -1,256 +0,0 @@
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<string, SideCarConfigRecord>()
private readonly runtime = new Map<string, SideCarRuntimeRecord>()
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<SideCar[]> {
await this.refreshPortStatuses()
return Array.from(this.configs.values()).map((record) => this.toSideCar(record))
}
async get(id: string): Promise<SideCar | undefined> {
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<SideCar> {
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<SideCar> {
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<boolean> {
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<SideCar, "port" | "insecure">): 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<string, unknown>
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<boolean> {
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
}
}

View File

@@ -147,49 +147,19 @@ export class OpenAICompatibleSpeechProvider {
}
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
let response: Response
try {
response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${settings.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: settings.ttsModel,
voice: settings.ttsVoice,
input: text,
response_format: format,
}),
})
} catch (error) {
const detailedError = error as Error & {
cause?: unknown
code?: string
errno?: number | string
syscall?: string
address?: string
port?: number
}
this.options.logger.error(
{
err: error,
endpoint: endpoint.toString(),
baseUrl: settings.baseUrl,
model: settings.ttsModel,
voice: settings.ttsVoice,
format,
cause: detailedError.cause,
code: detailedError.code,
errno: detailedError.errno,
syscall: detailedError.syscall,
address: detailedError.address,
port: detailedError.port,
},
"speech.synthesize fetch failed",
)
throw error
}
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${settings.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: settings.ttsModel,
voice: settings.ttsVoice,
input: text,
response_format: format,
}),
})
if (!response.ok) {
const detail = await response.text()

View File

@@ -55,31 +55,4 @@ describe("resolveUi local version preference", () => {
assert.equal(result.uiStaticDir, bundledDir)
assert.equal(result.uiVersion, "0.8.1")
})
it("prefers bundled when bundled and downloaded versions are equal", async () => {
const bundledDir = path.join(tempRoot, "bundled")
const configDir = path.join(tempRoot, "config")
const currentDir = path.join(configDir, "ui", "current")
await mkdir(bundledDir, { recursive: true })
await mkdir(currentDir, { recursive: true })
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
const result = await resolveUi({
serverVersion: "0.8.1",
bundledUiDir: bundledDir,
autoUpdate: false,
configDir,
logger: noopLogger,
})
assert.equal(result.source, "bundled")
assert.equal(result.uiStaticDir, bundledDir)
assert.equal(result.uiVersion, "0.8.1")
})
})

View File

@@ -250,7 +250,7 @@ async function pickBestLocalUi(args: {
uiStaticDir: currentResolved,
source: "downloaded",
uiVersion: await readUiVersion(currentResolved),
priority: 1,
priority: 2,
})
}
@@ -260,7 +260,7 @@ async function pickBestLocalUi(args: {
uiStaticDir: bundledResolved,
source: "bundled",
uiVersion: await readUiVersion(bundledResolved),
priority: 2,
priority: 1,
})
}

View File

@@ -1,121 +0,0 @@
import { spawn } from "child_process"
import path from "path"
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
class GitMutationError extends Error {
statusCode: number
constructor(message: string, statusCode = 400) {
super(message)
this.name = "GitMutationError"
this.statusCode = statusCode
}
}
function runGit(args: string[], cwd: string): Promise<GitResult> {
return new Promise((resolve) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
let stdout = ""
let stderr = ""
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString()
})
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString()
})
child.once("error", (error) => {
resolve({ ok: false, error, stdout, stderr })
})
child.once("close", (code) => {
if (code === 0) {
resolve({ ok: true, stdout })
} else {
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
resolve({ ok: false, error, stdout, stderr })
}
})
})
}
export function normalizeGitWorktreeRelativePath(input: string): string {
const normalized = input.trim().replace(/\\+/g, "/").replace(/^\.\//, "")
if (!normalized) {
throw new GitMutationError("Path is required", 400)
}
if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
throw new GitMutationError(`Absolute paths are not allowed: ${input}`, 400)
}
if (normalized === "." || normalized === "..") {
throw new GitMutationError(`Invalid path: ${input}`, 400)
}
if (normalized.startsWith("../") || normalized.includes("/../") || normalized.endsWith("/..")) {
throw new GitMutationError(`Path traversal is not allowed: ${input}`, 400)
}
return normalized
}
function normalizeGitMutationPaths(paths: string[]): string[] {
const deduped = new Set<string>()
for (const rawPath of paths) {
deduped.add(normalizeGitWorktreeRelativePath(rawPath))
}
const normalized = Array.from(deduped)
if (normalized.length === 0) {
throw new GitMutationError("At least one path is required", 400)
}
return normalized
}
async function ensureGitCommandSucceeded(resultPromise: Promise<GitResult>, fallbackMessage: string): Promise<string> {
const result = await resultPromise
if (!result.ok) {
const message = result.stderr?.trim() || result.error.message || fallbackMessage
throw new GitMutationError(message, 409)
}
return result.stdout
}
export function isGitMutationError(error: unknown): error is GitMutationError {
return error instanceof GitMutationError
}
export async function stageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise<void> {
const paths = normalizeGitMutationPaths(params.paths)
await ensureGitCommandSucceeded(runGit(["add", "--", ...paths], params.workspaceFolder), "Failed to stage files")
}
export async function unstageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise<void> {
const paths = normalizeGitMutationPaths(params.paths)
const headResult = await runGit(["rev-parse", "--verify", "HEAD"], params.workspaceFolder)
if (headResult.ok) {
await ensureGitCommandSucceeded(
runGit(["restore", "--staged", "--", ...paths], params.workspaceFolder),
"Failed to unstage files",
)
return
}
await ensureGitCommandSucceeded(
runGit(["rm", "--cached", "--quiet", "--", ...paths], params.workspaceFolder),
"Failed to unstage files",
)
}
export async function commitWorktreeChanges(params: { workspaceFolder: string; message: string }): Promise<{ commitSha?: string }> {
const message = params.message.trim()
if (!message) {
throw new GitMutationError("Commit message is required", 400)
}
await ensureGitCommandSucceeded(runGit(["commit", "-m", message], params.workspaceFolder), "Failed to create commit")
const shaResult = await runGit(["rev-parse", "HEAD"], params.workspaceFolder)
if (!shaResult.ok) {
return {}
}
const commitSha = shaResult.stdout.trim()
return commitSha ? { commitSha } : {}
}

View File

@@ -1,385 +0,0 @@
import { spawn } from "child_process"
import { readFile } from "fs/promises"
import path from "path"
import type { GitChangeKind, WorktreeGitDiffResponse, WorktreeGitDiffScope, WorktreeGitStatusEntry } from "../api-types"
import type { LogLike } from "./git-worktrees"
import { normalizeGitWorktreeRelativePath } from "./git-mutations"
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
type GitSuccessResult = Extract<GitResult, { ok: true }>
async function readFileAsDiffText(filePath: string): Promise<string> {
return readFile(filePath, "utf-8")
}
async function readGitBlobAsDiffText(resultPromise: Promise<GitResult>, missingOk = false): Promise<string> {
const result = await resultPromise
if (!result.ok) {
return decodeGitShowResult(result, missingOk)
}
return result.stdout
}
function runGit(args: string[], cwd: string, acceptedExitCodes: number[] = [0]): Promise<GitResult> {
return new Promise((resolve) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
let stdout = ""
let stderr = ""
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString()
})
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString()
})
child.once("error", (error) => {
resolve({ ok: false, error, stdout, stderr })
})
child.once("close", (code) => {
if (acceptedExitCodes.includes(code ?? 0)) {
resolve({ ok: true, stdout })
} else {
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
resolve({ ok: false, error, stdout, stderr })
}
})
})
}
function ensureEntry(map: Map<string, WorktreeGitStatusEntry>, path: string): WorktreeGitStatusEntry {
const existing = map.get(path)
if (existing) return existing
const next: WorktreeGitStatusEntry = {
path,
originalPath: null,
stagedStatus: null,
stagedAdditions: 0,
stagedDeletions: 0,
unstagedStatus: null,
unstagedAdditions: 0,
unstagedDeletions: 0,
}
map.set(path, next)
return next
}
function normalizeGitStatusPath(value: string): string {
return value.trim().replace(/\\+/g, "/")
}
function parseGitChangeKind(code: string): GitChangeKind | null {
const normalized = code.trim().toUpperCase()
if (!normalized) return null
if (normalized === "A") return "added"
if (normalized === "M") return "modified"
if (normalized === "D") return "deleted"
if (normalized.startsWith("R")) return "renamed"
if (normalized.startsWith("C")) return "copied"
if (normalized === "U") return "unmerged"
return null
}
function applyNameStatusOutput(
map: Map<string, WorktreeGitStatusEntry>,
output: string,
target: "stagedStatus" | "unstagedStatus",
) {
const tokens = output.split("\0")
let index = 0
while (index < tokens.length) {
const record = tokens[index++] ?? ""
if (!record) continue
const parts = record.split("\t")
const statusCode = parseGitChangeKind(parts[0] ?? "")
if (!statusCode) continue
const inlinePath = parts.slice(1).join("\t")
const firstPath = inlinePath || tokens[index++] || ""
const secondPath = statusCode === "renamed" || statusCode === "copied" ? tokens[index++] || "" : ""
const path = statusCode === "renamed" || statusCode === "copied" ? secondPath || firstPath : firstPath
const normalizedPath = normalizeGitStatusPath(path)
if (!normalizedPath) continue
const entry = ensureEntry(map, normalizedPath)
entry[target] = statusCode
if (statusCode === "renamed" || statusCode === "copied") {
const originalPath = normalizeGitStatusPath(firstPath)
entry.originalPath = originalPath || entry.originalPath || null
}
}
}
function applyUntrackedOutput(map: Map<string, WorktreeGitStatusEntry>, output: string) {
for (const rawLine of output.split(/\r?\n/)) {
const path = normalizeGitStatusPath(rawLine)
if (!path) continue
ensureEntry(map, path).unstagedStatus = "untracked"
}
}
function parseSingleNumstat(output: string): { additions: number; deletions: number; isBinary: boolean; found: boolean } {
for (const rawLine of output.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line) continue
const parts = rawLine.split("\t")
const isBinary = parts[0] === "-" || parts[1] === "-"
return {
additions: isBinary ? 0 : Number.parseInt(parts[0] ?? "0", 10) || 0,
deletions: isBinary ? 0 : Number.parseInt(parts[1] ?? "0", 10) || 0,
isBinary,
found: true,
}
}
return { additions: 0, deletions: 0, isBinary: false, found: false }
}
async function getUntrackedFileNumstat(workspaceFolder: string, relativePath: string): Promise<{ additions: number; deletions: number }> {
const absolutePath = path.join(workspaceFolder, relativePath)
const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], workspaceFolder, [0, 1])
if (!result.ok) {
throw result.error
}
const parsed = parseSingleNumstat(result.stdout)
return { additions: parsed.additions, deletions: parsed.deletions }
}
async function applyUntrackedFileStats(map: Map<string, WorktreeGitStatusEntry>, workspaceFolder: string) {
const pending = Array.from(map.values())
.filter((entry) => entry.unstagedStatus === "untracked")
.map(async (entry) => {
try {
const stats = await getUntrackedFileNumstat(workspaceFolder, entry.path)
entry.unstagedAdditions = stats.additions
entry.unstagedDeletions = stats.deletions
} catch {
entry.unstagedAdditions = 0
entry.unstagedDeletions = 0
}
})
await Promise.all(pending)
}
function applyNumstatOutput(
map: Map<string, WorktreeGitStatusEntry>,
output: string,
target: "staged" | "unstaged",
) {
const tokens = output.split("\0")
let index = 0
while (index < tokens.length) {
const record = tokens[index++] ?? ""
if (!record) continue
const parts = record.split("\t")
if (parts.length < 3) continue
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] ?? "0", 10)
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] ?? "0", 10)
const inlinePath = parts.slice(2).join("\t")
const isRenameLike = inlinePath === ""
const originalPath = isRenameLike ? normalizeGitStatusPath(tokens[index++] ?? "") : null
const normalizedPath = normalizeGitStatusPath(isRenameLike ? tokens[index++] ?? "" : inlinePath)
if (!normalizedPath) continue
const entry = ensureEntry(map, normalizedPath)
if (originalPath) {
entry.originalPath = originalPath
}
if (target === "staged") {
entry.stagedAdditions = Number.isFinite(additions) ? additions : 0
entry.stagedDeletions = Number.isFinite(deletions) ? deletions : 0
} else {
entry.unstagedAdditions = Number.isFinite(additions) ? additions : 0
entry.unstagedDeletions = Number.isFinite(deletions) ? deletions : 0
}
}
}
export async function getWorktreeGitStatus(params: {
workspaceFolder: string
logger?: LogLike
}): Promise<WorktreeGitStatusEntry[]> {
const { workspaceFolder, logger } = params
const [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult] = await Promise.all([
runGit(["diff", "--name-status", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder),
runGit(["diff", "--name-status", "-z", "--find-renames", "--find-copies"], workspaceFolder),
runGit(["ls-files", "--others", "--exclude-standard"], workspaceFolder),
runGit(["diff", "--numstat", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder),
runGit(["diff", "--numstat", "-z", "--find-renames", "--find-copies"], workspaceFolder),
])
for (const result of [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult]) {
if (!result.ok) {
logger?.warn?.({ workspaceFolder, err: result.error }, "Failed to read git status for worktree")
throw result.error
}
}
const stagedOutput = (stagedResult as GitSuccessResult).stdout
const unstagedOutput = (unstagedResult as GitSuccessResult).stdout
const untrackedOutput = (untrackedResult as GitSuccessResult).stdout
const stagedNumstatOutput = (stagedNumstatResult as GitSuccessResult).stdout
const unstagedNumstatOutput = (unstagedNumstatResult as GitSuccessResult).stdout
const entries = new Map<string, WorktreeGitStatusEntry>()
applyNameStatusOutput(entries, stagedOutput, "stagedStatus")
applyNameStatusOutput(entries, unstagedOutput, "unstagedStatus")
applyUntrackedOutput(entries, untrackedOutput)
applyNumstatOutput(entries, stagedNumstatOutput, "staged")
applyNumstatOutput(entries, unstagedNumstatOutput, "unstaged")
await applyUntrackedFileStats(entries, workspaceFolder)
return Array.from(entries.values()).sort((a, b) => a.path.localeCompare(b.path))
}
function decodeGitShowResult(result: GitResult, missingOk = false): string {
if (result.ok) return result.stdout
const message = result.stderr?.trim() || result.error.message || ""
if (
missingOk &&
(message.includes("exists on disk, but not in") ||
message.includes("Path '") ||
message.includes("does not exist") ||
message.includes("unknown revision or path not in the working tree"))
) {
return ""
}
throw result.error
}
async function readGitIndexBlob(workspaceFolder: string, normalizedPath: string): Promise<GitResult> {
return runGit(["cat-file", "-p", `:${normalizedPath}`], workspaceFolder)
}
async function getTrackedDiffMetadata(params: {
workspaceFolder: string
scope: WorktreeGitDiffScope
normalizedPath: string
normalizedOriginalPath: string | null
}): Promise<{ isBinary: boolean; found: boolean }> {
const args = ["diff", "--numstat"]
if (params.scope === "staged") {
args.push("--cached")
}
args.push("--find-renames", "--find-copies", "--")
args.push(params.normalizedPath)
if (params.normalizedOriginalPath && params.normalizedOriginalPath !== params.normalizedPath) {
args.push(params.normalizedOriginalPath)
}
const result = await runGit(args, params.workspaceFolder)
if (!result.ok) {
throw result.error
}
const parsed = parseSingleNumstat(result.stdout)
return { isBinary: parsed.isBinary, found: parsed.found }
}
async function getUntrackedDiffMetadata(params: {
workspaceFolder: string
normalizedPath: string
}): Promise<{ isBinary: boolean }> {
const absolutePath = path.join(params.workspaceFolder, params.normalizedPath)
const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], params.workspaceFolder, [0, 1])
if (!result.ok) {
throw result.error
}
return { isBinary: parseSingleNumstat(result.stdout).isBinary }
}
async function resolveUnstagedBeforePath(params: {
workspaceFolder: string
normalizedPath: string
normalizedOriginalPath: string | null
}): Promise<GitResult> {
const currentPathResult = await readGitIndexBlob(params.workspaceFolder, params.normalizedPath)
if (currentPathResult.ok || !params.normalizedOriginalPath || params.normalizedOriginalPath === params.normalizedPath) {
return currentPathResult
}
return readGitIndexBlob(params.workspaceFolder, params.normalizedOriginalPath)
}
export async function getWorktreeGitDiff(params: {
workspaceFolder: string
path: string
originalPath?: string | null
scope: WorktreeGitDiffScope
}): Promise<WorktreeGitDiffResponse> {
const normalizedPath = normalizeGitWorktreeRelativePath(params.path)
const normalizedOriginalPath = params.originalPath ? normalizeGitWorktreeRelativePath(params.originalPath) : null
const trackedMetadata = await getTrackedDiffMetadata({
workspaceFolder: params.workspaceFolder,
scope: params.scope,
normalizedPath,
normalizedOriginalPath,
})
const diffMetadata =
params.scope === "unstaged" && !trackedMetadata.found
? await getUntrackedDiffMetadata({
workspaceFolder: params.workspaceFolder,
normalizedPath,
})
: trackedMetadata
if (diffMetadata.isBinary) {
return {
path: normalizedPath,
originalPath: normalizedOriginalPath,
scope: params.scope,
before: "",
after: "",
isBinary: true,
}
}
if (params.scope === "staged") {
const [beforeResult, afterResult] = await Promise.all([
readGitBlobAsDiffText(runGit(["show", `HEAD:${normalizedOriginalPath ?? normalizedPath}`], params.workspaceFolder), true),
readGitBlobAsDiffText(readGitIndexBlob(params.workspaceFolder, normalizedPath), true),
])
return {
path: normalizedPath,
originalPath: normalizedOriginalPath,
scope: params.scope,
before: beforeResult,
after: afterResult,
isBinary: false,
}
}
const indexResult = await resolveUnstagedBeforePath({
workspaceFolder: params.workspaceFolder,
normalizedPath,
normalizedOriginalPath,
})
const beforeResult = await readGitBlobAsDiffText(Promise.resolve(indexResult), true)
let after = beforeResult
const fsPath = path.join(params.workspaceFolder, normalizedPath)
try {
after = await readFileAsDiffText(fsPath)
} catch {
after = ""
}
return {
path: normalizedPath,
originalPath: normalizedOriginalPath,
scope: params.scope,
before: beforeResult,
after,
isBinary: false,
}
}

View File

@@ -10,10 +10,6 @@ export interface LogLike {
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
function isGitUnavailableResult(result: GitResult): boolean {
return !result.ok && (result.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"
}
function runGit(args: string[], cwd: string): Promise<GitResult> {
return new Promise((resolve) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
@@ -42,9 +38,6 @@ function runGit(args: string[], cwd: string): Promise<GitResult> {
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
if (isGitUnavailableResult(result)) {
throw new Error("Git is not installed or not available in PATH")
}
if (!result.ok) {
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
return { repoRoot: folder, isGitRepo: false }
@@ -56,11 +49,6 @@ export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise
return { repoRoot, isGitRepo: true }
}
export async function isGitAvailable(folder: string): Promise<boolean> {
const result = await runGit(["--version"], folder)
return result.ok || !isGitUnavailableResult(result)
}
function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> {
const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
const lines = output.split(/\r?\n/)
@@ -102,22 +90,15 @@ export async function listWorktrees(params: {
logger?: LogLike
}): Promise<WorktreeDescriptor[]> {
const { repoRoot, workspaceFolder, logger } = params
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
if (!result.ok) {
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
return [rootDescriptor]
}
const records = parseWorktreePorcelain(result.stdout)
const rootRecord = records.find((record) => path.resolve(record.worktree) === path.resolve(repoRoot))
const rootDescriptor: WorktreeDescriptor = {
slug: "root",
directory: repoRoot,
kind: "root",
branch: rootRecord?.branch,
}
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
const seen = new Set<string>(["root"])

View File

@@ -83,12 +83,6 @@ export class WorkspaceManager {
}
}
writeFile(workspaceId: string, relativePath: string, contents: string): void {
const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path })
browser.writeFile(relativePath, contents)
}
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
const id = `${Date.now().toString(36)}`
@@ -142,15 +136,12 @@ 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),
})

View File

@@ -116,7 +116,6 @@ interface LaunchOptions {
folder: string
binaryPath: string
environment?: Record<string, string>
logLevel?: string
onExit?: (info: ProcessExitInfo) => void
}
@@ -140,8 +139,7 @@ export class WorkspaceRuntime {
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
this.validateFolder(options.folder)
const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG"
const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel]
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
const env = { ...process.env, ...(options.environment ?? {}) }
let exitResolve: ((info: ProcessExitInfo) => void) | null = null

View File

@@ -1,99 +0,0 @@
import { realpath } from "fs/promises"
import type { LogLike } from "./git-worktrees"
import { listWorktrees, resolveRepoRoot } from "./git-worktrees"
type WorktreeCacheEntry = {
expiresAt: number
repoRoot: string
worktrees: Array<{ slug: string; directory: string; normalizedDirectory: string }>
}
const WORKTREE_CACHE_TTL_MS = 2000
const worktreeCache = new Map<string, WorktreeCacheEntry>()
async function normalizeDirectoryPath(directory: string): Promise<string> {
const trimmed = (directory ?? "").trim()
if (!trimmed) return ""
try {
return await realpath(trimmed)
} catch {
return trimmed
}
}
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger?: LogLike }) {
const cached = worktreeCache.get(params.workspaceId)
const now = Date.now()
if (cached && cached.expiresAt > now) {
return cached
}
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
const entry: WorktreeCacheEntry = {
expiresAt: now + WORKTREE_CACHE_TTL_MS,
repoRoot,
worktrees: await Promise.all(
worktrees.map(async (wt) => ({
slug: wt.slug,
directory: wt.directory,
normalizedDirectory: await normalizeDirectoryPath(wt.directory),
})),
),
}
worktreeCache.set(params.workspaceId, entry)
return entry
}
export async function resolveWorktreeDirectory(params: {
workspaceId: string
workspacePath: string
worktreeSlug: string
logger?: LogLike
}): Promise<string | null> {
const cached = await getCachedWorktrees({
workspaceId: params.workspaceId,
workspacePath: params.workspacePath,
logger: params.logger,
})
const match = cached.worktrees.find((wt) => wt.slug === params.worktreeSlug)
if (match) {
return match.directory
}
worktreeCache.delete(params.workspaceId)
const refreshed = await getCachedWorktrees({
workspaceId: params.workspaceId,
workspacePath: params.workspacePath,
logger: params.logger,
})
return refreshed.worktrees.find((wt) => wt.slug === params.worktreeSlug)?.directory ?? null
}
export async function resolveWorktreeSlugForDirectory(params: {
workspaceId: string
workspacePath: string
directory: string
logger?: LogLike
}): Promise<string | null> {
const target = await normalizeDirectoryPath(params.directory ?? "")
if (!target) return null
const cached = await getCachedWorktrees({
workspaceId: params.workspaceId,
workspacePath: params.workspacePath,
logger: params.logger,
})
const match = cached.worktrees.find((wt) => wt.normalizedDirectory === target)
if (match) {
return match.slug
}
worktreeCache.delete(params.workspaceId)
const refreshed = await getCachedWorktrees({
workspaceId: params.workspaceId,
workspacePath: params.workspacePath,
logger: params.logger,
})
return refreshed.worktrees.find((wt) => wt.normalizedDirectory === target)?.slug ?? null
}

View File

@@ -47,15 +47,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arc-swap"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
dependencies = [
"rustversion",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -222,105 +213,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-server"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
dependencies = [
"arc-swap",
"bytes",
"fs-err",
"http",
"http-body",
"hyper",
"hyper-util",
"pin-project-lite",
"rustls",
"rustls-pemfile",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "base64"
version = "0.21.7"
@@ -516,8 +408,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -554,12 +444,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
@@ -572,34 +456,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "codenomad-tauri"
version = "0.14.0"
version = "0.12.3"
dependencies = [
"anyhow",
"axum",
"axum-server",
"base64 0.22.1",
"bytes",
"dirs 5.0.1",
"futures-util",
"keepawake",
"libc",
"once_cell",
"parking_lot",
"rand 0.8.5",
"regex",
"reqwest 0.12.28",
"rustls",
"serde",
"serde_json",
"serde_yaml",
@@ -610,9 +477,7 @@ dependencies = [
"tauri-plugin-notification",
"tauri-plugin-opener",
"thiserror 1.0.69",
"tokio",
"url",
"webkit2gtk",
"which",
"windows-sys 0.59.0",
]
@@ -1104,15 +969,6 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1283,22 +1139,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs-err"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0"
dependencies = [
"autocfg",
"tokio",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futf"
version = "0.1.5"
@@ -1539,10 +1379,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1552,11 +1390,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1738,25 +1574,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1872,12 +1689,6 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.8.1"
@@ -1888,11 +1699,9 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
@@ -1901,23 +1710,6 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2207,16 +1999,6 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.91"
@@ -2375,12 +2157,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@@ -2441,12 +2217,6 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
version = "2.8.0"
@@ -3225,61 +2995,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -3497,50 +3212,6 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@@ -3571,7 +3242,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.5.0",
"wasm-streams",
"web-sys",
]
@@ -3599,20 +3270,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -3654,53 +3311,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3892,17 +3502,6 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -3932,18 +3531,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.18.0"
@@ -4205,12 +3792,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -4362,7 +3943,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest 0.13.2",
"reqwest",
"serde",
"serde_json",
"serde_repr",
@@ -4786,21 +4367,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.50.0"
@@ -4812,31 +4378,9 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4968,7 +4512,6 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -5007,7 +4550,6 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -5149,12 +4691,6 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -5366,19 +4902,6 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
@@ -5414,16 +4937,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.3"
@@ -5480,15 +4993,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -5782,15 +5286,6 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -6432,12 +5927,6 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.14.0",
"version": "0.13.1",
"private": true,
"license": "MIT",
"scripts": {
@@ -8,13 +8,11 @@
"dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"sync:version": "node ./scripts/sync-tauri-version.js",
"prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm run prebuild",
"build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.4",
"@tauri-apps/cli-darwin-arm64": "^2.9.4"
"@tauri-apps/cli": "^2.9.4"
}
}

View File

@@ -56,7 +56,11 @@ async function ensureMonacoAssets() {
function ensureServerBuild() {
const distPath = path.join(serverRoot, "dist")
const publicPath = path.join(serverRoot, "public")
console.log("[prebuild] rebuilding server workspace for desktop packaging...")
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
return
}
console.log("[prebuild] server build missing; running workspace build...")
execSync("npm --workspace @neuralnomads/codenomad run build", {
cwd: workspaceRoot,
stdio: "inherit",

View File

@@ -1,102 +0,0 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const root = path.resolve(__dirname, "..")
const packageJsonPath = path.join(root, "package.json")
const cargoTomlPath = path.join(root, "src-tauri", "Cargo.toml")
const cargoLockPath = path.join(root, "Cargo.lock")
const tauriConfigPath = path.join(root, "src-tauri", "tauri.conf.json")
function readPackageVersion() {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error("Missing version in packages/tauri-app/package.json")
}
return packageJson.version
}
function syncCargoToml(version) {
const current = fs.readFileSync(cargoTomlPath, "utf8")
const packageVersionPattern = /(\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/m
const match = current.match(packageVersionPattern)
if (!match) {
throw new Error("Unable to find [package] version in packages/tauri-app/src-tauri/Cargo.toml")
}
if (match[2] === version) {
return false
}
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
fs.writeFileSync(cargoTomlPath, updated)
return true
}
function syncCargoLock(version) {
if (!fs.existsSync(cargoLockPath)) {
return false
}
const current = fs.readFileSync(cargoLockPath, "utf8")
const packageVersionPattern = /(\[\[package\]\]\r?\nname = "codenomad-tauri"\r?\nversion = ")([^"]+)(")/
const match = current.match(packageVersionPattern)
if (!match) {
throw new Error("Unable to find codenomad-tauri version in packages/tauri-app/Cargo.lock")
}
if (match[2] === version) {
return false
}
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
fs.writeFileSync(cargoLockPath, updated)
return true
}
function syncTauriConfig(version) {
const current = fs.readFileSync(tauriConfigPath, "utf8")
const config = JSON.parse(current)
if (config.version === version) {
return false
}
config.version = version
fs.writeFileSync(tauriConfigPath, `${JSON.stringify(config, null, 2)}\n`)
return true
}
function main() {
const version = readPackageVersion()
const changed = []
if (syncCargoToml(version)) {
changed.push(path.relative(root, cargoTomlPath))
}
if (syncCargoLock(version)) {
changed.push(path.relative(root, cargoLockPath))
}
if (syncTauriConfig(version)) {
changed.push(path.relative(root, tauriConfigPath))
}
if (changed.length === 0) {
console.log(`[sync-tauri-version] already aligned to ${version}`)
return
}
console.log(`[sync-tauri-version] synced ${version} -> ${changed.join(", ")}`)
}
try {
main()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[sync-tauri-version] failed: ${message}`)
process.exit(1)
}

View File

@@ -1,6 +1,6 @@
[package]
name = "codenomad-tauri"
version = "0.14.0"
version = "0.12.3"
edition = "2021"
license = "MIT"
@@ -12,20 +12,11 @@ tauri = { version = "2.5.2", features = [ "devtools"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
axum = "0.7"
axum-server = { version = "0.7", features = ["tls-rustls"] }
base64 = "0.22"
bytes = "1"
futures-util = "0.3"
rustls = { version = "0.23", features = ["ring"] }
reqwest = { version = "0.12", default-features = false, features = ["http2", "charset", "json", "stream", "rustls-tls"] }
rand = "0.8"
regex = "1"
once_cell = "1"
parking_lot = "0.12"
thiserror = "1"
anyhow = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
which = "4"
libc = "0.2"
keepawake = "0.6"
@@ -37,7 +28,4 @@ url = "2"
tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = "2.0.2"
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>CodeNomad needs microphone access for speech-to-text prompt input.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>CodeNomad needs local network access to connect to locally hosted AI and speech services.</string>
</dict>
</plist>

View File

@@ -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"]}}

View File

@@ -2378,72 +2378,6 @@
"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",

File diff suppressed because it is too large Load Diff

View File

@@ -1,248 +0,0 @@
use base64::Engine;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
const TLS_DIR_NAME: &str = "tls";
const CA_CERT_FILE: &str = "ca-cert.pem";
const SERVER_CERT_FILE: &str = "server-cert.pem";
const SERVER_KEY_FILE: &str = "server-key.pem";
const TRUSTED_MARKER: &str = "server-ca.trusted";
#[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
/// Holds the PEM-encoded certificate/key pair used by the local HTTPS proxy,
/// plus the CA certificate DER used for trust-store installation.
pub struct LocalCert {
pub cert_pem: String,
pub key_pem: String,
pub ca_cert_der: Vec<u8>,
}
struct TlsAssetPaths {
cert_path: PathBuf,
key_path: PathBuf,
trust_path: PathBuf,
append_ca_to_cert: bool,
}
/// Loads the TLS assets already managed by `packages/server`.
pub fn ensure_local_cert() -> Result<LocalCert, String> {
let assets = resolve_tls_asset_paths()?;
let mut cert_pem = read_pem_file(&assets.cert_path)?;
let key_pem = read_pem_file(&assets.key_path)?;
let trust_pem = read_pem_file(&assets.trust_path)?;
if assets.append_ca_to_cert {
cert_pem = format!("{}\n{}\n", cert_pem.trim(), trust_pem.trim());
}
let ca_cert_der = pem_to_der(&trust_pem)?;
Ok(LocalCert {
cert_pem,
key_pem,
ca_cert_der,
})
}
fn read_pem_file(path: &Path) -> Result<String, String> {
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))
}
fn server_tls_dir() -> Result<PathBuf, String> {
Ok(resolve_server_config_base_dir()?.join(TLS_DIR_NAME))
}
fn resolve_tls_asset_paths() -> Result<TlsAssetPaths, String> {
let tls_key_path = env::var("CLI_TLS_KEY")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
let tls_cert_path = env::var("CLI_TLS_CERT")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
let tls_ca_path = env::var("CLI_TLS_CA")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
match (tls_key_path, tls_cert_path) {
(Some(key_path), Some(cert_path)) => {
let append_ca_to_cert = tls_ca_path.is_some();
let trust_path = tls_ca_path.unwrap_or_else(|| cert_path.clone());
Ok(TlsAssetPaths {
cert_path,
key_path,
trust_path,
append_ca_to_cert,
})
}
(Some(_), None) | (None, Some(_)) => Err(
"CLI_TLS_KEY and CLI_TLS_CERT must both be set when using custom TLS files"
.to_string(),
),
(None, None) => {
let tls_dir = server_tls_dir()?;
Ok(TlsAssetPaths {
cert_path: tls_dir.join(SERVER_CERT_FILE),
key_path: tls_dir.join(SERVER_KEY_FILE),
trust_path: tls_dir.join(CA_CERT_FILE),
append_ca_to_cert: true,
})
}
}
}
fn resolve_server_config_base_dir() -> Result<PathBuf, String> {
let raw = env::var("CLI_CONFIG")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
let expanded = resolve_path_like_server(&raw)?;
let lower = raw.trim().to_lowercase();
if lower.ends_with(".yaml") || lower.ends_with(".yml") || lower.ends_with(".json") {
return expanded
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| format!("Failed to determine config base dir from {}", expanded.display()));
}
Ok(expanded)
}
fn resolve_path_like_server(path: &str) -> Result<PathBuf, String> {
if path.starts_with("~/") {
let home = dirs::home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from));
let home = home.ok_or_else(|| "Cannot determine home directory".to_string())?;
return Ok(home.join(path.trim_start_matches("~/")));
}
let path = PathBuf::from(path);
if path.is_absolute() {
return Ok(path);
}
let cwd = env::current_dir().map_err(|e| format!("Failed to read current dir: {e}"))?;
Ok(cwd.join(path))
}
fn trusted_marker_path() -> Result<PathBuf, String> {
let base = dirs::data_local_dir()
.ok_or_else(|| "Cannot determine local app data directory".to_string())?;
#[cfg(windows)]
{
return Ok(base.join(WINDOWS_APP_USER_MODEL_ID).join(TRUSTED_MARKER));
}
#[cfg(not(windows))]
{
Ok(base.join("codenomad").join(TRUSTED_MARKER))
}
}
fn trusted_marker_value(cert_der: &[u8]) -> String {
cert_der.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn has_matching_trusted_marker(cert_der: &[u8]) -> bool {
trusted_marker_path()
.ok()
.and_then(|path| fs::read_to_string(path).ok())
.map(|value| value.trim() == trusted_marker_value(cert_der))
.unwrap_or(false)
}
fn write_trusted_marker(cert_der: &[u8]) -> Result<(), String> {
let path = trusted_marker_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create trust state dir {}: {e}", parent.display()))?;
}
fs::write(path, trusted_marker_value(cert_der))
.map_err(|e| format!("Failed to write trust marker: {e}"))
}
/// Adds the DER-encoded CA certificate to the Windows `CurrentUser\Root` store.
/// This will show a one-time Windows security confirmation dialog when needed.
#[cfg(windows)]
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
use windows_sys::Win32::Security::Cryptography::{
CertAddEncodedCertificateToStore, CertCloseStore, CertOpenSystemStoreW,
CERT_STORE_ADD_REPLACE_EXISTING, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING,
};
if has_matching_trusted_marker(cert_der) {
return Ok(());
}
let store_name: Vec<u16> = "Root\0".encode_utf16().collect();
unsafe {
let store = CertOpenSystemStoreW(0, store_name.as_ptr());
if store.is_null() {
return Err("Failed to open CurrentUser\\Root certificate store".into());
}
let encoding = X509_ASN_ENCODING | PKCS_7_ASN_ENCODING;
let result = CertAddEncodedCertificateToStore(
store,
encoding,
cert_der.as_ptr(),
cert_der.len() as u32,
CERT_STORE_ADD_REPLACE_EXISTING,
std::ptr::null_mut(),
);
CertCloseStore(store, 0);
if result == 0 {
return Err(
"Failed to add certificate to trust store. The user may have declined the security dialog."
.into(),
);
}
}
write_trusted_marker(cert_der)?;
Ok(())
}
#[cfg(not(windows))]
pub fn trust_cert_in_store(_cert_der: &[u8]) -> Result<(), String> {
// Non-Windows platforms use native webview-specific handling instead of OS trust-store writes.
Ok(())
}
fn pem_to_der(pem: &str) -> Result<Vec<u8>, String> {
let mut body = String::new();
let mut in_block = false;
for line in pem.lines() {
if line.starts_with("-----BEGIN CERTIFICATE-----") {
in_block = true;
continue;
}
if line.starts_with("-----END CERTIFICATE-----") {
break;
}
if in_block {
body.push_str(line.trim());
}
}
if body.is_empty() {
return Err("No certificate found in PEM file".to_string());
}
base64::engine::general_purpose::STANDARD
.decode(body)
.map_err(|e| format!("Failed to decode certificate PEM: {e}"))
}

View File

@@ -5,13 +5,9 @@ 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;
@@ -23,95 +19,12 @@ 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<Self> {
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::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() 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}");
}
@@ -450,8 +363,6 @@ impl Default for CliStatus {
pub struct CliProcessManager {
status: Arc<Mutex<CliStatus>>,
child: Arc<Mutex<Option<Child>>>,
#[cfg(windows)]
job: Arc<Mutex<Option<WindowsJobObject>>>,
ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>,
}
@@ -461,8 +372,6 @@ 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)),
}
@@ -485,8 +394,6 @@ 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 || {
@@ -494,8 +401,6 @@ impl CliProcessManager {
app.clone(),
status_arc.clone(),
child_arc,
#[cfg(windows)]
job_arc,
ready_flag,
token_arc,
dev,
@@ -515,12 +420,11 @@ 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;
@@ -542,16 +446,18 @@ impl CliProcessManager {
Ok(Some(_)) => break,
Ok(None) => {
#[cfg(windows)]
if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) {
if !forced_tree_shutdown
&& 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) {
@@ -570,7 +476,11 @@ impl CliProcessManager {
}
#[cfg(windows)]
{
if !kill_process_tree_windows(child.id(), true) {
if !forced_tree_shutdown
&& !kill_process_tree_windows(child.id(), true)
{
let _ = child.kill();
} else if forced_tree_shutdown {
let _ = child.kill();
}
}
@@ -581,9 +491,6 @@ 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();
@@ -604,7 +511,6 @@ impl CliProcessManager {
app: AppHandle,
status: Arc<Mutex<CliStatus>>,
child_holder: Arc<Mutex<Option<Child>>>,
#[cfg(windows)] job_holder: Arc<Mutex<Option<WindowsJobObject>>>,
ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>,
dev: bool,
@@ -628,9 +534,7 @@ impl CliProcessManager {
log_line(&format!("using cwd={}", c.display()));
}
let use_user_shell = supports_user_shell();
let command_info = if use_user_shell {
let command_info = if supports_user_shell() {
log_line("spawning via user shell");
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
} else {
@@ -641,7 +545,7 @@ impl CliProcessManager {
})
};
if !use_user_shell {
if !supports_user_shell() {
if which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!(
"Node binary not found. Make sure Node.js is installed."
@@ -655,8 +559,6 @@ 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);
@@ -686,22 +588,6 @@ 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);
@@ -733,41 +619,26 @@ impl CliProcessManager {
.map(BufReader::new);
if let Some(reader) = stdout {
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(),
);
});
Self::process_stream(
reader,
"stdout",
&app_clone,
&status_clone,
&ready_clone,
&token_clone,
auth_cookie_name_clone.as_str(),
);
}
if let Some(reader) = stderr {
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(),
);
});
Self::process_stream(
reader,
"stderr",
&app_clone,
&status_clone,
&ready_clone,
&token_clone,
auth_cookie_name_clone.as_str(),
);
}
});
@@ -775,8 +646,6 @@ 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);
@@ -831,10 +700,6 @@ 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,
@@ -892,8 +757,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+)\s*$").ok();
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
loop {
@@ -935,6 +800,38 @@ 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::<u16>().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::<serde_json::Value>(line) {
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
Self::mark_ready(
app,
status,
ready,
bootstrap_token,
auth_cookie_name,
format!("http://localhost:{}", port),
);
continue;
}
}
}
}
}
Err(_) => break,
@@ -1079,7 +976,6 @@ impl CliEntry {
"--auth-cookie-name".to_string(),
auth_cookie_name.to_string(),
"--generate-token".to_string(),
"--unrestricted-root".to_string(),
];
if dev {
@@ -1135,58 +1031,27 @@ impl CliEntry {
}
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
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()
let candidates = vec![
std::env::current_dir()
.ok()
.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<String> {
let cwd = std::env::current_dir().ok();
let workspace = workspace_root();
let candidates = vec![
workspace
.as_ref()
std::env::current_dir()
.ok()
.map(|p| p.join("packages/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")),
std::env::current_dir()
.ok()
.map(|p| p.join("../server/src/index.ts")),
];
first_existing(candidates)
@@ -1288,8 +1153,11 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
.unwrap_or("")
.to_lowercase();
let _ = shell_name;
vec!["-l".into(), "-c".into(), command.into()]
if shell_name.contains("zsh") {
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
} else {
vec!["-l".into(), "-c".into(), command.into()]
}
}
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {

View File

@@ -1,88 +0,0 @@
use crate::AppState;
use tauri::{AppHandle, Manager, WebviewWindow};
use url::Url;
use webkit2gtk::{WebContextExt, WebView, WebViewExt};
pub fn should_bootstrap_tls_navigation(target_url: &Url, skip_tls_verify: bool) -> bool {
skip_tls_verify && target_url.scheme() == "https"
}
pub fn ensure_remote_window_tls_handler(
window: &WebviewWindow,
app_handle: &AppHandle,
window_label: &str,
) -> Result<(), String> {
{
let state = app_handle.state::<AppState>();
let mut handlers = state
.remote_tls_handlers
.lock()
.map_err(|err| err.to_string())?;
if !handlers.insert(window_label.to_string()) {
return Ok(());
}
}
let app_handle = app_handle.clone();
let window_label = window_label.to_string();
window
.with_webview(move |platform_webview| {
let webview = platform_webview.inner();
let app_handle = app_handle.clone();
let window_label = window_label.clone();
webview.connect_load_failed_with_tls_errors(move |view, failing_uri, certificate, _| {
allow_remote_tls_certificate(
&app_handle,
&window_label,
view,
failing_uri,
certificate,
)
});
})
.map_err(|err| err.to_string())
}
fn allow_remote_tls_certificate(
app_handle: &AppHandle,
window_label: &str,
view: &WebView,
failing_uri: &str,
certificate: &webkit2gtk::gio::TlsCertificate,
) -> bool {
let Ok(parsed_uri) = Url::parse(failing_uri) else {
return false;
};
let Some(host) = parsed_uri.host_str() else {
return false;
};
let state = app_handle.state::<AppState>();
let skip_tls_verify = state
.remote_skip_tls_verify
.lock()
.ok()
.and_then(|values| values.get(window_label).copied())
.unwrap_or(false);
if !skip_tls_verify {
return false;
}
let expected_origin = state
.remote_origins
.lock()
.ok()
.and_then(|origins| origins.get(window_label).cloned());
let parsed_origin = parsed_uri.origin().ascii_serialization();
if expected_origin.as_deref() != Some(parsed_origin.as_str()) {
return false;
}
let Some(context) = view.context() else {
return false;
};
context.allow_tls_certificate_for_host(certificate, host);
view.load_uri(failing_uri);
true
}

View File

@@ -1,25 +1,18 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[allow(dead_code)]
mod cert_manager;
mod cli_manager;
#[cfg(target_os = "linux")]
mod linux_tls;
use cli_manager::{CliProcessManager, CliStatus};
use keepawake::KeepAwake;
use serde::Deserialize;
use serde_json::json;
use std::collections::{HashMap, HashSet};
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, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
};
use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry};
use tauri_plugin_global_shortcut::{
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
};
@@ -37,7 +30,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.1;
const ZOOM_STEP: f64 = 0.2;
const MIN_ZOOM_LEVEL: f64 = 0.2;
const MAX_ZOOM_LEVEL: f64 = 5.0;
@@ -48,20 +41,6 @@ pub struct AppState {
pub manager: CliProcessManager,
pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>,
pub remote_origins: Mutex<HashMap<String, String>>,
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
pub remote_tls_handlers: Mutex<HashSet<String>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RemoteWindowPayload {
id: String,
name: String,
base_url: String,
entry_url: Option<String>,
#[allow(dead_code)]
skip_tls_verify: bool,
}
#[derive(Debug, Default, Deserialize)]
@@ -127,7 +106,7 @@ fn is_dev_mode() -> bool {
fn should_allow_internal(url: &Url) -> bool {
match url.scheme() {
"tauri" | "asset" | "file" | "about" => true,
"tauri" | "asset" | "file" => true,
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
// This must be treated as an internal origin or the navigation guard will
// redirect it to the system browser and the app will appear blank.
@@ -139,29 +118,8 @@ fn should_allow_internal(url: &Url) -> bool {
}
}
fn should_allow_window_origin<R: Runtime>(
app_handle: &AppHandle<R>,
window_label: &str,
url: &Url,
) -> bool {
if should_allow_internal(url) {
return true;
}
let state = app_handle.state::<AppState>();
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<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
let window_label = webview.label().to_string();
if should_allow_window_origin(&webview.app_handle(), &window_label, url) {
if should_allow_internal(url) {
return true;
}
@@ -175,115 +133,6 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
false
}
async fn open_remote_window_impl(
app: AppHandle,
payload: RemoteWindowPayload,
) -> Result<(), String> {
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
let label = format!("remote-{}", payload.id);
let title = format!(
"{} - {}",
payload.name,
Url::parse(&payload.base_url)
.ok()
.and_then(|url| url.host_str().map(str::to_string))
.unwrap_or_else(|| payload.base_url.clone())
);
let window_url = parsed.clone();
app.state::<AppState>()
.remote_origins
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), window_url.origin().ascii_serialization());
app.state::<AppState>()
.remote_skip_tls_verify
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), parsed.scheme() == "https");
if let Some(existing) = app.get_webview_window(&label) {
#[cfg(target_os = "linux")]
linux_tls::ensure_remote_window_tls_handler(&existing, &app, &label)?;
let _ = existing.navigate(window_url.clone());
let _ = existing.set_title(&title);
let _ = existing.show();
let _ = existing.unminimize();
let _ = existing.set_focus();
return Ok(());
}
#[cfg(target_os = "linux")]
let initial_url = if linux_tls::should_bootstrap_tls_navigation(&window_url, payload.skip_tls_verify)
{
Url::parse("about:blank").map_err(|err| err.to_string())?
} else {
window_url.clone()
};
#[cfg(not(target_os = "linux"))]
let initial_url = window_url.clone();
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
.title(title)
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.build()
.map_err(|err| err.to_string())?;
#[cfg(target_os = "linux")]
{
linux_tls::ensure_remote_window_tls_handler(&window, &app, &label)?;
if initial_url != window_url {
let _ = window.navigate(window_url.clone());
}
}
let app_handle = app.clone();
let label_for_cleanup = label.clone();
window.on_window_event(move |event| {
if let WindowEvent::Destroyed = event {
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
origins.remove(&label_for_cleanup);
}
if let Ok(mut values) = app_handle.state::<AppState>().remote_skip_tls_verify.lock() {
values.remove(&label_for_cleanup);
}
if let Ok(mut handlers) = app_handle.state::<AppState>().remote_tls_handlers.lock() {
handlers.remove(&label_for_cleanup);
}
}
});
Ok(())
}
#[tauri::command]
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
#[cfg(not(target_os = "linux"))]
{
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
if parsed.scheme() == "https" {
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
format!(
"Failed to load the local HTTPS certificate for the remote proxy window: {err}"
)
})?;
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) {
return Err(format!(
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
));
}
}
}
open_remote_window_impl(app, payload).await
}
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
paths
.iter()
@@ -411,8 +260,6 @@ fn set_windows_app_user_model_id() {
fn set_windows_app_user_model_id() {}
fn main() {
let _ = rustls::crypto::ring::default_provider().install_default();
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
.on_navigation(|webview, url| intercept_navigation(webview, url))
.build();
@@ -439,9 +286,6 @@ fn main() {
manager: CliProcessManager::new(),
wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
remote_origins: Mutex::new(HashMap::new()),
remote_skip_tls_verify: Mutex::new(HashMap::new()),
remote_tls_handlers: Mutex::new(HashSet::new()),
})
.setup(|app| {
set_windows_app_user_model_id();
@@ -479,8 +323,7 @@ fn main() {
cli_get_status,
cli_restart,
wake_lock_start,
wake_lock_stop,
open_remote_window
wake_lock_stop
])
.on_menu_event(|app_handle, event| {
match event.id().0.as_str() {
@@ -612,24 +455,11 @@ fn main() {
event: tauri::WindowEvent::CloseRequested { api, .. },
..
} => {
// 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.
// Ensure we have time to stop the CLI process before the app exits.
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::<AppState>() {

View File

@@ -1,460 +0,0 @@
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, Uri};
use axum::response::Response;
use axum::routing::any;
use axum::Router;
use axum_server::tls_rustls::RustlsConfig;
use futures_util::TryStreamExt;
use rand::RngCore;
use reqwest::redirect::Policy;
use reqwest::Client;
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use url::Url;
const PROXY_TOKEN_QUERY: &str = "proxy_token";
#[derive(Clone)]
struct ProxyState {
client: Client,
target_base_url: Url,
local_base_url: Url,
session_token: String,
session_activated: Arc<AtomicBool>,
}
/// TLS configuration for the local HTTPS proxy.
pub struct ProxyTlsConfig {
pub cert_pem: String,
pub key_pem: String,
}
pub struct RemoteProxyHandle {
local_base_url: Url,
entry_url: Url,
target_base_url: Url,
skip_tls_verify: bool,
server_handle: axum_server::Handle,
}
impl RemoteProxyHandle {
pub fn local_base_url(&self) -> &Url {
&self.local_base_url
}
pub fn entry_url(&self) -> &Url {
&self.entry_url
}
pub fn matches(&self, target_base_url: &Url, skip_tls_verify: bool) -> bool {
self.target_base_url == *target_base_url && self.skip_tls_verify == skip_tls_verify
}
pub fn shutdown(&self) {
self.server_handle.shutdown();
}
}
impl Drop for RemoteProxyHandle {
fn drop(&mut self) {
self.shutdown();
}
}
pub async fn start_remote_proxy(
target_base_url: Url,
skip_tls_verify: bool,
tls_config: Option<ProxyTlsConfig>,
) -> Result<RemoteProxyHandle, String> {
let client = Client::builder()
.redirect(Policy::none())
.danger_accept_invalid_certs(skip_tls_verify)
.build()
.map_err(|err| err.to_string())?;
// Pre-bind a std TcpListener on port 0 to discover the actual port
let std_listener = std::net::TcpListener::bind("127.0.0.1:0")
.map_err(|err| err.to_string())?;
let address = std_listener.local_addr().map_err(|err| err.to_string())?;
let scheme = if tls_config.is_some() { "https" } else { "http" };
let local_base_url =
Url::parse(&format!("{scheme}://{address}")).map_err(|err| err.to_string())?;
let session_token = generate_session_token();
let mut entry_url = local_base_url.clone();
entry_url.set_path(target_base_url.path());
entry_url.set_query(Some(&format!("{PROXY_TOKEN_QUERY}={session_token}")));
let state = Arc::new(ProxyState {
client,
target_base_url: target_base_url.clone(),
local_base_url: local_base_url.clone(),
session_token,
session_activated: Arc::new(AtomicBool::new(false)),
});
let app = Router::new()
.route("/*path", any(proxy_request))
.route("/", any(proxy_request))
.with_state(state);
let server_handle = axum_server::Handle::new();
let handle_clone = server_handle.clone();
if let Some(tls) = tls_config {
let rustls_config =
RustlsConfig::from_pem(tls.cert_pem.into_bytes(), tls.key_pem.into_bytes())
.await
.map_err(|err| format!("Failed to build RustlsConfig: {err}"))?;
tauri::async_runtime::spawn(async move {
let server = axum_server::from_tcp_rustls(std_listener, rustls_config)
.handle(handle_clone)
.serve(app.into_make_service());
if let Err(err) = server.await {
eprintln!("[tauri] remote proxy (HTTPS) stopped with error: {err}");
}
});
} else {
tauri::async_runtime::spawn(async move {
let server = axum_server::from_tcp(std_listener)
.handle(handle_clone)
.serve(app.into_make_service());
if let Err(err) = server.await {
eprintln!("[tauri] remote proxy (HTTP) stopped with error: {err}");
}
});
}
Ok(RemoteProxyHandle {
local_base_url,
entry_url,
target_base_url,
skip_tls_verify,
server_handle,
})
}
async fn proxy_request(
State(state): State<Arc<ProxyState>>,
request: Request,
) -> Result<Response<Body>, StatusCode> {
if !state.session_activated.load(Ordering::SeqCst) {
if request_bootstraps_session(&request, &state.session_token) {
state.session_activated.store(true, Ordering::SeqCst);
return Ok(build_bootstrap_response(request.uri())?);
}
return Err(StatusCode::FORBIDDEN);
}
let upstream_url = build_upstream_url(&state.target_base_url, request.uri())
.map_err(|_| StatusCode::BAD_REQUEST)?;
let mut builder = state
.client
.request(request.method().clone(), upstream_url.clone());
builder = builder.headers(filter_request_headers(
request.headers(),
&state.target_base_url,
)?);
let body = axum::body::to_bytes(request.into_body(), usize::MAX)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
if !body.is_empty() {
builder = builder.body(body);
}
let upstream = builder.send().await.map_err(map_upstream_error)?;
let status = upstream.status();
let headers = rewrite_response_headers(
upstream.headers(),
&state.target_base_url,
&state.local_base_url,
)?;
let stream = upstream
.bytes_stream()
.map_err(|err| std::io::Error::other(err.to_string()));
let mut response = Response::new(Body::from_stream(stream));
*response.status_mut() = status;
*response.headers_mut() = headers;
Ok(response)
}
fn build_upstream_url(base_url: &Url, uri: &Uri) -> Result<Url, url::ParseError> {
let mut url = base_url.clone();
url.set_path(&rewrite_request_path(base_url, uri.path()));
url.set_query(strip_proxy_token_query(uri.query()).as_deref());
Ok(url)
}
fn rewrite_request_path(base_url: &Url, request_path: &str) -> String {
let base_path = normalized_base_path(base_url);
if base_path == "/" {
return request_path.to_string();
}
if request_path == "/" {
return base_path.to_string();
}
if path_has_base_prefix(base_path, request_path) {
return request_path.to_string();
}
format!("{base_path}{request_path}")
}
fn normalized_base_path(base_url: &Url) -> &str {
let path = base_url.path();
if path.is_empty() { "/" } else { path }
}
fn path_has_base_prefix(base_path: &str, request_path: &str) -> bool {
request_path == base_path
|| request_path
.strip_prefix(base_path)
.is_some_and(|suffix| suffix.starts_with('/'))
}
fn generate_session_token() -> String {
let mut bytes = [0_u8; 16];
rand::thread_rng().fill_bytes(&mut bytes);
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn request_bootstraps_session(request: &Request, session_token: &str) -> bool {
request.uri().query().is_some_and(|query| {
url::form_urlencoded::parse(query.as_bytes())
.any(|(name, value)| name == PROXY_TOKEN_QUERY && value == session_token)
})
}
fn build_bootstrap_response(uri: &Uri) -> Result<Response<Body>, StatusCode> {
let redirect_target = sanitized_request_target(uri);
Response::builder()
.status(StatusCode::FOUND)
.header(axum::http::header::LOCATION, redirect_target)
.body(Body::empty())
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
fn sanitized_request_target(uri: &Uri) -> String {
let path = if uri.path().is_empty() { "/" } else { uri.path() };
match strip_proxy_token_query(uri.query()) {
Some(query) if !query.is_empty() => format!("{path}?{query}"),
_ => path.to_string(),
}
}
fn strip_proxy_token_query(query: Option<&str>) -> Option<String> {
let query = query?;
let filtered: Vec<(std::borrow::Cow<'_, str>, std::borrow::Cow<'_, str>)> =
url::form_urlencoded::parse(query.as_bytes())
.filter(|(name, _)| name != PROXY_TOKEN_QUERY)
.collect();
if filtered.is_empty() {
return None;
}
Some(
url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(filtered)
.finish(),
)
}
fn filter_request_headers(
headers: &HeaderMap,
target_base_url: &Url,
) -> Result<HeaderMap, StatusCode> {
let mut forwarded = HeaderMap::new();
for (name, value) in headers {
if is_hop_by_hop_header(name) || *name == axum::http::header::HOST {
continue;
}
forwarded.append(name.clone(), value.clone());
}
let host = target_base_url.host_str().ok_or(StatusCode::BAD_REQUEST)?;
let host_value = match target_base_url.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_string(),
};
forwarded.insert(
axum::http::header::HOST,
HeaderValue::from_str(&host_value).map_err(|_| StatusCode::BAD_REQUEST)?,
);
let target_origin = target_base_url.origin().ascii_serialization();
if let Ok(origin) = HeaderValue::from_str(&target_origin) {
forwarded.insert(axum::http::header::ORIGIN, origin);
}
if let Some(referer) = rewrite_referer_header(headers, target_base_url) {
forwarded.insert(
axum::http::header::REFERER,
HeaderValue::from_str(&referer).map_err(|_| StatusCode::BAD_REQUEST)?,
);
}
Ok(forwarded)
}
fn rewrite_referer_header(headers: &HeaderMap, target_base_url: &Url) -> Option<String> {
let referer = headers.get(axum::http::header::REFERER)?.to_str().ok()?;
let parsed = Url::parse(referer).ok()?;
let mut rewritten = target_base_url.clone();
rewritten.set_path(&rewrite_request_path(target_base_url, parsed.path()));
rewritten.set_query(parsed.query());
rewritten.set_fragment(parsed.fragment());
Some(rewritten.to_string())
}
fn rewrite_response_headers(
headers: &HeaderMap,
target_base_url: &Url,
local_base_url: &Url,
) -> Result<HeaderMap, StatusCode> {
let mut rewritten = HeaderMap::new();
for (name, value) in headers {
if is_hop_by_hop_header(name) {
continue;
}
if *name == axum::http::header::LOCATION {
if let Ok(location) = value.to_str() {
let next = rewrite_location(location, target_base_url, local_base_url);
rewritten.append(
name.clone(),
HeaderValue::from_str(&next).map_err(|_| StatusCode::BAD_GATEWAY)?,
);
continue;
}
}
if *name == axum::http::header::SET_COOKIE {
if let Ok(cookie) = value.to_str() {
let next = rewrite_set_cookie(cookie);
rewritten.append(
name.clone(),
HeaderValue::from_str(&next).map_err(|_| StatusCode::BAD_GATEWAY)?,
);
continue;
}
}
rewritten.append(name.clone(), value.clone());
}
Ok(rewritten)
}
fn rewrite_set_cookie(cookie: &str) -> String {
cookie
.split(';')
.map(str::trim)
.filter(|part| !part.get(..7).is_some_and(|prefix| prefix.eq_ignore_ascii_case("Domain=")))
.collect::<Vec<_>>()
.join("; ")
}
fn rewrite_location(location: &str, target_base_url: &Url, local_base_url: &Url) -> String {
let Ok(parsed) = target_base_url.join(location) else {
return location.to_string();
};
if parsed.origin() != target_base_url.origin() {
return location.to_string();
}
let mut rewritten = local_base_url.clone();
rewritten.set_path(parsed.path());
rewritten.set_query(parsed.query());
rewritten.set_fragment(parsed.fragment());
rewritten.to_string()
}
fn map_upstream_error(error: reqwest::Error) -> StatusCode {
if error.is_timeout() {
StatusCode::GATEWAY_TIMEOUT
} else if error.is_connect() {
StatusCode::BAD_GATEWAY
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
}
fn is_hop_by_hop_header(name: &HeaderName) -> bool {
static HOP_BY_HOP: std::sync::OnceLock<HashSet<&'static str>> = std::sync::OnceLock::new();
HOP_BY_HOP
.get_or_init(|| {
HashSet::from([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
])
})
.contains(name.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_upstream_url_prefixes_root_relative_requests_under_base_path() {
let base = Url::parse("https://example.com/app").unwrap();
let uri = "/api/auth/status?foo=bar".parse::<Uri>().unwrap();
let upstream = build_upstream_url(&base, &uri).unwrap();
assert_eq!(upstream.as_str(), "https://example.com/app/api/auth/status?foo=bar");
}
#[test]
fn build_upstream_url_keeps_requests_already_under_base_path() {
let base = Url::parse("https://example.com/app").unwrap();
let uri = "/app/api/auth/status?foo=bar".parse::<Uri>().unwrap();
let upstream = build_upstream_url(&base, &uri).unwrap();
assert_eq!(upstream.as_str(), "https://example.com/app/api/auth/status?foo=bar");
}
#[test]
fn build_upstream_url_maps_root_to_base_path() {
let base = Url::parse("https://example.com/app").unwrap();
let uri = "/".parse::<Uri>().unwrap();
let upstream = build_upstream_url(&base, &uri).unwrap();
assert_eq!(upstream.as_str(), "https://example.com/app");
}
#[test]
fn rewrite_referer_header_prefixes_root_relative_path_under_base_path() {
let target = Url::parse("https://example.com/app").unwrap();
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::REFERER,
HeaderValue::from_static("https://127.0.0.1:3000/api/auth/status?foo=bar"),
);
let referer = rewrite_referer_header(&headers, &target).unwrap();
assert_eq!(referer, "https://example.com/app/api/auth/status?foo=bar");
}
}

View File

@@ -1,13 +1,16 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad",
"version": "0.14.0",
"version": "0.12.3",
"identifier": "ai.neuralnomads.codenomad.client",
"build": {
"beforeDevCommand": "npm run dev:bootstrap",
"beforeBuildCommand": "npm run bundle:server",
"frontendDist": "resources/ui-loading"
},
"app": {
"withGlobalTauri": true,
"windows": [
@@ -30,13 +33,9 @@
],
"security": {
"assetProtocol": {
"scope": [
"**"
]
"scope": ["**"]
},
"capabilities": [
"main-window-native-dialogs"
]
"capabilities": ["main-window-native-dialogs"]
}
},
"bundle": {
@@ -45,17 +44,7 @@
"resources/server",
"resources/ui-loading"
],
"icon": [
"icon.icns",
"icon.ico",
"icon.png"
],
"targets": [
"app",
"appimage",
"deb",
"rpm",
"nsis"
]
"icon": ["icon.icns", "icon.ico", "icon.png"],
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.14.0",
"version": "0.13.1",
"private": true,
"license": "MIT",
"type": "module",

View File

@@ -10,10 +10,7 @@ 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"
@@ -26,6 +23,7 @@ import { runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n"
import { setWakeLockDesired } from "./lib/native/wake-lock"
import {
hasInstances,
isSelectingFolder,
setIsSelectingFolder,
showFolderSelection,
@@ -35,7 +33,10 @@ import { useConfig } from "./stores/preferences"
import {
createInstance,
instances,
activeInstanceId,
setActiveInstanceId,
stopInstance,
getActiveInstance,
disconnectedInstance,
acknowledgeDisconnectedInstance,
} from "./stores/instances"
@@ -52,22 +53,6 @@ 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")
@@ -92,7 +77,6 @@ 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())
@@ -222,7 +206,8 @@ const App: Component = () => {
})
createEffect(() => {
appTabs()
instances()
hasInstances()
requestAnimationFrame(() => updateInstanceTabBarHeight())
})
@@ -234,15 +219,7 @@ const App: Component = () => {
onCleanup(() => window.removeEventListener("resize", handleResize))
})
createEffect(() => {
appTabs()
ensureActiveAppTab()
})
const activeInstance = createMemo(() => {
const tab = activeAppTab()
return tab?.kind === "instance" ? tab.instance : null
})
const activeInstance = createMemo(() => getActiveInstance())
const activeSessionIdForInstance = createMemo(() => {
const instance = activeInstance()
if (!instance) return null
@@ -267,7 +244,6 @@ const App: Component = () => {
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary)
selectInstanceTab(instanceId)
setShowFolderSelection(false)
log.info("Created instance", {
@@ -294,27 +270,8 @@ const App: Component = () => {
}
function handleNewInstanceRequest() {
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)
if (hasInstances()) {
setShowFolderSelection(true)
}
}
@@ -375,23 +332,6 @@ 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)
@@ -421,7 +361,6 @@ const App: Component = () => {
setThinkingBlocksExpansion,
setToolInputsVisibility,
handleNewInstanceRequest,
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
handleCloseInstance,
handleNewSession,
handleCloseSession,
@@ -432,7 +371,6 @@ const App: Component = () => {
useAppLifecycle({
setEscapeInDebounce,
handleNewInstanceRequest,
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
handleCloseInstance,
handleNewSession,
handleCloseSession,
@@ -532,60 +470,52 @@ const App: Component = () => {
</div>
</Show>
<Show
when={appTabs().length === 0}
when={!hasInstances()}
fallback={
<>
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
<InstanceTabs
tabs={appTabs()}
activeTabId={activeAppTabId()}
onSelect={selectAppTab}
onClose={(tabId) => void handleCloseAppTab(tabId)}
instances={instances()}
activeInstanceId={activeInstanceId()}
onSelect={setActiveInstanceId}
onClose={handleCloseInstance}
onNew={handleNewInstanceRequest}
/>
</Show>
<For each={Array.from(instances().values())}>
{(instance) => {
const isActiveInstance = () => activeInstanceId() === instance.id
const isVisible = () => isActiveInstance() && !showFolderSelection()
return (
<div
class="flex-1 min-h-0 overflow-hidden"
style={{ display: isVisible() ? "flex" : "none" }}
data-instance-id={instance.id}
data-instance-active={isActiveInstance() ? "true" : "false"}
data-instance-visible={isVisible() ? "true" : "false"}
>
<InstanceMetadataProvider instance={instance}>
<InstanceShell
instance={instance}
isActiveInstance={isActiveInstance()}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => 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()}
/>
</InstanceMetadataProvider>
</div>
)
<For each={appTabs()}>
{(tab) => {
const isVisible = () => activeAppTabId() === tab.id && !showFolderSelection()
return tab.kind === "instance" ? (
<div
class="flex-1 min-h-0 overflow-hidden"
style={{ display: isVisible() ? "flex" : "none" }}
data-instance-id={tab.instance.id}
data-tab-id={tab.id}
data-tab-kind={tab.kind}
data-tab-visible={isVisible() ? "true" : "false"}
>
<InstanceMetadataProvider instance={tab.instance}>
<InstanceShell
instance={tab.instance}
isActiveInstance={isVisible()}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => 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()}
/>
</InstanceMetadataProvider>
</div>
) : (
<div
class="flex-1 min-h-0 overflow-hidden"
style={{ display: isVisible() ? "flex" : "none" }}
data-tab-id={tab.id}
data-tab-kind={tab.kind}
data-tab-visible={isVisible() ? "true" : "false"}
>
<SideCarView tab={tab.sidecarTab} />
</div>
)
}}
</For>
@@ -595,7 +525,6 @@ const App: Component = () => {
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
onOpenSidecar={handleOpenSidecarPicker}
/>
</Show>
@@ -605,7 +534,6 @@ const App: Component = () => {
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
onOpenSidecar={handleOpenSidecarPicker}
onClose={() => {
setShowFolderSelection(false)
clearLaunchError()
@@ -616,7 +544,6 @@ const App: Component = () => {
</Show>
<SettingsScreen />
<SideCarPickerDialog open={sidecarPickerOpen()} onClose={() => setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} />
<AlertDialog />

View File

@@ -108,15 +108,15 @@ const AlertDialog: Component = () => {
open
modal
onOpenChange={(open) => {
// Only handle dismiss if dialog is dismissible (default: true)
if (!open && payload.dismissible !== false) {
if (!open) {
dismiss(false, payload)
}
}}
>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay z-[60]" />
<Dialog.Content class="modal-surface fixed left-1/2 top-1/2 z-[1310] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 p-6 border border-base shadow-2xl" tabIndex={-1}>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
<div class="flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
@@ -140,11 +140,10 @@ const AlertDialog: Component = () => {
<Show when={isPrompt}>
<div class="mt-4">
<label for="prompt-input" class="text-sm font-medium text-secondary">
<label class="text-sm font-medium text-secondary">
{payload.inputLabel || t("alertDialog.prompt.inputLabel")}
</label>
<input
id="prompt-input"
ref={(el) => {
promptInputRef = el
}}
@@ -185,10 +184,11 @@ const AlertDialog: Component = () => {
>
{confirmLabel}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}}
</Show>

View File

@@ -1,4 +1,4 @@
import { createMemo, Show, createEffect } from "solid-js"
import { createMemo, Show, createEffect, onCleanup } 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,7 +20,6 @@ interface ToolCallDiffViewerProps {
filePath?: string
theme: "light" | "dark"
mode: DiffViewMode
wrap?: boolean
onRendered?: () => void
cachedHtml?: string
cacheEntryParams?: CacheEntryParams
@@ -32,183 +31,11 @@ type DiffData = {
hunks: string[]
}
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<HTMLElement>(".unified-diff-table-wrapper")
const table = container.querySelector<HTMLTableElement>(".unified-diff-table")
const numberCol = container.querySelector<HTMLTableColElement>(".unified-diff-table-num-col")
const gutterRows = container.querySelectorAll<HTMLElement>(".diff-line-num")
const hunkGutters = container.querySelectorAll<HTMLElement>(".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<HTMLElement>("[data-line-old-num]")
const newSpan = gutter.querySelector<HTMLElement>("[data-line-new-num]")
const spacer = gutter.querySelector<HTMLElement>(".shrink-0")
const flexWrapper = gutter.querySelector<HTMLElement>(":scope > .flex")
const currentLabel = gutter.querySelector<HTMLElement>(":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<HTMLElement>(".old-diff-table-wrapper")
const newWrapper = container.querySelector<HTMLElement>(".new-diff-table-wrapper")
const numberCells = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-old-num, .diff-line-new-num"))
const hunkActions = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper"))
const numberSpans = numberCells
.map((cell) => ({ cell, span: cell.querySelector<HTMLElement>("[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)
}
type CaptureContext = {
theme: ToolCallDiffViewerProps["theme"]
mode: DiffViewMode
diffText: string
cacheEntryParams?: CacheEntryParams
}
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
@@ -240,15 +67,12 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
const contextKey = createMemo(() => {
const data = diffData()
if (!data) return ""
return `${props.theme}|${props.mode}|${props.wrap ? "wrap" : "nowrap"}|${props.diffText}`
return `${props.theme}|${props.mode}|${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?.()
@@ -259,10 +83,9 @@ 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
@@ -272,7 +95,6 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
html: markup,
theme: props.theme,
mode: props.mode,
wrap: props.wrap,
})
}
props.onRendered?.()
@@ -300,7 +122,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
diffViewTheme={props.theme}
diffViewHighlight
diffViewWrap={Boolean(props.wrap)}
diffViewWrap={false}
diffViewFontSize={13}
/>
</ErrorBoundary>
@@ -309,7 +131,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
</div>
}
>
<div ref={diffContainerRef} innerHTML={props.cachedHtml} />
<div innerHTML={props.cachedHtml} />
</Show>
</div>
)

View File

@@ -1,22 +1,18 @@
import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { createEffect, 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
patch?: string
before?: string
after?: string
before: string
after: string
viewMode?: "split" | "unified"
contextMode?: "expanded" | "collapsed"
wordWrap?: "on" | "off"
onRequestInsertContext?: (selection: { startLine: number; endLine: number }) => void
insertContextLabel?: string
}
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
@@ -26,20 +22,6 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
let diffEditor: any = null
let monaco: any = null
const [ready, setReady] = createSignal(false)
const [hoveredLine, setHoveredLine] = createSignal<number | null>(null)
const [selectedRange, setSelectedRange] = createSignal<{ startLine: number; endLine: number } | null>(null)
const [widgetHovered, setWidgetHovered] = createSignal(false)
const [widgetPosition, setWidgetPosition] = createSignal<{ top: number; left: number } | null>(null)
const resolvedContent = createMemo(() => {
if (props.patch !== undefined && props.patch !== null) {
return parsePatchToBeforeAfter(props.patch)
}
return {
before: props.before ?? "",
after: props.after ?? "",
}
})
const disposeEditor = () => {
try {
@@ -55,52 +37,6 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
diffEditor = null
}
const getModifiedEditor = () => diffEditor?.getModifiedEditor?.() ?? null
const getActiveInsertRange = () => {
const selection = selectedRange()
if (selection) return selection
if (widgetHovered() && hoveredLine()) {
return { startLine: hoveredLine() as number, endLine: hoveredLine() as number }
}
const line = hoveredLine()
if (!line) return null
return { startLine: line, endLine: line }
}
const layoutInsertWidget = () => {
const modifiedEditor = getModifiedEditor()
const container = host
if (!modifiedEditor || !container) return
const activeRange = getActiveInsertRange()
if (!activeRange) {
setWidgetPosition(null)
return
}
try {
const modifiedDom = modifiedEditor.getDomNode?.() as HTMLElement | null
if (!modifiedDom) {
setWidgetPosition(null)
return
}
const margin = modifiedDom.querySelector<HTMLElement>(".margin")
const scrollable = modifiedDom.querySelector<HTMLElement>(".monaco-scrollable-element.editor-scrollable")
const lineTop = modifiedEditor.getTopForLineNumber?.(activeRange.startLine) ?? 0
const scrollTop = modifiedEditor.getScrollTop?.() ?? 0
const lineHeight = Number(modifiedEditor.getOption?.(monaco.editor.EditorOption.lineHeight) ?? 18)
const modifiedRect = modifiedDom.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
const seamLeft = modifiedRect.left - containerRect.left + (margin?.offsetWidth ?? scrollable?.offsetLeft ?? 0)
const centerTop = modifiedRect.top - containerRect.top + (lineTop - scrollTop) + lineHeight / 2
setWidgetPosition({ top: centerTop, left: seamLeft })
} catch {
setWidgetPosition(null)
}
}
onMount(() => {
let cancelled = false
void (async () => {
@@ -120,7 +56,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
renderWhitespace: "selection",
fontSize: 13,
wordWrap: props.wordWrap === "on" ? "on" : "off",
glyphMargin: true,
glyphMargin: false,
folding: false,
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
lineNumbersMinChars: 4,
@@ -133,8 +69,6 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
})
setReady(true)
layoutInsertWidget()
})()
onCleanup(() => {
@@ -149,74 +83,6 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const modifiedEditor = diffEditor.getModifiedEditor?.()
if (!modifiedEditor?.onDidChangeCursorSelection) return
const disposable = modifiedEditor.onDidChangeCursorSelection((event: any) => {
const selection = event?.selection
if (!selection || selection.isEmpty?.()) {
setSelectedRange(null)
layoutInsertWidget()
return
}
setSelectedRange({
startLine: Math.min(selection.startLineNumber, selection.endLineNumber),
endLine: Math.max(selection.startLineNumber, selection.endLineNumber),
})
layoutInsertWidget()
})
onCleanup(() => {
try {
disposable?.dispose?.()
} catch {
// ignore
}
})
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const modifiedEditor = getModifiedEditor()
if (!modifiedEditor?.onMouseMove || !modifiedEditor?.onMouseLeave || !modifiedEditor?.onMouseDown) return
const moveDisposable = modifiedEditor.onMouseMove((event: any) => {
const lineNumber = event?.target?.position?.lineNumber
setHoveredLine(typeof lineNumber === "number" ? lineNumber : null)
layoutInsertWidget()
})
const leaveDisposable = modifiedEditor.onMouseLeave(() => {
if (!widgetHovered()) {
setHoveredLine(null)
}
layoutInsertWidget()
})
const scrollDisposable = modifiedEditor.onDidScrollChange?.(() => {
layoutInsertWidget()
})
onCleanup(() => {
try {
moveDisposable?.dispose?.()
leaveDisposable?.dispose?.()
scrollDisposable?.dispose?.()
} catch {
// ignore
}
})
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const activeRange = getActiveInsertRange()
if (!activeRange) setWidgetPosition(null)
layoutInsertWidget()
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const viewMode = props.viewMode === "unified" ? "unified" : "split"
@@ -249,12 +115,11 @@ 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: before, languageId })
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: after, languageId })
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId })
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId })
diffEditor.setModel({ original, modified })
void ensureMonacoLanguageLoaded(languageId).then(() => {
@@ -267,46 +132,5 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
})
})
return (
<div class="monaco-viewer" ref={host}>
<div class="git-change-context-overlay">
<Show when={widgetPosition()}>
{(position: () => { top: number; left: number }) => (
<div
class="git-change-context-widget-host"
style={{ top: `${position().top}px`, left: `${position().left}px` }}
onMouseEnter={() => {
setWidgetHovered(true)
layoutInsertWidget()
}}
onMouseLeave={() => {
setWidgetHovered(false)
layoutInsertWidget()
}}
>
<button
type="button"
class="git-change-context-widget"
aria-label={props.insertContextLabel ?? "Add git change context to prompt"}
title={props.insertContextLabel ?? "Add git change context to prompt"}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
const activeRange = getActiveInsertRange()
if (!activeRange) return
props.onRequestInsertContext?.(activeRange)
}}
>
+
</button>
</div>
)}
</Show>
</div>
</div>
)
return <div class="monaco-viewer" ref={host} />
}

View File

@@ -9,8 +9,6 @@ interface MonacoFileViewerProps {
scopeKey: string
path: string
content: string
onSave?: (content: string) => void
onContentChange?: (content: string) => void
}
export function MonacoFileViewer(props: MonacoFileViewerProps) {
@@ -35,11 +33,6 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
editor = null
}
const saveContent = () => {
if (!editor || !props.onSave) return
props.onSave(editor.getValue())
}
onMount(() => {
let cancelled = false
void (async () => {
@@ -51,7 +44,7 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
editor = monaco.editor.create(host, {
value: "",
language: "plaintext",
readOnly: false,
readOnly: true,
automaticLayout: true,
lineNumbers: "on",
minimap: { enabled: false },
@@ -61,14 +54,6 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
fontSize: 13,
})
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveContent)
editor.onDidChangeModelContent(() => {
if (props.onContentChange) {
props.onContentChange(editor.getValue())
}
})
setReady(true)
})()

View File

@@ -1,7 +1,6 @@
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, Globe, Loader2 } from "lucide-solid"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
@@ -15,49 +14,25 @@ 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 { runtimeEnv } from "../lib/runtime-env"
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<FolderSelectionViewProps> = (props) => {
const {
recentFolders,
removeRecentFolder,
preferences,
updatePreferences,
serverSettings,
remoteServers,
saveRemoteServerProfile,
markRemoteServerConnected,
removeRemoteServerProfile,
} = useConfig()
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = 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<HomeTab>("local")
const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false)
const [serverName, setServerName] = createSignal("")
const [serverUrl, setServerUrl] = createSignal("")
const [skipTlsVerify, setSkipTlsVerify] = createSignal(false)
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
const [isSavingServer, setIsSavingServer] = createSignal(false)
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
@@ -74,15 +49,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (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
@@ -94,7 +64,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function scrollToIndex(index: number) {
const container = recentListRef
if (!container) return
const element = container.querySelector(`[data-list-index="${index}"]`) as HTMLElement | null
const element = container.querySelector(`[data-folder-index="${index}"]`) as HTMLElement | null
if (!element) return
const containerRect = container.getBoundingClientRect()
@@ -143,18 +113,19 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
return
}
const folderList = folders()
if (isBrowseShortcut) {
e.preventDefault()
void handleBrowse()
return
}
const listLength = getActiveListLength()
if (listLength === 0) return
if (folderList.length === 0) return
if (e.key === "ArrowDown") {
e.preventDefault()
const newIndex = Math.min(selectedIndex() + 1, listLength - 1)
const newIndex = Math.min(selectedIndex() + 1, folderList.length - 1)
setSelectedIndex(newIndex)
setFocusMode("recent")
scrollToIndex(newIndex)
@@ -167,7 +138,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
} else if (e.key === "PageDown") {
e.preventDefault()
const pageSize = 5
const newIndex = Math.min(selectedIndex() + pageSize, listLength - 1)
const newIndex = Math.min(selectedIndex() + pageSize, folderList.length - 1)
setSelectedIndex(newIndex)
setFocusMode("recent")
scrollToIndex(newIndex)
@@ -185,7 +156,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
scrollToIndex(0)
} else if (e.key === "End") {
e.preventDefault()
const newIndex = listLength - 1
const newIndex = folderList.length - 1
setSelectedIndex(newIndex)
setFocusMode("recent")
scrollToIndex(newIndex)
@@ -194,17 +165,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
handleEnterKey()
} else if (e.key === "Backspace" || e.key === "Delete") {
e.preventDefault()
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)
}
if (folderList.length > 0 && focusMode() === "recent") {
const folder = folderList[selectedIndex()]
if (folder) {
handleRemove(folder.path)
}
}
}
@@ -213,40 +177,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function handleEnterKey() {
if (isLoading()) return
const folderList = folders()
const index = selectedIndex()
if (activeTab() === "local") {
const folder = folders()[index]
if (folder) {
handleFolderSelect(folder.path)
}
return
}
const server = serverList()[index]
if (server) {
void handleConnectSavedServer(server.id)
const folder = folderList[index]
if (folder) {
handleFolderSelect(folder.path)
}
}
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)
@@ -297,95 +236,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (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) {
const windowUrl =
runtimeEnv.host === "tauri"
? (await serverApi.createRemoteProxySession({
baseUrl: profile.baseUrl,
skipTlsVerify: profile.skipTlsVerify,
})).windowUrl
: undefined
await openRemoteServerWindow(profile, windowUrl)
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")
@@ -593,7 +443,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
rel="noreferrer"
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
aria-label={t("folderSelection.links.githubStars")}
title={githubStars() !== null ? `${t("folderSelection.links.githubStars")}: ${githubStars()!.toLocaleString()}` : t("folderSelection.links.githubStars")}
title={t("folderSelection.links.githubStars")}
onClick={(event) => {
event.preventDefault()
void openExternalUrl(GITHUB_URL, "folder-selection")
@@ -626,223 +476,90 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
{/* Right column: recent folders */}
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header !gap-0 !p-0">
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
<button
type="button"
class="border-r border-base px-4 py-3 text-left transition-colors"
classList={{
"text-primary": activeTab() === "local",
"text-muted hover:text-secondary": activeTab() !== "local",
}}
style={{
"background-color": "var(--surface-secondary)",
}}
onClick={() => setActiveTab("local")}
>
<div
class="panel-title text-base"
style={{
color: activeTab() === "local" ? "var(--text-primary)" : "var(--text-secondary)",
}}
>
{t("folderSelection.recent.title")}
</div>
<p
class="panel-subtitle mt-1"
style={{
color: activeTab() === "local" ? "var(--text-muted)" : "var(--text-secondary)",
}}
>
{t(
folders().length === 1
? "folderSelection.recent.subtitle.one"
: "folderSelection.recent.subtitle.other",
{ count: folders().length },
)}
</p>
</button>
<button
type="button"
class="px-4 py-3 text-left transition-colors"
classList={{
"text-primary": activeTab() === "servers",
"text-muted hover:text-secondary": activeTab() !== "servers",
}}
style={{
"background-color": "var(--surface-secondary)",
}}
onClick={() => setActiveTab("servers")}
>
<div
class="panel-title text-base"
style={{
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
}}
>
{t("folderSelection.tabs.servers")}
</div>
<p
class="panel-subtitle mt-1"
style={{
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
}}
>
{t("folderSelection.servers.count", { count: remoteServers().length })}
</p>
</button>
<Show
when={folders().length > 0}
fallback={
<div class="panel panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
</div>
<Show
when={activeTab() === "local"}
fallback={
<Show
when={remoteServers().length > 0}
fallback={
<div class="panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Globe class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
<button
type="button"
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
onClick={openServerDialog}
>
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</button>
</div>
}
>
<div
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
ref={(el) => (recentListRef = el)}
>
<For each={remoteServers()}>
{(server, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-list-index={index()}
class="panel-list-item-content flex-1"
onClick={() => void handleConnectSavedServer(server.id)}
onMouseEnter={() => {
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0 text-left">
<div class="flex items-center gap-2 mb-1">
<Globe class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">{server.name}</span>
</div>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">{server.baseUrl}</span>
</div>
</div>
<Show when={connectingServerId() === server.id} fallback={<Show when={focusMode() === "recent" && selectedIndex() === index()}><kbd class="kbd"></kbd></Show>}>
<Loader2 class="w-4 h-4 animate-spin icon-muted" />
</Show>
</div>
</button>
<button
onClick={() => 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")}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
)}
</For>
</div>
</Show>
}
}
>
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header">
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
<p class="panel-subtitle">
{t(
folders().length === 1
? "folderSelection.recent.subtitle.one"
: "folderSelection.recent.subtitle.other",
{ count: folders().length },
)}
</p>
</div>
<div
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
ref={(el) => (recentListRef = el)}
>
<Show
when={folders().length > 0}
fallback={
<div class="panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
</div>
}
>
<div
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
ref={(el) => (recentListRef = el)}
>
<For each={folders()}>
{(folder, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
<For each={folders()}>
{(folder, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-folder-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-list-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{splitFolderPath(folder.path).baseName}
</span>
</div>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">
{getDisplayPath(folder.path)}
</span>
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{splitFolderPath(folder.path).baseName}
</span>
</div>
</button>
<button
onClick={(e) => 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")}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">
{getDisplayPath(folder.path)}
</span>
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
</button>
<button
onClick={(e) => 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")}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
</div>
@@ -850,11 +567,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
<div class="panel shrink-0">
<div class="panel-header hidden sm:block">
<h2 class="panel-title">{t("folderSelection.actions.title")}</h2>
<p class="panel-subtitle">{t("folderSelection.actions.subtitle")}</p>
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
</div>
<div class="panel-body flex flex-col gap-3">
<div class="panel-body">
<button
onClick={() => void handleBrowse()}
disabled={props.isLoading}
@@ -871,27 +588,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
</button>
<button
type="button"
onClick={() => props.onOpenSidecar?.()}
class="button-primary mt-3 w-full flex items-center justify-center text-sm"
>
<div class="flex items-center gap-2">
<MonitorUp class="w-4 h-4" />
<span>{t("folderSelection.sidecars.button")}</span>
</div>
</button>
<button
onClick={openServerDialog}
class="button-primary w-full flex items-center justify-center text-sm"
>
<div class="flex items-center gap-2">
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</div>
</button>
</div>
{/* OpenCode settings section */}
@@ -967,82 +663,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect}
/>
<Dialog open={isServerDialogOpen()} onOpenChange={(open) => !open && setIsServerDialogOpen(false)}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-[1300] flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-lg p-6 flex flex-col gap-5" tabIndex={-1}>
<div>
<Dialog.Title class="text-xl font-semibold text-primary">
{t("folderSelection.servers.dialog.title")}
</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
{t("folderSelection.servers.dialog.description")}
</Dialog.Description>
</div>
<label class="flex flex-col gap-2 text-sm text-secondary">
<span>{t("folderSelection.servers.dialog.name")}</span>
<input
class="selector-input w-full"
value={serverName()}
onInput={(event) => setServerName(event.currentTarget.value)}
placeholder={t("folderSelection.servers.dialog.namePlaceholder")}
/>
</label>
<label class="flex flex-col gap-2 text-sm text-secondary">
<span>{t("folderSelection.servers.dialog.url")}</span>
<input
class="selector-input w-full"
value={serverUrl()}
onInput={(event) => setServerUrl(event.currentTarget.value)}
placeholder={t("folderSelection.servers.dialog.urlPlaceholder")}
/>
</label>
<label class="flex items-start gap-3 text-sm text-secondary">
<input
type="checkbox"
checked={skipTlsVerify()}
onChange={(event) => setSkipTlsVerify(event.currentTarget.checked)}
/>
<span>{t("folderSelection.servers.dialog.skipTls")}</span>
</label>
<Show when={serverDialogError()}>
{(message) => <p class="text-sm text-red-500 break-words">{message()}</p>}
</Show>
<div class="flex items-center justify-end gap-3">
<button class="selector-button selector-button-secondary w-auto px-4" onClick={() => setIsServerDialogOpen(false)}>
{t("folderSelection.servers.dialog.cancel")}
</button>
<button
class="selector-button selector-button-secondary w-auto px-4"
disabled={isSavingServer()}
onClick={() => void handleSaveServer(false)}
>
{t("folderSelection.servers.dialog.save")}
</button>
<button
class="selector-button selector-button-secondary w-auto px-4"
disabled={isSavingServer()}
onClick={() => void handleSaveServer(true)}
>
<Show when={isSavingServer()} fallback={<span>{t("folderSelection.servers.dialog.connect")}</span>}>
<span class="inline-flex items-center gap-2">
<Loader2 class="w-4 h-4 animate-spin" />
{t("folderSelection.servers.dialog.connecting")}
</span>
</Show>
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
</>
)
}

View File

@@ -44,7 +44,6 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
variant: "warning",
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
dismissible: false,
})
if (!confirmed) return

View File

@@ -1,5 +1,6 @@
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"
@@ -8,13 +9,12 @@ 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 {
tabs: AppTabRecord[]
activeTabId: string | null
onSelect: (tabId: string) => void
onClose: (tabId: string) => void
instances: Map<string, Instance>
activeInstanceId: string | null
onSelect: (instanceId: string) => void
onClose: (instanceId: string) => void
onNew: () => void
}
@@ -42,25 +42,15 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
<div class="tab-scroll">
<div class="tab-strip">
<div class="tab-strip-tabs">
<For each={props.tabs}>
{(tab) =>
tab.kind === "instance" ? (
<InstanceTab
instance={tab.instance}
active={tab.id === props.activeTabId}
onSelect={() => props.onSelect(tab.id)}
onClose={() => props.onClose(tab.id)}
/>
) : (
<div class={`tab-pill ${tab.id === props.activeTabId ? "tab-pill-active" : ""}`}>
<button class="tab-pill-button" onClick={() => props.onSelect(tab.id)}>
<span class="truncate max-w-[180px]">{tab.sidecarTab.name}</span>
</button>
<button class="tab-pill-close" onClick={() => props.onClose(tab.id)} aria-label={tab.sidecarTab.name}>
×
</button>
</div>
)}
<For each={Array.from(props.instances.entries())}>
{([id, instance]) => (
<InstanceTab
instance={instance}
active={id === props.activeInstanceId}
onSelect={() => props.onSelect(id)}
onClose={() => props.onClose(id)}
/>
)}
</For>
<button
class="new-tab-button"
@@ -72,7 +62,7 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
</button>
</div>
<div class="tab-strip-spacer" />
<Show when={props.tabs.length > 1}>
<Show when={Array.from(props.instances.entries()).length > 1}>
<div class="tab-shortcuts">
<KeyboardHint
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(

View File

@@ -36,14 +36,13 @@ import { serverApi } from "../../lib/api-client"
import { loadBackgroundProcesses } from "../../stores/background-processes"
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
import { useI18n } from "../../lib/i18n"
import { getPermissionQueue, getPermissionQueueLength, getQuestionQueueLength, sendPermissionResponse } from "../../stores/instances"
import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances"
import SessionSidebar from "./shell/SessionSidebar"
import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
import RightPanel from "./shell/right-panel/RightPanel"
import { useDrawerChrome } from "./shell/useDrawerChrome"
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
import { getSessionStatus } from "../../stores/session-status"
import { Maximize2, ShieldAlert } from "lucide-solid"
import type { PromptInputApi } from "../prompt-input/types"
import type { LayoutMode } from "./shell/types"
import {
@@ -58,13 +57,6 @@ import { useDrawerHostMeasure } from "./shell/useDrawerHostMeasure"
import { useDrawerResize } from "./shell/useDrawerResize"
import { useSessionCache } from "./shell/useSessionCache"
import { useInstanceSessionContext } from "./shell/useInstanceSessionContext"
import { getPermissionSessionId } from "../../types/permission"
import {
canAutoRespondPermission,
finishAutoRespondPermission,
getPermissionAutoAcceptInFlightVersion,
isPermissionAutoAcceptEnabled,
} from "../../stores/permission-auto-accept"
const log = getLogger("session")
@@ -105,8 +97,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
const [now, setNow] = createSignal(Date.now())
const [sessionPromptApis, setSessionPromptApis] = createSignal<Record<string, PromptInputApi | null>>({})
// Worktree selector manages its own dialogs.
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
@@ -240,12 +230,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString())
})
createEffect(() => {
if (typeof window === "undefined") return
const timer = window.setInterval(() => setNow(Date.now()), 1000)
onCleanup(() => window.clearInterval(timer))
})
const connectionStatus = () => sseManager.getStatus(props.instance.id)
const connectionStatusClass = () => {
const status = connectionStatus()
@@ -268,46 +252,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return permissions + questions > 0
})
const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id))
const activePromptInputApi = createMemo(() => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return null
return sessionPromptApis()[sessionId] ?? null
})
const registerSessionPromptApi = (sessionId: string, api: PromptInputApi | null) => {
setSessionPromptApis((current) => ({
...current,
[sessionId]: api,
}))
}
createEffect(() => {
getPermissionAutoAcceptInFlightVersion()
for (const permission of permissionQueue()) {
const sessionId = getPermissionSessionId(permission)
if (!sessionId) continue
if (!permission?.id) continue
if (!canAutoRespondPermission(props.instance.id, sessionId, permission.id)) continue
void sendPermissionResponse(props.instance.id, sessionId, permission.id, "once")
.catch((error) => {
log.error("Failed to auto-accept permission", error)
})
.finally(() => {
finishAutoRespondPermission(props.instance.id, sessionId, permission.id)
})
}
})
const yoloModeEnabled = createMemo(() => {
const session = activeSessionForInstance()
if (!session) return false
return isPermissionAutoAcceptEnabled(props.instance.id, session.id)
})
const activeSessionStatusPill = createMemo(() => {
const activeSessionId = activeSessionIdForInstance()
if (!activeSessionId || activeSessionId === "info") return null
@@ -328,28 +272,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}
const status = getSessionStatus(props.instance.id, activeSessionId)
const retry = getSessionRetry(props.instance.id, activeSessionId)
const text = retry
? (() => {
const seconds = getRetrySeconds(retry.next, now())
return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying")
})()
: status === "working"
const text =
status === "working"
? t("sessionList.status.working")
: status === "compacting"
? t("sessionList.status.compacting")
: t("sessionList.status.idle")
return {
className: `session-${retry ? "retrying" : status}`,
className: `session-${status}`,
text,
showAlertIcon: false,
title: retry
? t("sessionList.status.retryTooltip", {
message: retry.message,
attempt: String(retry.attempt),
})
: undefined,
}
})
@@ -357,39 +290,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const pill = activeSessionStatusPill()
if (!pill) return null
return (
<span class={`status-indicator session-status session-status-list ${pill.className}`} title={pill.title}>
<span class={`status-indicator session-status session-status-list ${pill.className}`}>
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{pill.text}
</span>
)
}
const renderYoloModePill = () => {
if (!yoloModeEnabled()) return null
return (
<span
class="status-indicator session-status session-status-list session-yolo-mode"
aria-label={t("instanceShell.yoloMode.badgeAriaLabel")}
title={t("instanceShell.yoloMode.badgeAriaLabel")}
>
<span class="status-dot" />
{t("instanceShell.yoloMode.badge")}
</span>
)
}
const renderSessionHeaderIndicators = () => (
<div class="flex items-center flex-wrap justify-center gap-2">
{renderYoloModePill()}
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
</Show>
</div>
)
const handleCommandPaletteClick = () => {
showCommandPalette(props.instance.id)
}
@@ -513,7 +420,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onClose={closeLeftDrawer}
ModalProps={modalProps}
sx={{
zIndex: 60,
"& .MuiDrawer-paper": {
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
boxSizing: "border-box",
@@ -609,7 +515,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onCloseRightDrawer={closeRightDrawer}
onPinRightDrawer={pinRightDrawer}
onUnpinRightDrawer={unpinRightDrawer}
promptInputApi={activePromptInputApi}
setContentEl={setRightDrawerContentEl}
/>
</Box>
@@ -625,7 +530,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onClose={closeRightDrawer}
ModalProps={modalProps}
sx={{
zIndex: 60,
"& .MuiDrawer-paper": {
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
boxSizing: "border-box",
@@ -672,7 +576,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onCloseRightDrawer={closeRightDrawer}
onPinRightDrawer={pinRightDrawer}
onUnpinRightDrawer={unpinRightDrawer}
promptInputApi={activePromptInputApi}
setContentEl={setRightDrawerContentEl}
/>
</Drawer>
@@ -717,7 +620,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show>
<div class="flex-1 flex items-center justify-center min-w-0">
{renderSessionHeaderIndicators()}
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
</Show>
</div>
<div class="flex flex-wrap items-center justify-center gap-1">
@@ -809,7 +717,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show>
<div class="ml-auto flex items-center session-header-hints">
{renderSessionHeaderIndicators()}
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
</Show>
</div>
</div>
@@ -909,7 +822,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
escapeInDebounce={props.escapeInDebounce}
isPhoneLayout={isPhoneLayout()}
compactPromptLayout={compactPromptLayout()}
registerSessionPromptApi={registerSessionPromptApi}
showSidebarToggle={showEmbeddedSidebarToggle()}
onSidebarToggle={() => setLeftOpen(true)}
forceCompactStatusLayout={showEmbeddedSidebarToggle()}

View File

@@ -48,103 +48,104 @@ interface SessionSidebarProps {
}
const SessionSidebar: Component<SessionSidebarProps> = (props) => (
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
<div class="flex items-center justify-between gap-2">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
{props.t("instanceShell.leftPanel.sessionsTitle")}
</span>
<div class="flex items-center gap-2 text-primary">
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
<div class="flex items-center justify-between gap-2">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
{props.t("instanceShell.leftPanel.sessionsTitle")}
</span>
<div class="flex items-center gap-2 text-primary">
<IconButton
size="small"
color="inherit"
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
title={props.t("sessionList.actions.newSession.title")}
onClick={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
>
<PlusSquare class="w-5 h-5" />
</IconButton>
<IconButton
size="small"
color="inherit"
aria-label={props.t("sessionList.filter.ariaLabel")}
title={props.t("sessionList.filter.ariaLabel")}
aria-pressed={props.showSearch()}
onClick={props.onToggleSearch}
sx={{
color: props.showSearch() ? "var(--text-primary)" : "inherit",
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
"&:hover": {
backgroundColor: "var(--surface-hover)",
},
}}
>
<Search class="w-5 h-5" />
</IconButton>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
title={props.t("instanceShell.leftPanel.instanceInfo")}
onClick={() => props.onSelectSession("info")}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
<Show when={!props.isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
title={props.t("sessionList.actions.newSession.title")}
onClick={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
>
<PlusSquare class="w-5 h-5" />
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
<Show when={props.drawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={props.t("sessionList.filter.ariaLabel")}
title={props.t("sessionList.filter.ariaLabel")}
aria-pressed={props.showSearch()}
onClick={props.onToggleSearch}
sx={{
color: props.showSearch() ? "var(--text-primary)" : "inherit",
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
"&:hover": {
backgroundColor: "var(--surface-hover)",
},
}}
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
title={props.t("instanceShell.leftDrawer.toggle.close")}
onClick={props.onCloseLeftDrawer}
>
<Search class="w-5 h-5" />
<MenuOpenIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
title={props.t("instanceShell.leftPanel.instanceInfo")}
onClick={() => props.onSelectSession("info")}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
<Show when={!props.isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
>
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
<Show when={props.drawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
title={props.t("instanceShell.leftDrawer.toggle.close")}
onClick={props.onCloseLeftDrawer}
>
<MenuOpenIcon fontSize="small" />
</IconButton>
</Show>
</div>
</div>
<div class="session-sidebar-shortcuts">
<Show when={props.keyboardShortcuts().length}>
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
</Show>
</div>
</div>
<div class="session-sidebar-shortcuts">
<Show when={props.keyboardShortcuts().length}>
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
</Show>
</div>
</div>
<div class="session-sidebar flex flex-col flex-1 min-h-0">
<SessionList
instanceId={props.instanceId}
threads={props.threads()}
activeSessionId={props.activeSessionId()}
onSelect={props.onSelectSession}
onNew={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
enableFilterBar={props.showSearch()}
showHeader={false}
showFooter={false}
/>
<div class="session-sidebar flex flex-col flex-1 min-h-0">
<SessionList
instanceId={props.instanceId}
threads={props.threads()}
activeSessionId={props.activeSessionId()}
onSelect={props.onSelectSession}
onNew={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
enableFilterBar={props.showSearch()}
showHeader={false}
showFooter={false}
/>
<div class="session-sidebar-separator" />
<Show when={props.activeSession()}>
{(activeSession) => (
<div class="session-sidebar-separator" />
<Show when={props.activeSession()}>
{(activeSession) => (
<>
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
<WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} />
@@ -176,10 +177,11 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
showDescription={false}
/>
</div>
)}
</Show>
</div>
</>
)}
</Show>
</div>
)
</div>
)
export default SessionSidebar

View File

@@ -10,7 +10,7 @@ import {
type Component,
} from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client"
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import IconButton from "@suid/material/IconButton"
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
import PushPinIcon from "@suid/icons-material/PushPin"
@@ -19,23 +19,13 @@ import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
import type { Instance } from "../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
import type { Session } from "../../../../types/session"
import type { PromptInputApi } from "../../../prompt-input/types"
import type { DrawerViewState } from "../types"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
import {
getDefaultWorktreeSlug,
getGitRepoStatus,
getOrCreateWorktreeClient,
getWorktreeSlugForSession,
getWorktrees,
} from "../../../../stores/worktrees"
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
import { requestData } from "../../../../lib/opencode-api"
import { serverApi } from "../../../../lib/api-client"
import { showConfirmDialog } from "../../../../stores/alerts"
import { showToastNotification } from "../../../../lib/notifications"
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
import { useGitChanges } from "./useGitChanges"
import {
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
@@ -48,11 +38,7 @@ import {
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY,
RIGHT_PANEL_TAB_STORAGE_KEY,
readStoredBool,
readStoredEnum,
@@ -93,7 +79,6 @@ interface RightPanelProps {
onCloseRightDrawer: () => void
onPinRightDrawer: () => void
onUnpinRightDrawer: () => void
promptInputApi: Accessor<PromptInputApi | null>
setContentEl: (el: HTMLElement | null) => void
}
@@ -101,7 +86,6 @@ interface RightPanelProps {
const RightPanel: Component<RightPanelProps> = (props) => {
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
"yolo-mode",
"plan",
"background-processes",
"mcp",
@@ -118,9 +102,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false)
const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false)
const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal<string | null>(null)
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
@@ -145,8 +126,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const [changesListTouched, setChangesListTouched] = createSignal(false)
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
const [gitStagedOpen, setGitStagedOpen] = createSignal(true)
const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true)
const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone"))
@@ -163,28 +142,11 @@ const RightPanel: Component<RightPanelProps> = (props) => {
return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY
}
const gitSectionStorageKey = (section: "staged" | "unstaged") => {
const layout = listLayoutKey()
if (section === "staged") {
return layout === "phone"
? RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY
: RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY
}
return layout === "phone"
? RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY
: RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY
}
const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => {
if (typeof window === "undefined") return
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
}
const persistGitSectionOpen = (section: "staged" | "unstaged", value: boolean) => {
if (typeof window === "undefined") return
window.localStorage.setItem(gitSectionStorageKey(section), value ? "true" : "false")
}
createEffect(() => {
// Refresh persisted visibility when layout changes (phone vs non-phone).
const layout = listLayoutKey()
@@ -216,12 +178,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setGitChangesListOpen(true)
setGitChangesListTouched(false)
}
const stagedPersisted = readStoredBool(gitSectionStorageKey("staged"))
setGitStagedOpen(stagedPersisted ?? true)
const unstagedPersisted = readStoredBool(gitSectionStorageKey("unstaged"))
setGitUnstagedOpen(unstagedPersisted ?? true)
})
createEffect(() => {
@@ -376,56 +332,34 @@ const RightPanel: Component<RightPanelProps> = (props) => {
return getDefaultWorktreeSlug(props.instanceId)
})
const gitChangesWorktreeSlug = createMemo(() => {
if (getGitRepoStatus(props.instanceId) === false) return null
const slug = worktreeSlugForViewer().trim()
return slug ? slug : null
})
const gitChangesWorktree = createMemo(() => {
const slug = gitChangesWorktreeSlug()
if (!slug) return null
return getWorktrees(props.instanceId).find((worktree) => worktree.slug === slug) ?? null
})
const gitChangesBranchLabel = createMemo(() => {
const branch = gitChangesWorktree()?.branch?.trim()
return branch || null
})
const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer()))
const {
gitStatusEntries,
gitStatusLoading,
gitStatusError,
gitSelectedItemId,
gitBulkSelectedItemIds,
gitSelectedLoading,
gitSelectedError,
gitSelectedBefore,
gitSelectedAfter,
gitCommitMessage,
gitCommitSubmitting,
gitMostChangedItemId,
setGitCommitMessage,
handleGitRowClick,
refreshGitStatus,
insertGitChangeContext,
submitGitCommit,
stageGitFile,
unstageGitFile,
} = useGitChanges({
t: props.t,
instanceId: props.instanceId,
rightPanelTab,
worktreeSlug: worktreeSlugForViewer,
isPhoneLayout: props.isPhoneLayout,
promptInputApi: props.promptInputApi,
closeGitList: () => setGitChangesListOpen(false),
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitFileStatus[] | null>(null)
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
const [gitSelectedPath, setGitSelectedPath] = createSignal<string | null>(null)
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
const gitMostChangedPath = createMemo<string | null>(() => {
const entries = gitStatusEntries()
if (!Array.isArray(entries) || entries.length === 0) return null
const candidates = entries.filter((item) => item && item.status !== "deleted")
if (candidates.length === 0) return null
const best = candidates.reduce((currentBest, item) => {
const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0)
const score = (item?.added ?? 0) + (item?.removed ?? 0)
if (score > bestScore) return item
if (score < bestScore) return currentBest
return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest
}, candidates[0])
return typeof best?.path === "string" ? best.path : null
})
createEffect(() => {
// Reset tab state when worktree context changes.
worktreeSlugForViewer()
setBrowserPath(".")
setBrowserEntries(null)
@@ -434,8 +368,111 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setBrowserSelectedContent(null)
setBrowserSelectedError(null)
setBrowserSelectedLoading(false)
setGitStatusEntries(null)
setGitStatusError(null)
setGitStatusLoading(false)
setGitSelectedPath(null)
setGitSelectedLoading(false)
setGitSelectedError(null)
setGitSelectedBefore(null)
setGitSelectedAfter(null)
})
const loadGitStatus = async (force = false) => {
if (!force && gitStatusEntries() !== null) return
setGitStatusLoading(true)
setGitStatusError(null)
try {
const list = await requestData<GitFileStatus[]>(browserClient().file.status(), "file.status")
setGitStatusEntries(Array.isArray(list) ? list : [])
} catch (error) {
setGitStatusError(error instanceof Error ? error.message : "Failed to load git status")
setGitStatusEntries([])
} finally {
setGitStatusLoading(false)
}
}
async function openGitFile(path: string) {
setGitSelectedPath(path)
setGitSelectedLoading(true)
setGitSelectedError(null)
setGitSelectedBefore(null)
setGitSelectedAfter(null)
const list = gitStatusEntries() || []
const entry = list.find((item) => item.path === path) || null
if (entry?.status === "deleted") {
setGitSelectedError("Deleted file diff is not available yet")
setGitSelectedLoading(false)
return
}
// Phone: treat file selection as a commit action and close the overlay.
if (props.isPhoneLayout()) {
setGitChangesListOpen(false)
}
try {
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
const type = (content as any)?.type
const encoding = (content as any)?.encoding
if (type && type !== "text") {
throw new Error("Binary file cannot be displayed")
}
if (encoding === "base64") {
throw new Error("Binary file cannot be displayed")
}
const afterText = typeof (content as any)?.content === "string" ? ((content as any).content as string) : null
if (afterText === null) {
throw new Error("Unsupported file type")
}
setGitSelectedAfter(afterText)
if (entry?.status === "added") {
setGitSelectedBefore("")
return
}
const diffText =
typeof (content as any)?.diff === "string" && String((content as any).diff).trim().length > 0
? String((content as any).diff)
: (content as any)?.patch
? buildUnifiedDiffFromSdkPatch((content as any).patch)
: ""
const beforeText = tryReverseApplyUnifiedDiff(afterText, diffText)
if (beforeText === null) {
throw new Error("Unable to calculate diff for this file")
}
setGitSelectedBefore(beforeText)
} catch (error) {
setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes")
} finally {
setGitSelectedLoading(false)
}
}
createEffect(() => {
if (rightPanelTab() !== "git-changes") return
const entries = gitStatusEntries()
if (entries === null) return
if (gitSelectedPath()) return
const next = gitMostChangedPath()
if (!next) return
void openGitFile(next)
})
const refreshGitStatus = async () => {
await loadGitStatus(true)
const selected = gitSelectedPath()
if (selected) {
void openGitFile(selected)
}
}
const bestDiffFile = createMemo<string | null>(() => {
const diffs = props.activeSessionDiffs()
if (!Array.isArray(diffs) || diffs.length === 0) return null
@@ -502,8 +539,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setBrowserSelectedLoading(true)
setBrowserSelectedError(null)
setBrowserSelectedContent(null)
setBrowserSelectedDirty(false)
setBrowserSelectedOriginalContent(null)
// Phone: treat file selection as a commit action and close the overlay.
if (props.isPhoneLayout()) {
@@ -524,7 +559,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
throw new Error("Unsupported file type")
}
setBrowserSelectedContent(text)
setBrowserSelectedOriginalContent(text) // Track original content for conflict detection
} catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
} finally {
@@ -532,95 +566,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
}
}
const saveBrowserFile = async (content: string): Promise<boolean> => {
const path = browserSelectedPath()
if (!path) return false
// Check for conflict: agent edited file while user was editing
const originalContent = browserSelectedOriginalContent()
if (originalContent !== null) {
try {
const currentDiskContent = await requestData<FileContent>(
browserClient().file.read({ path }),
"file.read",
)
const diskContent = (currentDiskContent as any)?.content
// If disk content differs from what we originally loaded (agent edit)
// AND differs from user's current edits, we have a conflict
if (diskContent !== originalContent && diskContent !== content) {
const confirmed = await showConfirmDialog(
props.t("instanceShell.rightPanel.actions.conflict.message", { path }),
{
variant: "warning",
confirmLabel: props.t("instanceShell.rightPanel.actions.conflict.confirmLabel"),
cancelLabel: props.t("instanceShell.rightPanel.actions.conflict.cancelLabel"),
dismissible: false,
},
)
if (!confirmed) {
return false
}
// User chose to overwrite, proceed with save
}
} catch {
// If we can't check for conflict, proceed with save
}
}
setBrowserSelectedSaving(true)
try {
await serverApi.writeWorkspaceFile(props.instanceId, path, content)
setBrowserSelectedContent(content)
setBrowserSelectedOriginalContent(content) // Update original to match saved
setBrowserSelectedDirty(false)
showToastNotification({
message: props.t("instanceShell.rightPanel.toast.saveSuccess"),
variant: "success",
})
return true
} catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to save file")
showToastNotification({
message: props.t("instanceShell.rightPanel.toast.saveError"),
variant: "error",
})
return false
} finally {
setBrowserSelectedSaving(false)
}
}
const handleBrowserFileChange = (content: string) => {
setBrowserSelectedContent(content)
setBrowserSelectedDirty(true)
}
const handleOpenBrowserFileRequest = async (path: string) => {
if (browserSelectedDirty()) {
const confirmed = await showConfirmDialog(
props.t("instanceShell.rightPanel.actions.saveConfirm.message", { path: browserSelectedPath() || "" }),
{
variant: "warning",
confirmLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.confirmLabel"),
cancelLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.cancelLabel"),
dismissible: false,
},
)
if (confirmed) {
const saveSuccess = await saveBrowserFile(browserSelectedContent() || "")
if (!saveSuccess) {
// Save failed - stay on current file, error toast already shown
return
}
} else {
// User chose not to save - clear dirty state and discard edits
setBrowserSelectedDirty(false)
}
}
await openBrowserFile(path)
}
createEffect(() => {
if (rightPanelTab() !== "files") return
if (browserLoading()) return
@@ -633,7 +578,21 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setBrowserSelectedContent(null)
setBrowserSelectedLoading(false)
setBrowserSelectedError(null)
setBrowserSelectedDirty(false)
})
createEffect(() => {
if (rightPanelTab() !== "git-changes") return
if (gitStatusLoading()) return
if (gitStatusEntries() !== null) return
void loadGitStatus()
})
createEffect(() => {
if (rightPanelTab() === "git-changes") return
setGitSelectedBefore(null)
setGitSelectedAfter(null)
setGitSelectedLoading(false)
setGitSelectedError(null)
})
const handleSelectChangesFile = (file: string, closeList: boolean) => {
@@ -671,22 +630,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
}
const refreshFilesTab = async () => {
// Prompt for confirmation if file has unsaved changes
if (browserSelectedDirty()) {
const confirmed = await showConfirmDialog(
props.t("instanceShell.rightPanel.actions.refreshDirty.message"),
{
variant: "warning",
confirmLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"),
cancelLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"),
dismissible: false,
},
)
if (!confirmed) {
return
}
}
void loadBrowserEntries(browserPath())
const selected = browserSelectedPath()
if (selected) {
@@ -708,8 +651,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
throw new Error("Unsupported file type")
}
setBrowserSelectedContent(text)
setBrowserSelectedOriginalContent(text) // Update original content after refresh
setBrowserSelectedDirty(false) // Clear dirty after refresh
} catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
} finally {
@@ -729,7 +670,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setRightPanelTab("changes")
}
const statusSectionIds = ["yolo-mode", "session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
const statusSectionIds = ["session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
createEffect(() => {
const currentExpanded = new Set(rightPanelExpandedItems())
@@ -852,13 +793,12 @@ const RightPanel: Component<RightPanelProps> = (props) => {
entries={gitStatusEntries}
statusLoading={gitStatusLoading}
statusError={gitStatusError}
selectedItemId={gitSelectedItemId}
selectedBulkItemIds={gitBulkSelectedItemIds}
selectedPath={gitSelectedPath}
selectedLoading={gitSelectedLoading}
selectedError={gitSelectedError}
selectedBefore={gitSelectedBefore}
selectedAfter={gitSelectedAfter}
mostChangedItemId={gitMostChangedItemId}
mostChangedPath={gitMostChangedPath}
scopeKey={gitScopeKey}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
@@ -866,28 +806,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
onRowClick={handleGitRowClick}
onOpenFile={(path: string) => void openGitFile(path)}
onRefresh={() => void refreshGitStatus()}
onInsertContext={insertGitChangeContext}
onStageFile={stageGitFile}
onUnstageFile={unstageGitFile}
commitMessage={gitCommitMessage}
commitSubmitting={gitCommitSubmitting}
onCommitMessageInput={setGitCommitMessage}
onSubmitCommit={() => void submitGitCommit()}
branchLabel={gitChangesBranchLabel}
stagedOpen={gitStagedOpen}
unstagedOpen={gitUnstagedOpen}
onToggleStagedOpen={() => {
const next = !gitStagedOpen()
setGitStagedOpen(next)
persistGitSectionOpen("staged", next)
}}
onToggleUnstagedOpen={() => {
const next = !gitUnstagedOpen()
setGitUnstagedOpen(next)
persistGitSectionOpen("unstaged", next)
}}
listOpen={gitChangesListOpen}
onToggleList={toggleGitList}
splitWidth={gitChangesSplitWidth}
@@ -910,15 +830,11 @@ const RightPanel: Component<RightPanelProps> = (props) => {
browserSelectedContent={browserSelectedContent}
browserSelectedLoading={browserSelectedLoading}
browserSelectedError={browserSelectedError}
browserSelectedDirty={browserSelectedDirty}
browserSelectedSaving={browserSelectedSaving}
parentPath={browserParentPath}
scopeKey={browserScopeKey}
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)}
onOpenFile={(path: string) => void openBrowserFile(path)}
onRefresh={() => void refreshFilesTab()}
onSave={(content: string) => void saveBrowserFile(content)}
onContentChange={(content: string) => handleBrowserFileChange(content)}
listOpen={filesListOpen}
onToggleList={toggleFilesList}
splitWidth={filesSplitWidth}

View File

@@ -1,148 +0,0 @@
import type { File as SdkGitFileStatus } from "@opencode-ai/sdk/v2/client"
import type { WorktreeGitStatusEntry } from "../../../../../../server/src/api-types"
import type { GitChangeEntry, GitChangeListItem, GitChangeSection, GitChangeStatus } from "./types"
function normalizeGitChangePath(path: unknown): string {
if (typeof path !== "string") return ""
const normalized = path.replace(/\\+/g, "/").replace(/^\.\//, "").trim()
return normalized
}
export function normalizeGitChangeStatus(status: unknown): GitChangeStatus {
return typeof status === "string" && status.trim().length > 0 ? status : "modified"
}
export function adaptSdkGitStatusEntry(entry: SdkGitFileStatus): GitChangeEntry {
return {
path: normalizeGitChangePath(entry?.path),
originalPath: null,
additions: typeof entry?.added === "number" ? entry.added : 0,
deletions: typeof entry?.removed === "number" ? entry.removed : 0,
status: normalizeGitChangeStatus(entry?.status),
}
}
export function adaptSdkGitStatusEntries(
entries: SdkGitFileStatus[] | null | undefined,
details?: WorktreeGitStatusEntry[] | null,
): GitChangeEntry[] {
const detailsByPath = new Map(
(details ?? [])
.map((entry) => {
const path = normalizeGitChangePath(entry.path)
return path ? [{ ...entry, path }, path] : null
})
.filter((entry): entry is [WorktreeGitStatusEntry, string] => Boolean(entry))
.map(([entry, path]) => [path, entry] as const),
)
const adaptedByPath = new Map<string, GitChangeEntry>()
for (const entry of entries ?? []) {
const adapted = adaptSdkGitStatusEntry(entry)
if (!adapted.path) continue
const detail = detailsByPath.get(adapted.path)
adaptedByPath.set(adapted.path, {
...adapted,
originalPath: detail?.originalPath ? normalizeGitChangePath(detail.originalPath) : adapted.originalPath ?? null,
stagedStatus: detail?.stagedStatus ?? null,
unstagedStatus: detail?.unstagedStatus ?? null,
stagedAdditions: detail?.stagedAdditions ?? 0,
stagedDeletions: detail?.stagedDeletions ?? 0,
unstagedAdditions: detail?.unstagedAdditions ?? 0,
unstagedDeletions: detail?.unstagedDeletions ?? 0,
})
}
for (const detail of details ?? []) {
const normalizedPath = normalizeGitChangePath(detail.path)
if (!normalizedPath || adaptedByPath.has(normalizedPath)) continue
adaptedByPath.set(normalizedPath, {
path: normalizedPath,
originalPath: detail.originalPath ? normalizeGitChangePath(detail.originalPath) : null,
additions: 0,
deletions: 0,
status: detail.unstagedStatus ?? detail.stagedStatus ?? "modified",
stagedStatus: detail.stagedStatus,
unstagedStatus: detail.unstagedStatus,
stagedAdditions: detail.stagedAdditions,
stagedDeletions: detail.stagedDeletions,
unstagedAdditions: detail.unstagedAdditions,
unstagedDeletions: detail.unstagedDeletions,
})
}
return Array.from(adaptedByPath.values()).filter((entry) => entry.path.length > 0)
}
function buildGitChangeListItemId(section: GitChangeSection, path: string): string {
return `${section}:${path}`
}
function splitGitChangePath(path: string) {
const normalized = normalizeGitChangePath(path)
const lastSlash = normalized.lastIndexOf("/")
if (lastSlash === -1) {
return { displayName: normalized, parentPath: "" }
}
return {
displayName: normalized.slice(lastSlash + 1),
parentPath: normalized.slice(0, lastSlash),
}
}
export function buildGitChangeListItems(entries: GitChangeEntry[] | null | undefined): GitChangeListItem[] {
if (!Array.isArray(entries)) return []
const items: GitChangeListItem[] = []
for (const entry of entries) {
const pathParts = splitGitChangePath(entry.path)
if (entry.stagedStatus) {
items.push({
id: buildGitChangeListItemId("staged", entry.path),
path: entry.path,
originalPath: entry.originalPath ?? null,
section: "staged",
status: entry.stagedStatus,
additions: entry.stagedAdditions ?? 0,
deletions: entry.stagedDeletions ?? 0,
entry,
displayName: pathParts.displayName,
parentPath: pathParts.parentPath,
})
}
if (entry.unstagedStatus) {
items.push({
id: buildGitChangeListItemId("unstaged", entry.path),
path: entry.path,
originalPath: entry.originalPath ?? null,
section: "unstaged",
status: entry.unstagedStatus,
additions: entry.unstagedAdditions ?? entry.additions,
deletions: entry.unstagedDeletions ?? entry.deletions,
entry,
displayName: pathParts.displayName,
parentPath: pathParts.parentPath,
})
}
if (!entry.stagedStatus && !entry.unstagedStatus) {
items.push({
id: buildGitChangeListItemId("unstaged", entry.path),
path: entry.path,
originalPath: entry.originalPath ?? null,
section: "unstaged",
status: entry.status,
additions: entry.additions,
deletions: entry.deletions,
entry,
displayName: pathParts.displayName,
parentPath: pathParts.parentPath,
})
}
}
return items.sort((a, b) => {
if (a.section !== b.section) return a.section.localeCompare(b.section)
return a.path.localeCompare(b.path)
})
}

View File

@@ -115,22 +115,23 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
}
>
{(file) => (
<Suspense
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
}
>
<LazyMonacoDiffViewer
scopeKey={scopeKey()}
path={String(file().file || "")}
patch={String((file() as any).patch || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
</Suspense>
<Suspense
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
}
>
<LazyMonacoDiffViewer
scopeKey={scopeKey()}
path={String(file().file || "")}
before={String((file() as any).before || "")}
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
</Suspense>
)}
</Show>
</div>

View File

@@ -1,7 +1,7 @@
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
import type { FileNode } from "@opencode-ai/sdk/v2/client"
import { RefreshCw, Save } from "lucide-solid"
import { RefreshCw } from "lucide-solid"
import SplitFilePanel from "../components/SplitFilePanel"
@@ -21,17 +21,13 @@ interface FilesTabProps {
browserSelectedContent: Accessor<string | null>
browserSelectedLoading: Accessor<boolean>
browserSelectedError: Accessor<string | null>
browserSelectedDirty: Accessor<boolean>
browserSelectedSaving: Accessor<boolean>
parentPath: Accessor<string | null>
scopeKey: Accessor<string>
onLoadEntries: (path: string) => void
onRequestOpenFile: (path: string) => void
onOpenFile: (path: string) => void
onRefresh: () => void
onSave: (content: string) => void
onContentChange: (content: string) => void
listOpen: Accessor<boolean>
onToggleList: () => void
@@ -42,13 +38,6 @@ interface FilesTabProps {
}
const FilesTab: Component<FilesTabProps> = (props) => {
const handleSave = () => {
const content = props.browserSelectedContent()
if (content !== undefined && content !== null) {
props.onSave(content)
}
}
const renderContent = (): JSX.Element => {
const entriesValue = props.browserEntries()
const entries = entriesValue || []
@@ -97,13 +86,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</div>
}
>
<LazyMonacoFileViewer
scopeKey={props.scopeKey()}
path={payload().path}
content={payload().content}
onSave={props.onSave}
onContentChange={props.onContentChange}
/>
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
</Suspense>
)}
</Show>
@@ -152,7 +135,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
props.onLoadEntries(item.path)
return
}
props.onRequestOpenFile(item.path)
props.onOpenFile(item.path)
}}
title={item.path}
>
@@ -185,25 +168,14 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</Show>
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div>
<button
type="button"
class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.save") || "Save (Ctrl+S)"}
aria-label={props.t("instanceShell.rightPanel.actions.save") || "Save"}
disabled={props.browserSelectedSaving() || !props.browserSelectedDirty()}
style={{ "margin-inline-start": "auto" }}
onClick={handleSave}
>
<Show when={props.browserSelectedSaving()} fallback={<Save class="h-4 w-4" />}>
<RefreshCw class="h-4 w-4 animate-spin" />
</Show>
</button>
<button
type="button"
class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
disabled={props.browserLoading()}
style={{ "margin-inline-start": "auto" }}
onClick={() => props.onRefresh()}
>
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
@@ -226,4 +198,4 @@ const FilesTab: Component<FilesTabProps> = (props) => {
return <>{renderContent()}</>
}
export default FilesTab
export default FilesTab

View File

@@ -1,20 +1,11 @@
import {
For,
Show,
Suspense,
createMemo,
lazy,
type Accessor,
type Component,
type JSX,
} from "solid-js"
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import { ChevronDown, ChevronRight, GitBranch, RefreshCw } from "lucide-solid"
import { RefreshCw } from "lucide-solid"
import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, GitChangeEntry, GitChangeListItem } from "../types"
import { buildGitChangeListItems } from "../git-changes-model"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
const LazyMonacoDiffViewer = lazy(() =>
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
@@ -25,17 +16,16 @@ interface GitChangesTabProps {
activeSessionId: Accessor<string | null>
entries: Accessor<GitChangeEntry[] | null>
entries: Accessor<GitFileStatus[] | null>
statusLoading: Accessor<boolean>
statusError: Accessor<string | null>
selectedItemId: Accessor<string | null>
selectedBulkItemIds: Accessor<Set<string>>
selectedPath: Accessor<string | null>
selectedLoading: Accessor<boolean>
selectedError: Accessor<string | null>
selectedBefore: Accessor<string | null>
selectedAfter: Accessor<string | null>
mostChangedItemId: Accessor<string | null>
mostChangedPath: Accessor<string | null>
scopeKey: Accessor<string>
@@ -46,21 +36,8 @@ interface GitChangesTabProps {
onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
onRowClick: (item: GitChangeListItem, event: MouseEvent) => void
onOpenFile: (path: string) => void
onRefresh: () => void
onInsertContext: (item: GitChangeListItem, selection: { startLine: number; endLine: number }) => void
onStageFile: (item: GitChangeListItem) => void
onUnstageFile: (item: GitChangeListItem) => void
commitMessage: Accessor<string>
commitSubmitting: Accessor<boolean>
onCommitMessageInput: (value: string) => void
onSubmitCommit: () => void
branchLabel: Accessor<string | null>
stagedOpen: Accessor<boolean>
unstagedOpen: Accessor<boolean>
onToggleStagedOpen: () => void
onToggleUnstagedOpen: () => void
listOpen: Accessor<boolean>
onToggleList: () => void
@@ -75,54 +52,48 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
const entries = createMemo(() => (hasSession() ? props.entries() : null))
const sorted = createMemo<GitChangeEntry[]>(() => {
const sorted = createMemo<GitFileStatus[]>(() => {
const list = entries()
if (!Array.isArray(list)) return []
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
})
const listItems = createMemo<GitChangeListItem[]>(() => buildGitChangeListItems(sorted()))
const totals = createMemo(() => {
return listItems().reduce(
return sorted().reduce(
(acc, item) => {
acc.additions += typeof item.additions === "number" ? item.additions : 0
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
acc.additions += typeof item.added === "number" ? item.added : 0
acc.deletions += typeof item.removed === "number" ? item.removed : 0
return acc
},
{ additions: 0, deletions: 0 },
)
})
const stagedItems = createMemo(() => listItems().filter((item) => item.section === "staged"))
const unstagedItems = createMemo(() => listItems().filter((item) => item.section === "unstaged"))
const canCommit = createMemo(() => stagedItems().length > 0 && props.commitMessage().trim().length > 0 && !props.commitSubmitting())
const selectedEntry = createMemo<GitChangeEntry | null>(() => {
const list = listItems()
const selectedId = props.selectedItemId()
const fallbackId = props.mostChangedItemId()
const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted"))
const selectedEntry = createMemo<GitFileStatus | null>(() => {
const list = sorted()
const selectedPath = props.selectedPath()
const fallbackPath = props.mostChangedPath()
const found =
list.find((item) => item.id === selectedId) ||
(fallbackId ? list.find((item) => item.id === fallbackId) : undefined)
return found?.entry ?? null
list.find((item) => item.path === selectedPath) ||
(fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined)
return found ?? null
})
const emptyViewerMessage = createMemo(() => {
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
const currentEntries = entries()
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
if (listItems().length === 0) return props.t("instanceShell.gitChanges.empty")
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
return props.t("instanceShell.filesShell.viewerEmpty")
})
const binaryViewerActive = createMemo(() => props.selectedError() === props.t("instanceShell.gitChanges.binaryViewer"))
const renderContent = (): JSX.Element => {
const totalsValue = totals()
const selected = selectedEntry()
const allItems = listItems()
const stagedList = stagedItems()
const unstagedList = unstagedItems()
const sortedList = sorted()
const nonDeletedList = nonDeleted()
const renderViewer = () => (
<div class="file-viewer-panel flex-1">
@@ -138,7 +109,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
selected &&
props.selectedBefore() !== null &&
props.selectedAfter() !== null &&
true
selected.status !== "deleted"
? {
path: selected.path,
before: props.selectedBefore() as string,
@@ -168,14 +139,6 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
insertContextLabel={props.t("instanceShell.gitChanges.actions.insertContext")}
onRequestInsertContext={binaryViewerActive() ? undefined : (selection) => {
const selectedId = props.selectedItemId()
if (!selectedId) return
const item = listItems().find((entry) => entry.id === selectedId)
if (!item) return
props.onInsertContext(item, selection)
}}
/>
</Suspense>
)}
@@ -200,149 +163,66 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
const renderListItem = (item: GitChangeListItem) => {
const isBulkSelected = createMemo(() => props.selectedBulkItemIds().has(item.id))
const actionLabel =
item.section === "staged"
? props.t("instanceShell.gitChanges.actions.unstage")
: props.t("instanceShell.gitChanges.actions.stage")
const triggerAction = () => {
if (item.section === "staged") props.onUnstageFile(item)
else props.onStageFile(item)
}
return (
<div
class={`file-list-item git-change-list-item ${props.selectedItemId() === item.id ? "file-list-item-active" : ""} ${isBulkSelected() ? "git-change-list-item-bulk-selected" : ""}`}
onMouseDown={(event) => {
if (event.shiftKey || event.ctrlKey || event.metaKey) {
event.preventDefault()
}
}}
onClick={(event) => props.onRowClick(item, event)}
title={item.path}
>
<div class="file-list-item-content" title={item.path}>
<div class="file-list-item-path" title={item.path}>
<span class="file-path-text">{item.path}</span>
</div>
<div class="git-change-list-item-right">
<div class="file-list-item-stats">
<span class="file-list-item-additions">+{item.additions}</span>
<span class="file-list-item-deletions">-{item.deletions}</span>
const renderListPanel = () => (
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
<For each={sortedList}>
{(item) => (
<div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => {
props.onOpenFile(item.path)
}}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
<span class="file-path-text">{item.path}</span>
</div>
<div class="file-list-item-stats">
<Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
</Show>
<Show when={item.status !== "deleted"}>
<>
<span class="file-list-item-additions">+{item.added}</span>
<span class="file-list-item-deletions">-{item.removed}</span>
</>
</Show>
</div>
</div>
</div>
</div>
<div class="git-change-list-item-actions-zone">
<div class="git-change-list-item-actions">
<button
type="button"
class="git-change-row-action"
title={actionLabel}
aria-label={actionLabel}
onClick={(event) => {
event.stopPropagation()
triggerAction()
}}
>
<span
class={`git-change-row-action-glyph ${item.section === "staged" ? "git-change-row-action-glyph-minus" : "git-change-row-action-glyph-plus"}`}
aria-hidden="true"
>
<span class="git-change-row-action-bar git-change-row-action-bar-horizontal" />
<Show when={item.section !== "staged"}>
<span class="git-change-row-action-bar git-change-row-action-bar-vertical" />
</Show>
</span>
</button>
</div>
</div>
</div>
)
}
const renderSection = (
title: string,
items: GitChangeListItem[],
isOpen: boolean,
onToggle: () => void,
) => (
<div class="git-change-section">
<button type="button" class="git-change-section-header" onClick={onToggle}>
<span class="git-change-section-header-main">
<span class="git-change-section-chevron">
{isOpen ? <ChevronDown class="h-3.5 w-3.5" /> : <ChevronRight class="h-3.5 w-3.5" />}
</span>
<span class="git-change-section-title">{title}</span>
</span>
<span class="git-change-section-count">{items.length}</span>
</button>
<Show when={isOpen}>
<div class="git-change-section-items">
<For each={items}>{(item) => renderListItem(item)}</For>
</div>
</Show>
</div>
)}
</For>
</Show>
)
const renderGroupedList = () => (
<Show when={allItems.length > 0} fallback={renderEmptyList()}>
<div class="git-change-sections">
<div class="git-change-section">
<button type="button" class="git-change-section-header" onClick={props.onToggleStagedOpen}>
<span class="git-change-section-header-main">
<span class="git-change-section-chevron">
{props.stagedOpen() ? <ChevronDown class="h-3.5 w-3.5" /> : <ChevronRight class="h-3.5 w-3.5" />}
</span>
<span class="git-change-section-title-row">
<span class="git-change-section-title">{props.t("instanceShell.gitChanges.sections.staged")}</span>
<Show when={props.branchLabel()}>
{(label) => (
<span class="status-indicator session-status-list worktree-indicator git-change-section-badge" title={`Branch: ${label()}`}>
<GitBranch class="w-3.5 h-3.5" aria-hidden="true" />
<span class="worktree-indicator-label">{label()}</span>
</span>
)}
</Show>
</span>
</span>
<span class="git-change-section-count">{stagedList.length}</span>
</button>
<Show when={props.stagedOpen()}>
<div class="git-change-section-items">
<div class="git-change-commit-box">
<div class="git-change-commit-input-wrap">
<textarea
class="git-change-commit-input"
value={props.commitMessage()}
rows={1}
placeholder={props.t("instanceShell.gitChanges.commit.placeholder")}
onInput={(event) => props.onCommitMessageInput(event.currentTarget.value)}
/>
<button
type="button"
class="git-change-commit-button git-change-commit-button-overlay"
disabled={!canCommit()}
onClick={() => props.onSubmitCommit()}
>
{props.commitSubmitting()
? props.t("instanceShell.gitChanges.commit.submitting")
: props.t("instanceShell.gitChanges.commit.submit")}
</button>
</div>
const renderListOverlay = () => (
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
<For each={sortedList}>
{(item) => (
<div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => props.onOpenFile(item.path)}
title={item.path}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
<span class="file-path-text">{item.path}</span>
</div>
<div class="file-list-item-stats">
<Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
</Show>
<Show when={item.status !== "deleted"}>
<>
<span class="file-list-item-additions">+{item.added}</span>
<span class="file-list-item-deletions">-{item.removed}</span>
</>
</Show>
</div>
<For each={stagedList}>{(item) => renderListItem(item)}</For>
</div>
</Show>
</div>
{renderSection(
props.t("instanceShell.gitChanges.sections.unstaged"),
unstagedList,
props.unstagedOpen(),
props.onToggleUnstagedOpen,
</div>
)}
</div>
</For>
</Show>
)
@@ -386,7 +266,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
/>
</>
}
list={{ panel: renderGroupedList, overlay: renderGroupedList }}
list={{ panel: renderListPanel, overlay: renderListOverlay }}
viewer={renderViewer()}
listOpen={props.listOpen()}
onToggleList={props.onToggleList}

View File

@@ -2,9 +2,8 @@ import { For, Show, type Accessor, type Component } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import { Accordion } from "@kobalte/core"
import { Tooltip } from "@kobalte/core/tooltip"
import Switch from "@suid/material/Switch"
import { BellRing, ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import type { Instance } from "../../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
@@ -13,7 +12,6 @@ import type { Session } from "../../../../../types/session"
import ContextUsagePanel from "../../../../session/context-usage-panel"
import { TodoListView } from "../../../../tool-call/renderers/todo"
import InstanceServiceStatus from "../../../../instance-service-status"
import { isPermissionAutoAcceptEnabled, togglePermissionAutoAccept } from "../../../../../stores/permission-auto-accept"
interface StatusTabProps {
t: (key: string, vars?: Record<string, any>) => string
@@ -41,35 +39,6 @@ interface StatusTabProps {
const StatusTab: Component<StatusTabProps> = (props) => {
const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
const renderYoloModeSection = () => {
const session = props.activeSession()
if (!session) {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.yoloMode.noSessionSelected")}</span>
</div>
)
}
return (
<div class="rounded-md border border-base bg-surface-secondary px-3 py-2">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-medium text-primary">{props.t("instanceShell.yoloMode.title")}</div>
<p class="mt-1 text-xs text-secondary">{props.t("instanceShell.yoloMode.description")}</p>
</div>
<Switch
checked={isPermissionAutoAcceptEnabled(props.instanceId, session.id)}
color="warning"
size="small"
inputProps={{ "aria-label": props.t("instanceShell.yoloMode.title") }}
onChange={() => togglePermissionAutoAccept(props.instanceId, session.id)}
/>
</div>
</div>
)
}
const renderStatusSessionChanges = () => {
const sessionId = props.activeSessionId()
if (!sessionId || sessionId === "info") {
@@ -187,24 +156,6 @@ const StatusTab: Component<StatusTabProps> = (props) => {
<div class="status-process-header">
<span class="status-process-title">{process.title}</span>
<div class="status-process-meta">
<span
classList={{
"text-success": Boolean(process.notifyEnabled),
"text-tertiary": !process.notifyEnabled,
}}
aria-label={props.t(
process.notifyEnabled
? "instanceShell.backgroundProcesses.notify.enabled"
: "instanceShell.backgroundProcesses.notify.disabled",
)}
title={props.t(
process.notifyEnabled
? "instanceShell.backgroundProcesses.notify.enabled"
: "instanceShell.backgroundProcesses.notify.disabled",
)}
>
<BellRing class="h-3.5 w-3.5" />
</span>
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
<Show when={typeof process.outputSizeBytes === "number"}>
<span>
@@ -253,12 +204,6 @@ const StatusTab: Component<StatusTabProps> = (props) => {
}
const statusSections = [
{
id: "yolo-mode",
labelKey: "instanceShell.rightPanel.sections.yoloMode",
tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip",
render: renderYoloModeSection,
},
{
id: "session-changes",
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
@@ -336,23 +281,29 @@ const StatusTab: Component<StatusTabProps> = (props) => {
<For each={statusSections}>
{(section) => (
<Accordion.Item value={section.id} class="right-panel-accordion-item">
<Accordion.Header class="right-panel-accordion-header-row">
<Accordion.Header>
<Accordion.Trigger class="right-panel-accordion-trigger">
<span class="section-left">
<Tooltip openDelay={200} gutter={4} placement="top">
<Tooltip.Trigger
class="section-info-trigger"
aria-label={props.t(section.tooltipKey)}
onClick={(e) => e.stopPropagation()}
>
<Info class="section-info-icon" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content class="section-info-tooltip">
{props.t(section.tooltipKey)}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
<span class="section-label">{props.t(section.labelKey)}</span>
</span>
<ChevronDown
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
/>
</Accordion.Trigger>
<Tooltip openDelay={200} gutter={4} placement="top">
<Tooltip.Trigger as="button" type="button" class="section-info-trigger" aria-label={props.t(section.tooltipKey)}>
<Info class="section-info-icon" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content class="section-info-tooltip">{props.t(section.tooltipKey)}</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
</Accordion.Header>
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
</Accordion.Item>

View File

@@ -5,40 +5,3 @@ export type DiffViewMode = "split" | "unified"
export type DiffContextMode = "expanded" | "collapsed"
export type DiffWordWrapMode = "on" | "off"
export type GitChangeStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | string
export interface GitChangeEntry {
path: string
originalPath?: string | null
additions: number
deletions: number
status: GitChangeStatus
stagedStatus?: GitChangeStatus | null
unstagedStatus?: GitChangeStatus | null
stagedAdditions?: number
stagedDeletions?: number
unstagedAdditions?: number
unstagedDeletions?: number
}
export type GitChangeSection = "staged" | "unstaged"
export interface GitChangeListItem {
id: string
path: string
originalPath?: string | null
section: GitChangeSection
status: GitChangeStatus
additions: number
deletions: number
entry: GitChangeEntry
displayName: string
parentPath: string
}
export interface GitSelectionDescriptor {
itemId: string | null
path: string | null
section: GitChangeSection | null
}

View File

@@ -1,470 +0,0 @@
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import type { PromptInputApi } from "../../../prompt-input/types"
import type { GitChangeEntry, GitChangeListItem, GitSelectionDescriptor, RightPanelTab } from "./types"
import { getOrCreateWorktreeClient } from "../../../../stores/worktrees"
import { requestData } from "../../../../lib/opencode-api"
import { serverApi } from "../../../../lib/api-client"
import { serverEvents } from "../../../../lib/server-events"
import { showToastNotification } from "../../../../lib/notifications"
import { adaptSdkGitStatusEntries, buildGitChangeListItems } from "./git-changes-model"
type UseGitChangesOptions = {
t: (key: string, vars?: Record<string, any>) => string
instanceId: string
rightPanelTab: Accessor<RightPanelTab>
worktreeSlug: Accessor<string>
isPhoneLayout: Accessor<boolean>
promptInputApi: Accessor<PromptInputApi | null>
closeGitList: () => void
}
export function useGitChanges(options: UseGitChangesOptions) {
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitChangeEntry[] | null>(null)
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
const [gitSelectedItemId, setGitSelectedItemId] = createSignal<string | null>(null)
const [gitBulkSelectedItemIds, setGitBulkSelectedItemIds] = createSignal<Set<string>>(new Set())
const [gitBulkSelectionAnchorId, setGitBulkSelectionAnchorId] = createSignal<string | null>(null)
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
const [gitCommitMessage, setGitCommitMessage] = createSignal("")
const [gitCommitSubmitting, setGitCommitSubmitting] = createSignal(false)
let gitStatusRequestVersion = 0
let gitDiffRequestVersion = 0
let passiveGitRefreshInFlight = false
let pendingGitPassiveRefreshOptions: { forceReloadSelectedDiff?: boolean } | null = null
let previousGitChangesActivationKey: string | null = null
const gitListItems = createMemo(() => buildGitChangeListItems(gitStatusEntries()))
const clearGitBulkSelection = () => {
setGitBulkSelectedItemIds((current) => (current.size === 0 ? current : new Set<string>()))
setGitBulkSelectionAnchorId(null)
}
const toggleGitBulkSelection = (itemId: string) => {
setGitBulkSelectedItemIds((current) => {
const next = new Set(current)
if (next.has(itemId)) next.delete(itemId)
else next.add(itemId)
return next
})
}
const addGitBulkRange = (anchorId: string, itemId: string) => {
const items = gitListItems()
const anchorIndex = items.findIndex((entry) => entry.id === anchorId)
const itemIndex = items.findIndex((entry) => entry.id === itemId)
if (anchorIndex < 0 || itemIndex < 0) {
setGitBulkSelectedItemIds((current) => {
const next = new Set(current)
next.add(itemId)
return next
})
return
}
const start = Math.min(anchorIndex, itemIndex)
const end = Math.max(anchorIndex, itemIndex)
const rangeIds = items.slice(start, end + 1).map((entry) => entry.id)
setGitBulkSelectedItemIds((current) => {
const next = new Set(current)
for (const rangeId of rangeIds) {
next.add(rangeId)
}
return next
})
}
const describeGitSelection = (itemId: string | null): GitSelectionDescriptor => {
if (!itemId) {
return { itemId: null, path: null, section: null }
}
const match = gitListItems().find((item) => item.id === itemId) ?? null
return {
itemId,
path: match?.path ?? null,
section: match?.section ?? null,
}
}
const gitMostChangedItemId = createMemo<string | null>(() => {
const items = gitListItems()
if (items.length === 0) return null
const candidates = items.filter((item) => item.status !== "deleted")
if (candidates.length === 0) return null
const best = candidates.reduce((currentBest, item) => {
const bestScore = (currentBest?.additions ?? 0) + (currentBest?.deletions ?? 0)
const score = (item.additions ?? 0) + (item.deletions ?? 0)
if (score > bestScore) return item
if (score < bestScore) return currentBest
return String(item.id || "").localeCompare(String(currentBest?.id || "")) < 0 ? item : currentBest
}, candidates[0])
return typeof best?.id === "string" ? best.id : null
})
const resolveValidGitSelection = (selection: GitSelectionDescriptor): string | null => {
const items = gitListItems()
if (items.length === 0) return null
if (selection.itemId && items.some((item) => item.id === selection.itemId)) return selection.itemId
if (selection.path && selection.section) {
const oppositeSection = selection.section === "staged" ? "unstaged" : "staged"
const moved = items.find((item) => item.path === selection.path && item.section === oppositeSection)
if (moved) return moved.id
const samePath = items.find((item) => item.path === selection.path)
if (samePath) return samePath.id
}
return gitMostChangedItemId()
}
const describeGitSelectionFingerprint = (itemId: string | null) => {
if (!itemId) return null
const item = gitListItems().find((entry) => entry.id === itemId) ?? null
if (!item) return null
return `${item.path}::${item.originalPath ?? ""}::${item.section}::${item.status}::${item.additions}::${item.deletions}`
}
const clearSelectedGitDiff = () => {
setGitSelectedError(null)
setGitSelectedBefore(null)
setGitSelectedAfter(null)
}
const clearSelectedGitDiffAndSelection = () => {
setGitSelectedItemId(null)
clearGitBulkSelection()
setGitSelectedLoading(false)
clearSelectedGitDiff()
}
const pruneGitBulkSelection = () => {
const validIds = new Set(gitListItems().map((item) => item.id))
setGitBulkSelectedItemIds((current) => {
if (current.size === 0) return current
const next = new Set<string>()
for (const itemId of current) {
if (validIds.has(itemId)) next.add(itemId)
}
return next.size === current.size ? current : next
})
const anchorId = gitBulkSelectionAnchorId()
if (anchorId && !validIds.has(anchorId)) {
setGitBulkSelectionAnchorId(null)
}
}
createEffect(() => {
gitListItems()
pruneGitBulkSelection()
})
const loadGitStatus = async (force = false) => {
if (!force && gitStatusEntries() !== null) return
const slug = options.worktreeSlug()
const client = getOrCreateWorktreeClient(options.instanceId, slug)
const requestVersion = ++gitStatusRequestVersion
setGitStatusLoading(true)
setGitStatusError(null)
try {
const sdkStatusPromise = requestData<GitFileStatus[]>(client.file.status(), "file.status")
const detailList = await serverApi.fetchWorktreeGitStatus(options.instanceId, slug)
if (requestVersion !== gitStatusRequestVersion) return
if (slug !== options.worktreeSlug()) return
const sdkResult = await Promise.race([
sdkStatusPromise.then((value) => ({ kind: "fulfilled" as const, value })),
new Promise<{ kind: "timeout" }>((resolve) => setTimeout(() => resolve({ kind: "timeout" }), 1500)),
]).catch(() => null)
const sdkList = sdkResult && sdkResult.kind === "fulfilled" ? sdkResult.value : null
setGitStatusEntries(adaptSdkGitStatusEntries(sdkList, detailList))
} catch (error) {
if (requestVersion !== gitStatusRequestVersion) return
if (slug !== options.worktreeSlug()) return
setGitStatusError(error instanceof Error ? error.message : "Failed to load git status")
setGitStatusEntries([])
} finally {
if (requestVersion !== gitStatusRequestVersion) return
if (slug !== options.worktreeSlug()) return
setGitStatusLoading(false)
}
}
async function openGitFile(itemId: string) {
const requestVersion = ++gitDiffRequestVersion
setGitSelectedItemId(itemId)
setGitSelectedLoading(true)
clearSelectedGitDiff()
const item = gitListItems().find((entry) => entry.id === itemId) || null
if (!item) {
if (requestVersion !== gitDiffRequestVersion) return
clearSelectedGitDiffAndSelection()
return
}
if (options.isPhoneLayout()) {
options.closeGitList()
}
try {
const diff = await serverApi.fetchWorktreeGitDiff(options.instanceId, options.worktreeSlug(), {
path: item.path,
originalPath: item.originalPath ?? null,
scope: item.section,
})
if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
if (diff.isBinary) {
setGitSelectedError(options.t("instanceShell.gitChanges.binaryViewer"))
return
}
setGitSelectedBefore(diff.before)
setGitSelectedAfter(diff.after)
} catch (error) {
if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes")
} finally {
if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
setGitSelectedLoading(false)
}
}
const passiveRefreshGitStatus = async (optionsArg?: { forceReloadSelectedDiff?: boolean }) => {
if (options.rightPanelTab() !== "git-changes") return
if (passiveGitRefreshInFlight) {
pendingGitPassiveRefreshOptions = {
forceReloadSelectedDiff:
pendingGitPassiveRefreshOptions?.forceReloadSelectedDiff || optionsArg?.forceReloadSelectedDiff || false,
}
return
}
if (gitCommitSubmitting()) return
passiveGitRefreshInFlight = true
const refreshSelectionId = gitSelectedItemId()
const previousSelection = describeGitSelection(gitSelectedItemId())
const previousFingerprint = describeGitSelectionFingerprint(previousSelection.itemId)
const hadSelectedDiff =
previousSelection.itemId !== null &&
(gitSelectedBefore() !== null || gitSelectedAfter() !== null || gitSelectedError() !== null)
try {
await loadGitStatus(true)
if (gitSelectedItemId() !== refreshSelectionId) return
const nextSelection = resolveValidGitSelection(previousSelection)
setGitSelectedItemId(nextSelection)
if (!nextSelection) {
clearSelectedGitDiff()
return
}
const nextFingerprint = describeGitSelectionFingerprint(nextSelection)
const shouldReloadSelectedDiff =
optionsArg?.forceReloadSelectedDiff ||
!hadSelectedDiff ||
previousFingerprint !== nextFingerprint ||
previousSelection.itemId === nextSelection
if (shouldReloadSelectedDiff) {
await openGitFile(nextSelection)
}
} finally {
passiveGitRefreshInFlight = false
if (pendingGitPassiveRefreshOptions) {
const nextOptions = pendingGitPassiveRefreshOptions
pendingGitPassiveRefreshOptions = null
void passiveRefreshGitStatus(nextOptions)
}
}
}
const mutateGitFile = async (item: GitChangeListItem, action: "stage" | "unstage") => {
const currentSelection = describeGitSelection(gitSelectedItemId())
const fallbackSelection = currentSelection.path === item.path ? currentSelection : describeGitSelection(item.id)
const selectedIds = gitBulkSelectedItemIds()
const selectedItems = gitListItems().filter((candidate) => selectedIds.has(candidate.id))
const bulkTargets = selectedItems.filter((candidate) => candidate.section === item.section)
const targetItems = bulkTargets.some((candidate) => candidate.id === item.id) ? bulkTargets : [item]
const targetPaths = Array.from(new Set(targetItems.map((candidate) => candidate.path)))
try {
if (action === "stage") {
await serverApi.stageWorktreeGitPaths(options.instanceId, options.worktreeSlug(), { paths: targetPaths })
} else {
await serverApi.unstageWorktreeGitPaths(options.instanceId, options.worktreeSlug(), { paths: targetPaths })
}
await loadGitStatus(true)
clearGitBulkSelection()
const nextSelection = resolveValidGitSelection(fallbackSelection)
setGitSelectedItemId(nextSelection)
if (nextSelection) {
await openGitFile(nextSelection)
} else {
clearSelectedGitDiff()
}
} catch (error) {
showToastNotification({
message: error instanceof Error ? error.message : `Failed to ${action} file`,
variant: "error",
})
}
}
const handleGitRowClick = (item: GitChangeListItem, event: MouseEvent) => {
if (event.shiftKey) {
event.preventDefault()
const anchorId = gitBulkSelectionAnchorId() ?? item.id
addGitBulkRange(anchorId, item.id)
return
}
if (event.ctrlKey || event.metaKey) {
event.preventDefault()
toggleGitBulkSelection(item.id)
setGitBulkSelectionAnchorId(item.id)
return
}
clearGitBulkSelection()
setGitBulkSelectionAnchorId(item.id)
void openGitFile(item.id)
}
const submitGitCommit = async () => {
const message = gitCommitMessage().trim()
if (!message || gitCommitSubmitting()) return
setGitCommitSubmitting(true)
try {
await serverApi.commitWorktreeGitChanges(options.instanceId, options.worktreeSlug(), { message })
setGitCommitMessage("")
await loadGitStatus(true)
const nextSelection = resolveValidGitSelection(describeGitSelection(gitSelectedItemId()))
setGitSelectedItemId(nextSelection)
if (nextSelection) {
await openGitFile(nextSelection)
} else {
clearSelectedGitDiff()
}
showToastNotification({
message: options.t("instanceShell.gitChanges.commit.success"),
variant: "success",
})
} catch (error) {
showToastNotification({
message: error instanceof Error ? error.message : options.t("instanceShell.gitChanges.commit.error"),
variant: "error",
})
} finally {
setGitCommitSubmitting(false)
}
}
const refreshGitStatus = async () => {
await loadGitStatus(true)
const selected = resolveValidGitSelection(describeGitSelection(gitSelectedItemId()))
setGitSelectedItemId(selected)
if (selected) {
void openGitFile(selected)
} else {
clearSelectedGitDiff()
}
}
const insertGitChangeContext = (item: GitChangeListItem, selection: { startLine: number; endLine: number } | null) => {
const startLine = selection?.startLine ?? 1
const endLine = selection?.endLine ?? startLine
options.promptInputApi()?.insertComment(`Git Diff: File: ${item.path} : ${startLine}-${endLine}`)
}
createEffect(() => {
options.worktreeSlug()
gitStatusRequestVersion += 1
gitDiffRequestVersion += 1
passiveGitRefreshInFlight = false
pendingGitPassiveRefreshOptions = null
setGitStatusEntries(null)
setGitStatusError(null)
setGitStatusLoading(false)
setGitSelectedItemId(null)
clearGitBulkSelection()
setGitSelectedLoading(false)
clearSelectedGitDiff()
setGitCommitMessage("")
setGitCommitSubmitting(false)
})
createEffect(() => {
if (options.rightPanelTab() !== "git-changes") return
const items = gitListItems()
if (gitStatusEntries() === null) return
if (items.length === 0) return
if (gitSelectedItemId()) return
const next = gitMostChangedItemId()
if (!next) return
void openGitFile(next)
})
createEffect(() => {
const activationKey = options.rightPanelTab() === "git-changes" ? `${options.instanceId}:${options.worktreeSlug()}` : null
if (!activationKey) {
previousGitChangesActivationKey = null
return
}
if (previousGitChangesActivationKey === activationKey) return
previousGitChangesActivationKey = activationKey
void passiveRefreshGitStatus()
})
createEffect(() => {
if (options.rightPanelTab() !== "git-changes") return
const unsubscribe = serverEvents.on("instance.event", (event) => {
if (event.type !== "instance.event") return
if (event.instanceId !== options.instanceId) return
const eventType = (event.event as { type?: unknown } | undefined)?.type
if (eventType !== "session.updated" && eventType !== "session.diff") return
void passiveRefreshGitStatus({ forceReloadSelectedDiff: true })
})
onCleanup(() => {
unsubscribe()
})
})
createEffect(() => {
if (options.rightPanelTab() === "git-changes") return
setGitSelectedBefore(null)
setGitSelectedAfter(null)
setGitSelectedLoading(false)
setGitSelectedError(null)
})
return {
gitStatusEntries,
gitStatusLoading,
gitStatusError,
gitSelectedItemId,
gitBulkSelectedItemIds,
gitSelectedLoading,
gitSelectedError,
gitSelectedBefore,
gitSelectedAfter,
gitCommitMessage,
gitCommitSubmitting,
gitMostChangedItemId,
setGitCommitMessage,
handleGitRowClick,
refreshGitStatus,
insertGitChangeContext,
submitGitCommit,
stageGitFile: (item: GitChangeListItem) => void mutateGitFile(item, "stage"),
unstageGitFile: (item: GitChangeListItem) => void mutateGitFile(item, "unstage"),
}
}

View File

@@ -21,10 +21,6 @@ export const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-
export const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1"
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-v1"
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
export const RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-staged-open-nonphone-v1"
export const RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-staged-open-phone-v1"
export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-unstaged-open-nonphone-v1"
export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-unstaged-open-phone-v1"
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"

View File

@@ -83,7 +83,6 @@ interface MarkdownProps {
isDark?: boolean
size?: "base" | "sm" | "tight"
disableHighlight?: boolean
escapeRawHtml?: boolean
onRendered?: () => void
}
@@ -104,12 +103,11 @@ export function Markdown(props: MarkdownProps) {
const text = decodeHtmlEntitiesLocally(rawText)
const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight
const escapeRawHtml = Boolean(props.escapeRawHtml)
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
const cacheId = resolvePartCacheId(part, text)
const version = resolvePartVersion(part, text)
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}`
return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey }
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
})
const cacheHandle = useGlobalCache({
@@ -118,26 +116,20 @@ export function Markdown(props: MarkdownProps) {
scope: "markdown",
cacheId: () => {
const { cacheId, themeKey, highlightEnabled } = resolved()
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}`
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
},
version: () => resolved().version,
})
const commitCacheEntry = (
snapshot: ReturnType<typeof resolved>,
renderedHtml: string,
options?: { cache?: boolean },
) => {
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
const cacheEntry: RenderCache = {
text: snapshot.text,
html: renderedHtml,
theme: snapshot.themeKey,
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
mode: snapshot.version,
}
setHtml(renderedHtml)
if (options?.cache ?? true) {
cacheHandle.set(cacheEntry)
}
cacheHandle.set(cacheEntry)
notifyRendered()
}
@@ -146,23 +138,20 @@ export function Markdown(props: MarkdownProps) {
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
const rendered = await markdown.renderMarkdown(snapshot.text, {
suppressHighlight: !snapshot.highlightEnabled,
escapeRawHtml: snapshot.escapeRawHtml,
})
const shouldCache = !snapshot.highlightEnabled || !markdown.hasPendingCodeHighlight(snapshot.text)
if (latestRequestKey === snapshot.requestKey) {
commitCacheEntry(snapshot, rendered, { cache: shouldCache })
commitCacheEntry(snapshot, rendered)
}
}
createEffect(() => {
const snapshot = resolved()
latestRequestKey = snapshot.requestKey
const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`
const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false
return cache.theme === snapshot.themeKey && cache.mode === cacheMode
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
}
const localCache = snapshot.part.renderCache

View File

@@ -1,22 +1,21 @@
import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js"
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } 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 { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
import { 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 { selectInstanceTab } from "../stores/app-tabs"
import { setActiveInstanceId } from "../stores/instances"
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 (
@@ -30,7 +29,6 @@ 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"))
@@ -132,7 +130,7 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string
}
function navigateToTaskSession(location: TaskSessionLocation) {
selectInstanceTab(location.instanceId)
setActiveInstanceId(location.instanceId)
const parentToActivate = location.parentId ?? location.sessionId
setActiveParentSession(location.instanceId, parentToActivate)
if (location.parentId) {
@@ -231,12 +229,6 @@ 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))
@@ -270,15 +262,13 @@ 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 (visibleParts().length === 0) {
if (!currentParts.some((part) => partHasRenderableText(part))) {
return false
}
@@ -294,10 +284,10 @@ function MessageContentItem(props: MessageContentItemProps) {
if (!isSupportedPartType(part)) continue
if (!isContentPartType((part as any).type)) continue
if (isVisibleContentPart(part)) {
return false
}
if (partHasRenderableText(part)) {
return false
}
}
return true
})
@@ -308,7 +298,7 @@ function MessageContentItem(props: MessageContentItemProps) {
<MessageItem
record={resolvedRecord()}
messageInfo={messageInfo()}
parts={visibleParts()}
parts={parts()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={isQueued()}
@@ -629,12 +619,13 @@ export default function MessageBlock(props: MessageBlockProps) {
const lastAssistantIdx = props.lastAssistantIndex()
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
const messageInfoVersion = props.store().state.messageInfoVersion[current.id] ?? 0
// Intentionally untracked: messageInfoVersion updates should not trigger
// a full message block rebuild; record revision is the invalidation key.
const info = untrack(messageInfo)
const cacheSignature = [
current.id,
current.revision,
messageInfoVersion,
isQueued ? 1 : 0,
props.showThinking() ? 1 : 0,
props.thinkingDefaultExpanded() ? 1 : 0,
@@ -646,9 +637,6 @@ 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[] = []
@@ -815,19 +803,19 @@ export default function MessageBlock(props: MessageBlockProps) {
data-message-id={resolvedBlock().record.id}
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
>
<Index each={resolvedBlock().items}>
<For each={resolvedBlock().items}>
{(item, index) => (
<Switch>
<Match when={item().type === "content"}>
<Match when={item.type === "content"}>
<MessageContentItem
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageId={(item() as ContentDisplayItem).messageId}
startPartId={(item() as ContentDisplayItem).startPartId}
messageId={(item as ContentDisplayItem).messageId}
startPartId={(item as ContentDisplayItem).startPartId}
messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex}
showDeleteMessage={index === 0}
showDeleteMessage={index() === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
@@ -837,18 +825,18 @@ export default function MessageBlock(props: MessageBlockProps) {
onContentRendered={props.onContentRendered}
/>
</Match>
<Match when={item().type === "tool"}>
<Match when={item.type === "tool"}>
{(() => {
const toolItem = item() as ToolDisplayItem
const toolItem = item as ToolDisplayItem
return (
<div class="tool-call-message" data-key={toolItem.key}>
<ToolCallItem
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageId={toolItem.messageId}
partId={toolItem.partId}
showDeleteMessage={index === 0}
<ToolCallItem
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageId={toolItem.messageId}
partId={toolItem.partId}
showDeleteMessage={index() === 0}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
@@ -861,13 +849,13 @@ export default function MessageBlock(props: MessageBlockProps) {
)
})()}
</Match>
<Match when={item().type === "step-start"}>
<Match when={item.type === "step-start"}>
<StepCard
kind="start"
part={(item() as StepDisplayItem).part}
messageInfo={(item() as StepDisplayItem).messageInfo}
part={(item as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo}
showAgentMeta
showDeleteMessage={index === 0}
showDeleteMessage={index() === 0}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
@@ -877,14 +865,14 @@ export default function MessageBlock(props: MessageBlockProps) {
onToggleSelectedMessage={props.onToggleSelectedMessage}
/>
</Match>
<Match when={item().type === "step-finish"}>
<Match when={item.type === "step-finish"}>
<StepCard
kind="finish"
part={(item() as StepDisplayItem).part}
messageInfo={(item() as StepDisplayItem).messageInfo}
part={(item as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo}
showUsage={props.showUsageMetrics()}
borderColor={(item() as StepDisplayItem).accentColor}
showDeleteMessage={index === 0}
borderColor={(item as StepDisplayItem).accentColor}
showDeleteMessage={index() === 0}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
@@ -894,31 +882,31 @@ export default function MessageBlock(props: MessageBlockProps) {
onToggleSelectedMessage={props.onToggleSelectedMessage}
/>
</Match>
<Match when={item().type === "compaction"}>
<Match when={item.type === "compaction"}>
<CompactionCard
part={(item() as CompactionDisplayItem).part}
messageInfo={(item() as CompactionDisplayItem).messageInfo}
borderColor={(item() as CompactionDisplayItem).accentColor}
part={(item as CompactionDisplayItem).part}
messageInfo={(item as CompactionDisplayItem).messageInfo}
borderColor={(item as CompactionDisplayItem).accentColor}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={(item() as CompactionDisplayItem).messageId}
showDeleteMessage={index === 0}
messageId={(item as CompactionDisplayItem).messageId}
showDeleteMessage={index() === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/>
</Match>
<Match when={item().type === "reasoning"}>
<Match when={item.type === "reasoning"}>
<ReasoningCard
part={(item() as ReasoningDisplayItem).part}
messageInfo={(item() as ReasoningDisplayItem).messageInfo}
part={(item as ReasoningDisplayItem).part}
messageInfo={(item as ReasoningDisplayItem).messageInfo}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={(item() as ReasoningDisplayItem).messageId}
showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded}
showDeleteMessage={index === 0}
messageId={(item as ReasoningDisplayItem).messageId}
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
showDeleteMessage={index() === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
@@ -928,7 +916,7 @@ export default function MessageBlock(props: MessageBlockProps) {
</Match>
</Switch>
)}
</Index>
</For>
</div>
)}
</Show>
@@ -1110,23 +1098,17 @@ function StepCard(props: StepCardProps) {
return null
}
const info = props.messageInfo
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) {
if (!info || info.role !== "assistant" || !info.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: (part?.cost ?? (info && info.role === "assistant" ? info.cost : 0)) ?? 0,
cost: info.cost ?? 0,
}
}
@@ -1311,23 +1293,14 @@ interface ReasoningCardProps {
onContentRendered?: () => void
}
function ReasoningStreamOutput(props: {
text: Accessor<string>
scrollTopSnapshot: Accessor<number>
setScrollTopSnapshot: (next: number) => void
onContentRendered?: () => void
ariaLabel: string
}) {
let preRef: HTMLPreElement | undefined
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))
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) {
@@ -1339,15 +1312,6 @@ function ReasoningStreamOutput(props: {
})
}
createEffect(() => {
const nextText = props.text()
if (preRef && preRef.textContent !== nextText) {
preRef.textContent = nextText
}
followScroll.restoreAfterRender()
notifyContentRendered()
})
onCleanup(() => {
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
@@ -1355,37 +1319,6 @@ function ReasoningStreamOutput(props: {
}
})
return (
<div
ref={followScroll.registerContainer}
class="message-reasoning-output"
role="region"
aria-label={props.ariaLabel}
onScroll={followScroll.handleScroll}
>
<pre
ref={(element) => {
preRef = element || undefined
if (preRef) {
preRef.textContent = props.text() || ""
}
}}
class="message-reasoning-text"
dir="auto"
/>
{followScroll.renderSentinel()}
</div>
)
}
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))
})
@@ -1460,6 +1393,12 @@ 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) => {
@@ -1614,13 +1553,9 @@ function ReasoningCard(props: ReasoningCardProps) {
<Show when={expanded()}>
<div class="message-reasoning-expanded">
<div class="message-reasoning-body">
<ReasoningStreamOutput
text={reasoningText}
scrollTopSnapshot={scrollTopSnapshot}
setScrollTopSnapshot={setScrollTopSnapshot}
onContentRendered={props.onContentRendered}
ariaLabel={t("messageBlock.reasoning.detailsAriaLabel")}
/>
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
</div>
</div>
</div>
</Show>

View File

@@ -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 { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part"
import { copyToClipboard } from "../lib/clipboard"
@@ -290,9 +290,9 @@ export default function MessageItem(props: MessageItemProps) {
const getRawContent = () => {
return props.parts
.filter((part) => part.type === "text" && !isHiddenSyntheticTextPart(part))
.map((part) => (part as { text?: string }).text || "")
.filter((text) => text.trim().length > 0)
.filter(part => part.type === "text")
.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 (!hasContent() && !isGenerating()) {
if (!isUser() && !hasContent() && !isGenerating()) {
return null
}

View File

@@ -33,7 +33,19 @@ export default function MessagePart(props: MessagePartProps) {
const shouldHideTextPart = () => {
const part = props.part
if (!part || part.type !== "text") return false
return Boolean((part as any).synthetic)
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
}
@@ -134,7 +146,6 @@ export default function MessagePart(props: MessagePartProps) {
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
escapeRawHtml={props.messageType === "user"}
onRendered={props.onRendered}
/>
</Show>

View File

@@ -1,5 +1,5 @@
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
import { MoreHorizontal, Pause, Trash, X } from "lucide-solid"
import { MoreHorizontal, Trash, X } from "lucide-solid"
import Kbd from "./kbd"
import MessageBlock from "./message-block"
import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
@@ -16,14 +16,12 @@ 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 {
@@ -42,40 +40,12 @@ export interface MessageSectionProps {
}
export default function MessageSection(props: MessageSectionProps) {
const { preferences, updatePreferences } = useConfig()
const { preferences } = useConfig()
const { t } = useI18n()
const showUsagePreference = () => preferences().showUsageMetrics ?? true
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
const holdLongAssistantRepliesEnabled = () => preferences().holdLongAssistantReplies ?? true
const store = createMemo<InstanceMessageStore>(() => 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,
@@ -159,8 +129,6 @@ 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.
@@ -347,9 +315,15 @@ export default function MessageSection(props: MessageSectionProps) {
}
const lastAssistantIndex = createMemo(() => {
const messageId = lastAssistantMessageId()
if (!messageId) return -1
return messageIndexById().get(messageId) ?? -1
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 [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
@@ -597,10 +571,7 @@ export default function MessageSection(props: MessageSectionProps) {
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>()
// 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 followToken = createMemo(() => `${sessionRevision()}|${preferenceSignature()}`)
const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
@@ -630,35 +601,6 @@ 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
@@ -673,7 +615,7 @@ export default function MessageSection(props: MessageSectionProps) {
const api = listApi()
if (!element || !api) return
if (props.loading) return
if (visibleMessageIds().length === 0) return
if (messageIds().length === 0) return
if (didRestoreScroll()) return
scrollCache.restore(element, {
@@ -792,93 +734,88 @@ export default function MessageSection(props: MessageSectionProps) {
const loading = Boolean(props.loading)
const ids = messageIds()
// 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
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
}
return
}
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
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)
}
previousTimelineIds = [...ids]
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.slice()
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]
})
if (newIds.length > 0) {
newIds.forEach((id) => {
seenTimelineMessageIds.add(id)
appendTimelineForMessage(id)
})
}
previousTimelineIds = ids.slice()
})
function clearPendingTimelinePartUpdateFrame() {
@@ -949,49 +886,36 @@ export default function MessageSection(props: MessageSectionProps) {
createEffect(() => {
if (props.loading) return
const ids = messageIds()
// 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()
const resolvedStore = store()
// 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
let hasChanges = false
for (const messageId of ids) {
const record = resolvedStore.getMessage(messageId)
const partCount = record?.partIds.length ?? 0
const previousCount = timelinePartCountsByMessageId.get(messageId)
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 === undefined) {
timelinePartCountsByMessageId.set(messageId, partCount)
continue
}
// 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)
}
if (previousCount !== partCount) {
timelinePartCountsByMessageId.set(messageId, partCount)
pendingTimelineMessagePartUpdates.add(messageId)
hasChanges = true
}
}
if (hasChanges) {
scheduleTimelinePartUpdateFlush()
// 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()
}
})
createEffect(() => {
@@ -1065,7 +989,7 @@ export default function MessageSection(props: MessageSectionProps) {
data-scroll-buttons={scrollButtonsCount()}
>
<VirtualFollowList
items={visibleMessageIds}
items={messageIds}
getKey={(messageId) => messageId}
getAnchorId={getMessageAnchorId}
getKeyFromAnchorId={getMessageIdFromAnchorId}
@@ -1079,12 +1003,6 @@ 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<HTMLElement>(`.message-item-base[data-message-id="${key}"][data-message-role="assistant"]`))
return candidates[candidates.length - 1] ?? null
}}
onScroll={() => {
clearQuoteSelection()
scrollCache.persist(streamElement())
@@ -1115,55 +1033,9 @@ export default function MessageSection(props: MessageSectionProps) {
scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")}
registerApi={(api) => setListApi(api)}
registerState={(state) => setListState(state)}
renderControls={(state, api) => (
<div class="message-scroll-button-wrapper">
<button
type="button"
class="message-scroll-button"
data-active={holdLongAssistantRepliesEnabled() ? "true" : "false"}
onClick={toggleHoldLongAssistantReplies}
aria-label={
holdLongAssistantRepliesEnabled()
? t("messageSection.scroll.disableHoldAriaLabel")
: t("messageSection.scroll.enableHoldAriaLabel")
}
title={
holdLongAssistantRepliesEnabled()
? t("messageSection.scroll.disableHoldAriaLabel")
: t("messageSection.scroll.enableHoldAriaLabel")
}
>
<Pause class="message-scroll-icon message-scroll-icon--toggle w-4 h-4" aria-hidden="true" />
</button>
<Show when={state.showScrollTopButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => api.scrollToTop()}
aria-label={t("messageSection.scroll.toFirstAriaLabel")}
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
<Show when={state.showScrollBottomButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => api.scrollToBottom()}
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
</div>
)}
renderBeforeItems={() => (
<>
<Show when={!props.loading && visibleMessageIds().length === 0}>
<Show when={!props.loading && messageIds().length === 0}>
<div class="empty-state">
<div class="empty-state-content">
<div class="flex flex-col items-center gap-3 mb-6">

Some files were not shown because too many files have changed in this diff Show More