Compare commits

..

63 Commits

Author SHA1 Message Date
Shantur Rathore
0e755b721c fix(ui): exclude routes from service worker cache
Configure Workbox to precache only static UI assets and ignore HTML documents, preventing route responses like / and /login from being served out of cache.
2026-02-09 01:04:15 +00:00
Shantur Rathore
b244d9f98c Min version 0.10.2 2026-02-09 00:58:28 +00:00
Shantur Rathore
9e3dbc5dfb Bump v0.10.2 2026-02-09 00:57:30 +00:00
Shantur Rathore
4cf980fb97 fix(permissions): reply in originating worktree
Track the worktree slug when permissions are enqueued and send permission replies through a worktree-scoped client so x-opencode-directory matches the originating context.
2026-02-09 00:56:20 +00:00
Shantur Rathore
5bde55f8d4 feat(ui): add session status notifications 2026-02-09 00:42:33 +00:00
Shantur Rathore
0d4a4ccad7 fix(ui): expand launch error modal
Let the 'Unable to launch OpenCode' dialog grow up to 80vh and keep only the error output pane scrollable so longer stderr is visible without cramped nested scrolling.
2026-02-08 21:46:36 +00:00
Shantur Rathore
56a0e8aa6e fix(ui): refresh timeline when parts change
Track per-message part count changes and rebuild timeline segments so deletions or streaming updates don't leave stale entries in the message timeline.
2026-02-08 21:32:35 +00:00
Shantur Rathore
2a5bb6304d fix(ui): keep timeline preview tooltip interactive
Allow pointer interaction with the message preview tooltip and delay hover dismissal so users can move from the timeline segment onto the preview to copy or delete.
2026-02-08 21:06:32 +00:00
Shantur Rathore
322a880a02 fix(dev): avoid localhost dual-stack collisions 2026-02-08 20:44:43 +00:00
Shantur Rathore
ded31078d4 fix(opencode-config): tolerate self-signed HTTPS for plugin bridge 2026-02-08 19:45:27 +00:00
Shantur Rathore
dcbe3475ed chore(proxy): trace upstream requests
Log the exact upstream OpenCode target URL, redacted headers, and JSON body (best-effort for streams) when trace logging is enabled.
2026-02-08 17:54:12 +00:00
Shantur Rathore
338a88fb5a feat(server): add HTTPS with self-signed certs
Default to HTTPS with optional loopback HTTP, generate/rotate self-signed certs via node-forge, and surface Local/Remote connection URLs. Update /api/meta schema, UI remote access overlay, and desktop shells to follow the new startup output.
2026-02-08 15:48:00 +00:00
Shantur Rathore
7eb1551e4b Min server 0.10.2 2026-02-07 23:40:14 +00:00
Shantur Rathore
0414f924e6 Bump version to 0.10.1 2026-02-07 23:39:39 +00:00
Shantur Rathore
9456871271 chore(deps): install tauri keepawake api 2026-02-07 22:58:35 +00:00
Shantur Rathore
5b4edef785 feat(desktop): prevent sleep while instances busy 2026-02-07 22:53:46 +00:00
Shantur Rathore
6b81d0d703 fix(ui): keep command picker highlight in sync 2026-02-07 22:38:17 +00:00
Shantur Rathore
4097637169 fix(ui): preserve question custom input on refocus 2026-02-07 22:08:38 +00:00
Shantur Rathore
9bd66e7297 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-07 21:37:50 +00:00
Shantur Rathore
883b0724e0 Merge pull request #121 from jderehag/dev
feat(ui): add PWA support with vite-plugin-pwa
2026-02-07 21:34:29 +00:00
Shantur Rathore
7b6ed88be4 fix(ui): integrate PWA build and avoid api caching
Move PWA config into the default Vite build, ensure the PWA icon source is generated, and restrict Workbox caching to static assets only. Update server UI build wiring and clarify TLS requirements in docs.
2026-02-07 21:33:14 +00:00
Shantur Rathore
e0bb867948 feat(ui): add enter-to-submit toggle for prompt input 2026-02-07 19:18:39 +00:00
Shantur Rathore
ca28f503b7 chore(ui): refine thinking command palette copy 2026-02-07 18:58:23 +00:00
Shantur Rathore
c83028abc2 feat(ui): label root worktree as workspace
Display the root checkout as 'Workspace' in the worktree selector to avoid confusing 'root' terminology.
2026-02-07 16:17:34 +00:00
Shantur Rathore
60406ca8fb feat(ui): show worktree badge in session list
Render a worktree pill on parent sessions using the session status chip styling, with a distinct icon and selection-aware colors.
2026-02-07 16:15:16 +00:00
Shantur Rathore
e878c3c83b fix(instance-events): unwrap payload-only SSE events
Accept OpenCode SSE chunks shaped as { payload: { type, ... } } even when no directory is present, and attach directory when available to avoid dropping heartbeat events as malformed.
2026-02-07 16:00:28 +00:00
Shantur Rathore
bdd3fe8899 fix(worktrees): prune stale worktree mappings
Fall back to root when a mapped worktree slug is missing and persistently remove missing slugs from the worktree map to prevent proxy 404s.
2026-02-07 15:55:35 +00:00
Shantur Rathore
3cfaf689e7 fix(worktrees): disable selector outside git repos
Expose isGitRepo on worktree listing and show Worktree: Unavailable while disabling the dropdown when a workspace folder is not a Git repository.
2026-02-07 15:23:27 +00:00
Shantur Rathore
b41da03e8a feat(worktrees): refine worktree selector UX 2026-02-07 14:57:34 +00:00
Shantur Rathore
ef14b9acb6 worktrees - Implementation 2026-02-07 11:46:56 +00:00
Jesper Derehag
99474955af feat(ui): add PWA support with vite-plugin-pwa
- Add vite.config.pwa.ts extending the base config with VitePWA plugin
- Generate PWA icons at build time from source logo via @vite-pwa/assets-generator
- Add web app manifest with name, theme color, display overrides
- Add Workbox runtime caching: NetworkFirst for API, CacheFirst for assets
- Set navigateFallback to null to preserve server-side auth redirects
- Server build uses build:pwa for PWA-enabled output; Electron/Tauri use
  the base build without PWA

Signed-off-by: Jesper Derehag <jderehag@hotmail.com>
2026-02-07 00:18:28 +01:00
Shantur Rathore
6f73adaef6 feat(ui): move context usage pills to right drawer header 2026-02-06 10:34:44 +00:00
Shantur Rathore
e2ff758003 feat(ui): add toggleable session search in left drawer 2026-02-06 10:25:37 +00:00
Shantur Rathore
748a99c9c4 fix(ui): split left drawer header into two rows 2026-02-06 10:18:12 +00:00
Shantur Rathore
db2d764cce fix(ui): refine instance drawer layout and controls 2026-02-06 10:10:42 +00:00
Shantur Rathore
157fe9d6b4 feat(ui): switch message actions to icon buttons 2026-02-05 23:42:48 +00:00
Shantur Rathore
6c42b64466 feat(ui): copy tool call header title 2026-02-05 23:30:38 +00:00
Shantur Rathore
88605a4617 feat(ui): add copy option for selected text 2026-02-05 23:20:13 +00:00
Shantur Rathore
e8f8e7bd65 fix(ui): avoid trailing blank line after quote insert 2026-02-05 23:17:22 +00:00
Shantur Rathore
750a87ef45 fix(ui): render task steps from child session 2026-02-05 23:08:59 +00:00
Shantur Rathore
8fda9aed71 fix(ui): focus prompt on session activate 2026-02-04 14:20:50 +00:00
Shantur Rathore
7e1dab8384 fix(electron): stop server process tree on quit 2026-02-04 10:28:51 +00:00
Shantur Rathore
5b24f0cd40 fix(ui): tighten question tool layout
Remove the redundant header row, tighten spacing, and square off question cards. Also adjust answered question container styling to match tool call layout.
2026-02-04 00:34:40 +00:00
Shantur Rathore
a6b1f4ba19 fix(ui): improve question tool contrast
Make question tool prompt, labels, and the type pill use primary text color for readability in light mode, and bump the Q header line to text-sm.
2026-02-04 00:20:19 +00:00
Shantur Rathore
df02b7cdca fix(ui): repair question tool styling
Use token-backed surface/background classes for the question tool cards and ensure radio/checkbox inputs use accent-color so the view renders correctly in both light and dark themes.
2026-02-04 00:14:50 +00:00
Shantur Rathore
06b0d03c31 fix(ui): align stop button icon contrast
Use --text-inverted for stop button icon color in dark mode so it matches send button styling, with a safe fallback in CSS.
2026-02-03 22:22:47 +00:00
Shantur Rathore
fd22a5ed9d fix(ui): restore stop button styling
Avoid color-mix for the stop button danger palette so it renders consistently across runtimes; add safe rgba fallbacks for the background colors.
2026-02-03 22:15:03 +00:00
Shantur Rathore
86db407c0b fix(ui): restore tool call colors in dark mode
Use a dedicated --text-on-accent token for accent chips/checkmarks and tweak task list item surfaces so task/todo renderers keep contrast in dark mode.
2026-02-03 22:09:02 +00:00
Shantur Rathore
f1520be777 Bump version to 0.9.5 2026-02-03 22:01:41 +00:00
Shantur Rathore
8a91e04ff9 Bump to v0.9.4 2026-02-03 20:22:17 +00:00
Shantur Rathore
76b1134c95 fix(ui): apply theme before initial render 2026-02-03 20:12:02 +00:00
Shantur Rathore
d98d519fd3 feat(ui): persist theme preference
Persist system/light/dark theme mode in app config and default new installs to system so the UI follows OS theme unless overridden.
2026-02-03 19:42:24 +00:00
Shantur Rathore
02407e0f7a fix(ui): restore dark tab and tool output styling
Use tokenized border contrast so dark mode borders stay subtle, keep instance tab status dots vivid in dark themes, and adjust tool-call code block header background via a dedicated token.
2026-02-03 19:02:47 +00:00
Shantur Rathore
0261154a5e feat(ui): add delete action for message parts 2026-02-03 18:32:54 +00:00
Shantur Rathore
d2b68159be chore(opencode-config): bump @opencode-ai/plugin 2026-02-03 17:37:02 +00:00
Shantur Rathore
aab0692403 fix(ui): tune light mode contrast 2026-02-03 17:37:02 +00:00
Shantur Rathore
17a3e43ac7 feat(ui): add system/light/dark theme toggle
Add a 3-state theme toggle in folder selection and instance tabs, and update tokens/styles so light mode has readable contrast. Sync MUI surfaces and Shiki highlighting to CSS variables to prevent stale colors when switching themes.
2026-02-03 16:49:42 +00:00
Shantur Rathore
a2127a11ac fix(server): include symlink directories in listings
Fixes https://github.com/NeuralNomadsAI/CodeNomad/issues/106
2026-02-03 15:22:49 +00:00
Shantur Rathore
ea4c687125 chore: add MIT License 2026-02-03 15:08:24 +00:00
Shantur Rathore
de20b3adf3 fix(ui): allow collapsing active parent thread 2026-02-03 15:07:05 +00:00
Shantur Rathore
929e79befd chore(license): add MIT license
Clarifies usage and redistribution terms across the monorepo.
2026-02-02 11:22:49 +00:00
Shantur Rathore
3522d3dff5 fix(electron): quit on last window close 2026-01-31 11:24:56 +00:00
Shantur Rathore
1af01680ee feat(ui): add session sidebar search and bulk selection
Adds an optional session filter bar to the left sidebar with title search across parent/child sessions and a scoped Select All. Introduces multi-select checkboxes, bulk delete with clear selection controls, and confirmation dialogs for both single and bulk deletions using the existing alert dialog flow. Updates session i18n strings across supported locales.
2026-01-30 17:34:25 +00:00
124 changed files with 12085 additions and 1129 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Neural Nomads
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

4677
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.9.3", "version": "0.10.2",
"private": true, "private": true,
"description": "CodeNomad monorepo workspace", "description": "CodeNomad monorepo workspace",
"license": "MIT",
"workspaces": { "workspaces": {
"packages": [ "packages": [
"packages/server", "packages/server",

View File

@@ -1,6 +1,7 @@
{ {
"name": "@codenomad/ui-host-worker", "name": "@codenomad/ui-host-worker",
"private": true, "private": true,
"license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build:manifest": "node ./scripts/build-manifest.mjs", "build:manifest": "node ./scripts/build-manifest.mjs",

View File

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

View File

@@ -1,6 +1,8 @@
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron" import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
import type { CliProcessManager, CliStatus } from "./process-manager" import type { CliProcessManager, CliStatus } from "./process-manager"
let wakeLockId: number | null = null
interface DialogOpenRequest { interface DialogOpenRequest {
mode: "directory" | "file" mode: "directory" | "file"
title?: string title?: string
@@ -62,4 +64,50 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return { canceled: result.canceled, paths: result.filePaths } return { canceled: result.canceled, paths: result.filePaths }
}) })
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
const next = Boolean(enabled)
if (next) {
if (wakeLockId !== null && powerSaveBlocker.isStarted(wakeLockId)) {
return { enabled: true }
}
try {
wakeLockId = powerSaveBlocker.start("prevent-display-sleep")
} catch {
wakeLockId = null
return { enabled: false }
}
return { enabled: true }
}
if (wakeLockId !== null) {
try {
if (powerSaveBlocker.isStarted(wakeLockId)) {
powerSaveBlocker.stop(wakeLockId)
}
} finally {
wakeLockId = null
}
}
return { enabled: false }
})
ipcMain.handle(
"notifications:show",
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
if (!Notification.isSupported()) {
return { ok: false, reason: "unsupported" }
}
const title = typeof payload?.title === "string" ? payload.title : "CodeNomad"
const body = typeof payload?.body === "string" ? payload.body : ""
try {
const notification = new Notification({ title, body })
notification.show()
return { ok: true }
} catch (error) {
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
}
},
)
} }

View File

@@ -473,6 +473,14 @@ if (isMac) {
} }
app.whenReady().then(() => { app.whenReady().then(() => {
// Required for Windows notifications / taskbar grouping.
// Keep in sync with desktop app identifier.
try {
app.setAppUserModelId("ai.neuralnomads.codenomad.client")
} catch {
// ignore
}
startCli() startCli()
if (isMac) { if (isMac) {
@@ -505,7 +513,6 @@ app.on("before-quit", async (event) => {
}) })
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
if (process.platform !== "darwin") { // CodeNomad supports a single window; closing it should quit the app on all platforms.
app.quit() app.quit()
}
}) })

View File

@@ -1,4 +1,4 @@
import { spawn, type ChildProcess } from "child_process" import { spawn, spawnSync, type ChildProcess } from "child_process"
import { app } from "electron" import { app } from "electron"
import { createRequire } from "module" import { createRequire } from "module"
import { EventEmitter } from "events" import { EventEmitter } from "events"
@@ -82,6 +82,7 @@ export class CliProcessManager extends EventEmitter {
private stdoutBuffer = "" private stdoutBuffer = ""
private stderrBuffer = "" private stderrBuffer = ""
private bootstrapToken: string | null = null private bootstrapToken: string | null = null
private requestedStop = false
async start(options: StartOptions): Promise<CliStatus> { async start(options: StartOptions): Promise<CliStatus> {
if (this.child) { if (this.child) {
@@ -91,6 +92,7 @@ export class CliProcessManager extends EventEmitter {
this.stdoutBuffer = "" this.stdoutBuffer = ""
this.stderrBuffer = "" this.stderrBuffer = ""
this.bootstrapToken = null this.bootstrapToken = null
this.requestedStop = false
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined }) this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
const cliEntry = this.resolveCliEntry(options) const cliEntry = this.resolveCliEntry(options)
@@ -109,11 +111,13 @@ export class CliProcessManager extends EventEmitter {
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`) ? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args) : this.buildDirectSpawn(cliEntry, args)
const detached = process.platform !== "win32"
const child = spawn(spawnDetails.command, spawnDetails.args, { const child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(), cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env, env,
shell: false, shell: false,
detached,
}) })
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`) console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
@@ -175,12 +179,89 @@ export class CliProcessManager extends EventEmitter {
return return
} }
this.requestedStop = true
const pid = child.pid
if (!pid) {
this.child = undefined
this.updateStatus({ state: "stopped" })
return
}
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
try {
// Negative PID targets the process group (POSIX).
process.kill(-pid, signal)
return true
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err?.code === "ESRCH") {
return true
}
return false
}
}
const tryKillSinglePid = (signal: NodeJS.Signals) => {
try {
process.kill(pid, signal)
return true
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err?.code === "ESRCH") {
return true
}
return false
}
}
const tryTaskkill = (force: boolean) => {
const args = ["/PID", String(pid), "/T"]
if (force) {
args.push("/F")
}
try {
const result = spawnSync("taskkill", args, { encoding: "utf8" })
const exitCode = result.status
if (exitCode === 0) {
return true
}
// If the PID is already gone, treat it as success.
const stderr = (result.stderr ?? "").toString().toLowerCase()
const stdout = (result.stdout ?? "").toString().toLowerCase()
const combined = `${stdout}\n${stderr}`
if (combined.includes("not found") || combined.includes("no running instance")) {
return true
}
return false
} catch {
return false
}
}
const sendStopSignal = (signal: NodeJS.Signals) => {
if (process.platform === "win32") {
tryTaskkill(signal === "SIGKILL")
return
}
// Prefer process-group signaling so wrapper launchers (shell/tsx) don't outlive Electron.
const groupOk = tryKillPosixGroup(signal)
if (!groupOk) {
tryKillSinglePid(signal)
}
}
return new Promise((resolve) => { return new Promise((resolve) => {
const killTimeout = setTimeout(() => { const killTimeout = setTimeout(() => {
console.warn( console.warn(
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`, `[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
) )
child.kill("SIGKILL") sendStopSignal("SIGKILL")
}, 30000) }, 30000)
child.on("exit", () => { child.on("exit", () => {
@@ -191,7 +272,15 @@ export class CliProcessManager extends EventEmitter {
resolve() resolve()
}) })
child.kill("SIGTERM") if (isAlreadyExited()) {
clearTimeout(killTimeout)
this.child = undefined
this.updateStatus({ state: "stopped" })
resolve()
return
}
sendStopSignal("SIGTERM")
}) })
} }
@@ -205,7 +294,16 @@ export class CliProcessManager extends EventEmitter {
private handleTimeout() { private handleTimeout() {
if (this.child) { if (this.child) {
this.child.kill("SIGKILL") const pid = this.child.pid
if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGKILL")
} catch {
this.child.kill("SIGKILL")
}
} else {
this.child.kill("SIGKILL")
}
this.child = undefined this.child = undefined
} }
this.updateStatus({ state: "error", error: "CLI did not start in time" }) this.updateStatus({ state: "error", error: "CLI did not start in time" })
@@ -249,38 +347,27 @@ export class CliProcessManager extends EventEmitter {
console.info(`[cli][${stream}] ${trimmed}`) console.info(`[cli][${stream}] ${trimmed}`)
this.emit("log", { stream, message: trimmed }) this.emit("log", { stream, message: trimmed })
const port = this.extractPort(trimmed) const localUrl = this.extractLocalUrl(trimmed)
if (port && this.status.state === "starting") { if (localUrl && this.status.state === "starting") {
const url = `http://127.0.0.1:${port}` let port: number | undefined
console.info(`[cli] ready on ${url}`) try {
this.updateStatus({ state: "ready", port, url }) port = Number(new URL(localUrl).port) || undefined
} catch {
port = undefined
}
console.info(`[cli] ready on ${localUrl}`)
this.updateStatus({ state: "ready", port, url: localUrl })
this.emit("ready", this.status) this.emit("ready", this.status)
} }
} }
} }
private extractPort(line: string): number | null { private extractLocalUrl(line: string): string | null {
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i) const match = line.match(/^Local\s+Connection\s+URL\s*:\s*(https?:\/\/\S+)\s*$/i)
if (readyMatch) { if (!match) {
return parseInt(readyMatch[1], 10) return null
} }
return match[1] ?? null
if (line.toLowerCase().includes("http server listening")) {
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
if (httpMatch) {
return parseInt(httpMatch[1], 10)
}
try {
const parsed = JSON.parse(line)
if (typeof parsed.port === "number") {
return parsed.port
}
} catch {
// not JSON, ignore
}
}
return null
} }
private updateStatus(patch: Partial<CliStatus>) { private updateStatus(patch: Partial<CliStatus>) {
@@ -289,7 +376,18 @@ export class CliProcessManager extends EventEmitter {
} }
private buildCliArgs(options: StartOptions, host: string): string[] { private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--port", "0", "--generate-token"] const args = ["serve", "--host", host, "--generate-token"]
if (options.dev) {
// Dev: run plain HTTP + Vite dev server proxy.
args.push("--https", "false", "--http", "true")
// Avoid collisions with an already-running server (and dual-stack ::/0.0.0.0 quirks)
// by forcing an ephemeral port in dev.
args.push("--http-port", "0")
} else {
// Prod desktop: always keep loopback HTTP enabled.
args.push("--https", "true", "--http", "true")
}
if (options.dev) { if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug") args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")

View File

@@ -12,6 +12,8 @@ const electronAPI = {
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"), getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"), restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options), openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
} }
contextBridge.exposeInMainWorld("electronAPI", electronAPI) contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -1,7 +1,8 @@
{ {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.9.3", "version": "0.10.2",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"license": "MIT",
"author": { "author": {
"name": "Neural Nomads", "name": "Neural Nomads",
"email": "codenomad@neuralnomads.ai" "email": "codenomad@neuralnomads.ai"

View File

@@ -2,7 +2,8 @@
"name": "@codenomad/opencode-config", "name": "@codenomad/opencode-config",
"version": "0.5.0", "version": "0.5.0",
"private": true, "private": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.1.36" "@opencode-ai/plugin": "1.1.53"
} }
} }

View File

@@ -1,3 +1,7 @@
import http from "http"
import https from "https"
import { Readable } from "stream"
export type PluginEvent = { export type PluginEvent = {
type: string type: string
properties?: Record<string, unknown> properties?: Record<string, unknown>
@@ -16,7 +20,8 @@ export function getCodeNomadConfig(): CodeNomadConfig {
} }
export function createCodeNomadRequester(config: CodeNomadConfig) { export function createCodeNomadRequester(config: CodeNomadConfig) {
const baseUrl = config.baseUrl.replace(/\/+$/, "") const rawBaseUrl = (config.baseUrl ?? "").trim()
const baseUrl = rawBaseUrl.replace(/\/+$/, "")
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin` const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
const authorization = buildInstanceAuthorizationHeader() const authorization = buildInstanceAuthorizationHeader()
@@ -42,10 +47,10 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
const hasBody = init?.body !== undefined const hasBody = init?.body !== undefined
const headers = buildHeaders(init?.headers, hasBody) const headers = buildHeaders(init?.headers, hasBody)
return fetch(url, { // The CodeNomad plugin only talks to the local CodeNomad server.
...init, // Use a single request implementation that tolerates custom/self-signed certs
headers, // without disabling TLS verification for the whole Node process.
}) return nodeFetch(url, { ...init, headers }, { rejectUnauthorized: false })
} }
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => { const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
@@ -87,6 +92,91 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
} }
} }
async function nodeFetch(
url: string,
init: RequestInit & { headers?: Record<string, string> },
tls: { rejectUnauthorized: boolean },
): Promise<Response> {
const parsed = new URL(url)
const isHttps = parsed.protocol === "https:"
const requestFn = isHttps ? https.request : http.request
const method = (init.method ?? "GET").toUpperCase()
const headers = init.headers ?? {}
const body = init.body
return await new Promise<Response>((resolve, reject) => {
const req = requestFn(
{
protocol: parsed.protocol,
hostname: parsed.hostname,
port: parsed.port ? Number(parsed.port) : undefined,
path: `${parsed.pathname}${parsed.search}`,
method,
headers,
...(isHttps ? { rejectUnauthorized: tls.rejectUnauthorized } : {}),
},
(res) => {
const responseHeaders = new Headers()
for (const [key, value] of Object.entries(res.headers)) {
if (value === undefined) continue
if (Array.isArray(value)) {
responseHeaders.set(key, value.join(", "))
} else {
responseHeaders.set(key, String(value))
}
}
// Convert Node stream -> Web ReadableStream for Response.
const webBody = Readable.toWeb(res) as unknown as ReadableStream<Uint8Array>
resolve(new Response(webBody, { status: res.statusCode ?? 0, headers: responseHeaders }))
},
)
const signal = init.signal
const abort = () => {
const err = new Error("Request aborted")
;(err as any).name = "AbortError"
req.destroy(err)
reject(err)
}
if (signal) {
if (signal.aborted) {
abort()
return
}
signal.addEventListener("abort", abort, { once: true })
req.once("close", () => signal.removeEventListener("abort", abort))
}
req.once("error", reject)
if (body === undefined || body === null) {
req.end()
return
}
if (typeof body === "string") {
req.end(body)
return
}
if (body instanceof Uint8Array) {
req.end(Buffer.from(body))
return
}
if (body instanceof ArrayBuffer) {
req.end(Buffer.from(new Uint8Array(body)))
return
}
// Fallback for less common BodyInit types.
req.end(String(body))
})
}
function requireEnv(key: string): string { function requireEnv(key: string): string {
const value = process.env[key] const value = process.env[key]
if (!value || !value.trim()) { if (!value || !value.trim()) {

View File

@@ -31,6 +31,11 @@ You can run CodeNomad directly without installing it:
npx @neuralnomads/codenomad --launch npx @neuralnomads/codenomad --launch
``` ```
On startup, CodeNomad prints two URLs:
- `Local Connection URL : ...` (used by desktop shells)
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
### Install Globally ### Install Globally
Or install it globally to use the `codenomad` command: Or install it globally to use the `codenomad` command:
@@ -44,7 +49,14 @@ You can configure the server using flags or environment variables:
| Flag | Env Variable | Description | | Flag | Env Variable | Description |
|------|--------------|-------------| |------|--------------|-------------|
| `--port <number>` | `CLI_PORT` | HTTP port (default 9898) | | `--https <enabled>` | `CLI_HTTPS` | Enable HTTPS listener (default `true`) |
| `--http <enabled>` | `CLI_HTTP` | Enable HTTP listener (default `false`) |
| `--https-port <number>` | `CLI_HTTPS_PORT` | HTTPS port (default `9898`, use `0` for auto) |
| `--http-port <number>` | `CLI_HTTP_PORT` | HTTP port (default `9899`, use `0` for auto) |
| `--tls-key <path>` | `CLI_TLS_KEY` | TLS private key (PEM). Requires `--tls-cert`. |
| `--tls-cert <path>` | `CLI_TLS_CERT` | TLS certificate (PEM). Requires `--tls-key`. |
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) | | `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces | | `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing | | `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
@@ -56,12 +68,59 @@ You can configure the server using flags or environment variables:
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows | | `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) | | `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
### HTTP vs HTTPS
- Default: `--https=true --http=false` (HTTPS only).
- To run plain HTTP only (useful for development):
```sh
codenomad --https=false --http=true
```
- To run both HTTPS (for remote) and HTTP loopback (for desktop):
```sh
codenomad --https=true --http=true
```
### Remote Access Binding Rules
- When remote access is enabled (bind host is non-loopback, e.g. `--host 0.0.0.0`):
- HTTP listens on `127.0.0.1` only.
- HTTPS listens on `--host` (LAN/all interfaces).
- When remote access is disabled (bind host is loopback, e.g. `--host 127.0.0.1`):
- Both HTTP and HTTPS listen on `127.0.0.1`.
### Self-Signed Certificates
If `--https=true` and you do not provide `--tls-key/--tls-cert`, CodeNomad generates a local certificate automatically under your config directory:
- `~/.config/codenomad/tls/ca-cert.pem`
- `~/.config/codenomad/tls/server-cert.pem`
Certificates are valid for about 30 days and rotate automatically on startup when needed. You can add extra SANs via:
```sh
codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
```
### Authentication ### Authentication
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser. - Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated. - `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.). Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API. If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
### Progressive Web App (PWA)
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
2. Click the install icon in the address bar, or use the browser menu → "Install CodeNomad".
3. The app will open in a standalone window and appear in your OS app list.
> **TLS requirement**
> Browsers require a secure (`https://`) connection for PWA installation.
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
### Data Storage ### Data Storage
- **Config**: `~/.config/codenomad/config.json` - **Config**: `~/.config/codenomad/config.json`
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.) - **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)

View File

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

View File

@@ -1,7 +1,8 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.9.3", "version": "0.10.2",
"description": "CodeNomad Server", "description": "CodeNomad Server",
"license": "MIT",
"author": { "author": {
"name": "Neural Nomads", "name": "Neural Nomads",
"email": "codenomad@neuralnomads.ai" "email": "codenomad@neuralnomads.ai"
@@ -20,7 +21,7 @@
"build:ui": "npm run build --prefix ../ui", "build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs", "prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"prepare-config": "node ./scripts/copy-opencode-config.mjs", "prepare-config": "node ./scripts/copy-opencode-config.mjs",
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts", "dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 CLI_HTTPS=false CLI_HTTP=true tsx src/index.ts",
"typecheck": "tsc --noEmit -p tsconfig.json" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
@@ -30,12 +31,14 @@
"commander": "^12.1.0", "commander": "^12.1.0",
"fastify": "^4.28.1", "fastify": "^4.28.1",
"fuzzysort": "^2.0.4", "fuzzysort": "^2.0.4",
"node-forge": "^1.3.3",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^6.19.8",
"yauzl": "^2.10.0", "yauzl": "^2.10.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/node-forge": "^1.3.14",
"@types/yauzl": "^2.10.0", "@types/yauzl": "^2.10.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",

View File

@@ -50,6 +50,38 @@ export interface WorkspaceDeleteResponse {
status: WorkspaceStatus status: WorkspaceStatus
} }
export type WorktreeKind = "root" | "worktree"
export interface WorktreeDescriptor {
/** Stable identifier used by CodeNomad + clients ("root" for repo root). */
slug: string
/** Absolute directory path on the server host. */
directory: string
kind: WorktreeKind
/** Optional VCS branch name when available. */
branch?: string
}
export interface WorktreeListResponse {
worktrees: WorktreeDescriptor[]
/** True when the workspace folder resolves to a Git repository. */
isGitRepo?: boolean
}
export interface WorktreeCreateRequest {
slug: string
/** Optional branch name (defaults to slug). */
branch?: string
}
export interface WorktreeMap {
version: 1
/** Default worktree to use for new sessions and as fallback. */
defaultWorktreeSlug: string
/** Mapping of *parent* session IDs to a worktree slug. */
parentSessionWorktreeSlug: Record<string, string>
}
export type LogLevel = "debug" | "info" | "warn" | "error" export type LogLevel = "debug" | "info" | "warn" | "error"
export interface WorkspaceLogEntry { export interface WorkspaceLogEntry {
@@ -204,7 +236,8 @@ export interface NetworkAddress {
ip: string ip: string
family: "ipv4" | "ipv6" family: "ipv4" | "ipv6"
scope: "external" | "internal" | "loopback" scope: "external" | "internal" | "loopback"
url: string /** Remote URL using the server's remote protocol/port for this IP. */
remoteUrl: string
} }
export interface LatestReleaseInfo { export interface LatestReleaseInfo {
@@ -230,16 +263,20 @@ export interface SupportMeta {
} }
export interface ServerMeta { export interface ServerMeta {
/** Base URL clients should target for REST calls (useful for Electron embedding). */ /** URL desktop apps should use to connect (prefers loopback HTTP when enabled). */
httpBaseUrl: string localUrl: string
/** URL remote clients should use (prefers HTTPS when enabled). */
remoteUrl?: string
/** SSE endpoint advertised to clients (`/api/events` by default). */ /** SSE endpoint advertised to clients (`/api/events` by default). */
eventsUrl: string eventsUrl: string
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */ /** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
host: string host: string
/** Listening mode derived from host binding. */ /** Listening mode derived from host binding. */
listeningMode: "local" | "all" listeningMode: "local" | "all"
/** Actual port in use after binding. */ /** Actual local port in use after binding. */
port: number localPort: number
/** Actual remote port in use after binding (when remoteUrl is set). */
remotePort?: number
/** Display label for the host (e.g., hostname or friendly name). */ /** Display label for the host (e.g., hostname or friendly name). */
hostLabel: string hostLabel: string
/** Absolute path of the filesystem root exposed to clients. */ /** Absolute path of the filesystem root exposed to clients. */

View File

@@ -119,10 +119,18 @@ export class AuthManager {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId)) reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
} }
setSessionCookieWithOptions(reply: FastifyReply, sessionId: string, options?: { secure?: boolean }) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId, options))
}
clearSessionCookie(reply: FastifyReply) { clearSessionCookie(reply: FastifyReply) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 })) reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
} }
clearSessionCookieWithOptions(reply: FastifyReply, options?: { secure?: boolean }) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0, ...options }))
}
private requireAuthStore(): AuthStore { private requireAuthStore(): AuthStore {
if (!this.authStore) { if (!this.authStore) {
throw new Error("Auth store is unavailable") throw new Error("Auth store is unavailable")
@@ -143,8 +151,11 @@ function resolvePath(filePath: string) {
return path.resolve(filePath) return path.resolve(filePath)
} }
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number }) { function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number; secure?: boolean }) {
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"] const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
if (options?.secure) {
parts.push("Secure")
}
if (options?.maxAgeSeconds !== undefined) { if (options?.maxAgeSeconds !== undefined) {
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`) parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
} }

View File

@@ -12,6 +12,7 @@ const PreferencesSchema = z.object({
showThinkingBlocks: z.boolean().default(false), showThinkingBlocks: z.boolean().default(false),
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showTimelineTools: z.boolean().default(true), showTimelineTools: z.boolean().default(true),
promptSubmitOnEnter: z.boolean().default(false),
lastUsedBinary: z.string().optional(), lastUsedBinary: z.string().optional(),
locale: z.string().optional(), locale: z.string().optional(),
environmentVariables: z.record(z.string()).default({}), environmentVariables: z.record(z.string()).default({}),
@@ -24,6 +25,12 @@ const PreferencesSchema = z.object({
showUsageMetrics: z.boolean().default(true), showUsageMetrics: z.boolean().default(true),
autoCleanupBlankSessions: z.boolean().default(true), autoCleanupBlankSessions: z.boolean().default(true),
listeningMode: z.enum(["local", "all"]).default("local"), listeningMode: z.enum(["local", "all"]).default("local"),
// OS notifications
osNotificationsEnabled: z.boolean().default(false),
osNotificationsAllowWhenVisible: z.boolean().default(false),
notifyOnNeedsInput: z.boolean().default(true),
notifyOnIdle: z.boolean().default(true),
}) })
const RecentFolderSchema = z.object({ const RecentFolderSchema = z.object({

View File

@@ -222,20 +222,18 @@ export class FileSystemBrowser {
const results: FileSystemEntry[] = [] const results: FileSystemEntry[] = []
for (const entry of dirents) { for (const entry of dirents) {
if (!options.includeFiles && !entry.isDirectory()) {
continue
}
const absoluteEntryPath = path.join(directory, entry.name) const absoluteEntryPath = path.join(directory, entry.name)
let stats: fs.Stats let stats: fs.Stats
try { try {
// Use fs.statSync (not Dirent.isDirectory) so symlinks to directories
// are treated as directories in directory-only listings.
stats = fs.statSync(absoluteEntryPath) stats = fs.statSync(absoluteEntryPath)
} catch { } catch {
// Skip entries we cannot stat (insufficient permissions, etc.) // Skip entries we cannot stat (insufficient permissions, etc.)
continue continue
} }
const isDirectory = entry.isDirectory() const isDirectory = stats.isDirectory()
if (!options.includeFiles && !isDirectory) { if (!options.includeFiles && !isDirectory) {
continue continue
} }

View File

@@ -19,6 +19,8 @@ import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher" import { launchInBrowser } from "./launcher"
import { resolveUi } from "./ui/remote-ui" import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses } from "./server/network-addresses"
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
@@ -28,8 +30,15 @@ const __dirname = path.dirname(__filename)
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public") const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
interface CliOptions { interface CliOptions {
port: number
host: string host: string
https: boolean
http: boolean
httpsPort: number
httpPort: number
tlsKeyPath?: string
tlsCertPath?: string
tlsCaPath?: string
tlsSANs?: string
rootDir: string rootDir: string
configPath: string configPath: string
unrestrictedRoot: boolean unrestrictedRoot: boolean
@@ -47,9 +56,10 @@ interface CliOptions {
dangerouslySkipAuth: boolean dangerouslySkipAuth: boolean
} }
const DEFAULT_PORT = 9898
const DEFAULT_HOST = "127.0.0.1" const DEFAULT_HOST = "127.0.0.1"
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
const DEFAULT_HTTPS_PORT = 9898
const DEFAULT_HTTP_PORT = 9899
function parseCliOptions(argv: string[]): CliOptions { function parseCliOptions(argv: string[]): CliOptions {
const program = new Command() const program = new Command()
@@ -57,7 +67,14 @@ function parseCliOptions(argv: string[]): CliOptions {
.description("CodeNomad CLI server") .description("CodeNomad CLI server")
.version(packageJson.version, "-v, --version", "Show the CLI version") .version(packageJson.version, "-v, --version", "Show the CLI version")
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST)) .addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
.addOption(new Option("--port <number>", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort)) .addOption(new Option("--https <enabled>", "Enable HTTPS listener (true|false)").env("CLI_HTTPS").default("true"))
.addOption(new Option("--http <enabled>", "Enable HTTP listener (true|false)").env("CLI_HTTP").default("false"))
.addOption(new Option("--https-port <number>", "HTTPS port (0 for auto)").env("CLI_HTTPS_PORT").default(DEFAULT_HTTPS_PORT).argParser(parsePort))
.addOption(new Option("--http-port <number>", "HTTP port (0 for auto)").env("CLI_HTTP_PORT").default(DEFAULT_HTTP_PORT).argParser(parsePort))
.addOption(new Option("--tls-key <path>", "TLS private key (PEM)").env("CLI_TLS_KEY"))
.addOption(new Option("--tls-cert <path>", "TLS certificate (PEM)").env("CLI_TLS_CERT"))
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
.addOption( .addOption(
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()), new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
) )
@@ -97,7 +114,14 @@ function parseCliOptions(argv: string[]): CliOptions {
program.parse(argv, { from: "user" }) program.parse(argv, { from: "user" })
const parsed = program.opts<{ const parsed = program.opts<{
host: string host: string
port: number https?: string
http?: string
httpsPort: number
httpPort: number
tlsKey?: string
tlsCert?: string
tlsCa?: string
tlsSANs?: string
workspaceRoot?: string workspaceRoot?: string
root?: string root?: string
unrestrictedRoot?: boolean unrestrictedRoot?: boolean
@@ -128,9 +152,23 @@ function parseCliOptions(argv: string[]): CliOptions {
const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase() const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase()
const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes" const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes"
const httpsEnabled = parseBooleanEnv(parsed.https)
const httpEnabled = parseBooleanEnv(parsed.http)
if (!httpsEnabled && !httpEnabled) {
throw new InvalidArgumentError("At least one listener must be enabled (--https or --http)")
}
return { return {
port: parsed.port,
host: normalizedHost, host: normalizedHost,
https: httpsEnabled,
http: httpEnabled,
httpsPort: parsed.httpsPort,
httpPort: parsed.httpPort,
tlsKeyPath: parsed.tlsKey,
tlsCertPath: parsed.tlsCert,
tlsCaPath: parsed.tlsCa,
tlsSANs: parsed.tlsSANs,
rootDir: resolvedRoot, rootDir: resolvedRoot,
configPath: parsed.config, configPath: parsed.config,
unrestrictedRoot: Boolean(parsed.unrestrictedRoot), unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
@@ -172,6 +210,13 @@ function resolveHost(input: string | undefined): string {
return trimmed return trimmed
} }
function resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
function programHasArg(argv: string[], flag: string): boolean { function programHasArg(argv: string[], flag: string): boolean {
return argv.includes(flag) return argv.includes(flag)
} }
@@ -200,12 +245,20 @@ async function main() {
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.") const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
const configDir = path.dirname(resolvePath(options.configPath))
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
}
const serverMeta: ServerMeta = { const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`, localUrl: "http://localhost:0",
remoteUrl: undefined,
eventsUrl: `/api/events`, eventsUrl: `/api/events`,
host: options.host, host: options.host,
listeningMode: isLoopbackHost(options.host) ? "local" : "all", listeningMode: isLoopbackHost(options.host) ? "local" : "all",
port: options.port, localPort: 0,
remotePort: undefined,
hostLabel: options.host, hostLabel: options.host,
workspaceRoot: options.rootDir, workspaceRoot: options.rootDir,
addresses: [], addresses: [],
@@ -229,6 +282,19 @@ async function main() {
} }
} }
const tlsResolution = resolveHttpsOptions({
enabled: options.https,
configDir,
host: options.host,
tlsKeyPath: options.tlsKeyPath,
tlsCertPath: options.tlsCertPath,
tlsCaPath: options.tlsCaPath,
tlsSANs: options.tlsSANs,
logger: logger.child({ component: "tls" }),
})
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
const configStore = new ConfigStore(options.configPath, eventBus, configLogger) const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger) const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({ const workspaceManager = new WorkspaceManager({
@@ -237,7 +303,8 @@ async function main() {
binaryRegistry, binaryRegistry,
eventBus, eventBus,
logger: workspaceLogger, logger: workspaceLogger,
getServerBaseUrl: () => serverMeta.httpBaseUrl, getServerBaseUrl: () => serverMeta.localUrl,
nodeExtraCaCertsPath,
}) })
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore() const instanceStore = new InstanceStore()
@@ -277,28 +344,125 @@ async function main() {
minServerVersion: uiResolution.minServerVersion, minServerVersion: uiResolution.minServerVersion,
} }
const server = createHttpServer({ if (uiResolution.uiDevServerUrl && options.https) {
host: options.host, throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
port: options.port, }
workspaceManager,
configStore,
binaryRegistry,
fileSystemBrowser,
eventBus,
serverMeta,
instanceStore,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
})
const startInfo = await server.start() const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening")
console.log(`CodeNomad Server is ready at ${startInfo.url}`) const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
const httpsBindPort = httpsPortExplicit ? options.httpsPort : 0
const httpBindPort = httpPortExplicit ? options.httpPort : 0
// Listener binding rules:
// - Remote access enabled: HTTP listens on loopback, HTTPS on all IPs (host=0.0.0.0 / LAN IP).
// - Remote access disabled: both listen on loopback.
// - HTTP-only mode: respect --host (used for dev/testing).
const httpsBindHost = remoteAccessEnabled ? options.host : "127.0.0.1"
const httpBindHost = options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1"
const servers: Array<ReturnType<typeof createHttpServer>> = []
const httpServer = options.http
? createHttpServer({
bindHost: httpBindHost,
bindPort: httpBindPort,
defaultPort: options.httpPort,
protocol: "http",
workspaceManager,
configStore,
binaryRegistry,
fileSystemBrowser,
eventBus,
serverMeta,
instanceStore,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
})
: null
const httpsServer = options.https
? createHttpServer({
bindHost: httpsBindHost,
bindPort: httpsBindPort,
defaultPort: options.httpsPort,
protocol: "https",
httpsOptions: tlsResolution?.httpsOptions,
workspaceManager,
configStore,
binaryRegistry,
fileSystemBrowser,
eventBus,
serverMeta,
instanceStore,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined,
logger,
})
: null
if (httpServer) servers.push(httpServer)
if (httpsServer) servers.push(httpsServer)
const [httpStart, httpsStart] = await Promise.all([
httpServer ? httpServer.start() : Promise.resolve(null),
httpsServer ? httpsServer.start() : Promise.resolve(null),
])
const localStart = httpStart ?? httpsStart
if (!localStart) {
throw new Error("No listeners started")
}
const remoteStart = httpsStart ?? httpStart
const localProtocol: "http" | "https" = httpStart ? "http" : "https"
const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http"
// Use an explicit IPv4 loopback address for the "local" URL.
// On macOS, `localhost` often resolves to ::1 first, and it is possible to have
// another instance bound on IPv6 while this instance binds IPv4 (or vice versa),
// which can lead clients to talk to the wrong process.
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
let remoteUrl: string | undefined
if (remoteStart) {
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
let remoteHost = options.host
if (wantsAll) {
if (options.host === "0.0.0.0") {
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
}
} else {
remoteHost = "localhost"
}
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
}
serverMeta.localUrl = localUrl
serverMeta.localPort = localStart.port
serverMeta.remoteUrl = remoteUrl
serverMeta.remotePort = remoteStart?.port
serverMeta.host = options.host
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
if (serverMeta.remotePort && remoteUrl) {
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
} else {
serverMeta.addresses = []
}
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
if (serverMeta.remoteUrl) {
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
}
if (options.launch) { if (options.launch) {
await launchInBrowser(startInfo.url, logger.child({ component: "launcher" })) await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" }))
} }
let shuttingDown = false let shuttingDown = false
@@ -328,8 +492,8 @@ async function main() {
const shutdownHttp = (async () => { const shutdownHttp = (async () => {
try { try {
await server.stop() await Promise.allSettled(servers.map((srv) => srv.stop()))
logger.info("HTTP server stopped") logger.info("HTTP server(s) stopped")
} catch (error) { } catch (error) {
logger.error({ err: error }, "Failed to stop HTTP server") logger.error({ err: error }, "Failed to stop HTTP server")
} }

View File

@@ -7,6 +7,7 @@ import path from "path"
import { fetch } from "undici" import { fetch } from "undici"
import type { Logger } from "../logger" import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager" import { WorkspaceManager } from "../workspaces/manager"
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
import { ConfigStore } from "../config/store" import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries" import { BinaryRegistry } from "../config/binaries"
@@ -20,6 +21,7 @@ import { registerEventRoutes } from "./routes/events"
import { registerStorageRoutes } from "./routes/storage" import { registerStorageRoutes } from "./routes/storage"
import { registerPluginRoutes } from "./routes/plugin" import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes" import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees"
import { ServerMeta } from "../api-types" import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store" import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager" import { BackgroundProcessManager } from "../background-processes/manager"
@@ -28,8 +30,12 @@ import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth" import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
interface HttpServerDeps { interface HttpServerDeps {
host: string bindHost: string
port: number bindPort: number
/** When bindPort is 0, try this first. */
defaultPort: number
protocol: "http" | "https"
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
workspaceManager: WorkspaceManager workspaceManager: WorkspaceManager
configStore: ConfigStore configStore: ConfigStore
binaryRegistry: BinaryRegistry binaryRegistry: BinaryRegistry
@@ -49,10 +55,15 @@ interface HttpServerStartResult {
displayHost: string displayHost: string
} }
const DEFAULT_HTTP_PORT = 9898
export function createHttpServer(deps: HttpServerDeps) { export function createHttpServer(deps: HttpServerDeps) {
const app = Fastify({ logger: false }) // Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS.
// We keep the runtime behavior correct and cast the instance to a generic FastifyInstance.
const app = Fastify(
({
logger: false,
...(deps.protocol === "https" && deps.httpsOptions ? { https: deps.httpsOptions } : {}),
} as unknown) as any,
) as unknown as FastifyInstance
const proxyLogger = deps.logger.child({ component: "proxy" }) const proxyLogger = deps.logger.child({ component: "proxy" })
const apiLogger = deps.logger.child({ component: "http" }) const apiLogger = deps.logger.child({ component: "http" })
const sseLogger = deps.logger.child({ component: "sse" }) const sseLogger = deps.logger.child({ component: "sse" })
@@ -95,6 +106,27 @@ export function createHttpServer(deps: HttpServerDeps) {
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"]) const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.") const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
const getSelfOrigins = (): Set<string> => {
const origins = new Set<string>()
const candidates: Array<string | undefined> = [deps.serverMeta.localUrl, deps.serverMeta.remoteUrl]
for (const candidate of candidates) {
if (!candidate) continue
try {
origins.add(new URL(candidate).origin)
} catch {
// ignore
}
}
for (const addr of deps.serverMeta.addresses ?? []) {
try {
origins.add(new URL(addr.remoteUrl).origin)
} catch {
// ignore
}
}
return origins
}
app.register(cors, { app.register(cors, {
origin: (origin, cb) => { origin: (origin, cb) => {
if (!origin) { if (!origin) {
@@ -102,14 +134,8 @@ export function createHttpServer(deps: HttpServerDeps) {
return return
} }
let selfOrigin: string | null = null const selfOrigins = getSelfOrigins()
try { if (selfOrigins.has(origin)) {
selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin
} catch {
selfOrigin = null
}
if (selfOrigin && origin === selfOrigin) {
cb(null, true) cb(null, true)
return return
} }
@@ -120,7 +146,7 @@ export function createHttpServer(deps: HttpServerDeps) {
} }
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access. // When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
if (deps.host === "0.0.0.0" || !isLoopbackHost(deps.host)) { if (deps.bindHost === "0.0.0.0" || !isLoopbackHost(deps.bindHost)) {
cb(null, true) cb(null, true)
return return
} }
@@ -222,6 +248,7 @@ export function createHttpServer(deps: HttpServerDeps) {
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger }) registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
registerStorageRoutes(app, { registerStorageRoutes(app, {
instanceStore: deps.instanceStore, instanceStore: deps.instanceStore,
eventBus: deps.eventBus, eventBus: deps.eventBus,
@@ -242,12 +269,12 @@ export function createHttpServer(deps: HttpServerDeps) {
instance: app, instance: app,
start: async (): Promise<HttpServerStartResult> => { start: async (): Promise<HttpServerStartResult> => {
const attemptListen = async (requestedPort: number) => { const attemptListen = async (requestedPort: number) => {
const addressInfo = await app.listen({ port: requestedPort, host: deps.host }) const addressInfo = await app.listen({ port: requestedPort, host: deps.bindHost })
return { addressInfo, requestedPort } return { addressInfo, requestedPort }
} }
const autoPortRequested = deps.port === 0 const autoPortRequested = deps.bindPort === 0
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port const primaryPort = autoPortRequested ? deps.defaultPort : deps.bindPort
const shouldRetryWithEphemeral = (error: unknown) => { const shouldRetryWithEphemeral = (error: unknown) => {
if (!autoPortRequested) return false if (!autoPortRequested) return false
@@ -283,15 +310,10 @@ export function createHttpServer(deps: HttpServerDeps) {
} }
} }
const displayHost = deps.host === "127.0.0.1" ? "localhost" : deps.host const displayHost = deps.bindHost === "127.0.0.1" ? "localhost" : deps.bindHost
const serverUrl = `http://${displayHost}:${actualPort}` const serverUrl = `${deps.protocol}://${displayHost}:${actualPort}`
deps.serverMeta.httpBaseUrl = serverUrl deps.logger.info({ port: actualPort, host: deps.bindHost, protocol: deps.protocol }, "HTTP server listening")
deps.serverMeta.host = deps.host
deps.serverMeta.port = actualPort
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" || !isLoopbackHost(deps.host) ? "all" : "local"
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
console.log(`CodeNomad Server is ready at ${serverUrl}`)
return { port: actualPort, url: serverUrl, displayHost } return { port: actualPort, url: serverUrl, displayHost }
}, },
@@ -312,31 +334,36 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
instance.removeAllContentTypeParsers() instance.removeAllContentTypeParsers()
instance.addContentTypeParser("*", (req, body, done) => done(null, body)) instance.addContentTypeParser("*", (req, body, done) => done(null, body))
const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const proxyBaseHandler = async (
await proxyWorkspaceRequest({ request: FastifyRequest<{ Params: { id: string; slug: string } }>,
request,
reply,
workspaceManager: deps.workspaceManager,
pathSuffix: "",
logger: deps.logger,
})
}
const proxyWildcardHandler = async (
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
reply: FastifyReply, reply: FastifyReply,
) => { ) => {
await proxyWorkspaceRequest({ await proxyWorkspaceRequest({
request, request,
reply, reply,
workspaceManager: deps.workspaceManager, workspaceManager: deps.workspaceManager,
worktreeSlug: request.params.slug,
pathSuffix: "",
logger: deps.logger,
})
}
const proxyWildcardHandler = async (
request: FastifyRequest<{ Params: { id: string; slug: string; "*": string } }>,
reply: FastifyReply,
) => {
await proxyWorkspaceRequest({
request,
reply,
workspaceManager: deps.workspaceManager,
worktreeSlug: request.params.slug,
pathSuffix: request.params["*"] ?? "", pathSuffix: request.params["*"] ?? "",
logger: deps.logger, logger: deps.logger,
}) })
} }
instance.all("/workspaces/:id/instance", proxyBaseHandler) instance.all("/workspaces/:id/worktrees/:slug/instance", proxyBaseHandler)
instance.all("/workspaces/:id/instance/*", proxyWildcardHandler) instance.all("/workspaces/:id/worktrees/:slug/instance/*", proxyWildcardHandler)
}) })
} }
@@ -347,12 +374,75 @@ async function proxyWorkspaceRequest(args: {
reply: FastifyReply reply: FastifyReply
workspaceManager: WorkspaceManager workspaceManager: WorkspaceManager
logger: Logger logger: Logger
worktreeSlug: string
pathSuffix?: string pathSuffix?: string
}) { }) {
const { request, reply, workspaceManager, logger } = args const { request, reply, workspaceManager, logger, worktreeSlug } = args
const workspaceId = (request.params as { id: string }).id const workspaceId = (request.params as { id: string }).id
const workspace = workspaceManager.get(workspaceId) const workspace = workspaceManager.get(workspaceId)
const bodyToJson = (body: unknown): unknown => {
if (body == null) return null
const anyBody = body as any
if (anyBody && typeof anyBody.pipe === "function") {
// Don't consume streams (would break proxying).
// Best-effort: if the stream already has buffered chunks, parse those.
try {
const buffered = anyBody?._readableState?.buffer
if (Array.isArray(buffered) && buffered.length > 0) {
const chunks: Buffer[] = []
for (const entry of buffered) {
if (!entry) continue
if (Buffer.isBuffer(entry)) {
chunks.push(entry)
continue
}
const data = (entry as any).data
if (Buffer.isBuffer(data)) {
chunks.push(data)
}
}
if (chunks.length > 0) {
const text = Buffer.concat(chunks).toString("utf-8")
try {
return JSON.parse(text)
} catch {
return { __raw: text }
}
}
}
} catch {
// fall through
}
return { __stream: true }
}
const maybeParse = (input: string): unknown => {
try {
return JSON.parse(input)
} catch {
return { __raw: input }
}
}
if (Buffer.isBuffer(body)) {
return maybeParse(body.toString("utf-8"))
}
if (typeof body === "string") {
return maybeParse(body)
}
if (typeof body === "object") {
return body
}
return body
}
if (!workspace) { if (!workspace) {
reply.code(404).send({ error: "Workspace not found" }) reply.code(404).send({ error: "Workspace not found" })
return return
@@ -364,6 +454,23 @@ async function proxyWorkspaceRequest(args: {
return return
} }
if (!isValidWorktreeSlug(worktreeSlug)) {
reply.code(400).send({ error: "Invalid worktree slug" })
return
}
const directory = await resolveWorktreeDirectory({
workspaceId,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
return
}
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix) const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
const queryIndex = (request.raw.url ?? "").indexOf("?") const queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
@@ -381,15 +488,42 @@ async function proxyWorkspaceRequest(args: {
headers.authorization = instanceAuthHeader headers.authorization = instanceAuthHeader
} }
// Enforce per-workspace directory scoping for all proxied OpenCode requests.
// OpenCode expects the *full* path; we send it via header to avoid query tampering. // OpenCode expects the *full* path; we send it via header to avoid query tampering.
const directory = workspace.path
const isNonASCII = /[^\x00-\x7F]/.test(directory) const isNonASCII = /[^\x00-\x7F]/.test(directory)
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
// Overwrite any client-provided value (case-insensitive headers are normalized by Node). // Overwrite any client-provided value (case-insensitive headers are normalized by Node).
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory ;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
if (logger.isLevelEnabled("trace")) {
const outgoing: Record<string, unknown> = {}
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
outgoing[key] = value
}
// Redact sensitive headers.
for (const key of Object.keys(outgoing)) {
const lower = key.toLowerCase()
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
outgoing[key] = "<redacted>"
}
}
logger.trace(
{
workspaceId,
method: request.method,
targetUrl,
worktreeSlug,
directory,
contentType: request.headers["content-type"],
body: bodyToJson(request.body),
headers: outgoing,
},
"Proxy -> OpenCode request",
)
}
return headers return headers
}, },
onError: (proxyReply, { error }) => { onError: (proxyReply, { error }) => {
@@ -409,6 +543,52 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
return trimmed.length === 0 ? "/" : `/${trimmed}` 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) { function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
if (!uiDir) { if (!uiDir) {
app.log.warn("UI static directory not provided; API endpoints only") app.log.warn("UI static directory not provided; API endpoints only")

View File

@@ -0,0 +1,75 @@
import os from "os"
import type { NetworkAddress } from "../api-types"
export function resolveNetworkAddresses(args: {
host: string
protocol: "http" | "https"
port: number
}): NetworkAddress[] {
const { host, protocol, port } = args
const interfaces = os.networkInterfaces()
const seen = new Set<string>()
const results: NetworkAddress[] = []
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
if (!ip || ip === "0.0.0.0") return
const key = `ipv4-${ip}`
if (seen.has(key)) return
seen.add(key)
results.push({ ip, family: "ipv4", scope, remoteUrl: `${protocol}://${ip}:${port}` })
}
const normalizeFamily = (value: string | number) => {
if (typeof value === "string") {
const lowered = value.toLowerCase()
if (lowered === "ipv4") {
return "ipv4" as const
}
}
if (value === 4) return "ipv4" as const
return null
}
if (host === "0.0.0.0") {
// Enumerate system interfaces (IPv4 only)
for (const entries of Object.values(interfaces)) {
if (!entries) continue
for (const entry of entries) {
const family = normalizeFamily(entry.family)
if (!family) continue
if (!entry.address || entry.address === "0.0.0.0") continue
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
addAddress(entry.address, scope)
}
}
}
// Always include loopback address
addAddress("127.0.0.1", "loopback")
// Include explicitly configured host if it was IPv4
if (isIPv4Address(host) && host !== "0.0.0.0") {
const isLoopback = host.startsWith("127.")
addAddress(host, isLoopback ? "loopback" : "external")
}
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
return results.sort((a, b) => {
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
if (scopeDelta !== 0) return scopeDelta
return a.ip.localeCompare(b.ip)
})
}
function isIPv4Address(value: string | undefined): value is string {
if (!value) return false
const parts = value.split(".")
if (parts.length !== 4) return false
return parts.every((part) => {
if (part.length === 0 || part.length > 3) return false
if (!/^[0-9]+$/.test(part)) return false
const num = Number(part)
return Number.isInteger(num) && num >= 0 && num <= 255
})
}

View File

@@ -88,7 +88,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
} }
const session = deps.authManager.createSession(body.username) const session = deps.authManager.createSession(body.username)
deps.authManager.setSessionCookie(reply, session.id) deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) })
reply.send({ ok: true }) reply.send({ ok: true })
}) })
@@ -112,12 +112,12 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
const username = deps.authManager.getStatus().username const username = deps.authManager.getStatus().username
const session = deps.authManager.createSession(username) const session = deps.authManager.createSession(username)
deps.authManager.setSessionCookie(reply, session.id) deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) })
reply.send({ ok: true }) reply.send({ ok: true })
}) })
app.post("/api/auth/logout", async (_request, reply) => { app.post("/api/auth/logout", async (request, reply) => {
deps.authManager.clearSessionCookie(reply) deps.authManager.clearSessionCookieWithOptions(reply, { secure: isSecureRequest(request) })
reply.send({ ok: true }) reply.send({ ok: true })
}) })
@@ -139,6 +139,13 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
}) })
} }
function isSecureRequest(request: any) {
if (request.protocol === "https") {
return true
}
return Boolean(request.raw?.socket && request.raw.socket.encrypted)
}
function escapeHtml(value: string) { function escapeHtml(value: string) {
return value.replace(/[&<>"]/g, (char) => { return value.replace(/[&<>"]/g, (char) => {
switch (char) { switch (char) {

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import os from "os" import { ServerMeta } from "../../api-types"
import { NetworkAddress, ServerMeta } from "../../api-types" import { resolveNetworkAddresses } from "../network-addresses"
interface RouteDeps { interface RouteDeps {
serverMeta: ServerMeta serverMeta: ServerMeta
@@ -11,23 +11,25 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
} }
function buildMetaResponse(meta: ServerMeta): ServerMeta { function buildMetaResponse(meta: ServerMeta): ServerMeta {
const port = resolvePort(meta) const localPort = resolveLocalPort(meta)
const addresses = port > 0 ? resolveAddresses(port, meta.host) : [] const remote = resolveRemote(meta)
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
return { return {
...meta, ...meta,
port, localPort,
remotePort: remote?.port,
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local", listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
addresses, addresses,
} }
} }
function resolvePort(meta: ServerMeta): number { function resolveLocalPort(meta: ServerMeta): number {
if (Number.isInteger(meta.port) && meta.port > 0) { if (Number.isInteger(meta.localPort) && meta.localPort > 0) {
return meta.port return meta.localPort
} }
try { try {
const parsed = new URL(meta.httpBaseUrl) const parsed = new URL(meta.localUrl)
const port = Number(parsed.port) const port = Number(parsed.port)
return Number.isInteger(port) && port > 0 ? port : 0 return Number.isInteger(port) && port > 0 ? port : 0
} catch { } catch {
@@ -35,74 +37,22 @@ function resolvePort(meta: ServerMeta): number {
} }
} }
function resolveRemote(meta: ServerMeta): { protocol: "http" | "https"; port: number } | null {
if (!meta.remoteUrl) {
return null
}
try {
const parsed = new URL(meta.remoteUrl)
const protocol = parsed.protocol === "https:" ? "https" : "http"
const port = Number(parsed.port)
return { protocol, port: Number.isInteger(port) && port > 0 ? port : 0 }
} catch {
return null
}
}
function isLoopbackHost(host: string): boolean { function isLoopbackHost(host: string): boolean {
return host === "127.0.0.1" || host === "::1" || host.startsWith("127.") return host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
} }
function resolveAddresses(port: number, host: string): NetworkAddress[] { // NetworkAddress shape is resolved in ../network-addresses
const interfaces = os.networkInterfaces()
const seen = new Set<string>()
const results: NetworkAddress[] = []
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
if (!ip || ip === "0.0.0.0") return
const key = `ipv4-${ip}`
if (seen.has(key)) return
seen.add(key)
results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${port}` })
}
const normalizeFamily = (value: string | number) => {
if (typeof value === "string") {
const lowered = value.toLowerCase()
if (lowered === "ipv4") {
return "ipv4" as const
}
}
if (value === 4) return "ipv4" as const
return null
}
if (host === "0.0.0.0") {
// Enumerate system interfaces (IPv4 only)
for (const entries of Object.values(interfaces)) {
if (!entries) continue
for (const entry of entries) {
const family = normalizeFamily(entry.family)
if (!family) continue
if (!entry.address || entry.address === "0.0.0.0") continue
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
addAddress(entry.address, scope)
}
}
}
// Always include loopback address
addAddress("127.0.0.1", "loopback")
// Include explicitly configured host if it was IPv4
if (isIPv4Address(host) && host !== "0.0.0.0") {
const isLoopback = host.startsWith("127.")
addAddress(host, isLoopback ? "loopback" : "external")
}
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
return results.sort((a, b) => {
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
if (scopeDelta !== 0) return scopeDelta
return a.ip.localeCompare(b.ip)
})
}
function isIPv4Address(value: string | undefined): value is string {
if (!value) return false
const parts = value.split(".")
if (parts.length !== 4) return false
return parts.every((part) => {
if (part.length === 0 || part.length > 3) return false
if (!/^[0-9]+$/.test(part)) return false
const num = Number(part)
return Number.isInteger(num) && num >= 0 && num <= 255
})
}

View File

@@ -0,0 +1,195 @@
import type { FastifyInstance, FastifyReply } from "fastify"
import { z } from "zod"
import { WorkspaceManager } from "../../workspaces/manager"
import {
resolveRepoRoot,
listWorktrees,
isValidWorktreeSlug,
createManagedWorktree,
removeWorktree,
} from "../../workspaces/git-worktrees"
import type { WorktreeListResponse, WorktreeMap } from "../../api-types"
import { ensureCodenomadGitExclude, readWorktreeMap, writeWorktreeMap } from "../../workspaces/worktree-map"
interface RouteDeps {
workspaceManager: WorkspaceManager
}
const WorktreeMapSchema = z.object({
version: z.literal(1),
defaultWorktreeSlug: z.string().min(1).default("root"),
parentSessionWorktreeSlug: z.record(z.string(), z.string()).default({}),
})
const WorktreeCreateSchema = z.object({
slug: z.string().trim().min(1),
branch: z.string().trim().min(1).optional(),
})
export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
const response: WorktreeListResponse = { worktrees, isGitRepo }
return response
})
app.post<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
try {
const body = WorktreeCreateSchema.parse(request.body ?? {})
const slug = body.slug
if (!isValidWorktreeSlug(slug) || slug === "root") {
reply.code(400)
return { error: "Invalid worktree slug" }
}
if (body.branch) {
if (!isValidWorktreeSlug(body.branch) || body.branch === "root") {
reply.code(400)
return { error: "Invalid worktree branch" }
}
if (body.branch !== slug) {
reply.code(400)
return { error: "Branch must match slug" }
}
}
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
if (!isGitRepo) {
reply.code(400)
return { error: "Workspace is not a Git repository" }
}
await ensureCodenomadGitExclude(workspace.path, request.log).catch(() => undefined)
const created = await createManagedWorktree({
repoRoot,
workspaceFolder: workspace.path,
slug,
logger: request.log,
})
reply.code(201)
return created
} catch (error) {
return handleError(error, reply)
}
})
app.delete<{ Params: { id: string; slug: string }; Querystring: { force?: string } }>(
"/api/workspaces/:id/worktrees/:slug",
async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const slug = (request.params.slug ?? "").trim()
if (!isValidWorktreeSlug(slug) || slug === "root") {
reply.code(400)
return { error: "Invalid worktree slug" }
}
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
if (!isGitRepo) {
reply.code(400)
return { error: "Workspace is not a Git repository" }
}
const force = (request.query?.force ?? "").toString().toLowerCase() === "true"
try {
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
const match = worktrees.find((wt) => wt.slug === slug)
if (!match || match.kind === "root") {
reply.code(404)
return { error: "Worktree not found" }
}
await removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log })
// Best-effort: prune any mappings that point at the deleted worktree.
const current = await readWorktreeMap(workspace.path, request.log)
let changed = false
const nextMapping: Record<string, string> = { ...(current.parentSessionWorktreeSlug ?? {}) }
for (const [sessionId, mapped] of Object.entries(nextMapping)) {
if (mapped === slug) {
delete nextMapping[sessionId]
changed = true
}
}
const nextDefault = current.defaultWorktreeSlug === slug ? "root" : current.defaultWorktreeSlug
if (nextDefault !== current.defaultWorktreeSlug) {
changed = true
}
if (changed) {
await writeWorktreeMap(
workspace.path,
{
version: 1,
defaultWorktreeSlug: nextDefault,
parentSessionWorktreeSlug: nextMapping,
},
request.log,
)
}
reply.code(204)
} catch (error) {
return handleError(error, reply)
}
},
)
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
return await readWorktreeMap(workspace.path, request.log)
})
app.put<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
try {
const parsed = WorktreeMapSchema.parse(request.body ?? {}) as WorktreeMap
if (!isValidWorktreeSlug(parsed.defaultWorktreeSlug)) {
reply.code(400)
return { error: "Invalid defaultWorktreeSlug" }
}
for (const slug of Object.values(parsed.parentSessionWorktreeSlug ?? {})) {
if (!isValidWorktreeSlug(slug)) {
reply.code(400)
return { error: "Invalid worktree slug in mapping" }
}
}
await writeWorktreeMap(workspace.path, parsed, request.log)
reply.code(204)
} catch (error) {
return handleError(error, reply)
}
})
}
function handleError(error: unknown, reply: FastifyReply) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Unable to fulfill request" }
}

View File

@@ -0,0 +1,283 @@
import crypto from "crypto"
import fs from "fs"
import path from "path"
import { createRequire } from "module"
import type { Logger } from "../logger"
const require = createRequire(import.meta.url)
type Forge = typeof import("node-forge")
function loadForge(): Forge {
// node-forge is CJS in many installs; require keeps this compatible with our ESM output.
return require("node-forge") as Forge
}
export interface ResolvedHttpsOptions {
httpsOptions: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
/** Path to CA certificate suitable for NODE_EXTRA_CA_CERTS. */
caCertPath?: string
mode: "provided" | "generated"
}
export interface ResolveHttpsOptionsArgs {
enabled: boolean
configDir: string
host: string
tlsKeyPath?: string
tlsCertPath?: string
tlsCaPath?: string
tlsSANs?: string
logger: Logger
}
const LEAF_VALIDITY_DAYS = 30
const ROTATE_IF_EXPIRES_WITHIN_DAYS = 3
const CA_VALIDITY_DAYS = 365
export function resolveHttpsOptions(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions | null {
if (!args.enabled) {
return null
}
const hasProvided = Boolean(args.tlsKeyPath && args.tlsCertPath)
if (hasProvided) {
const key = fs.readFileSync(args.tlsKeyPath!, "utf-8")
const cert = fs.readFileSync(args.tlsCertPath!, "utf-8")
const ca = args.tlsCaPath ? fs.readFileSync(args.tlsCaPath, "utf-8") : undefined
return {
httpsOptions: { key, cert, ca },
caCertPath: args.tlsCaPath,
mode: "provided",
}
}
return ensureGeneratedTls(args)
}
function ensureGeneratedTls(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions {
const tlsDir = path.join(args.configDir, "tls")
const caKeyPath = path.join(tlsDir, "ca-key.pem")
const caCertPath = path.join(tlsDir, "ca-cert.pem")
const keyPath = path.join(tlsDir, "server-key.pem")
const certPath = path.join(tlsDir, "server-cert.pem")
fs.mkdirSync(tlsDir, { recursive: true })
const shouldRotateLeaf = () => {
try {
if (!fs.existsSync(certPath)) return true
const pem = fs.readFileSync(certPath, "utf-8")
const x509 = new crypto.X509Certificate(pem)
const validToMs = Date.parse(x509.validTo)
if (!Number.isFinite(validToMs)) return true
const rotateAt = validToMs - ROTATE_IF_EXPIRES_WITHIN_DAYS * 24 * 60 * 60 * 1000
return Date.now() >= rotateAt
} catch {
return true
}
}
const shouldRotateCa = () => {
try {
if (!fs.existsSync(caCertPath)) return true
const pem = fs.readFileSync(caCertPath, "utf-8")
const x509 = new crypto.X509Certificate(pem)
const validToMs = Date.parse(x509.validTo)
if (!Number.isFinite(validToMs)) return true
// CA rotates only when expired.
return Date.now() >= validToMs
} catch {
return true
}
}
if (shouldRotateCa() || !fs.existsSync(caKeyPath)) {
const { caKeyPem, caCertPem } = generateCaCertificate()
writePemFile(caKeyPath, caKeyPem, 0o600)
writePemFile(caCertPath, caCertPem, 0o644)
args.logger.info({ caCertPath }, "Generated self-signed CodeNomad CA certificate")
}
if (shouldRotateLeaf() || !fs.existsSync(keyPath)) {
const caKeyPem = fs.readFileSync(caKeyPath, "utf-8")
const caCertPem = fs.readFileSync(caCertPath, "utf-8")
const { keyPem, certPem } = generateServerCertificate({
host: args.host,
tlsSANs: args.tlsSANs,
caKeyPem,
caCertPem,
})
writePemFile(keyPath, keyPem, 0o600)
writePemFile(certPath, certPem, 0o644)
args.logger.info({ certPath }, "Generated CodeNomad HTTPS certificate")
}
const key = fs.readFileSync(keyPath, "utf-8")
const cert = fs.readFileSync(certPath, "utf-8")
const ca = fs.readFileSync(caCertPath, "utf-8")
// Present the CA as part of the chain.
const chainedCert = `${cert.trim()}\n${ca.trim()}\n`
return {
httpsOptions: {
key,
cert: chainedCert,
},
caCertPath,
mode: "generated",
}
}
function writePemFile(filePath: string, content: string, mode: number) {
fs.writeFileSync(filePath, content, { encoding: "utf-8", mode })
try {
fs.chmodSync(filePath, mode)
} catch {
// best effort on platforms that ignore chmod
}
}
function generateCaCertificate(): { caKeyPem: string; caCertPem: string } {
const forge = loadForge()
const keys = forge.pki.rsa.generateKeyPair(2048)
const cert = forge.pki.createCertificate()
cert.publicKey = keys.publicKey
cert.serialNumber = crypto.randomBytes(16).toString("hex")
const now = new Date()
const notBefore = new Date(now.getTime() - 60_000)
const notAfter = new Date(now.getTime() + CA_VALIDITY_DAYS * 24 * 60 * 60 * 1000)
cert.validity.notBefore = notBefore
cert.validity.notAfter = notAfter
const attrs = [{ name: "commonName", value: "CodeNomad Local CA" }]
cert.setSubject(attrs)
cert.setIssuer(attrs)
cert.setExtensions([
{ name: "basicConstraints", cA: true },
{ name: "keyUsage", keyCertSign: true, cRLSign: true, digitalSignature: true },
{ name: "subjectKeyIdentifier" },
])
cert.sign(keys.privateKey, forge.md.sha256.create())
return {
caKeyPem: forge.pki.privateKeyToPem(keys.privateKey),
caCertPem: forge.pki.certificateToPem(cert),
}
}
function generateServerCertificate(args: {
host: string
tlsSANs?: string
caKeyPem: string
caCertPem: string
}): { keyPem: string; certPem: string } {
const forge = loadForge()
const caKey = forge.pki.privateKeyFromPem(args.caKeyPem)
const caCert = forge.pki.certificateFromPem(args.caCertPem)
const keys = forge.pki.rsa.generateKeyPair(2048)
const cert = forge.pki.createCertificate()
cert.publicKey = keys.publicKey
cert.serialNumber = crypto.randomBytes(16).toString("hex")
const now = new Date()
const notBefore = new Date(now.getTime() - 60_000)
const notAfter = new Date(now.getTime() + LEAF_VALIDITY_DAYS * 24 * 60 * 60 * 1000)
cert.validity.notBefore = notBefore
cert.validity.notAfter = notAfter
const commonName = pickCommonName(args.host)
cert.setSubject([{ name: "commonName", value: commonName }])
cert.setIssuer(caCert.subject.attributes)
const san = buildSubjectAltNames(args.host, args.tlsSANs)
cert.setExtensions([
{ name: "basicConstraints", cA: false },
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true },
{ name: "extKeyUsage", serverAuth: true },
{ name: "subjectAltName", altNames: san },
{ name: "subjectKeyIdentifier" },
])
cert.sign(caKey, forge.md.sha256.create())
return {
keyPem: forge.pki.privateKeyToPem(keys.privateKey),
certPem: forge.pki.certificateToPem(cert),
}
}
function pickCommonName(host: string): string {
if (!host || host === "0.0.0.0") {
return "localhost"
}
if (host === "127.0.0.1") {
return "localhost"
}
return host
}
function buildSubjectAltNames(host: string, tlsSANs?: string): Array<{ type: number; value?: string; ip?: string }> {
const dns = new Set<string>()
const ips = new Set<string>()
dns.add("localhost")
ips.add("127.0.0.1")
if (host && host !== "0.0.0.0") {
if (isIPv4(host)) {
ips.add(host)
} else {
dns.add(host)
}
}
for (const token of splitList(tlsSANs)) {
if (isIPv4(token)) {
ips.add(token)
} else if (token) {
dns.add(token)
}
}
const altNames: Array<{ type: number; value?: string; ip?: string }> = []
// 2 = DNS, 7 = IP
for (const name of Array.from(dns)) {
altNames.push({ type: 2, value: name })
}
for (const ip of Array.from(ips)) {
altNames.push({ type: 7, ip })
}
return altNames
}
function splitList(input: string | undefined): string[] {
if (!input) return []
return input
.split(",")
.map((part) => part.trim())
.filter(Boolean)
}
function isIPv4(value: string): boolean {
const parts = value.split(".")
if (parts.length !== 4) return false
return parts.every((part) => {
if (!/^[0-9]+$/.test(part)) return false
const num = Number(part)
return Number.isInteger(num) && num >= 0 && num <= 255
})
}

View File

@@ -0,0 +1,241 @@
import path from "path"
import { spawn } from "child_process"
import type { WorktreeDescriptor } from "../api-types"
import { promises as fsp } from "fs"
export interface LogLike {
debug?: (obj: any, msg?: string) => void
warn?: (obj: any, msg?: string) => void
}
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
function runGit(args: string[], cwd: string): Promise<GitResult> {
return new Promise((resolve) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
let stdout = ""
let stderr = ""
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString()
})
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString()
})
child.once("error", (error) => {
resolve({ ok: false, error, stdout, stderr })
})
child.once("close", (code) => {
if (code === 0) {
resolve({ ok: true, stdout })
} else {
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
resolve({ ok: false, error, stdout, stderr })
}
})
})
}
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
if (!result.ok) {
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
return { repoRoot: folder, isGitRepo: false }
}
const repoRoot = result.stdout.trim()
if (!repoRoot) {
return { repoRoot: folder, isGitRepo: false }
}
return { repoRoot, isGitRepo: true }
}
function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> {
const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
const lines = output.split(/\r?\n/)
let current: { worktree?: string; branch?: string; head?: string; detached?: boolean } = {}
const flush = () => {
if (current.worktree) {
records.push({ worktree: current.worktree, branch: current.branch })
}
current = {}
}
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) {
flush()
continue
}
const [key, ...rest] = trimmed.split(" ")
const value = rest.join(" ").trim()
if (key === "worktree") {
current.worktree = value
} else if (key === "branch") {
// branch is like refs/heads/foo
current.branch = value.replace(/^refs\/heads\//, "")
} else if (key === "HEAD") {
current.head = value
} else if (key === "detached") {
current.detached = true
}
}
flush()
return records
}
export async function listWorktrees(params: {
repoRoot: string
workspaceFolder: string
logger?: LogLike
}): Promise<WorktreeDescriptor[]> {
const { repoRoot, workspaceFolder, logger } = params
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
if (!result.ok) {
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
return [rootDescriptor]
}
const records = parseWorktreePorcelain(result.stdout)
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
const seen = new Set<string>(["root"])
const normalizeSlug = (record: { branch?: string; head?: string; detached?: boolean; worktree: string }): string => {
const branch = (record.branch ?? "").trim()
if (branch) {
return branch
}
const head = (record.head ?? "").trim()
if (head && /^[0-9a-f]{7,40}$/i.test(head)) {
return `detached-${head.slice(0, 7)}`
}
// Fallback: stable-ish identifier derived from directory basename.
const base = path.basename(record.worktree || "")
return base ? `worktree-${base}` : "worktree"
}
for (const record of records) {
const abs = record.worktree
if (!abs || typeof abs !== "string") continue
// Skip the root record (we always expose it as slug="root").
if (path.resolve(abs) === path.resolve(repoRoot)) {
continue
}
const slug = normalizeSlug(record)
if (!slug || slug === "root") {
continue
}
if (seen.has(slug)) {
continue
}
seen.add(slug)
worktrees.push({ slug, directory: abs, kind: "worktree", branch: record.branch })
}
return worktrees
}
export function isValidWorktreeSlug(slug: string): boolean {
if (!slug) return false
const trimmed = slug.trim()
if (!trimmed) return false
if (trimmed.length > 200) return false
// Disallow control characters; allow branch-like slugs including '/'.
if (/[\x00-\x1F\x7F]/.test(trimmed)) return false
return true
}
export async function createManagedWorktree(params: {
repoRoot: string
workspaceFolder: string
slug: string
logger?: LogLike
}): Promise<{ slug: string; directory: string; branch?: string }> {
const { repoRoot, workspaceFolder, logger } = params
const branch = params.slug.trim()
if (!branch || branch === "root" || !isValidWorktreeSlug(branch)) {
throw new Error("Invalid worktree slug")
}
const sanitizeDirName = (input: string): string => {
const normalized = input
.trim()
.replace(/[\\/]+/g, "-")
.replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "")
return normalized || "worktree"
}
const worktreesDir = path.join(repoRoot, ".codenomad", "worktrees")
const targetDir = path.join(worktreesDir, sanitizeDirName(branch))
await fsp.mkdir(worktreesDir, { recursive: true })
try {
const stat = await fsp.stat(targetDir)
if (stat.isDirectory()) {
throw new Error("Worktree directory already exists")
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== "ENOENT") {
throw error
}
}
logger?.debug?.({ slug: branch, branch, targetDir }, "Creating managed git worktree")
// Prefer creating a new branch from HEAD.
const first = await runGit(["worktree", "add", "-b", branch, targetDir, "HEAD"], workspaceFolder)
if (first.ok) {
return { slug: branch, directory: targetDir, branch }
}
const message = first.stderr?.toLowerCase() ?? first.error.message.toLowerCase()
if (message.includes("already exists")) {
// If the branch already exists, add worktree for that branch.
const second = await runGit(["worktree", "add", targetDir, branch], workspaceFolder)
if (second.ok) {
return { slug: branch, directory: targetDir, branch }
}
throw second.error
}
throw first.error
}
export async function removeWorktree(params: {
workspaceFolder: string
directory: string
force?: boolean
logger?: LogLike
}): Promise<void> {
const { workspaceFolder, logger } = params
const directory = (params.directory ?? "").trim()
if (!directory) {
throw new Error("Invalid worktree directory")
}
logger?.debug?.({ directory, force: Boolean(params.force) }, "Removing git worktree")
const args = ["worktree", "remove"]
if (params.force) {
args.push("--force")
}
args.push(directory)
const result = await runGit(args, workspaceFolder)
if (!result.ok) {
throw result.error
}
// Best-effort cleanup of stale metadata.
await runGit(["worktree", "prune"], workspaceFolder).catch(() => undefined)
}

View File

@@ -95,7 +95,7 @@ export class InstanceEventBridge {
} }
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) { private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
const url = `http://${INSTANCE_HOST}:${port}/event` const url = `http://${INSTANCE_HOST}:${port}/global/event`
const headers: Record<string, string> = { Accept: "text/event-stream" } const headers: Record<string, string> = { Accept: "text/event-stream" }
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId) const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
@@ -165,8 +165,32 @@ export class InstanceEventBridge {
} }
try { try {
const event = JSON.parse(payload) as InstanceStreamEvent const parsed = JSON.parse(payload) as any
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received") if (!parsed || typeof parsed !== "object") {
this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event")
return
}
// OpenCode SSE payload shapes vary across versions.
// Common variants:
// - { type, properties, ... }
// - { payload: { type, properties, ... }, directory: "/abs/path" }
// - { payload: { type, properties, ... } }
const base = parsed.payload && typeof parsed.payload === "object" ? parsed.payload : parsed
const event: InstanceStreamEvent | null = base && typeof base === "object" ? ({ ...base } as any) : null
// Attach directory when available (don't overwrite if already present).
if (event && !(event as any).directory && typeof (parsed as any).directory === "string") {
;(event as any).directory = (parsed as any).directory
}
if (!event || typeof (event as any).type !== "string") {
this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event")
return
}
this.options.logger.debug({ workspaceId, eventType: (event as any).type }, "Instance SSE event received")
if (this.options.logger.isLevelEnabled("trace")) { if (this.options.logger.isLevelEnabled("trace")) {
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload") this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
} }

View File

@@ -28,6 +28,8 @@ interface WorkspaceManagerOptions {
eventBus: EventBus eventBus: EventBus
logger: Logger logger: Logger
getServerBaseUrl: () => string getServerBaseUrl: () => string
/** Optional CA bundle path to trust CodeNomad HTTPS certs. */
nodeExtraCaCertsPath?: string
} }
interface WorkspaceRecord extends WorkspaceDescriptor {} interface WorkspaceRecord extends WorkspaceDescriptor {}
@@ -91,7 +93,7 @@ export class WorkspaceManager {
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace") this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
const proxyPath = `/workspaces/${id}/instance` const proxyPath = `/workspaces/${id}/worktrees/root/instance`
const descriptor: WorkspaceRecord = { const descriptor: WorkspaceRecord = {
@@ -132,6 +134,7 @@ export class WorkspaceManager {
OPENCODE_CONFIG_DIR: this.opencodeConfigDir, OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
CODENOMAD_INSTANCE_ID: id, CODENOMAD_INSTANCE_ID: id,
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(), CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}),
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername, [OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword, [OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
} }

View File

@@ -0,0 +1,129 @@
import fs from "fs"
import { promises as fsp } from "fs"
import path from "path"
import type { WorktreeMap } from "../api-types"
import { resolveRepoRoot } from "./git-worktrees"
import type { LogLike } from "./git-worktrees"
const DEFAULT_MAP: WorktreeMap = {
version: 1,
defaultWorktreeSlug: "root",
parentSessionWorktreeSlug: {},
}
function getMapPath(repoRoot: string): string {
return path.join(repoRoot, ".codenomad", "worktreeMap.json")
}
function getGitExcludePath(repoRoot: string): string {
return path.join(repoRoot, ".git", "info", "exclude")
}
async function ensureGitExclude(repoRoot: string, logger?: LogLike): Promise<void> {
const excludePath = getGitExcludePath(repoRoot)
try {
await fsp.mkdir(path.dirname(excludePath), { recursive: true })
} catch {
return
}
const entries = [
".codenomad/worktrees/",
".codenomad/worktreeMap.json",
]
let existing = ""
try {
existing = await fsp.readFile(excludePath, "utf-8")
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== "ENOENT") {
logger?.debug?.({ err: error, excludePath }, "Failed to read .git/info/exclude")
return
}
existing = ""
}
const lines = new Set(existing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean))
const missing = entries.filter((e) => !lines.has(e))
if (missing.length === 0) {
return
}
const header = existing.includes("# codenomad") ? "" : (existing.trim() ? "\n" : "") + "# codenomad\n"
const suffix = missing.map((e) => `${e}\n`).join("")
await fsp.writeFile(excludePath, `${existing}${header}${suffix}`, "utf-8")
}
export async function ensureCodenomadGitExclude(workspaceFolder: string, logger?: LogLike): Promise<void> {
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
if (!isGitRepo) {
return
}
await ensureGitExclude(repoRoot, logger)
}
export async function readWorktreeMap(workspaceFolder: string, logger?: LogLike): Promise<WorktreeMap> {
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
const filePath = getMapPath(repoRoot)
try {
const raw = await fsp.readFile(filePath, "utf-8")
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object") {
return DEFAULT_MAP
}
const version = (parsed as any).version
if (version !== 1) {
return DEFAULT_MAP
}
const defaultWorktreeSlug = typeof (parsed as any).defaultWorktreeSlug === "string" ? (parsed as any).defaultWorktreeSlug : "root"
const parentSessionWorktreeSlug = (parsed as any).parentSessionWorktreeSlug
const mapping = parentSessionWorktreeSlug && typeof parentSessionWorktreeSlug === "object" ? parentSessionWorktreeSlug : {}
return {
version: 1,
defaultWorktreeSlug,
parentSessionWorktreeSlug: { ...mapping },
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === "ENOENT") {
if (isGitRepo) {
// Best-effort ignore setup on first use.
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
}
return DEFAULT_MAP
}
logger?.warn?.({ err: error, filePath }, "Failed to read worktree map")
return DEFAULT_MAP
}
}
export async function writeWorktreeMap(workspaceFolder: string, next: WorktreeMap, logger?: LogLike): Promise<void> {
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
const filePath = getMapPath(repoRoot)
await fsp.mkdir(path.dirname(filePath), { recursive: true })
// Ensure ignore rules are present (local-only).
if (isGitRepo) {
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
}
const payload: WorktreeMap = {
version: 1,
defaultWorktreeSlug: next.defaultWorktreeSlug || "root",
parentSessionWorktreeSlug: next.parentSessionWorktreeSlug ?? {},
}
// Write atomically.
const tmpPath = `${filePath}.${process.pid}.tmp`
await fsp.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf-8")
await fsp.rename(tmpPath, filePath)
}
export function worktreeMapExists(repoRoot: string): boolean {
try {
return fs.existsSync(getMapPath(repoRoot))
} catch {
return false
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
{ {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.9.3", "version": "0.10.2",
"private": true, "private": true,
"license": "MIT",
"scripts": { "scripts": {
"dev": "tauri dev", "dev": "tauri dev",
"dev:ui": "npm run dev --workspace @codenomad/ui", "dev:ui": "npm run dev --workspace @codenomad/ui",

View File

@@ -2,6 +2,7 @@
name = "codenomad-tauri" name = "codenomad-tauri"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
license = "MIT"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.5.2", features = [] } tauri-build = { version = "2.5.2", features = [] }
@@ -21,3 +22,5 @@ tauri-plugin-dialog = "2"
dirs = "5" dirs = "5"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
url = "2" url = "2"
tauri-plugin-keepawake = "0.1.1"
tauri-plugin-notification = "2"

View File

@@ -11,6 +11,10 @@
"core:menu:default", "core:menu:default",
"dialog:allow-open", "dialog:allow-open",
"opener:allow-default-urls", "opener:allow-default-urls",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
"notification:allow-show",
"core:webview:allow-set-webview-zoom" "core:webview:allow-set-webview-zoom"
] ]
} }

File diff suppressed because one or more lines are too long

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","core:webview:allow-set-webview-zoom"]}} {"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}

View File

@@ -2378,6 +2378,234 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"type": "string",
"const": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
},
{
"description": "Enables the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{ {
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string", "type": "string",

View File

@@ -2378,6 +2378,234 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"type": "string",
"const": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
},
{
"description": "Enables the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{ {
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string", "type": "string",

View File

@@ -531,7 +531,7 @@ impl CliProcessManager {
bootstrap_token: &Arc<Mutex<Option<String>>>, bootstrap_token: &Arc<Mutex<Option<String>>>,
) { ) {
let mut buffer = String::new(); let mut buffer = String::new();
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok(); let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok(); let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:"; let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
@@ -559,12 +559,12 @@ impl CliProcessManager {
continue; continue;
} }
if let Some(port) = port_regex if let Some(url) = local_url_regex
.as_ref() .as_ref()
.and_then(|re| re.captures(line).and_then(|c| c.get(1))) .and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok()) .map(|m| m.as_str().to_string())
{ {
Self::mark_ready(app, status, ready, bootstrap_token, port); Self::mark_ready(app, status, ready, bootstrap_token, url);
continue; continue;
} }
@@ -574,13 +574,13 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1))) .and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok()) .and_then(|m| m.as_str().parse::<u16>().ok())
{ {
Self::mark_ready(app, status, ready, bootstrap_token, port); Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{port}"));
continue; continue;
} }
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) { 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()) { if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
Self::mark_ready(app, status, ready, bootstrap_token, port as u16); Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{}", port));
continue; continue;
} }
} }
@@ -597,12 +597,15 @@ impl CliProcessManager {
status: &Arc<Mutex<CliStatus>>, status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>, ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>, bootstrap_token: &Arc<Mutex<Option<String>>>,
port: u16, base_url: String,
) { ) {
ready.store(true, Ordering::SeqCst); ready.store(true, Ordering::SeqCst);
let base_url = format!("http://127.0.0.1:{port}"); let port = Url::parse(&base_url)
.ok()
.and_then(|u| u.port_or_known_default())
.map(|p| p as u16);
let mut locked = status.lock(); let mut locked = status.lock();
locked.port = Some(port); locked.port = port;
locked.url = Some(base_url.clone()); locked.url = Some(base_url.clone());
locked.state = CliState::Ready; locked.state = CliState::Ready;
locked.error = None; locked.error = None;
@@ -611,22 +614,29 @@ impl CliProcessManager {
let token = bootstrap_token.lock().take(); let token = bootstrap_token.lock().take();
if let Some(token) = token { if let Some(token) = token {
match exchange_bootstrap_token(&base_url, &token) { // Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS,
Ok(Some(session_id)) => { // skip the exchange and let the user authenticate normally.
if let Err(err) = set_session_cookie(app, &base_url, &session_id) { let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string());
log_line(&format!("failed to set session cookie: {err}")); if scheme.as_deref() != Some("http") {
navigate_main(app, &format!("{base_url}/login")); navigate_main(app, &base_url);
} else { } else {
navigate_main(app, &base_url); match exchange_bootstrap_token(&base_url, &token) {
Ok(Some(session_id)) => {
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
log_line(&format!("failed to set session cookie: {err}"));
navigate_main(app, &format!("{base_url}/login"));
} else {
navigate_main(app, &base_url);
}
}
Ok(None) => {
log_line("bootstrap token exchange failed (invalid token)");
navigate_main(app, &format!("{base_url}/login"));
}
Err(err) => {
log_line(&format!("bootstrap token exchange failed: {err}"));
navigate_main(app, &format!("{base_url}/login"));
} }
}
Ok(None) => {
log_line("bootstrap token exchange failed (invalid token)");
navigate_main(app, &format!("{base_url}/login"));
}
Err(err) => {
log_line(&format!("bootstrap token exchange failed: {err}"));
navigate_main(app, &format!("{base_url}/login"));
} }
} }
} else { } else {
@@ -709,19 +719,24 @@ impl CliEntry {
} }
fn build_args(&self, dev: bool, host: &str) -> Vec<String> { fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
let mut args = vec![ let mut args = vec!["serve".to_string(), "--host".to_string(), host.to_string(), "--generate-token".to_string()];
"serve".to_string(),
"--host".to_string(),
host.to_string(),
"--port".to_string(),
"0".to_string(),
"--generate-token".to_string(),
];
if dev { if dev {
// Dev: plain HTTP + Vite dev server proxy.
args.push("--https".to_string());
args.push("false".to_string());
args.push("--http".to_string());
args.push("true".to_string());
args.push("--ui-dev-server".to_string()); args.push("--ui-dev-server".to_string());
args.push("http://localhost:3000".to_string()); args.push("http://localhost:3000".to_string());
args.push("--log-level".to_string()); args.push("--log-level".to_string());
args.push("debug".to_string()); args.push("debug".to_string());
} else {
// Prod desktop: always keep loopback HTTP enabled.
args.push("--https".to_string());
args.push("true".to_string());
args.push("--http".to_string());
args.push("true".to_string());
} }
args args
} }

View File

@@ -35,6 +35,7 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
Ok(state.manager.status()) Ok(state.manager.status())
} }
fn is_dev_mode() -> bool { fn is_dev_mode() -> bool {
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok() cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
} }
@@ -73,6 +74,8 @@ fn main() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_keepawake::init())
.plugin(tauri_plugin_notification::init())
.plugin(navigation_guard) .plugin(navigation_guard)
.manage(AppState { .manage(AppState {
manager: CliProcessManager::new(), manager: CliProcessManager::new(),

View File

@@ -1,3 +1,4 @@
node_modules/ node_modules/
dist/ dist/
.vite/ .vite/
src/renderer/public/logo.png

View File

@@ -1,7 +1,8 @@
{ {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.9.3", "version": "0.10.2",
"private": true, "private": true,
"license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -18,6 +19,7 @@
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",
"@suid/system": "^0.14.0", "@suid/system": "^0.14.0",
"@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-notification": "^2.3.3",
"ansi-sequence-parser": "^1.1.3", "ansi-sequence-parser": "^1.1.3",
"debug": "^4.4.3", "debug": "^4.4.3",
"github-markdown-css": "^5.8.1", "github-markdown-css": "^5.8.1",
@@ -26,14 +28,17 @@
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"shiki": "^3.13.0", "shiki": "^3.13.0",
"solid-js": "^1.8.0", "solid-js": "^1.8.0",
"solid-toast": "^0.5.0" "solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0"
}, },
"devDependencies": { "devDependencies": {
"@vite-pwa/assets-generator": "^1.0.2",
"autoprefixer": "10.4.21", "autoprefixer": "10.4.21",
"postcss": "8.5.6", "postcss": "8.5.6",
"tailwindcss": "3", "tailwindcss": "3",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^5.0.0", "vite": "^5.0.0",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-solid": "^2.10.0" "vite-plugin-solid": "^2.10.0"
} }
} }

View File

@@ -19,6 +19,7 @@ import { getLogger } from "./lib/logger"
import { initReleaseNotifications } from "./stores/releases" import { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env" import { runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n" import { useI18n } from "./lib/i18n"
import { setWakeLockDesired } from "./lib/native/wake-lock"
import { import {
hasInstances, hasInstances,
isSelectingFolder, isSelectingFolder,
@@ -48,6 +49,8 @@ import {
updateSessionModel, updateSessionModel,
} from "./stores/sessions" } from "./stores/sessions"
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
const log = getLogger("actions") const log = getLogger("actions")
const App: Component = () => { const App: Component = () => {
@@ -60,6 +63,7 @@ const App: Component = () => {
toggleShowTimelineTools, toggleShowTimelineTools,
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
toggleUsageMetrics, toggleUsageMetrics,
togglePromptSubmitOnEnter,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
@@ -90,6 +94,26 @@ const App: Component = () => {
initReleaseNotifications() initReleaseNotifications()
}) })
const shouldHoldWakeLock = createMemo(() => {
const map = instances()
for (const id of map.keys()) {
const status = getInstanceSessionIndicatorStatus(id)
if (status !== "idle") {
return true
}
}
return false
})
createEffect(() => {
const hold = shouldHoldWakeLock()
void setWakeLockDesired(hold)
})
onCleanup(() => {
void setWakeLockDesired(false)
})
createEffect(() => { createEffect(() => {
instances() instances()
hasInstances() hasInstances()
@@ -271,6 +295,7 @@ const App: Component = () => {
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
toggleShowTimelineTools, toggleShowTimelineTools,
toggleUsageMetrics, toggleUsageMetrics,
togglePromptSubmitOnEnter,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
@@ -329,32 +354,34 @@ const App: Component = () => {
<Dialog open={Boolean(launchError())} modal> <Dialog open={Boolean(launchError())} modal>
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class="modal-overlay" /> <Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6"> <Dialog.Content class="modal-surface w-full max-w-3xl p-6 flex flex-col gap-6 max-h-[80vh] min-h-0 overflow-hidden">
<div> <div>
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title> <Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2 break-words"> <Dialog.Description class="text-sm text-secondary mt-2 break-words">
{t("app.launchError.description")} {t("app.launchError.description")}
</Dialog.Description> </Dialog.Description>
</div> </div>
<div class="rounded-lg border border-base bg-surface-secondary p-4"> <div class={`flex flex-col gap-4 ${launchErrorMessage() ? "flex-1 min-h-0" : ""}`}>
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p> <div class="rounded-lg border border-base bg-surface-secondary p-4 flex-shrink-0">
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p> <p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
</div> <p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
</div>
<Show when={launchErrorMessage()}>
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex flex-col gap-2 flex-1 min-h-0">
<p class="text-xs font-medium text-muted uppercase tracking-wide">{t("app.launchError.errorOutputLabel")}</p>
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words overflow-auto flex-1 min-h-0">{launchErrorMessage()}</pre>
</div>
</Show>
</div>
<Show when={launchErrorMessage()}> <div class="flex justify-end gap-2">
<div class="rounded-lg border border-base bg-surface-secondary p-4"> <Show when={launchError()?.missingBinary}>
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.errorOutputLabel")}</p> <button
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre> type="button"
</div> class="selector-button selector-button-secondary"
</Show>
<div class="flex justify-end gap-2">
<Show when={launchError()?.missingBinary}>
<button
type="button"
class="selector-button selector-button-secondary"
onClick={handleLaunchErrorAdvanced} onClick={handleLaunchErrorAdvanced}
> >
{t("app.launchError.openAdvancedSettings")} {t("app.launchError.openAdvancedSettings")}

View File

@@ -55,7 +55,7 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
const highlighted = highlighter.codeToHtml(props.code, { const highlighted = highlighter.codeToHtml(props.code, {
lang: props.language as CodeToHtmlOptions["lang"], lang: props.language as CodeToHtmlOptions["lang"],
theme: isDark() ? "github-dark" : "github-light", theme: isDark() ? "github-dark" : "github-light-high-contrast",
}) })
setHtml(highlighted) setHtml(highlighted)
} catch { } catch {

View File

@@ -5,6 +5,7 @@ import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal" import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog" import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd" import Kbd from "./kbd"
import { ThemeModeToggle } from "./theme-mode-toggle"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions" import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import VersionPill from "./version-pill" import VersionPill from "./version-pill"
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons" import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
@@ -313,8 +314,9 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Select.Portal> </Select.Portal>
</Select> </Select>
</div> </div>
<Show when={props.onOpenRemoteAccess}> <div class="absolute top-4 right-6 flex items-center gap-2">
<div class="absolute top-4 right-6"> <ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
<Show when={props.onOpenRemoteAccess}>
<button <button
type="button" type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
@@ -322,8 +324,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
> >
<MonitorUp class="w-4 h-4" /> <MonitorUp class="w-4 h-4" />
</button> </button>
</div> </Show>
</Show> </div>
<div class="mb-6 text-center shrink-0"> <div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center"> <div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt={t("folderSelection.logoAlt")} class="h-32 w-auto sm:h-48" loading="lazy" /> <img src={codeNomadLogo} alt={t("folderSelection.logoAlt")} class="h-32 w-auto sm:h-48" loading="lazy" />

View File

@@ -1,10 +1,15 @@
import { Component, For, Show } from "solid-js" import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { Dynamic } from "solid-js/web"
import type { Instance } from "../types/instance" import type { Instance } from "../types/instance"
import InstanceTab from "./instance-tab" import InstanceTab from "./instance-tab"
import KeyboardHint from "./keyboard-hint" import KeyboardHint from "./keyboard-hint"
import { Plus, MonitorUp } from "lucide-solid" import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry" import { keyboardRegistry } from "../lib/keyboard-registry"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { ThemeModeToggle } from "./theme-mode-toggle"
import NotificationsSettingsModal from "./notifications-settings-modal"
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
import { useConfig } from "../stores/preferences"
interface InstanceTabsProps { interface InstanceTabsProps {
instances: Map<string, Instance> instances: Map<string, Instance>
@@ -17,6 +22,21 @@ interface InstanceTabsProps {
const InstanceTabs: Component<InstanceTabsProps> = (props) => { const InstanceTabs: Component<InstanceTabsProps> = (props) => {
const { t } = useI18n() const { t } = useI18n()
const { preferences } = useConfig()
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
const notificationIcon = createMemo(() => {
if (!notificationsSupported()) return BellOff
return notificationsEnabled() ? Bell : BellOff
})
const notificationTitle = createMemo(() => {
if (!notificationsSupported()) return "Notifications unsupported"
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
})
return ( return (
<div class="tab-bar tab-bar-instance"> <div class="tab-bar tab-bar-instance">
<div class="tab-container" role="tablist"> <div class="tab-container" role="tablist">
@@ -52,6 +72,17 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
/> />
</div> </div>
</Show> </Show>
<ThemeModeToggle class="new-tab-button" />
<button
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
onClick={() => setNotificationsOpen(true)}
title={notificationTitle()}
aria-label={notificationTitle()}
>
<Dynamic component={notificationIcon()} class="w-4 h-4" />
</button>
<Show when={Boolean(props.onOpenRemoteAccess)}> <Show when={Boolean(props.onOpenRemoteAccess)}>
<button <button
class="new-tab-button tab-remote-button" class="new-tab-button tab-remote-button"
@@ -65,6 +96,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
</div> </div>
</div> </div>
</div> </div>
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
</div> </div>
) )

View File

@@ -12,16 +12,14 @@ import {
} from "solid-js" } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk"
import { Accordion } from "@kobalte/core" import { Accordion } from "@kobalte/core"
import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import { ChevronDown, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import AppBar from "@suid/material/AppBar" import AppBar from "@suid/material/AppBar"
import Box from "@suid/material/Box" import Box from "@suid/material/Box"
import Divider from "@suid/material/Divider"
import Drawer from "@suid/material/Drawer" import Drawer from "@suid/material/Drawer"
import IconButton from "@suid/material/IconButton" import IconButton from "@suid/material/IconButton"
import Toolbar from "@suid/material/Toolbar" import Toolbar from "@suid/material/Toolbar"
import Typography from "@suid/material/Typography" import Typography from "@suid/material/Typography"
import useMediaQuery from "@suid/material/useMediaQuery" import useMediaQuery from "@suid/material/useMediaQuery"
import CloseIcon from "@suid/icons-material/Close"
import MenuIcon from "@suid/icons-material/Menu" import MenuIcon from "@suid/icons-material/Menu"
import MenuOpenIcon from "@suid/icons-material/MenuOpen" import MenuOpenIcon from "@suid/icons-material/MenuOpen"
import PushPinIcon from "@suid/icons-material/PushPin" import PushPinIcon from "@suid/icons-material/PushPin"
@@ -37,6 +35,7 @@ import {
getSessionFamily, getSessionFamily,
getSessionInfo, getSessionInfo,
getSessionThreads, getSessionThreads,
loadMessages,
sessions, sessions,
setActiveParentSession, setActiveParentSession,
setActiveSession, setActiveSession,
@@ -65,6 +64,7 @@ import { formatTokenTotal } from "../../lib/formatters"
import { sseManager } from "../../lib/sse-manager" import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
import { serverApi } from "../../lib/api-client" import { serverApi } from "../../lib/api-client"
import WorktreeSelector from "../worktree-selector"
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes" import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog" import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
import { useI18n } from "../../lib/i18n" import { useI18n } from "../../lib/i18n"
@@ -95,7 +95,6 @@ const RIGHT_DRAWER_WIDTH = 260
const MIN_RIGHT_DRAWER_WIDTH = 200 const MIN_RIGHT_DRAWER_WIDTH = 200
const MAX_RIGHT_DRAWER_WIDTH = 380 const MAX_RIGHT_DRAWER_WIDTH = 380
const SESSION_CACHE_LIMIT = 5 const SESSION_CACHE_LIMIT = 5
const APP_BAR_HEIGHT = 56
const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8" const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1" const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1" const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1"
@@ -153,6 +152,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
// Worktree selector manages its own dialogs.
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id)) const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
const desktopQuery = useMediaQuery("(min-width: 1280px)") const desktopQuery = useMediaQuery("(min-width: 1280px)")
@@ -215,10 +217,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const host = drawerHost() const host = drawerHost()
if (!host) return if (!host) return
const rect = host.getBoundingClientRect() const rect = host.getBoundingClientRect()
const toolbar = host.querySelector<HTMLElement>(".session-toolbar") setFloatingDrawerTop(rect.top)
const toolbarHeight = toolbar?.offsetHeight ?? APP_BAR_HEIGHT setFloatingDrawerHeight(Math.max(0, rect.height))
setFloatingDrawerTop(rect.top + toolbarHeight)
setFloatingDrawerHeight(Math.max(0, rect.height - toolbarHeight))
} }
onMount(() => { onMount(() => {
@@ -618,7 +618,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return undefined return undefined
} }
const fallbackDrawerTop = () => APP_BAR_HEIGHT + props.tabBarOffset const fallbackDrawerTop = () => props.tabBarOffset
const floatingTop = () => { const floatingTop = () => {
const measured = floatingDrawerTop() const measured = floatingDrawerTop()
if (measured > 0) return measured if (measured > 0) return measured
@@ -728,27 +728,21 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const leftAppBarButtonLabel = () => { const leftAppBarButtonLabel = () => {
const state = leftDrawerState() const state = leftDrawerState()
if (state === "pinned") return t("instanceShell.leftDrawer.toggle.pinned") if (state === "pinned") return t("instanceShell.leftDrawer.toggle.pinned")
if (state === "floating-closed") return t("instanceShell.leftDrawer.toggle.open") return t("instanceShell.leftDrawer.toggle.open")
return t("instanceShell.leftDrawer.toggle.close")
} }
const rightAppBarButtonLabel = () => { const rightAppBarButtonLabel = () => {
const state = rightDrawerState() const state = rightDrawerState()
if (state === "pinned") return t("instanceShell.rightDrawer.toggle.pinned") if (state === "pinned") return t("instanceShell.rightDrawer.toggle.pinned")
if (state === "floating-closed") return t("instanceShell.rightDrawer.toggle.open") return t("instanceShell.rightDrawer.toggle.open")
return t("instanceShell.rightDrawer.toggle.close")
} }
const leftAppBarButtonIcon = () => { const leftAppBarButtonIcon = () => {
const state = leftDrawerState() return <MenuIcon fontSize="small" />
if (state === "floating-closed") return <MenuIcon fontSize="small" />
return <MenuOpenIcon fontSize="small" />
} }
const rightAppBarButtonIcon = () => { const rightAppBarButtonIcon = () => {
const state = rightDrawerState() return <MenuIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
if (state === "floating-closed") return <MenuIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
return <MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
} }
@@ -796,29 +790,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const handleLeftAppBarButtonClick = () => { const handleLeftAppBarButtonClick = () => {
const state = leftDrawerState() const state = leftDrawerState()
if (state === "pinned") return if (state !== "floating-closed") return
if (state === "floating-closed") { setLeftOpen(true)
setLeftOpen(true)
measureDrawerHost()
return
}
blurIfInside(leftDrawerContentEl())
setLeftOpen(false)
focusTarget(leftToggleButtonEl())
measureDrawerHost() measureDrawerHost()
} }
const handleRightAppBarButtonClick = () => { const handleRightAppBarButtonClick = () => {
const state = rightDrawerState() const state = rightDrawerState()
if (state === "pinned") return if (state !== "floating-closed") return
if (state === "floating-closed") { setRightOpen(true)
setRightOpen(true)
measureDrawerHost()
return
}
blurIfInside(rightDrawerContentEl())
setRightOpen(false)
focusTarget(rightToggleButtonEl())
measureDrawerHost() measureDrawerHost()
} }
@@ -864,39 +844,66 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const LeftDrawerContent = () => ( const LeftDrawerContent = () => (
<div class="flex flex-col h-full min-h-0" ref={setLeftDrawerContentEl}> <div class="flex flex-col h-full min-h-0" ref={setLeftDrawerContentEl}>
<div class="flex items-start justify-between gap-2 px-4 py-3 border-b border-base"> <div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
<div class="flex flex-col gap-1"> <div class="flex items-center justify-between gap-2">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary"> <span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
{t("instanceShell.leftPanel.sessionsTitle")} {t("instanceShell.leftPanel.sessionsTitle")}
</span> </span>
<div class="session-sidebar-shortcuts"> <div class="flex items-center gap-2 text-primary">
<Show when={keyboardShortcuts().length}>
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
</Show>
</div>
</div>
<div class="flex items-center gap-2">
<IconButton
size="small"
color="inherit"
aria-label={t("instanceShell.leftPanel.instanceInfo")}
title={t("instanceShell.leftPanel.instanceInfo")}
onClick={() => handleSessionSelect("info")}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
<Show when={!isPhoneLayout()}>
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
aria-label={leftPinned() ? t("instanceShell.leftDrawer.unpin") : t("instanceShell.leftDrawer.pin")} aria-label={t("sessionList.filter.ariaLabel")}
onClick={() => (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())} title={t("sessionList.filter.ariaLabel")}
aria-pressed={showSessionSearch()}
onClick={() => setShowSessionSearch((current) => !current)}
sx={{
color: showSessionSearch() ? "var(--text-primary)" : "inherit",
backgroundColor: showSessionSearch() ? "var(--surface-hover)" : "transparent",
"&:hover": {
backgroundColor: "var(--surface-hover)",
},
}}
> >
{leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />} <Search class={showSessionSearch() ? "w-4 h-4" : "w-4 h-4 opacity-70"} />
</IconButton> </IconButton>
<IconButton
size="small"
color="inherit"
aria-label={t("instanceShell.leftPanel.instanceInfo")}
title={t("instanceShell.leftPanel.instanceInfo")}
onClick={() => handleSessionSelect("info")}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
<Show when={!isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={leftPinned() ? t("instanceShell.leftDrawer.unpin") : t("instanceShell.leftDrawer.pin")}
onClick={() => (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())}
>
{leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
<Show when={leftDrawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={t("instanceShell.leftDrawer.toggle.close")}
title={t("instanceShell.leftDrawer.toggle.close")}
onClick={closeLeftDrawer}
>
<MenuOpenIcon fontSize="small" />
</IconButton>
</Show>
</div>
</div>
<div class="session-sidebar-shortcuts">
<Show when={keyboardShortcuts().length}>
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
</Show> </Show>
</div> </div>
</div> </div>
<div class="session-sidebar flex flex-col flex-1 min-h-0"> <div class="session-sidebar flex flex-col flex-1 min-h-0">
@@ -911,22 +918,24 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
void result.catch((error) => log.error("Failed to create session:", error)) void result.catch((error) => log.error("Failed to create session:", error))
} }
}} }}
enableFilterBar={showSessionSearch()}
showHeader={false} showHeader={false}
showFooter={false} showFooter={false}
/> />
<Divider /> <div class="session-sidebar-separator" />
<Show when={activeSessionForInstance()}> <Show when={activeSessionForInstance()}>
{(activeSession) => ( {(activeSession) => (
<> <>
<ContextUsagePanel instanceId={props.instance.id} sessionId={activeSession().id} /> <div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3"> <WorktreeSelector instanceId={props.instance.id} sessionId={activeSession().id} />
<AgentSelector
instanceId={props.instance.id} <AgentSelector
sessionId={activeSession().id} instanceId={props.instance.id}
currentAgent={activeSession().agent} sessionId={activeSession().id}
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)} currentAgent={activeSession().agent}
/> onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
/>
<ModelSelector <ModelSelector
instanceId={props.instance.id} instanceId={props.instance.id}
@@ -1087,22 +1096,46 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return ( return (
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}> <div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
<div class="flex items-center justify-between px-4 py-2 border-b border-base"> <div class="border-b border-base text-primary">
<Typography variant="subtitle2" class="uppercase tracking-wide text-xs font-semibold"> <div class="relative flex items-center px-4 py-2">
{t("instanceShell.rightPanel.title")} <div class="flex items-center gap-2">
</Typography> <Show when={rightDrawerState() === "floating-open"}>
<div class="flex items-center gap-2"> <IconButton
<Show when={!isPhoneLayout()}> size="small"
<IconButton color="inherit"
size="small" aria-label={t("instanceShell.rightDrawer.toggle.close")}
color="inherit" title={t("instanceShell.rightDrawer.toggle.close")}
aria-label={rightPinned() ? t("instanceShell.rightDrawer.unpin") : t("instanceShell.rightDrawer.pin")} onClick={closeRightDrawer}
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())} >
> <MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />} </IconButton>
</IconButton> </Show>
</Show> <Show when={!isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={rightPinned() ? t("instanceShell.rightDrawer.unpin") : t("instanceShell.rightDrawer.pin")}
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())}
>
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
</div>
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
{t("instanceShell.rightPanel.title")}
</span>
</div>
</div> </div>
<Show when={activeSessionForInstance()}>
{(activeSession) => (
<ContextUsagePanel
instanceId={props.instance.id}
sessionId={activeSession().id}
class="border-t border-base"
/>
)}
</Show>
</div> </div>
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
<Accordion.Root <Accordion.Root
@@ -1263,19 +1296,93 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const sessionLayout = ( const sessionLayout = (
<div <div
class="session-shell-panels flex flex-col flex-1 min-h-0 overflow-x-hidden" class="session-shell-panels flex flex-1 min-h-0 overflow-x-hidden"
ref={(element) => { ref={(element) => {
setDrawerHost(element) setDrawerHost(element)
measureDrawerHost() measureDrawerHost()
}} }}
> >
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base"> {renderLeftPanel()}
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
<Show <Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}>
when={!isPhoneLayout()} <AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
fallback={ <Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
<div class="flex flex-col w-full gap-1.5"> <Show
<div class="flex flex-wrap items-center justify-between gap-2 w-full"> when={!isPhoneLayout()}
fallback={
<div class="flex flex-col w-full gap-1.5">
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
<Show when={leftDrawerState() === "floating-closed"}>
<IconButton
ref={setLeftToggleButtonEl}
color="inherit"
onClick={handleLeftAppBarButtonClick}
aria-label={leftAppBarButtonLabel()}
size="small"
aria-expanded={leftDrawerState() !== "floating-closed"}
>
{leftAppBarButtonIcon()}
</IconButton>
</Show>
<div class="flex flex-wrap items-center gap-1 justify-center">
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
<button
type="button"
class="connection-status-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick}
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }}
>
{t("instanceShell.commandPalette.button")}
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
</span>
<span
class={`status-indicator ${connectionStatusClass()}`}
aria-label={t("instanceShell.connection.ariaLabel", { status: connectionStatusLabel() })}
>
<span class="status-dot" />
</span>
</div>
<Show when={rightDrawerState() === "floating-closed"}>
<IconButton
ref={setRightToggleButtonEl}
color="inherit"
onClick={handleRightAppBarButtonClick}
aria-label={rightAppBarButtonLabel()}
size="small"
aria-expanded={rightDrawerState() !== "floating-closed"}
>
{rightAppBarButtonIcon()}
</IconButton>
</Show>
</div>
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.usedLabel")}
</span>
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.availableLabel")}
</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div>
</div>
</div>
}
>
<div class="session-toolbar-left flex items-center gap-3 min-w-0">
<Show when={leftDrawerState() === "floating-closed"}>
<IconButton <IconButton
ref={setLeftToggleButtonEl} ref={setLeftToggleButtonEl}
color="inherit" color="inherit"
@@ -1283,38 +1390,68 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
aria-label={leftAppBarButtonLabel()} aria-label={leftAppBarButtonLabel()}
size="small" size="small"
aria-expanded={leftDrawerState() !== "floating-closed"} aria-expanded={leftDrawerState() !== "floating-closed"}
disabled={leftDrawerState() === "pinned"}
> >
{leftAppBarButtonIcon()} {leftAppBarButtonIcon()}
</IconButton> </IconButton>
</Show>
<div class="flex flex-wrap items-center gap-1 justify-center"> <Show when={!showingInfoView()}>
<PermissionNotificationBanner <div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
instanceId={props.instance.id} <span class="uppercase text-[10px] tracking-wide text-muted">
onClick={() => setPermissionModalOpen(true)} {t("instanceShell.metrics.usedLabel")}
/>
<button
type="button"
class="connection-status-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick}
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }}
>
{t("instanceShell.commandPalette.button")}
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
</span> </span>
<span <span class="font-semibold text-primary">{formattedUsedTokens()}</span>
class={`status-indicator ${connectionStatusClass()}`}
aria-label={t("instanceShell.connection.ariaLabel", { status: connectionStatusLabel() })}
>
<span class="status-dot" />
</span>
</div> </div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.availableLabel")}
</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div>
</Show>
</div>
<div class="session-toolbar-center flex-1 flex items-center justify-center gap-2 min-w-[160px]">
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
<button
type="button"
class="connection-status-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick}
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }}
>
{t("instanceShell.commandPalette.button")}
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
</span>
</div>
<div class="session-toolbar-right flex items-center gap-3">
<div class="connection-status-meta flex items-center gap-3">
<Show when={connectionStatus() === "connected"}>
<span class="status-indicator connected">
<span class="status-dot" />
<span class="status-text">{t("instanceShell.connection.connected")}</span>
</span>
</Show>
<Show when={connectionStatus() === "connecting"}>
<span class="status-indicator connecting">
<span class="status-dot" />
<span class="status-text">{t("instanceShell.connection.connecting")}</span>
</span>
</Show>
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
<span class="status-indicator disconnected">
<span class="status-dot" />
<span class="status-text">{t("instanceShell.connection.disconnected")}</span>
</span>
</Show>
</div>
<Show when={rightDrawerState() === "floating-closed"}>
<IconButton <IconButton
ref={setRightToggleButtonEl} ref={setRightToggleButtonEl}
color="inherit" color="inherit"
@@ -1322,120 +1459,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
aria-label={rightAppBarButtonLabel()} aria-label={rightAppBarButtonLabel()}
size="small" size="small"
aria-expanded={rightDrawerState() !== "floating-closed"} aria-expanded={rightDrawerState() !== "floating-closed"}
disabled={rightDrawerState() === "pinned"}
> >
{rightAppBarButtonIcon()} {rightAppBarButtonIcon()}
</IconButton> </IconButton>
</div>
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">
{t("instanceShell.metrics.usedLabel")}
</span>
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">
{t("instanceShell.metrics.availableLabel")}
</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div>
</div>
</div>
}
>
<div class="session-toolbar-left flex items-center gap-3 min-w-0">
<IconButton
ref={setLeftToggleButtonEl}
color="inherit"
onClick={handleLeftAppBarButtonClick}
aria-label={leftAppBarButtonLabel()}
size="small"
aria-expanded={leftDrawerState() !== "floating-closed"}
disabled={leftDrawerState() === "pinned"}
>
{leftAppBarButtonIcon()}
</IconButton>
<Show when={!showingInfoView()}>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">
{t("instanceShell.metrics.usedLabel")}
</span>
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">
{t("instanceShell.metrics.availableLabel")}
</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div>
</Show>
</div>
<div class="session-toolbar-center flex-1 flex items-center justify-center gap-2 min-w-[160px]">
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
<button
type="button"
class="connection-status-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick}
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }}
>
{t("instanceShell.commandPalette.button")}
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
</span>
</div>
<div class="session-toolbar-right flex items-center gap-3">
<div class="connection-status-meta flex items-center gap-3">
<Show when={connectionStatus() === "connected"}>
<span class="status-indicator connected">
<span class="status-dot" />
<span class="status-text">{t("instanceShell.connection.connected")}</span>
</span>
</Show>
<Show when={connectionStatus() === "connecting"}>
<span class="status-indicator connecting">
<span class="status-dot" />
<span class="status-text">{t("instanceShell.connection.connecting")}</span>
</span>
</Show>
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
<span class="status-indicator disconnected">
<span class="status-dot" />
<span class="status-text">{t("instanceShell.connection.disconnected")}</span>
</span>
</Show> </Show>
</div> </div>
<IconButton </Show>
ref={setRightToggleButtonEl} </Toolbar>
color="inherit" </AppBar>
onClick={handleRightAppBarButtonClick}
aria-label={rightAppBarButtonLabel()}
size="small"
aria-expanded={rightDrawerState() !== "floating-closed"}
disabled={rightDrawerState() === "pinned"}
>
{rightAppBarButtonIcon()}
</IconButton>
</div>
</Show>
</Toolbar>
</AppBar>
<Box sx={{ display: "flex", flex: 1, minHeight: 0, overflowX: "hidden" }}>
{renderLeftPanel()}
<Box <Box
component="main" component="main"
@@ -1489,9 +1520,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</div> </div>
</Show> </Show>
</Box> </Box>
{renderRightPanel()}
</Box> </Box>
{renderRightPanel()}
</div> </div>
) )

View File

@@ -1,5 +1,5 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities } from "../lib/markdown" import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
import { useGlobalCache } from "../lib/hooks/use-global-cache" import { useGlobalCache } from "../lib/hooks/use-global-cache"
import type { TextPart, RenderCache } from "../types/message" import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
@@ -72,6 +72,9 @@ export function Markdown(props: MarkdownProps) {
createEffect(async () => { createEffect(async () => {
const { part, text, themeKey, highlightEnabled, version } = resolved() const { part, text, themeKey, highlightEnabled, version } = resolved()
// Ensure the markdown highlighter theme matches the active UI theme.
setMarkdownTheme(themeKey === "dark")
latestRequestedText = text latestRequestedText = text
const cacheMatches = (cache: RenderCache | undefined) => { const cacheMatches = (cache: RenderCache | undefined) => {
@@ -171,6 +174,8 @@ export function Markdown(props: MarkdownProps) {
const { part, text, themeKey, version } = resolved() const { part, text, themeKey, version } = resolved()
setMarkdownTheme(themeKey === "dark")
if (latestRequestedText !== text) { if (latestRequestedText !== text) {
return return
} }

View File

@@ -1,5 +1,5 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js" import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { FoldVertical } from "lucide-solid" import { ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
import MessageItem from "./message-item" import MessageItem from "./message-item"
import ToolCall from "./tool-call" import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -11,6 +11,8 @@ import { messageStoreBus } from "../stores/message-v2/bus"
import { formatTokenTotal } from "../lib/formatters" import { formatTokenTotal } from "../lib/formatters"
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances" import { setActiveInstanceId } from "../stores/instances"
import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
const TOOL_ICON = "🔧" const TOOL_ICON = "🔧"
@@ -302,6 +304,7 @@ interface ToolCallItemProps {
function ToolCallItem(props: ToolCallItemProps) { function ToolCallItem(props: ToolCallItemProps) {
const { t } = useI18n() const { t } = useI18n()
const [deleting, setDeleting] = createSignal(false)
const record = createMemo(() => props.store().getMessage(props.messageId)) const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
@@ -318,6 +321,14 @@ function ToolCallItem(props: ToolCallItemProps) {
const messageVersion = createMemo(() => record()?.revision ?? 0) const messageVersion = createMemo(() => record()?.revision ?? 0)
const partVersion = createMemo(() => partEntry()?.revision ?? 0) const partVersion = createMemo(() => partEntry()?.revision ?? 0)
const deleteDisabled = createMemo(() => {
if (deleting()) return true
// Avoid deleting while a tool is actively running to prevent confusing UI states.
if (isToolStateRunning(toolState())) return true
// Avoid deleting permission prompts from here; those are interactive.
return Boolean(toolPart()?.pendingPermission)
})
const taskSessionId = createMemo(() => { const taskSessionId = createMemo(() => {
const state = toolState() const state = toolState()
if (!state) return "" if (!state) return ""
@@ -341,6 +352,26 @@ function ToolCallItem(props: ToolCallItemProps) {
navigateToTaskSession(location) navigateToTaskSession(location)
} }
const handleDeleteToolPart = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (deleteDisabled()) return
setDeleting(true)
try {
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
} catch (error) {
showAlertDialog(t("messageBlock.tool.deletePart.failed.message"), {
title: t("messageBlock.tool.deletePart.failed.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setDeleting(false)
}
}
return ( return (
<Show when={toolPart()}> <Show when={toolPart()}>
{(resolvedToolPart) => ( {(resolvedToolPart) => (
@@ -351,17 +382,32 @@ function ToolCallItem(props: ToolCallItemProps) {
<span>{t("messageBlock.tool.header")}</span> <span>{t("messageBlock.tool.header")}</span>
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span> <span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
</div> </div>
<Show when={taskSessionId()}>
<div class="flex items-center gap-2">
<Show when={taskSessionId()}>
<button
class="tool-call-header-button"
type="button"
disabled={!taskLocation()}
onClick={handleGoToTaskSession}
title={t("messageBlock.tool.goToSession.label")}
aria-label={t("messageBlock.tool.goToSession.label")}
>
<ExternalLink class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<button <button
class="tool-call-header-button" class="tool-call-header-button"
type="button" type="button"
disabled={!taskLocation()} disabled={deleteDisabled()}
onClick={handleGoToTaskSession} onClick={handleDeleteToolPart}
title={!taskLocation() ? t("messageBlock.tool.goToSession.unavailableTitle") : t("messageBlock.tool.goToSession.title")} title={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
aria-label={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
> >
{t("messageBlock.tool.goToSession.label")} <Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</Show> </div>
</div> </div>
<ToolCall <ToolCall
@@ -395,6 +441,8 @@ type ReasoningDisplayItem = {
messageInfo?: MessageInfo messageInfo?: MessageInfo
showAgentMeta?: boolean showAgentMeta?: boolean
defaultExpanded: boolean defaultExpanded: boolean
messageId: string
partId: string
} }
type CompactionDisplayItem = { type CompactionDisplayItem = {
@@ -403,6 +451,8 @@ type CompactionDisplayItem = {
part: ClientPart part: ClientPart
messageInfo?: MessageInfo messageInfo?: MessageInfo
accentColor?: string accentColor?: string
messageId: string
partId: string
} }
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem
@@ -530,7 +580,8 @@ export default function MessageBlock(props: MessageBlockProps) {
if (part.type === "compaction") { if (part.type === "compaction") {
flushContent() flushContent()
const key = `${current.id}:${part.id ?? partIndex}:compaction` const partId = part.id ?? ""
const key = `${current.id}:${partId || partIndex}:compaction`
const isAuto = Boolean((part as any)?.auto) const isAuto = Boolean((part as any)?.auto)
items.push({ items.push({
type: "compaction", type: "compaction",
@@ -538,6 +589,8 @@ export default function MessageBlock(props: MessageBlockProps) {
part, part,
messageInfo: info, messageInfo: info,
accentColor: isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR, accentColor: isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR,
messageId: current.id,
partId,
}) })
lastAccentColor = isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR lastAccentColor = isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR
return return
@@ -562,7 +615,8 @@ export default function MessageBlock(props: MessageBlockProps) {
if (part.type === "reasoning") { if (part.type === "reasoning") {
flushContent() flushContent()
if (props.showThinking() && reasoningHasRenderableContent(part)) { if (props.showThinking() && reasoningHasRenderableContent(part)) {
const key = `${current.id}:${part.id ?? partIndex}:reasoning` const partId = part.id ?? ""
const key = `${current.id}:${partId || partIndex}:reasoning`
const showAgentMeta = current.role === "assistant" && !agentMetaAttached const showAgentMeta = current.role === "assistant" && !agentMetaAttached
if (showAgentMeta) { if (showAgentMeta) {
agentMetaAttached = true agentMetaAttached = true
@@ -574,6 +628,8 @@ export default function MessageBlock(props: MessageBlockProps) {
messageInfo: info, messageInfo: info,
showAgentMeta, showAgentMeta,
defaultExpanded: props.thinkingDefaultExpanded(), defaultExpanded: props.thinkingDefaultExpanded(),
messageId: current.id,
partId,
}) })
lastAccentColor = ASSISTANT_BORDER_COLOR lastAccentColor = ASSISTANT_BORDER_COLOR
} }
@@ -647,7 +703,12 @@ export default function MessageBlock(props: MessageBlockProps) {
})()} })()}
</Match> </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} showAgentMeta /> <StepCard
kind="start"
part={(item as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo}
showAgentMeta
/>
</Match> </Match>
<Match when={item.type === "step-finish"}> <Match when={item.type === "step-finish"}>
<StepCard <StepCard
@@ -659,7 +720,15 @@ export default function MessageBlock(props: MessageBlockProps) {
/> />
</Match> </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} /> <CompactionCard
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}
partId={(item as CompactionDisplayItem).partId}
/>
</Match> </Match>
<Match when={item.type === "reasoning"}> <Match when={item.type === "reasoning"}>
<ReasoningCard <ReasoningCard
@@ -667,6 +736,8 @@ export default function MessageBlock(props: MessageBlockProps) {
messageInfo={(item as ReasoningDisplayItem).messageInfo} messageInfo={(item as ReasoningDisplayItem).messageInfo}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={(item as ReasoningDisplayItem).messageId}
partId={(item as ReasoningDisplayItem).partId}
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta} showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded} defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
/> />
@@ -689,8 +760,19 @@ interface StepCardProps {
borderColor?: string borderColor?: string
} }
function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; borderColor?: string }) { interface CompactionCardProps {
part: ClientPart
messageInfo?: MessageInfo
borderColor?: string
instanceId: string
sessionId: string
messageId: string
partId: string
}
function CompactionCard(props: CompactionCardProps) {
const { t } = useI18n() const { t } = useI18n()
const [deleting, setDeleting] = createSignal(false)
const isAuto = () => Boolean((props.part as any)?.auto) const isAuto = () => Boolean((props.part as any)?.auto)
const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel")) const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel"))
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR) const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
@@ -698,13 +780,43 @@ function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; bo
const containerClass = () => const containerClass = () =>
`message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}` `message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}`
const canDelete = () => Boolean(props.partId) && !deleting()
const handleDelete = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!canDelete()) return
setDeleting(true)
try {
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
} catch (error) {
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
title: t("messagePart.actions.deleteFailedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setDeleting(false)
}
}
return ( return (
<div <div
class={containerClass()} class={`${containerClass()} relative`}
style={{ "border-left": `4px solid ${borderColor()}` }} style={{ "border-left": `4px solid ${borderColor()}` }}
role="status" role="status"
aria-label={t("messageBlock.compaction.ariaLabel")} aria-label={t("messageBlock.compaction.ariaLabel")}
> >
<button
type="button"
class="tool-call-header-button absolute right-2 top-1/2 -translate-y-1/2"
disabled={!canDelete()}
onClick={handleDelete}
title={t("messagePart.actions.deleteTitle")}
>
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
</button>
<div class="message-compaction-row"> <div class="message-compaction-row">
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" /> <FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
<span class="message-compaction-label">{label()}</span> <span class="message-compaction-label">{label()}</span>
@@ -759,6 +871,7 @@ function StepCard(props: StepCardProps) {
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined) const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => { const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
const entries = [ const entries = [
{ label: t("messageBlock.usage.input"), value: usage.input, formatter: formatTokenTotal }, { label: t("messageBlock.usage.input"), value: usage.input, formatter: formatTokenTotal },
@@ -824,6 +937,8 @@ interface ReasoningCardProps {
messageInfo?: MessageInfo messageInfo?: MessageInfo
instanceId: string instanceId: string
sessionId: string sessionId: string
messageId: string
partId: string
showAgentMeta?: boolean showAgentMeta?: boolean
defaultExpanded?: boolean defaultExpanded?: boolean
} }
@@ -831,6 +946,7 @@ interface ReasoningCardProps {
function ReasoningCard(props: ReasoningCardProps) { function ReasoningCard(props: ReasoningCardProps) {
const { t } = useI18n() const { t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded)) const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deleting, setDeleting] = createSignal(false)
createEffect(() => { createEffect(() => {
setExpanded(Boolean(props.defaultExpanded)) setExpanded(Boolean(props.defaultExpanded))
@@ -894,6 +1010,27 @@ function ReasoningCard(props: ReasoningCardProps) {
const toggle = () => setExpanded((prev) => !prev) const toggle = () => setExpanded((prev) => !prev)
const hasDeleteTarget = () => Boolean(props.partId)
const canDelete = () => hasDeleteTarget() && !deleting()
const handleDelete = async (event: Event) => {
event.preventDefault()
event.stopPropagation()
if (!canDelete()) return
setDeleting(true)
try {
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
} catch (error) {
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
title: t("messagePart.actions.deleteFailedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setDeleting(false)
}
}
return ( return (
<div class="message-reasoning-card"> <div class="message-reasoning-card">
<button <button
@@ -924,6 +1061,25 @@ function ReasoningCard(props: ReasoningCardProps) {
<span class="message-reasoning-indicator"> <span class="message-reasoning-indicator">
{expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")} {expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")}
</span> </span>
<Show when={hasDeleteTarget()}>
<span
class={`message-reasoning-indicator${canDelete() ? "" : " opacity-50 pointer-events-none"}`}
role="button"
tabIndex={0}
onClick={handleDelete}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
handleDelete(event)
}
}}
aria-label={t("messagePart.actions.deleteTitle")}
title={t("messagePart.actions.deleteTitle")}
>
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
</span>
</Show>
<span class="message-reasoning-time">{timestamp()}</span> <span class="message-reasoning-time">{timestamp()}</span>
</span> </span>
</button> </button>

View File

@@ -1,10 +1,13 @@
import { For, Show, createSignal } from "solid-js" import { For, Show, createSignal } from "solid-js"
import { Copy, Split, Trash2, Undo } from "lucide-solid"
import type { MessageInfo, ClientPart } from "../types/message" import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message" import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part" import MessagePart from "./message-part"
import { copyToClipboard } from "../lib/clipboard" import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions"
interface MessageItemProps { interface MessageItemProps {
record: MessageRecord record: MessageRecord
@@ -22,6 +25,7 @@ interface MessageItemProps {
export default function MessageItem(props: MessageItemProps) { export default function MessageItem(props: MessageItemProps) {
const { t } = useI18n() const { t } = useI18n()
const [copied, setCopied] = createSignal(false) const [copied, setCopied] = createSignal(false)
const [deletingParts, setDeletingParts] = createSignal<Set<string>>(new Set())
const isUser = () => props.record.role === "user" const isUser = () => props.record.role === "user"
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
@@ -156,6 +160,8 @@ export default function MessageItem(props: MessageItemProps) {
} }
} }
const copyLabel = () => (copied() ? t("messageItem.actions.copied") : t("messageItem.actions.copy"))
const getRawContent = () => { const getRawContent = () => {
return props.parts return props.parts
.filter(part => part.type === "text") .filter(part => part.type === "text")
@@ -172,6 +178,50 @@ export default function MessageItem(props: MessageItemProps) {
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} }
const deletableTextPartId = () => {
const part = props.parts.find((candidate) => {
if (!candidate || candidate.type !== "text") return false
const id = (candidate as any).id
if (typeof id !== "string" || id.length === 0) return false
return !Boolean((candidate as any).synthetic)
})
return (part as any)?.id as string | undefined
}
const isDeletingPart = (partId?: string) => {
if (!partId) return false
return deletingParts().has(partId)
}
const setPartDeleting = (partId: string, value: boolean) => {
setDeletingParts((prev) => {
const next = new Set(prev)
if (value) {
next.add(partId)
} else {
next.delete(partId)
}
return next
})
}
const handleDeletePart = async (partId?: string) => {
if (!partId) return
if (isDeletingPart(partId)) return
setPartDeleting(partId, true)
try {
await deleteMessagePart(props.instanceId, props.sessionId, props.record.id, partId)
} catch (error) {
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
title: t("messagePart.actions.deleteFailedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setPartDeleting(partId, false)
}
}
if (!isUser() && !hasContent() && !isGenerating()) { if (!isUser() && !hasContent() && !isGenerating()) {
return null return null
} }
@@ -231,45 +281,70 @@ export default function MessageItem(props: MessageItemProps) {
<button <button
class="message-action-button" class="message-action-button"
onClick={handleRevert} onClick={handleRevert}
title={t("messageItem.actions.revertTitle")} title={t("messageItem.actions.revert")}
aria-label={t("messageItem.actions.revertTitle")} aria-label={t("messageItem.actions.revert")}
> >
{t("messageItem.actions.revert")} <Undo class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</Show> </Show>
<Show when={props.onFork}> <Show when={props.onFork}>
<button <button
class="message-action-button" class="message-action-button"
onClick={() => props.onFork?.(props.record.id)} onClick={() => props.onFork?.(props.record.id)}
title={t("messageItem.actions.forkTitle")} title={t("messageItem.actions.fork")}
aria-label={t("messageItem.actions.forkTitle")} aria-label={t("messageItem.actions.fork")}
> >
{t("messageItem.actions.fork")} <Split class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</Show> </Show>
<button <button
class="message-action-button" class="message-action-button"
onClick={handleCopy} onClick={handleCopy}
title={t("messageItem.actions.copyTitle")} title={copyLabel()}
aria-label={t("messageItem.actions.copyTitle")} aria-label={copyLabel()}
> >
<Show when={copied()} fallback={t("messageItem.actions.copy")}> <Copy class="w-3.5 h-3.5" aria-hidden="true" />
{t("messageItem.actions.copied")}
</Show>
</button> </button>
<Show when={deletableTextPartId()}>
{(partId) => (
<button
class="message-action-button"
onClick={() => void handleDeletePart(partId())}
disabled={isDeletingPart(partId())}
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
</Show>
</div> </div>
</Show> </Show>
<Show when={!isUser()}> <Show when={!isUser()}>
<button <div class="message-action-group">
class="message-action-button" <button
onClick={handleCopy} class="message-action-button"
title={t("messageItem.actions.copyTitle")} onClick={handleCopy}
aria-label={t("messageItem.actions.copyTitle")} title={copyLabel()}
> aria-label={copyLabel()}
<Show when={copied()} fallback={t("messageItem.actions.copy")}> >
{t("messageItem.actions.copied")} <Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<Show when={deletableTextPartId()}>
{(partId) => (
<button
class="message-action-button"
onClick={() => void handleDeletePart(partId())}
disabled={isDeletingPart(partId())}
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
</Show> </Show>
</button> </div>
</Show> </Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time> <time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div> </div>
@@ -337,6 +412,19 @@ export default function MessageItem(props: MessageItemProps) {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg> </svg>
</button> </button>
<button
type="button"
onClick={() => void handleDeletePart(attachment.id)}
class="attachment-remove"
disabled={isDeletingPart(attachment.id)}
aria-label={t("messagePart.actions.deleteTitle")}
title={t("messagePart.actions.deleteTitle")}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<Show when={isImage}> <Show when={isImage}>
<div class="attachment-chip-preview"> <div class="attachment-chip-preview">
<img src={attachment.url} alt={name} /> <img src={attachment.url} alt={name} />

View File

@@ -3,7 +3,7 @@ import Kbd from "./kbd"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary" const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-primary/70" const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
interface MessageListHeaderProps { interface MessageListHeaderProps {
usedTokens: number usedTokens: number

View File

@@ -15,7 +15,7 @@ interface MessagePartProps {
sessionId: string sessionId: string
onRendered?: () => void onRendered?: () => void
} }
export default function MessagePart(props: MessagePartProps) { export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme() const { isDark } = useTheme()
const { preferences } = useConfig() const { preferences } = useConfig()
@@ -32,6 +32,7 @@ interface MessagePartProps {
return Boolean((part as any).synthetic) && props.messageType !== "user" return Boolean((part as any).synthetic) && props.messageType !== "user"
} }
const plainTextContent = () => { const plainTextContent = () => {
const part = props.part const part = props.part
@@ -103,21 +104,21 @@ interface MessagePartProps {
<Match when={partType() === "text"}> <Match when={partType() === "text"}>
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}> <Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div class={textContainerClass()}> <div class={textContainerClass()}>
<Show <Show
when={isAssistantMessage()} when={isAssistantMessage()}
fallback={<span>{plainTextContent()}</span>} fallback={<span class="text-primary">{plainTextContent()}</span>}
> >
<Markdown <Markdown
part={createTextPartForMarkdown()} part={createTextPartForMarkdown()}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
isDark={isDark()} isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"} size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered} onRendered={props.onRendered}
/> />
</Show> </Show>
</div> </div>
</Show> </Show>
</Match> </Match>

View File

@@ -7,6 +7,8 @@ import { getSessionInfo } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import { useScrollCache } from "../lib/hooks/use-scroll-cache" import { useScrollCache } from "../lib/hooks/use-scroll-cache"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { copyToClipboard } from "../lib/clipboard"
import { showToastNotification } from "../lib/notifications"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
const SCROLL_SCOPE = "session" const SCROLL_SCOPE = "session"
@@ -94,6 +96,9 @@ export default function MessageSection(props: MessageSectionProps) {
const seenTimelineMessageIds = new Set<string>() const seenTimelineMessageIds = new Set<string>()
const seenTimelineSegmentKeys = new Set<string>() const seenTimelineSegmentKeys = new Set<string>()
const timelinePartCountsByMessageId = new Map<string, number>()
let pendingTimelineMessagePartUpdates = new Set<string>()
let pendingTimelinePartUpdateFrame: number | null = null
function makeTimelineKey(segment: TimelineSegment) { function makeTimelineKey(segment: TimelineSegment) {
return `${segment.messageId}:${segment.id}:${segment.type}` return `${segment.messageId}:${segment.id}:${segment.type}`
@@ -102,6 +107,7 @@ export default function MessageSection(props: MessageSectionProps) {
function seedTimeline() { function seedTimeline() {
seenTimelineMessageIds.clear() seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear() seenTimelineSegmentKeys.clear()
timelinePartCountsByMessageId.clear()
const ids = untrack(messageIds) const ids = untrack(messageIds)
const resolvedStore = untrack(store) const resolvedStore = untrack(store)
const segments: TimelineSegment[] = [] const segments: TimelineSegment[] = []
@@ -109,6 +115,7 @@ export default function MessageSection(props: MessageSectionProps) {
const record = resolvedStore.getMessage(messageId) const record = resolvedStore.getMessage(messageId)
if (!record) return if (!record) return
seenTimelineMessageIds.add(messageId) seenTimelineMessageIds.add(messageId)
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
const built = buildTimelineSegments(props.instanceId, record, t) const built = buildTimelineSegments(props.instanceId, record, t)
built.forEach((segment) => { built.forEach((segment) => {
const key = makeTimelineKey(segment) const key = makeTimelineKey(segment)
@@ -123,6 +130,7 @@ export default function MessageSection(props: MessageSectionProps) {
function appendTimelineForMessage(messageId: string) { function appendTimelineForMessage(messageId: string) {
const record = untrack(() => store().getMessage(messageId)) const record = untrack(() => store().getMessage(messageId))
if (!record) return if (!record) return
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
const built = buildTimelineSegments(props.instanceId, record, t) const built = buildTimelineSegments(props.instanceId, record, t)
if (built.length === 0) return if (built.length === 0) return
const newSegments: TimelineSegment[] = [] const newSegments: TimelineSegment[] = []
@@ -375,7 +383,9 @@ export default function MessageSection(props: MessageSectionProps) {
const anchorRect = rects.length > 0 ? rects[0] : range.getBoundingClientRect() const anchorRect = rects.length > 0 ? rects[0] : range.getBoundingClientRect()
const shellRect = shell.getBoundingClientRect() const shellRect = shell.getBoundingClientRect()
const relativeTop = Math.max(anchorRect.top - shellRect.top - 40, 8) const relativeTop = Math.max(anchorRect.top - shellRect.top - 40, 8)
const maxLeft = Math.max(shell.clientWidth - 180, 8) // Keep the popover within the stream shell. The quote popover currently
// renders 3 actions; keep enough horizontal room for the pill.
const maxLeft = Math.max(shell.clientWidth - 260, 8)
const relativeLeft = Math.min(Math.max(anchorRect.left - shellRect.left, 8), maxLeft) const relativeLeft = Math.min(Math.max(anchorRect.left - shellRect.left, 8), maxLeft)
setQuoteSelection({ text: limited, top: relativeTop, left: relativeLeft }) setQuoteSelection({ text: limited, top: relativeTop, left: relativeLeft })
} }
@@ -394,6 +404,24 @@ export default function MessageSection(props: MessageSectionProps) {
selection?.removeAllRanges() selection?.removeAllRanges()
} }
} }
async function handleCopySelectionRequest() {
const info = quoteSelection()
if (!info) return
const success = await copyToClipboard(info.text)
showToastNotification({
message: success ? t("messageSection.quote.copied") : t("messageSection.quote.copyFailed"),
variant: success ? "success" : "error",
duration: success ? 2000 : 6000,
})
clearQuoteSelection()
if (typeof window !== "undefined") {
const selection = window.getSelection()
selection?.removeAllRanges()
}
}
function handleContentRendered() { function handleContentRendered() {
if (props.loading) { if (props.loading) {
@@ -468,8 +496,6 @@ export default function MessageSection(props: MessageSectionProps) {
}) })
let previousTimelineIds: string[] = [] let previousTimelineIds: string[] = []
let previousLastTimelineMessageId: string | null = null
let previousLastTimelinePartCount = 0
createEffect(() => { createEffect(() => {
const loading = Boolean(props.loading) const loading = Boolean(props.loading)
@@ -477,11 +503,15 @@ export default function MessageSection(props: MessageSectionProps) {
if (loading) { if (loading) {
previousTimelineIds = [] previousTimelineIds = []
previousLastTimelineMessageId = null
previousLastTimelinePartCount = 0
setTimelineSegments([]) setTimelineSegments([])
seenTimelineMessageIds.clear() seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear() seenTimelineSegmentKeys.clear()
timelinePartCountsByMessageId.clear()
pendingTimelineMessagePartUpdates.clear()
if (pendingTimelinePartUpdateFrame !== null) {
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
pendingTimelinePartUpdateFrame = null
}
return return
} }
@@ -523,6 +553,14 @@ export default function MessageSection(props: MessageSectionProps) {
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment))) next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next return next
}) })
// Keep part count tracking in sync with id replacement.
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
if (existingPartCount !== undefined) {
timelinePartCountsByMessageId.delete(oldId)
timelinePartCountsByMessageId.set(newId, existingPartCount)
}
previousTimelineIds = ids.slice() previousTimelineIds = ids.slice()
return return
} }
@@ -546,30 +584,95 @@ export default function MessageSection(props: MessageSectionProps) {
previousTimelineIds = ids.slice() previousTimelineIds = ids.slice()
}) })
function clearPendingTimelinePartUpdateFrame() {
if (pendingTimelinePartUpdateFrame !== null) {
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
pendingTimelinePartUpdateFrame = null
}
}
function scheduleTimelinePartUpdateFlush() {
if (pendingTimelinePartUpdateFrame !== null) return
pendingTimelinePartUpdateFrame = requestAnimationFrame(() => {
pendingTimelinePartUpdateFrame = null
if (pendingTimelineMessagePartUpdates.size === 0) return
const changedIds = Array.from(pendingTimelineMessagePartUpdates)
pendingTimelineMessagePartUpdates = new Set<string>()
const ids = messageIds()
const resolvedStore = store()
setTimelineSegments((prev) => {
let next = prev
for (const changedId of changedIds) {
// Remove old segments for this message.
next = next.filter((segment) => segment.messageId !== changedId)
const record = resolvedStore.getMessage(changedId)
const rebuilt = record ? buildTimelineSegments(props.instanceId, record, t) : []
// Insert rebuilt segments in the correct place based on session message order.
if (rebuilt.length > 0) {
let insertAt = next.length
const changedIndex = ids.indexOf(changedId)
if (changedIndex >= 0) {
for (let i = changedIndex + 1; i < ids.length; i++) {
const followingId = ids[i]
const existingIndex = next.findIndex((segment) => segment.messageId === followingId)
if (existingIndex >= 0) {
insertAt = existingIndex
break
}
}
}
next = [...next.slice(0, insertAt), ...rebuilt, ...next.slice(insertAt)]
}
}
// Rebuild the segment key set since we may have removed/replaced segments.
seenTimelineSegmentKeys.clear()
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next
})
})
}
// Keep timeline segments in sync when message parts are added/removed.
// Part deletion does not remove message ids from the session, so we must
// explicitly replace segments for messages whose part count changed.
createEffect(() => { createEffect(() => {
if (props.loading) return if (props.loading) return
const ids = messageIds() const ids = messageIds()
if (ids.length === 0) return const resolvedStore = store()
const lastId = ids[ids.length - 1]
if (!lastId) return let hasChanges = false
const record = store().getMessage(lastId) for (const messageId of ids) {
if (!record) return const record = resolvedStore.getMessage(messageId)
const partCount = record.partIds.length const partCount = record?.partIds.length ?? 0
if (lastId === previousLastTimelineMessageId && partCount === previousLastTimelinePartCount) { const previousCount = timelinePartCountsByMessageId.get(messageId)
return
if (previousCount === undefined) {
timelinePartCountsByMessageId.set(messageId, partCount)
continue
}
if (previousCount !== partCount) {
timelinePartCountsByMessageId.set(messageId, partCount)
pendingTimelineMessagePartUpdates.add(messageId)
hasChanges = true
}
} }
previousLastTimelineMessageId = lastId
previousLastTimelinePartCount = partCount // Drop tracking for ids that are no longer present.
const built = buildTimelineSegments(props.instanceId, record, t) for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
const newSegments: TimelineSegment[] = [] if (!ids.includes(trackedId)) {
built.forEach((segment) => { timelinePartCountsByMessageId.delete(trackedId)
const key = makeTimelineKey(segment) }
if (seenTimelineSegmentKeys.has(key)) return }
seenTimelineSegmentKeys.add(key)
newSegments.push(segment) if (hasChanges) {
}) scheduleTimelinePartUpdateFlush()
if (newSegments.length > 0) {
setTimelineSegments((prev) => [...prev, ...newSegments])
} }
}) })
@@ -736,6 +839,7 @@ export default function MessageSection(props: MessageSectionProps) {
cancelAnimationFrame(pendingAnchorScroll) cancelAnimationFrame(pendingAnchorScroll)
} }
clearScrollToBottomFrames() clearScrollToBottomFrames()
clearPendingTimelinePartUpdateFrame()
if (detachScrollIntentListeners) { if (detachScrollIntentListeners) {
detachScrollIntentListeners() detachScrollIntentListeners()
} }
@@ -835,6 +939,9 @@ export default function MessageSection(props: MessageSectionProps) {
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}> <button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
{t("messageSection.quote.addAsCode")} {t("messageSection.quote.addAsCode")}
</button> </button>
<button type="button" class="message-quote-button" onClick={() => void handleCopySelectionRequest()}>
{t("messageSection.quote.copy")}
</button>
</div> </div>
</div> </div>
)} )}

View File

@@ -276,6 +276,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
const [tooltipSize, setTooltipSize] = createSignal<{ width: number; height: number }>({ width: 360, height: 420 }) const [tooltipSize, setTooltipSize] = createSignal<{ width: number; height: number }>({ width: 360, height: 420 })
const [tooltipElement, setTooltipElement] = createSignal<HTMLDivElement | null>(null) const [tooltipElement, setTooltipElement] = createSignal<HTMLDivElement | null>(null)
let hoverTimer: number | null = null let hoverTimer: number | null = null
let closeTimer: number | null = null
const showTools = () => props.showToolSegments ?? true const showTools = () => props.showToolSegments ?? true
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => { const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
@@ -292,10 +293,30 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
hoverTimer = null hoverTimer = null
} }
} }
const clearCloseTimer = () => {
if (closeTimer !== null && typeof window !== "undefined") {
window.clearTimeout(closeTimer)
closeTimer = null
}
}
const scheduleClose = () => {
if (typeof window === "undefined") return
clearHoverTimer()
clearCloseTimer()
// Small delay so the pointer can travel from the segment to the tooltip.
closeTimer = window.setTimeout(() => {
closeTimer = null
setHoveredSegment(null)
setHoverAnchorRect(null)
}, 160)
}
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => { const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
if (typeof window === "undefined") return if (typeof window === "undefined") return
clearHoverTimer() clearHoverTimer()
clearCloseTimer()
const target = event.currentTarget as HTMLButtonElement const target = event.currentTarget as HTMLButtonElement
hoverTimer = window.setTimeout(() => { hoverTimer = window.setTimeout(() => {
const rect = target.getBoundingClientRect() const rect = target.getBoundingClientRect()
@@ -305,9 +326,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
const handleMouseLeave = () => { const handleMouseLeave = () => {
clearHoverTimer() scheduleClose()
setHoveredSegment(null)
setHoverAnchorRect(null)
} }
createEffect(() => { createEffect(() => {
@@ -326,7 +345,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
setTooltipCoords({ top: clampedTop, left: clampedLeft }) setTooltipCoords({ top: clampedTop, left: clampedLeft })
}) })
onCleanup(() => clearHoverTimer()) onCleanup(() => {
clearHoverTimer()
clearCloseTimer()
})
createEffect(() => { createEffect(() => {
const activeId = props.activeMessageId const activeId = props.activeMessageId
@@ -432,6 +454,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
ref={(element) => setTooltipElement(element)} ref={(element) => setTooltipElement(element)}
class="message-timeline-tooltip" class="message-timeline-tooltip"
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }} style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
onMouseEnter={() => clearCloseTimer()}
onMouseLeave={() => scheduleClose()}
> >
<MessagePreview <MessagePreview
messageId={data().messageId} messageId={data().messageId}

View File

@@ -0,0 +1,232 @@
import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect, createResource } from "solid-js"
import { showToastNotification } from "../lib/notifications"
import {
getOsNotificationCapability,
requestOsNotificationPermission,
type OsNotificationPermission,
} from "../lib/os-notifications"
import { useConfig } from "../stores/preferences"
interface NotificationsSettingsModalProps {
open: boolean
onClose: () => void
}
function formatPermissionLabel(permission: OsNotificationPermission): string {
switch (permission) {
case "granted":
return "Granted"
case "denied":
return "Denied"
case "default":
return "Not granted"
case "unsupported":
return "Unsupported"
default:
return String(permission)
}
}
const NotificationsSettingsModal: Component<NotificationsSettingsModalProps> = (props) => {
const { preferences, updatePreferences } = useConfig()
const [capability, { refetch }] = createResource(() => getOsNotificationCapability())
createEffect(() => {
if (props.open) {
void refetch()
}
})
const handleEnableToggle = async (enabled: boolean) => {
if (!enabled) {
updatePreferences({ osNotificationsEnabled: false })
return
}
const cap = capability()
if (cap && !cap.supported) {
showToastNotification({
title: "Notifications",
message: cap.info ?? "OS notifications are not supported in this environment.",
variant: "warning",
})
updatePreferences({ osNotificationsEnabled: false })
return
}
const permission = await requestOsNotificationPermission()
if (permission !== "granted") {
showToastNotification({
title: "Notifications",
message:
permission === "denied"
? "Notification permission denied. Enable notifications in your system/browser settings."
: "Notification permission not granted.",
variant: "warning",
})
updatePreferences({ osNotificationsEnabled: false })
return
}
updatePreferences({ osNotificationsEnabled: true })
void refetch()
}
const handleRequestPermission = async () => {
const cap = capability()
if (cap && !cap.supported) {
showToastNotification({
title: "Notifications",
message: cap.info ?? "Notifications are not supported in this environment.",
variant: "warning",
})
return
}
const permission = await requestOsNotificationPermission()
if (permission === "granted") {
showToastNotification({
title: "Notifications",
message: "Permission granted. You can now enable notifications.",
variant: "success",
duration: 6000,
})
void refetch()
return
}
showToastNotification({
title: "Notifications",
message:
permission === "denied"
? "Permission denied. You may need to enable notifications in your system/browser settings."
: "Permission not granted.",
variant: "warning",
})
void refetch()
}
const supported = () => capability()?.supported ?? false
const permissionLabel = () => formatPermissionLabel(capability()?.permission ?? "unsupported")
const infoMessage = () => capability()?.info
return (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-xl max-h-[90vh] flex flex-col overflow-hidden">
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
<Dialog.Title class="text-xl font-semibold text-primary">Notifications</Dialog.Title>
</header>
<div class="flex-1 overflow-y-auto p-6 space-y-6">
<div class="panel">
<div class="panel-header">
<h3 class="panel-title">Session Status Notifications</h3>
</div>
<div class="panel-body space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-sm font-semibold text-primary">Enable</div>
<div class="text-xs text-secondary">Permission: {permissionLabel()}</div>
</div>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(preferences().osNotificationsEnabled)}
disabled={!supported() && capability.state === "ready"}
onChange={(e) => void handleEnableToggle(e.currentTarget.checked)}
/>
<span class="text-sm">Enabled</span>
</label>
</div>
<Show when={supported() && (capability()?.permission ?? "unsupported") !== "granted"}>
<div class="flex items-center justify-between gap-4">
<div class="text-sm text-primary">Request permission</div>
<button
type="button"
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
onClick={() => void handleRequestPermission()}
>
Request
</button>
</div>
</Show>
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-sm font-semibold text-primary">Notify when app is focused</div>
</div>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(preferences().osNotificationsAllowWhenVisible)}
disabled={!preferences().osNotificationsEnabled}
onChange={(e) => updatePreferences({ osNotificationsAllowWhenVisible: e.currentTarget.checked })}
/>
<span class="text-sm">Enabled</span>
</label>
</div>
<Show when={Boolean(infoMessage())}>
<div class="text-xs text-secondary">{infoMessage()}</div>
</Show>
<Show when={!supported() && capability.state === "ready"}>
<div class="text-xs text-secondary">
Notifications are not supported in this environment. The bell icon stays disabled.
</div>
</Show>
<div class="border-t pt-4" style={{ "border-color": "var(--border-base)" }}>
<div class="text-sm font-semibold text-primary mb-2">Notify me when</div>
<div class="space-y-3">
<div class="flex items-center justify-between gap-4">
<div class="text-sm text-primary">Session needs input</div>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(preferences().notifyOnNeedsInput)}
disabled={!preferences().osNotificationsEnabled}
onChange={(e) => updatePreferences({ notifyOnNeedsInput: e.currentTarget.checked })}
/>
<span class="text-sm">Enabled</span>
</label>
</div>
<div class="flex items-center justify-between gap-4">
<div class="text-sm text-primary">Session becomes idle</div>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(preferences().notifyOnIdle)}
disabled={!preferences().osNotificationsEnabled}
onChange={(e) => updatePreferences({ notifyOnIdle: e.currentTarget.checked })}
/>
<span class="text-sm">Enabled</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="px-6 py-4 border-t flex justify-end" style={{ "border-color": "var(--border-base)" }}>
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
Close
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}
export default NotificationsSettingsModal

View File

@@ -16,6 +16,7 @@ import { getCommands } from "../stores/commands"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { preferences } from "../stores/preferences"
const log = getLogger("actions") const log = getLogger("actions")
@@ -542,13 +543,46 @@ export default function PromptInput(props: PromptInputProps) {
} }
} }
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { if (e.key === "Enter") {
e.preventDefault() const isModified = e.metaKey || e.ctrlKey
if (showPicker()) {
handlePickerClose() // If the picker is open, Enter should select from it.
if (!isModified && showPicker()) {
return
}
if (submitOnEnter()) {
// Swapped mode: Enter submits, Cmd/Ctrl+Enter inserts a newline.
if (isModified) {
e.preventDefault()
e.stopPropagation()
insertNewlineAtCursor()
return
}
// Shift+Enter always inserts a newline.
if (e.shiftKey) {
// If the picker is open, avoid selecting an item on Enter.
if (showPicker()) {
e.stopPropagation()
}
return
}
e.preventDefault()
handleSend()
return
}
// Default: Cmd/Ctrl+Enter submits.
if (isModified) {
e.preventDefault()
if (showPicker()) {
handlePickerClose()
}
handleSend()
return
} }
handleSend()
return
} }
if (e.key === "ArrowUp") { if (e.key === "ArrowUp") {
@@ -1021,7 +1055,7 @@ export default function PromptInput(props: PromptInputProps) {
const blockquote = lines.map((line) => `> ${line}`).join("\n") const blockquote = lines.map((line) => `> ${line}`).join("\n")
if (!blockquote) return if (!blockquote) return
insertBlockContent(`${blockquote}\n\n`) insertBlockContent(`${blockquote}\n`)
} }
function insertCodeSelection(rawText: string) { function insertCodeSelection(rawText: string) {
@@ -1056,6 +1090,25 @@ export default function PromptInput(props: PromptInputProps) {
: { key: "!", text: t("promptInput.hints.shell.enable") } : { key: "!", text: t("promptInput.hints.shell.enable") }
const commandHint = () => ({ key: "/", text: t("promptInput.hints.commands") }) const commandHint = () => ({ key: "/", text: t("promptInput.hints.commands") })
const submitOnEnter = () => preferences().promptSubmitOnEnter
function insertNewlineAtCursor() {
const textarea = textareaRef
const current = prompt()
const start = textarea ? textarea.selectionStart : current.length
const end = textarea ? textarea.selectionEnd : current.length
const nextValue = current.substring(0, start) + "\n" + current.substring(end)
const nextCursor = start + 1
setPrompt(nextValue)
setTimeout(() => {
if (!textareaRef) return
textareaRef.focus()
textareaRef.setSelectionRange(nextCursor, nextCursor)
}, 0)
}
const shouldShowOverlay = () => prompt().length === 0 const shouldShowOverlay = () => prompt().length === 0
const instance = () => getActiveInstance() const instance = () => getActiveInstance()
@@ -1142,7 +1195,19 @@ export default function PromptInput(props: PromptInputProps) {
fallback={ fallback={
<> <>
<span class="prompt-overlay-text"> <span class="prompt-overlay-text">
<Kbd>Enter</Kbd> {t("promptInput.overlay.newLine")} <Kbd shortcut="cmd+enter" /> {t("promptInput.overlay.send")} <Kbd>@</Kbd> {t("promptInput.overlay.filesAgents")} <Kbd></Kbd> {t("promptInput.overlay.history")} <Show
when={submitOnEnter()}
fallback={
<>
<Kbd>Enter</Kbd> {t("promptInput.overlay.newLine")} <Kbd shortcut="cmd+enter" /> {t("promptInput.overlay.send")}
</>
}
>
<>
<Kbd>Enter</Kbd> {t("promptInput.overlay.send")} <Kbd shortcut="cmd+enter" /> {t("promptInput.overlay.newLine")}
</>
</Show>
{" "} <Kbd>@</Kbd> {t("promptInput.overlay.filesAgents")} <Kbd></Kbd> {t("promptInput.overlay.history")}
</span> </span>
<Show when={attachments().length > 0}> <Show when={attachments().length > 0}>
<span class="prompt-overlay-text prompt-overlay-muted">{t("promptInput.overlay.attachments", { count: attachments().length })}</span> <span class="prompt-overlay-text prompt-overlay-muted">{t("promptInput.overlay.attachments", { count: attachments().length })}</span>

View File

@@ -37,10 +37,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const allowExternalConnections = createMemo(() => currentMode() === "all") const allowExternalConnections = createMemo(() => currentMode() === "all")
const displayAddresses = createMemo(() => { const displayAddresses = createMemo(() => {
const list = addresses() const list = addresses()
if (allowExternalConnections()) { if (!allowExternalConnections()) {
return list.filter((address) => address.scope !== "loopback") return []
} }
return list.filter((address) => address.scope === "loopback") // Local URL is displayed separately; list only remote-friendly addresses.
return list.filter((address) => address.scope !== "loopback")
}) })
const refreshMeta = async () => { const refreshMeta = async () => {
@@ -311,34 +312,27 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}> <Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}> <Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
<div class="remote-address-list"> <div class="remote-address-list">
<For each={displayAddresses()}> <Show when={meta()?.localUrl}>
{(address) => { {(url) => {
const expandedState = () => expandedUrl() === address.url const value = () => url()
const qr = () => qrCodes()[address.url] const expandedState = () => expandedUrl() === value()
const scopeLabel = () => const qr = () => qrCodes()[value()]
address.scope === "external"
? t("remoteAccess.address.scope.network")
: address.scope === "loopback"
? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal")
return ( return (
<div class="remote-address"> <div class="remote-address">
<div class="remote-address-main"> <div class="remote-address-main">
<div> <div>
<p class="remote-address-url">{address.url}</p> <p class="remote-address-url">{value()}</p>
<p class="remote-address-meta"> <p class="remote-address-meta">{t("remoteAccess.address.scope.loopback")}</p>
{address.family.toUpperCase()} {scopeLabel()} {address.ip}
</p>
</div> </div>
<div class="remote-actions"> <div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(address.url)}> <button class="remote-pill" type="button" onClick={() => handleOpenUrl(value())}>
<ExternalLink class="remote-icon" /> <ExternalLink class="remote-icon" />
{t("remoteAccess.address.open")} {t("remoteAccess.address.open")}
</button> </button>
<button <button
class="remote-pill" class="remote-pill"
type="button" type="button"
onClick={() => void toggleExpanded(address.url)} onClick={() => void toggleExpanded(value())}
aria-expanded={expandedState()} aria-expanded={expandedState()}
> >
<Link2 class="remote-icon" /> <Link2 class="remote-icon" />
@@ -352,7 +346,60 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
{(dataUrl) => ( {(dataUrl) => (
<img <img
src={dataUrl()} src={dataUrl()}
alt={t("remoteAccess.address.qrAlt", { url: address.url })} alt={t("remoteAccess.address.qrAlt", { url: value() })}
class="remote-qr-img"
/>
)}
</Show>
</div>
</Show>
</div>
)
}}
</Show>
<For each={displayAddresses()}>
{(address) => {
const url = address.remoteUrl
const expandedState = () => expandedUrl() === url
const qr = () => qrCodes()[url]
const scopeLabel = () =>
address.scope === "external"
? t("remoteAccess.address.scope.network")
: address.scope === "loopback"
? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal")
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{url}</p>
<p class="remote-address-meta">
{address.family.toUpperCase()} {scopeLabel()} {address.ip}
</p>
</div>
<div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
<ExternalLink class="remote-icon" />
{t("remoteAccess.address.open")}
</button>
<button
class="remote-pill"
type="button"
onClick={() => void toggleExpanded(url)}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
</button>
</div>
</div>
<Show when={expandedState()}>
<div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => (
<img
src={dataUrl()}
alt={t("remoteAccess.address.qrAlt", { url })}
class="remote-qr-img" class="remote-qr-img"
/> />
)} )}

View File

@@ -2,12 +2,13 @@ import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCl
import type { SessionStatus } from "../types/session" import type { SessionStatus } from "../types/session"
import type { SessionThread } from "../stores/session-state" import type { SessionThread } from "../stores/session-state"
import { getSessionStatus } from "../stores/session-status" import { getSessionStatus } from "../stores/session-status"
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid" import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split } from "lucide-solid"
import KeyboardHint from "./keyboard-hint" import KeyboardHint from "./keyboard-hint"
import SessionRenameDialog from "./session-rename-dialog" import SessionRenameDialog from "./session-rename-dialog"
import { keyboardRegistry } from "../lib/keyboard-registry" import { keyboardRegistry } from "../lib/keyboard-registry"
import { showToastNotification } from "../lib/notifications" import { showToastNotification } from "../lib/notifications"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { showConfirmDialog } from "../stores/alerts"
import { import {
deleteSession, deleteSession,
ensureSessionParentExpanded, ensureSessionParentExpanded,
@@ -19,6 +20,7 @@ import {
setActiveSessionFromList, setActiveSessionFromList,
toggleSessionParentExpanded, toggleSessionParentExpanded,
} from "../stores/sessions" } from "../stores/sessions"
import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard" import { copyToClipboard } from "../lib/clipboard"
const log = getLogger("session") const log = getLogger("session")
@@ -35,6 +37,7 @@ interface SessionListProps {
showFooter?: boolean showFooter?: boolean
headerContent?: JSX.Element headerContent?: JSX.Element
footerContent?: JSX.Element footerContent?: JSX.Element
enableFilterBar?: boolean
} }
function formatSessionStatus(status: SessionStatus): string { function formatSessionStatus(status: SessionStatus): string {
@@ -46,6 +49,70 @@ const SessionList: Component<SessionListProps> = (props) => {
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null) const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
const [isRenaming, setIsRenaming] = createSignal(false) const [isRenaming, setIsRenaming] = createSignal(false)
const [filterQuery, setFilterQuery] = createSignal("")
const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : ""))
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
const normalizeSessionLabel = (sessionId: string) => {
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
const title = (session?.title ?? "").trim()
return title || t("sessionList.session.untitled")
}
const sessionMatchesQuery = (sessionId: string, query: string) => {
if (!query) return true
const label = normalizeSessionLabel(sessionId).toLowerCase()
if (label.includes(query)) return true
return sessionId.toLowerCase().includes(query)
}
const filteredThreads = createMemo<SessionThread[]>(() => {
const query = normalizedQuery()
if (!query) return props.threads
const next: SessionThread[] = []
for (const thread of props.threads) {
const parentMatches = sessionMatchesQuery(thread.parent.id, query)
const matchingChildren = thread.children.filter((child) => sessionMatchesQuery(child.id, query))
if (!parentMatches && matchingChildren.length === 0) continue
next.push({
parent: thread.parent,
children: matchingChildren,
latestUpdated: thread.latestUpdated,
})
}
return next
})
const allMatchingSessionIds = createMemo<string[]>(() => {
const ids: string[] = []
for (const thread of filteredThreads()) {
ids.push(thread.parent.id)
for (const child of thread.children) ids.push(child.id)
}
return ids
})
const selectedCount = createMemo(() => selectedSessionIds().size)
const isAllSelected = createMemo(() => {
const ids = allMatchingSessionIds()
if (ids.length === 0) return false
const selected = selectedSessionIds()
return ids.every((id) => selected.has(id))
})
const isSelectAllIndeterminate = createMemo(() => {
const ids = allMatchingSessionIds()
const total = ids.length
if (total === 0) return false
const count = selectedCount()
return count > 0 && count < total
})
const isSessionDeleting = (sessionId: string) => { const isSessionDeleting = (sessionId: string) => {
const deleting = loading().deletingSession.get(props.instanceId) const deleting = loading().deletingSession.get(props.instanceId)
return deleting ? deleting.has(sessionId) : false return deleting ? deleting.has(sessionId) : false
@@ -54,9 +121,10 @@ const SessionList: Component<SessionListProps> = (props) => {
const selectSession = (sessionId: string) => { const selectSession = (sessionId: string) => {
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId) const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
const parentId = session?.parentId ?? session?.id // If the user selects a child session, make sure its parent thread is expanded.
if (parentId) { // For parent sessions we don't force expansion; user can collapse/expand freely.
ensureSessionParentExpanded(props.instanceId, parentId) if (session?.parentId) {
ensureSessionParentExpanded(props.instanceId, session.parentId)
} }
props.onSelect(sessionId) props.onSelect(sessionId)
@@ -82,6 +150,17 @@ const SessionList: Component<SessionListProps> = (props) => {
event.stopPropagation() event.stopPropagation()
if (isSessionDeleting(sessionId)) return if (isSessionDeleting(sessionId)) return
const confirmed = await showConfirmDialog(
t("sessionList.delete.confirmMessage", { label: normalizeSessionLabel(sessionId) }),
{
title: t("sessionList.delete.title"),
variant: "warning",
confirmLabel: t("sessionList.delete.confirmLabel"),
cancelLabel: t("sessionList.delete.cancelLabel"),
},
)
if (!confirmed) return
const shouldSelectFallback = props.activeSessionId === sessionId const shouldSelectFallback = props.activeSessionId === sessionId
let fallbackSessionId: string | undefined let fallbackSessionId: string | undefined
@@ -152,6 +231,115 @@ const SessionList: Component<SessionListProps> = (props) => {
setIsRenaming(false) setIsRenaming(false)
} }
} }
const setSelectedMany = (sessionIds: string[], checked: boolean) => {
if (sessionIds.length === 0) return
setSelectedSessionIds((prev) => {
const next = new Set(prev)
sessionIds.forEach((id) => {
if (checked) next.add(id)
else next.delete(id)
})
return next
})
}
const getSelectableThreadIds = (parentId: string): string[] => {
const query = normalizedQuery()
const source = query ? filteredThreads() : props.threads
const thread = source.find((t) => t.parent.id === parentId)
if (!thread) return [parentId]
return [thread.parent.id, ...thread.children.map((c) => c.id)]
}
const getAllSessionIdsInOrder = (threads: SessionThread[]): string[] => {
const ids: string[] = []
threads.forEach((thread) => {
ids.push(thread.parent.id)
thread.children.forEach((child) => ids.push(child.id))
})
return ids
}
const handleToggleSelectAll = (checked: boolean) => {
const ids = allMatchingSessionIds()
setSelectedMany(ids, checked)
}
const toggleSelectAll = () => {
if (isAllSelected()) {
handleToggleSelectAll(false)
return
}
handleToggleSelectAll(true)
}
const handleBulkDelete = async () => {
const selected = Array.from(selectedSessionIds())
if (selected.length === 0) return
const confirmed = await showConfirmDialog(
t("sessionList.bulkDelete.confirmMessage", { count: selected.length }),
{
title: t("sessionList.bulkDelete.title"),
variant: "warning",
confirmLabel: t("sessionList.bulkDelete.confirmLabel"),
cancelLabel: t("sessionList.bulkDelete.cancelLabel"),
},
)
if (!confirmed) return
const deletedSet = new Set(selected)
const currentActiveId = props.activeSessionId
let fallbackSessionId: string | undefined
if (currentActiveId && deletedSet.has(currentActiveId)) {
const ordered = getAllSessionIdsInOrder(props.threads)
const currentIndex = ordered.indexOf(currentActiveId)
for (let i = Math.max(0, currentIndex); i < ordered.length; i++) {
const candidate = ordered[i]
if (candidate && !deletedSet.has(candidate)) {
fallbackSessionId = candidate
break
}
}
if (!fallbackSessionId) {
for (let i = currentIndex - 1; i >= 0; i--) {
const candidate = ordered[i]
if (candidate && !deletedSet.has(candidate)) {
fallbackSessionId = candidate
break
}
}
}
}
let failed = 0
for (const sessionId of selected) {
try {
// eslint-disable-next-line no-await-in-loop
await deleteSession(props.instanceId, sessionId)
} catch (error) {
failed += 1
log.error(`Failed to delete session ${sessionId}:`, error)
}
}
setSelectedSessionIds(new Set<string>())
if (fallbackSessionId) {
setActiveSessionFromList(props.instanceId, fallbackSessionId)
}
if (failed > 0) {
showToastNotification({
message: t("sessionList.bulkDelete.error", { count: failed }),
variant: "error",
})
}
}
const SessionRow: Component<{ const SessionRow: Component<{
@@ -166,6 +354,19 @@ const SessionList: Component<SessionListProps> = (props) => {
if (!session()) { if (!session()) {
return <></> return <></>
} }
const worktreeSlug = createMemo(() => {
if (rowProps.isChild) return "root"
return getWorktreeSlugForParentSession(props.instanceId, rowProps.sessionId)
})
const showWorktreeBadge = createMemo(() => {
if (rowProps.isChild) return false
if (getGitRepoStatus(props.instanceId) === false) return false
const slug = worktreeSlug()
return Boolean(slug) && slug !== "root"
})
const isActive = () => props.activeSessionId === rowProps.sessionId const isActive = () => props.activeSessionId === rowProps.sessionId
const title = () => session()?.title || t("sessionList.session.untitled") const title = () => session()?.title || t("sessionList.session.untitled")
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId) const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
@@ -190,9 +391,31 @@ const SessionList: Component<SessionListProps> = (props) => {
? t("sessionList.status.needsInput") ? t("sessionList.status.needsInput")
: statusLabel() : statusLabel()
return ( const isSelected = () => selectedSessionIds().has(rowProps.sessionId)
<div class="session-list-item group">
const parentGroupState = createMemo(() => {
if (rowProps.isChild) {
return { checked: isSelected(), indeterminate: false, ids: [rowProps.sessionId] }
}
const ids = getSelectableThreadIds(rowProps.sessionId)
const selected = selectedSessionIds()
const selectedInGroup = ids.reduce((count, id) => (selected.has(id) ? count + 1 : count), 0)
return {
checked: selectedInGroup > 0 && selectedInGroup === ids.length,
indeterminate: selectedInGroup > 0 && selectedInGroup < ids.length,
ids,
}
})
let rowCheckboxEl: HTMLInputElement | null = null
createEffect(() => {
if (!rowCheckboxEl) return
rowCheckboxEl.indeterminate = parentGroupState().indeterminate
})
return (
<div class="session-list-item group">
<button <button
class={`session-item-base ${rowProps.isChild ? `session-item-child${rowProps.isLastChild ? " session-item-child-last" : ""} session-item-border-assistant session-item-kind-assistant` : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`} class={`session-item-base ${rowProps.isChild ? `session-item-child${rowProps.isLastChild ? " session-item-child-last" : ""} session-item-border-assistant session-item-kind-assistant` : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`}
data-session-id={rowProps.sessionId} data-session-id={rowProps.sessionId}
@@ -204,11 +427,23 @@ const SessionList: Component<SessionListProps> = (props) => {
> >
<div class="session-item-row session-item-header"> <div class="session-item-row session-item-header">
<div class="session-item-title-row"> <div class="session-item-title-row">
{rowProps.isChild ? ( <Show when={props.enableFilterBar}>
<Bot class="w-4 h-4 flex-shrink-0" /> <input
) : ( ref={(el) => {
<User class="w-4 h-4 flex-shrink-0" /> rowCheckboxEl = el
)} }}
type="checkbox"
checked={parentGroupState().checked}
onClick={(event) => event.stopPropagation()}
onChange={(event) => {
event.stopPropagation()
setSelectedMany(parentGroupState().ids, event.currentTarget.checked)
}}
aria-label={t("sessionList.selection.checkboxAriaLabel")}
/>
</Show>
{rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />}
<span class="session-item-title session-item-title--clamp">{title()}</span> <span class="session-item-title session-item-title--clamp">{title()}</span>
</div> </div>
</div> </div>
@@ -216,9 +451,7 @@ const SessionList: Component<SessionListProps> = (props) => {
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<Show <Show
when={rowProps.hasChildren && !rowProps.isChild} when={rowProps.hasChildren && !rowProps.isChild}
fallback={ fallback={rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />}
rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />
}
> >
<span <span
class={`session-item-expander opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`} class={`session-item-expander opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
@@ -228,20 +461,24 @@ const SessionList: Component<SessionListProps> = (props) => {
}} }}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label={rowProps.expanded ? t("sessionList.expand.collapseAriaLabel") : t("sessionList.expand.expandAriaLabel")} aria-label={
rowProps.expanded ? t("sessionList.expand.collapseAriaLabel") : t("sessionList.expand.expandAriaLabel")
}
title={rowProps.expanded ? t("sessionList.expand.collapseTitle") : t("sessionList.expand.expandTitle")} title={rowProps.expanded ? t("sessionList.expand.collapseTitle") : t("sessionList.expand.expandTitle")}
> >
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} /> <ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
</span> </span>
</Show> </Show>
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}> <span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
{needsInput() ? ( {needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
) : (
<span class="status-dot" />
)}
{statusText()} {statusText()}
</span> </span>
<Show when={showWorktreeBadge()}>
<span class="status-indicator session-status-list worktree-indicator" title={`Worktree: ${worktreeSlug()}`}>
<Split class="w-3.5 h-3.5" aria-hidden="true" />
<span class="worktree-indicator-label">{worktreeSlug()}</span>
</span>
</Show>
</div> </div>
<div class="session-item-actions"> <div class="session-item-actions">
<span <span
@@ -309,6 +546,13 @@ const SessionList: Component<SessionListProps> = (props) => {
}) })
createEffect(() => { createEffect(() => {
// Keep the active child session visible by ensuring its parent is expanded.
// Don't force-expanding when the active session itself is a parent lets users collapse it.
const activeId = props.activeSessionId
if (!activeId || activeId === "info") return
const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId)
if (!activeSession) return
if (!activeSession.parentId) return
const parentId = activeParentId() const parentId = activeParentId()
if (!parentId) return if (!parentId) return
ensureSessionParentExpanded(props.instanceId, parentId) ensureSessionParentExpanded(props.instanceId, parentId)
@@ -365,6 +609,63 @@ const SessionList: Component<SessionListProps> = (props) => {
<div <div
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full" class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full"
> >
<Show when={props.enableFilterBar}>
<div class="p-3 border-b border-base">
<div class="flex items-center gap-2">
<div class="relative flex-1 min-w-0">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted" aria-hidden="true">
<Search class="w-4 h-4" />
</span>
<input
type="text"
class="form-input pl-9"
value={filterQuery()}
onInput={(e) => setFilterQuery(e.currentTarget.value)}
placeholder={t("sessionList.filter.placeholder")}
aria-label={t("sessionList.filter.ariaLabel")}
/>
</div>
<button
type="button"
class="button-tertiary p-2 inline-flex items-center justify-center"
onClick={toggleSelectAll}
disabled={allMatchingSessionIds().length === 0}
aria-label={t("sessionList.selection.selectAllAriaLabel")}
title={t("sessionList.selection.selectAllLabel")}
>
<Show
when={isSelectAllIndeterminate()}
fallback={isAllSelected() ? <CheckSquare class="w-4 h-4" /> : <Square class="w-4 h-4" />}
>
<MinusSquare class="w-4 h-4" />
</Show>
</button>
</div>
<Show when={selectedCount() > 0}>
<div class="mt-2 flex items-center justify-end gap-2">
<button
type="button"
class="button-tertiary"
onClick={handleBulkDelete}
aria-label={t("sessionList.bulkDelete.ariaLabel", { count: selectedCount() })}
>
{t("sessionList.bulkDelete.button", { count: selectedCount() })}
</button>
<button
type="button"
class="button-tertiary"
onClick={() => setSelectedSessionIds(new Set<string>())}
aria-label={t("sessionList.selection.clearAriaLabel")}
>
{t("sessionList.selection.clearLabel")}
</button>
</div>
</Show>
</div>
</Show>
<Show when={props.showHeader !== false}> <Show when={props.showHeader !== false}>
<div class="session-list-header p-3 border-b border-base"> <div class="session-list-header p-3 border-b border-base">
{props.headerContent ?? ( {props.headerContent ?? (
@@ -378,33 +679,33 @@ const SessionList: Component<SessionListProps> = (props) => {
</div> </div>
</Show> </Show>
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}> <div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
<Show when={props.threads.length > 0}> <Show when={filteredThreads().length > 0}>
<div class="session-section"> <div class="session-section">
<For each={props.threads}> <For each={filteredThreads()}>
{(thread) => { {(thread) => {
const expanded = () => isSessionParentExpanded(props.instanceId, thread.parent.id) const expanded = () => (normalizedQuery() ? true : isSessionParentExpanded(props.instanceId, thread.parent.id))
return ( return (
<> <>
<SessionRow <SessionRow
sessionId={thread.parent.id} sessionId={thread.parent.id}
hasChildren={thread.children.length > 0} hasChildren={thread.children.length > 0}
expanded={expanded()} expanded={expanded()}
onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)} onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)}
/> />
<Show when={expanded() && thread.children.length > 0}> <Show when={expanded() && thread.children.length > 0}>
<For each={thread.children}> <For each={thread.children}>
{(child, index) => ( {(child, index) => (
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} /> <SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
)} )}
</For> </For>
</Show> </Show>
</> </>
) )
}} }}
</For> </For>
</div> </div>
</Show> </Show>

View File

@@ -6,11 +6,11 @@ import { useI18n } from "../../lib/i18n"
interface ContextUsagePanelProps { interface ContextUsagePanelProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
class?: string
} }
const chipClass = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary" const chipClass = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
const chipLabelClass = "uppercase text-[10px] tracking-wide text-primary/70" const chipLabelClass = "uppercase text-[10px] tracking-wide text-muted"
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => { const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
const { t } = useI18n() const { t } = useI18n()
@@ -31,26 +31,16 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
const inputTokens = createMemo(() => info().inputTokens ?? 0) const inputTokens = createMemo(() => info().inputTokens ?? 0)
const outputTokens = createMemo(() => info().outputTokens ?? 0) const outputTokens = createMemo(() => info().outputTokens ?? 0)
const actualUsageTokens = createMemo(() => info().actualUsageTokens ?? 0)
const availableTokens = createMemo(() => info().contextAvailableTokens)
const outputLimit = createMemo(() => info().modelOutputLimit ?? 0)
const costValue = createMemo(() => { const costValue = createMemo(() => {
const value = info().isSubscriptionModel ? 0 : info().cost const value = info().isSubscriptionModel ? 0 : info().cost
return value > 0 ? value : 0 return value > 0 ? value : 0
}) })
const formatTokenValue = (value: number | null | undefined) => {
if (value === null || value === undefined) return t("contextUsagePanel.unavailable")
return formatTokenTotal(value)
}
const costDisplay = createMemo(() => `$${costValue().toFixed(2)}`) const costDisplay = createMemo(() => `$${costValue().toFixed(2)}`)
return ( return (
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3"> <div class={`session-context-panel px-4 py-2 ${props.class ?? ""}`}>
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90"> <div class="flex flex-wrap items-center gap-2 text-xs text-primary">
<div class={headingClass}>{t("contextUsagePanel.headings.tokens")}</div>
<div class={chipClass}> <div class={chipClass}>
<span class={chipLabelClass}>{t("contextUsagePanel.labels.input")}</span> <span class={chipLabelClass}>{t("contextUsagePanel.labels.input")}</span>
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span> <span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
@@ -64,18 +54,6 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
<span class="font-semibold text-primary">{costDisplay()}</span> <span class="font-semibold text-primary">{costDisplay()}</span>
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
<div class={headingClass}>{t("contextUsagePanel.headings.context")}</div>
<div class={chipClass}>
<span class={chipLabelClass}>{t("contextUsagePanel.labels.used")}</span>
<span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span>
</div>
<div class={chipClass}>
<span class={chipLabelClass}>{t("contextUsagePanel.labels.available")}</span>
<span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span>
</div>
</div>
</div> </div>
) )
} }

View File

@@ -1,4 +1,4 @@
import { Show, For, createMemo, createEffect, type Component } from "solid-js" import { Show, For, createMemo, createEffect, on, type Component } from "solid-js"
import { Expand } from "lucide-solid" import { Expand } from "lucide-solid"
import type { Session } from "../../types/session" import type { Session } from "../../types/session"
import type { Attachment } from "../../types/attachment" import type { Attachment } from "../../types/attachment"
@@ -112,6 +112,43 @@ export const SessionView: Component<SessionViewProps> = (props) => {
if (!props.isActive) return if (!props.isActive) return
scheduleScrollToBottom() scheduleScrollToBottom()
}) })
createEffect(
on(
() => props.isActive,
(isActive) => {
if (!isActive) return
// Don't steal focus from other inputs (command palette, dialogs, selectors, etc.)
if (typeof document === "undefined") return
const activeEl = document.activeElement as HTMLElement | null
const activeIsInput =
activeEl?.tagName === "INPUT" ||
activeEl?.tagName === "TEXTAREA" ||
activeEl?.tagName === "SELECT" ||
Boolean(activeEl?.isContentEditable)
if (activeIsInput) return
const modalOpen = Boolean(document.querySelector('[role="dialog"][aria-modal="true"]'))
if (modalOpen) return
// Defer until the session pane is visible and the textarea is mounted.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const textarea = rootRef?.querySelector<HTMLTextAreaElement>(".prompt-input")
if (!textarea) return
if (textarea.disabled) return
try {
textarea.focus({ preventScroll: true } as any)
} catch {
textarea.focus()
}
})
})
},
),
)
let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null
createEffect(() => { createEffect(() => {

View File

@@ -0,0 +1,39 @@
import { createMemo, type Component } from "solid-js"
import { Laptop, Moon, Sun } from "lucide-solid"
import { useI18n } from "../lib/i18n"
import { useTheme } from "../lib/theme"
interface ThemeModeToggleProps {
class?: string
}
export const ThemeModeToggle: Component<ThemeModeToggleProps> = (props) => {
const { t } = useI18n()
const { themeMode, cycleThemeMode } = useTheme()
const modeLabel = () => {
const mode = themeMode()
if (mode === "system") return t("theme.mode.system")
if (mode === "light") return t("theme.mode.light")
return t("theme.mode.dark")
}
const icon = createMemo(() => {
const mode = themeMode()
if (mode === "system") return <Laptop class="w-4 h-4" />
if (mode === "light") return <Sun class="w-4 h-4" />
return <Moon class="w-4 h-4" />
})
return (
<button
type="button"
class={props.class ?? "new-tab-button"}
onClick={cycleThemeMode}
aria-label={t("theme.toggle.ariaLabel", { mode: modeLabel() })}
title={t("theme.toggle.title", { mode: modeLabel() })}
>
{icon()}
</button>
)
}

View File

@@ -1,9 +1,11 @@
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js" import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
import { Copy } from "lucide-solid"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import { useTheme } from "../lib/theme" import { useTheme } from "../lib/theme"
import { useGlobalCache } from "../lib/hooks/use-global-cache" import { useGlobalCache } from "../lib/hooks/use-global-cache"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances" import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
import { copyToClipboard } from "../lib/clipboard"
import type { PermissionRequestLike } from "../types/permission" import type { PermissionRequestLike } from "../types/permission"
import { getPermissionSessionId } from "../types/permission" import { getPermissionSessionId } from "../types/permission"
import type { QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionRequest } from "@opencode-ai/sdk/v2"
@@ -59,6 +61,11 @@ interface ToolCallProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
onContentRendered?: () => void onContentRendered?: () => void
/**
* When true, tool call starts collapsed regardless of user preferences.
* Users can still expand/collapse manually.
*/
forceCollapsed?: boolean
} }
@@ -142,6 +149,9 @@ export default function ToolCall(props: ToolCallProps) {
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded") const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
const defaultExpandedForTool = createMemo(() => { const defaultExpandedForTool = createMemo(() => {
if (props.forceCollapsed) {
return false
}
const prefExpanded = toolOutputDefaultExpanded() const prefExpanded = toolOutputDefaultExpanded()
const toolName = toolCallMemo()?.tool || "" const toolName = toolCallMemo()?.tool || ""
if (toolName === "read") { if (toolName === "read") {
@@ -575,12 +585,29 @@ export default function ToolCall(props: ToolCallProps) {
toolCall: toolCallMemo, toolCall: toolCallMemo,
toolState, toolState,
toolName, toolName,
instanceId: props.instanceId,
sessionId: props.sessionId,
t, t,
messageVersion: messageVersionAccessor, messageVersion: messageVersionAccessor,
partVersion: partVersionAccessor, partVersion: partVersionAccessor,
renderMarkdown: renderMarkdownContent, renderMarkdown: renderMarkdownContent,
renderAnsi: renderAnsiContent, renderAnsi: renderAnsiContent,
renderDiff: renderDiffContent, renderDiff: renderDiffContent,
renderToolCall: (options) => {
if (!options?.toolCall) return null
return (
<ToolCall
toolCall={options.toolCall}
toolCallId={options.toolCall.id}
messageId={options.messageId}
messageVersion={options.messageVersion}
partVersion={options.partVersion}
instanceId={props.instanceId}
sessionId={options.sessionId}
forceCollapsed={options.forceCollapsed}
/>
)
},
scrollHelpers, scrollHelpers,
} }
@@ -634,6 +661,19 @@ export default function ToolCall(props: ToolCallProps) {
return getToolName(currentTool) return getToolName(currentTool)
} }
const headerText = createMemo(() => {
// Keep this as a memo so copy always matches what's rendered.
return renderToolTitle()
})
const handleCopyHeader = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const text = headerText()
if (!text) return
await copyToClipboard(text)
}
const renderToolBody = () => { const renderToolBody = () => {
return renderer().renderBody(rendererContext) return renderer().renderBody(rendererContext)
} }
@@ -737,16 +777,32 @@ export default function ToolCall(props: ToolCallProps) {
}} }}
class={`tool-call ${combinedStatusClass()}`} class={`tool-call ${combinedStatusClass()}`}
> >
<button <div class="tool-call-header">
class="tool-call-header" <button
onClick={toggle} type="button"
aria-expanded={expanded()} class="tool-call-header-toggle"
data-status-icon={statusIcon()} onClick={toggle}
> aria-expanded={expanded()}
<span class="tool-call-summary" data-tool-icon={getToolIcon(toolName())}> >
{renderToolTitle()} <span class="tool-call-summary" data-tool-icon={getToolIcon(toolName())}>
{headerText()}
</span>
</button>
<button
type="button"
class="tool-call-header-copy"
onClick={handleCopyHeader}
aria-label={t("toolCall.header.copyAriaLabel")}
title={t("toolCall.header.copyTitle")}
>
<Copy class="w-3.5 h-3.5" />
</button>
<span class="tool-call-header-status" aria-hidden="true">
{statusIcon()}
</span> </span>
</button> </div>
{expanded() && ( {expanded() && (
<div class="tool-call-details"> <div class="tool-call-details">

View File

@@ -80,6 +80,19 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
return Array.isArray(draft) ? draft : [] return Array.isArray(draft) ? draft : []
}) })
const hasFinalAnswers = createMemo(() => {
const state = props.toolState()
if ((state as any)?.status === "completed") return true
const request = props.request()
const requestAnswers = request?.questions?.map((q) => (q as any)?.answer)
if (Array.isArray(requestAnswers) && requestAnswers.length > 0) {
return requestAnswers.every((row) => Array.isArray(row) && row.length > 0)
}
return false
})
const updateAnswer = (questionIndex: number, next: string[]) => { const updateAnswer = (questionIndex: number, next: string[]) => {
if (!props.active()) return if (!props.active()) return
props.setDraftAnswers((prev) => { props.setDraftAnswers((prev) => {
@@ -119,9 +132,16 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
if (!multi) { if (!multi) {
// When switching a radio to custom, clear existing selection first. // When switching a radio to custom, clear existing selection first.
updateAnswer(questionIndex, []) updateAnswer(questionIndex, [])
toggleOption(questionIndex, value)
return
} }
toggleOption(questionIndex, value) // For multi-select, focusing the input should never toggle an existing custom value off.
// Ensure the current input value is selected; removal is handled by unchecking Custom.
const existing = answers()[questionIndex] ?? []
if (!existing.includes(value)) {
updateAnswer(questionIndex, [...existing, value])
}
} }
const clearCustomAnswer = (questionIndex: number, valuesToRemove: string[]) => { const clearCustomAnswer = (questionIndex: number, valuesToRemove: string[]) => {
@@ -170,22 +190,11 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
return ( return (
<Show when={isVisible() && questions().length > 0}> <Show when={isVisible() && questions().length > 0}>
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}> <div
<div class="tool-call-permission-header"> class={`tool-call-permission p-0 gap-2 ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"} ${hasFinalAnswers() ? "tool-call-permission-answered" : ""}`}
<span class="tool-call-permission-label"> >
{props.active()
? t("toolCall.question.status.required")
: props.request()
? t("toolCall.question.status.queued")
: t("toolCall.question.status.questions")}
</span>
<span class="tool-call-permission-type">
{questions().length === 1 ? t("toolCall.question.type.one") : t("toolCall.question.type.other")}
</span>
</div>
<div class="tool-call-permission-body"> <div class="tool-call-permission-body">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-2">
<For each={questions()}> <For each={questions()}>
{(q, index) => { {(q, index) => {
const i = () => index() const i = () => index()
@@ -199,9 +208,9 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
const customChecked = () => customValue().length > 0 const customChecked = () => customValue().length > 0
return ( return (
<div class="rounded-md border border-base/60 bg-surface/30 p-3"> <div class="border border-base bg-surface-secondary p-3 text-primary">
<div class="flex items-baseline justify-between gap-2"> <div class="flex items-baseline justify-between gap-2">
<div class="text-xs"> <div class="text-sm text-primary">
{t("toolCall.question.number", { number: i() + 1 })} <span class="font-semibold">{q?.header}</span> {t("toolCall.question.number", { number: i() + 1 })} <span class="font-semibold">{q?.header}</span>
</div> </div>
<Show when={multi()}> <Show when={multi()}>
@@ -209,7 +218,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
</Show> </Show>
</div> </div>
<div class="mt-1 text-sm font-medium">{q?.question}</div> <div class="mt-1 text-sm font-medium text-primary">{q?.question}</div>
<div class="mt-3 flex flex-col gap-1"> <div class="mt-3 flex flex-col gap-1">
<For each={q?.options ?? []}> <For each={q?.options ?? []}>
@@ -226,12 +235,13 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
}} }}
type={inputType()} type={inputType()}
name={groupName()} name={groupName()}
class="mt-0.5 accent-[var(--accent-primary)]"
checked={checked()} checked={checked()}
disabled={!props.active() || props.submitting()} disabled={!props.active() || props.submitting()}
onChange={() => toggleOption(i(), opt.label)} onChange={() => toggleOption(i(), opt.label)}
/> />
<div class="flex flex-col"> <div class="flex flex-col">
<div class="text-sm leading-tight">{opt.label}</div> <div class="text-sm leading-tight text-primary">{opt.label}</div>
<div class="text-xs text-muted leading-tight">{opt.description}</div> <div class="text-xs text-muted leading-tight">{opt.description}</div>
</div> </div>
</label> </label>
@@ -249,6 +259,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
}} }}
type={inputType()} type={inputType()}
name={groupName()} name={groupName()}
class="mt-0.5 accent-[var(--accent-primary)]"
checked={customChecked()} checked={customChecked()}
disabled={!props.active() || props.submitting()} disabled={!props.active() || props.submitting()}
onChange={(e) => { onChange={(e) => {
@@ -266,13 +277,13 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
}} }}
/> />
<div class="flex flex-1 flex-col gap-2"> <div class="flex flex-1 flex-col gap-2">
<div class="text-sm leading-tight">{t("toolCall.question.custom.label")}</div> <div class="text-sm leading-tight text-primary">{t("toolCall.question.custom.label")}</div>
<input <input
class="w-full rounded-md border border-base/50 bg-surface px-2 py-1 text-sm" class="w-full rounded-md border border-base bg-surface-base px-2 py-1 text-sm text-primary"
type="text" type="text"
placeholder={t("toolCall.question.custom.placeholder")} placeholder={t("toolCall.question.custom.placeholder")}
disabled={!props.active() || props.submitting()} disabled={!props.active() || props.submitting()}
value={customValue()} value={customValue()}
onFocus={(e) => { onFocus={(e) => {
if (!props.active()) return if (!props.active()) return
// Keep the radio/checkbox selected while editing. // Keep the radio/checkbox selected while editing.
@@ -296,7 +307,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
</For> </For>
<Show when={props.active()}> <Show when={props.active()}>
<div class="tool-call-permission-actions"> <div class="tool-call-permission-actions px-3 pb-3">
<div class="tool-call-permission-buttons"> <div class="tool-call-permission-buttons">
<button <button
type="button" type="button"
@@ -330,7 +341,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
</Show> </Show>
<Show when={!props.active() && props.request()}> <Show when={!props.active() && props.request()}>
<p class="tool-call-permission-queued-text">{t("toolCall.question.queuedText")}</p> <p class="tool-call-permission-queued-text px-3 pb-3">{t("toolCall.question.queuedText")}</p>
</Show> </Show>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,11 @@
import { For, Show, createMemo } from "solid-js" import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk"
import type { ToolRenderer } from "../types" import type { ToolRenderer } from "../types"
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils" import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
import { resolveTitleForTool } from "../tool-title" import { resolveTitleForTool } from "../tool-title"
import { messageStoreBus } from "../../../stores/message-v2/bus"
import { loadMessages } from "../../../stores/session-api"
import { loading, messagesLoaded } from "../../../stores/session-state"
interface TaskSummaryItem { interface TaskSummaryItem {
id: string id: string
@@ -14,6 +17,70 @@ interface TaskSummaryItem {
title?: string title?: string
} }
function extractSessionIdFromTaskState(state?: ToolState): string {
if (!state) return ""
const metadata = (state as unknown as { metadata?: Record<string, unknown> }).metadata ?? {}
const directId = (metadata as any)?.sessionId ?? (metadata as any)?.sessionID
return typeof directId === "string" ? directId : ""
}
function splitToolKey(key: string): { messageId: string; partId: string } | null {
const separator = "::"
const index = key.lastIndexOf(separator)
if (index <= 0) return null
const messageId = key.slice(0, index)
const partId = key.slice(index + separator.length)
if (!messageId || !partId) return null
return { messageId, partId }
}
function TaskToolCallRow(props: {
toolKey: string
store: ReturnType<typeof messageStoreBus.getOrCreate>
sessionId: string
renderToolCall: NonNullable<import("../types").ToolRendererContext["renderToolCall"]>
}) {
const parts = createMemo(() => splitToolKey(props.toolKey))
const messageId = createMemo(() => parts()?.messageId ?? "")
const partId = createMemo(() => parts()?.partId ?? "")
const record = createMemo(() => {
const id = messageId()
if (!id) return undefined
return props.store.getMessage(id)
})
const partEntry = createMemo(() => {
const rec = record()
const pid = partId()
if (!rec || !pid) return undefined
return rec.parts?.[pid]
})
const toolPart = createMemo(() => {
const data = partEntry()?.data
return data && (data as any).type === "tool" ? (data as any) : undefined
})
const messageVersion = createMemo(() => record()?.revision ?? 0)
const partVersion = createMemo(() => partEntry()?.revision ?? 0)
const rendered = createMemo(() => {
const part = toolPart()
if (!part) return null
return props.renderToolCall({
toolCall: part as any,
messageId: messageId(),
messageVersion: messageVersion(),
partVersion: partVersion(),
sessionId: props.sessionId,
forceCollapsed: true,
})
})
return <>{rendered()}</>
}
function normalizeStatus(status?: string | null): ToolState["status"] | undefined { function normalizeStatus(status?: string | null): ToolState["status"] | undefined {
if (status === "pending" || status === "running" || status === "completed" || status === "error") { if (status === "pending" || status === "running" || status === "completed" || status === "error") {
return status return status
@@ -78,7 +145,63 @@ export const taskRenderer: ToolRenderer = {
const { input } = readToolStatePayload(state) const { input } = readToolStatePayload(state)
return describeTaskTitle(input) return describeTaskTitle(input)
}, },
renderBody({ toolState, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) { renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) {
const store = messageStoreBus.getOrCreate(instanceId)
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
const childSessionId = createMemo(() => {
const state = toolState()
return extractSessionIdFromTaskState(state)
})
const childSessionLoaded = createMemo(() => {
const id = childSessionId()
if (!id) return false
const loadedForInstance = messagesLoaded().get(instanceId)
return loadedForInstance?.has(id) ?? false
})
const childSessionLoading = createMemo(() => {
const id = childSessionId()
if (!id) return false
const loadingSet = loading().loadingMessages.get(instanceId)
return loadingSet?.has(id) ?? false
})
createEffect(() => {
const id = childSessionId()
if (!id) return
if (requestedChildLoad()) return
if (childSessionLoaded()) return
if (childSessionLoading()) return
setRequestedChildLoad(true)
void loadMessages(instanceId, id)
})
const childToolKeys = createMemo(() => {
const id = childSessionId()
if (!id) return [] as string[]
if (!childSessionLoaded()) return [] as string[]
// React to session changes, but do the scan untracked to avoid
// subscribing to every message/part node in the store.
store.getSessionRevision(id)
return untrack(() => {
const messageIds = store.getSessionMessageIds(id)
const keys: string[] = []
for (const messageId of messageIds) {
const record = store.getMessage(messageId)
if (!record) continue
for (const partId of record.partIds) {
const entry = record.parts?.[partId]
const data = entry?.data
if (!data || (data as any).type !== "tool") continue
keys.push(`${messageId}::${partId}`)
}
}
return keys
})
})
const promptContent = createMemo(() => { const promptContent = createMemo(() => {
const state = toolState() const state = toolState()
if (!state) return null if (!state) return null
@@ -123,7 +246,7 @@ export const taskRenderer: ToolRenderer = {
return null return null
}) })
const items = createMemo(() => { const legacyItems = createMemo(() => {
// Track the reactive change points so we only recompute when the part/message changes // Track the reactive change points so we only recompute when the part/message changes
messageVersion?.() messageVersion?.()
partVersion?.() partVersion?.()
@@ -131,6 +254,9 @@ export const taskRenderer: ToolRenderer = {
const state = toolState() const state = toolState()
if (!state) return [] if (!state) return []
// Prefer deriving steps from the child session when loaded.
if (childSessionLoaded()) return []
const { metadata } = readToolStatePayload(state) const { metadata } = readToolStatePayload(state)
const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : [] const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : []
@@ -167,51 +293,84 @@ export const taskRenderer: ToolRenderer = {
</section> </section>
</Show> </Show>
<Show when={items().length > 0}> <Show when={childToolKeys().length > 0 || legacyItems().length > 0}>
<section class="tool-call-task-section"> <section class="tool-call-task-section">
<header class="tool-call-task-section-header"> <header class="tool-call-task-section-header">
<span class="tool-call-task-section-title">{t("toolCall.task.sections.steps")}</span> <span class="tool-call-task-section-title">{t("toolCall.task.sections.steps")}</span>
<span class="tool-call-task-section-meta">{t("toolCall.task.steps.count", { count: items().length })}</span> <span class="tool-call-task-section-meta">
{t("toolCall.task.steps.count", { count: childToolKeys().length > 0 ? childToolKeys().length : legacyItems().length })}
</span>
</header> </header>
<div class="tool-call-task-section-body"> <div class="tool-call-task-section-body">
<div <Show
class="message-text tool-call-markdown tool-call-task-container" when={childToolKeys().length > 0}
ref={scrollHelpers?.registerContainer} fallback={
onScroll={ <div
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined class="message-text tool-call-markdown tool-call-task-container"
ref={scrollHelpers?.registerContainer}
onScroll={
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
}
>
<div class="tool-call-task-summary">
<For each={legacyItems()}>
{(item) => {
const icon = getToolIcon(item.tool)
const description = describeToolTitle(item)
const toolLabel = getToolName(item.tool)
const status = normalizeStatus(item.status ?? item.state?.status)
const statusIcon = summarizeStatusIcon(status)
const statusKey = summarizeStatusLabel(status)
const statusLabel = statusKey
? t(`toolCall.status.${statusKey}`)
: t("toolCall.status.unknown")
const statusAttr = status ?? "pending"
return (
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
<span class="tool-call-task-icon">{icon}</span>
<span class="tool-call-task-label">{toolLabel}</span>
<span class="tool-call-task-separator" aria-hidden="true"></span>
<span class="tool-call-task-text">{description}</span>
<Show when={statusIcon}>
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
{statusIcon}
</span>
</Show>
</div>
)
}}
</For>
</div>
{scrollHelpers?.renderSentinel?.()}
</div>
} }
> >
<div class="tool-call-task-summary"> <div
<For each={items()}> class="message-text tool-call-markdown tool-call-task-container"
{(item) => { ref={scrollHelpers?.registerContainer}
const icon = getToolIcon(item.tool) onScroll={
const description = describeToolTitle(item) scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
const toolLabel = getToolName(item.tool) }
const status = normalizeStatus(item.status ?? item.state?.status) >
const statusIcon = summarizeStatusIcon(status) <div class="tool-call-task-summary">
const statusKey = summarizeStatusLabel(status) <For each={childToolKeys()}>
const statusLabel = statusKey {(key) => (
? t(`toolCall.status.${statusKey}`) <Show when={renderToolCall}>
: t("toolCall.status.unknown") {(render) => (
const statusAttr = status ?? "pending" <TaskToolCallRow
return ( toolKey={key}
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}> store={store}
<span class="tool-call-task-icon">{icon}</span> sessionId={childSessionId()}
<span class="tool-call-task-label">{toolLabel}</span> renderToolCall={render()}
<span class="tool-call-task-separator" aria-hidden="true"></span> />
<span class="tool-call-task-text">{description}</span> )}
<Show when={statusIcon}> </Show>
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}> )}
{statusIcon} </For>
</span> </div>
</Show> {scrollHelpers?.renderSentinel?.()}
</div>
)
}}
</For>
</div> </div>
{scrollHelpers?.renderSentinel?.()} </Show>
</div>
</div> </div>
</section> </section>
</Show> </Show>

View File

@@ -74,12 +74,15 @@ function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
toolCall: toolCallAccessor, toolCall: toolCallAccessor,
toolState: toolStateAccessor, toolState: toolStateAccessor,
toolName: toolNameAccessor, toolName: toolNameAccessor,
instanceId: "",
sessionId: "",
t, t,
messageVersion: messageVersionAccessor, messageVersion: messageVersionAccessor,
partVersion: partVersionAccessor, partVersion: partVersionAccessor,
renderMarkdown, renderMarkdown,
renderAnsi, renderAnsi,
renderDiff, renderDiff,
renderToolCall: () => null,
scrollHelpers: undefined, scrollHelpers: undefined,
} }
} }

View File

@@ -53,12 +53,26 @@ export interface ToolRendererContext {
toolCall: Accessor<ToolCallPart> toolCall: Accessor<ToolCallPart>
toolState: Accessor<ToolState | undefined> toolState: Accessor<ToolState | undefined>
toolName: Accessor<string> toolName: Accessor<string>
instanceId: string
sessionId: string
t: (key: string, params?: Record<string, unknown>) => string t: (key: string, params?: Record<string, unknown>) => string
messageVersion?: Accessor<number | undefined> messageVersion?: Accessor<number | undefined>
partVersion?: Accessor<number | undefined> partVersion?: Accessor<number | undefined>
renderMarkdown(options: MarkdownRenderOptions): JSXElement | null renderMarkdown(options: MarkdownRenderOptions): JSXElement | null
renderAnsi(options: AnsiRenderOptions): JSXElement | null renderAnsi(options: AnsiRenderOptions): JSXElement | null
renderDiff(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null renderDiff(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null
/**
* Render another tool call inline. This is provided by the ToolCall shell
* to avoid renderer-level imports that would create cyclic dependencies.
*/
renderToolCall?: (options: {
toolCall: ToolCallPart
messageId?: string
messageVersion?: number
partVersion?: number
sessionId: string
forceCollapsed?: boolean
}) => JSXElement | null
scrollHelpers?: ToolScrollHelpers scrollHelpers?: ToolScrollHelpers
} }

View File

@@ -103,6 +103,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
let scrollContainerRef: HTMLDivElement | undefined let scrollContainerRef: HTMLDivElement | undefined
let lastWorkspaceId: string | null = null let lastWorkspaceId: string | null = null
let lastQuery = "" let lastQuery = ""
let lastCommandQuery = ""
let inflightWorkspaceId: string | null = null let inflightWorkspaceId: string | null = null
let inflightSnapshotPromise: Promise<FileItem[]> | null = null let inflightSnapshotPromise: Promise<FileItem[]> | null = null
let activeRequestId = 0 let activeRequestId = 0
@@ -243,6 +244,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
setLoadingState("idle") setLoadingState("idle")
lastWorkspaceId = null lastWorkspaceId = null
lastQuery = "" lastQuery = ""
lastCommandQuery = ""
activeRequestId = 0 activeRequestId = 0
} }
@@ -273,8 +275,6 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
} }
}) })
createEffect(() => { createEffect(() => {
if (!props.open) return if (!props.open) return
if (mode() !== "mention") return if (mode() !== "mention") return
@@ -303,6 +303,37 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
}) })
}) })
createEffect(() => {
if (!props.open) return
if (mode() !== "command") return
const query = props.searchQuery
const count = filteredCommands().length
if (query !== lastCommandQuery) {
lastCommandQuery = query
setSelectedIndex(0)
resetScrollPosition()
return
}
if (count <= 0) {
if (selectedIndex() !== 0) {
setSelectedIndex(0)
}
return
}
const current = selectedIndex()
if (current < 0) {
setSelectedIndex(0)
return
}
if (current >= count) {
setSelectedIndex(count - 1)
}
})
const allItems = (): PickerItem[] => { const allItems = (): PickerItem[] => {
const items: PickerItem[] = [] const items: PickerItem[] = []
if (mode() === "command") { if (mode() === "command") {
@@ -335,20 +366,24 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault() e.preventDefault()
e.stopPropagation()
setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1)) setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1))
scrollToSelected() scrollToSelected()
} else if (e.key === "ArrowUp") { } else if (e.key === "ArrowUp") {
e.preventDefault() e.preventDefault()
e.stopPropagation()
setSelectedIndex((prev) => Math.max(prev - 1, 0)) setSelectedIndex((prev) => Math.max(prev - 1, 0))
scrollToSelected() scrollToSelected()
} else if (e.key === "Enter" || e.key === "Tab") { } else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault() e.preventDefault()
e.stopPropagation()
const selected = items[selectedIndex()] const selected = items[selectedIndex()]
if (selected) { if (selected) {
handleSelect(selected) handleSelect(selected)
} }
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
e.preventDefault() e.preventDefault()
e.stopPropagation()
props.onClose() props.onClose()
} }
} }
@@ -402,12 +437,12 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
<Show when={mode() === "command" && commandCount() > 0}> <Show when={mode() === "command" && commandCount() > 0}>
<div class="dropdown-section-header">{t("unifiedPicker.sections.commands")}</div> <div class="dropdown-section-header">{t("unifiedPicker.sections.commands")}</div>
<For each={filteredCommands()}> <For each={filteredCommands()}>
{(command) => { {(command, index) => {
const itemIndex = allItems().findIndex((item) => item.type === "command" && item.command.name === command.name) const isSelected = () => index() === selectedIndex()
return ( return (
<div <div
class={`dropdown-item ${itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""}`} class={`dropdown-item ${isSelected() ? "dropdown-item-highlight" : ""}`}
data-picker-selected={itemIndex === selectedIndex()} data-picker-selected={isSelected()}
onClick={() => handleSelect({ type: "command", command })} onClick={() => handleSelect({ type: "command", command })}
> >
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">

View File

@@ -0,0 +1,429 @@
import { Select } from "@kobalte/core/select"
import { Dialog } from "@kobalte/core/dialog"
import { For, Show, createMemo, createSignal } from "solid-js"
import { ChevronDown, Copy, Trash2 } from "lucide-solid"
import type { WorktreeDescriptor } from "../../../server/src/api-types"
import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
import { showToastNotification } from "../lib/notifications"
import {
createWorktree,
deleteWorktree,
getParentSessionId,
getGitRepoStatus,
getWorktreeSlugForParentSession,
getWorktrees,
reloadWorktreeMap,
reloadWorktrees,
setWorktreeSlugForParentSession,
} from "../stores/worktrees"
import { sessions } from "../stores/sessions"
const log = getLogger("session")
type WorktreeOption =
| { kind: "action"; key: "__create__"; label: string }
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
const CREATE_OPTION: WorktreeOption = { kind: "action", key: "__create__", label: "+ Create worktree" }
function preventSelectPress(event: PointerEvent | MouseEvent) {
// Prevent Select.Item from treating this as a selection.
// We intentionally prevent default to stop Kobalte's internal press handling.
event.preventDefault()
event.stopImmediatePropagation?.()
event.stopPropagation()
}
function normalizePath(input: string): string {
return (input ?? "").replace(/\\/g, "/").replace(/\/+$/, "")
}
function relativePath(fromDir: string, toDir: string): string {
const from = normalizePath(fromDir)
const to = normalizePath(toDir)
if (!from || !to) return to || from || ""
if (from === to) return "."
const fromParts = from.split("/").filter(Boolean)
const toParts = to.split("/").filter(Boolean)
let i = 0
while (i < fromParts.length && i < toParts.length) {
const a = fromParts[i]
const b = toParts[i]
if (!a || !b) break
if (a.toLowerCase() !== b.toLowerCase()) break
i++
}
const up = fromParts.length - i
const down = toParts.slice(i)
const relParts: string[] = []
for (let j = 0; j < up; j++) relParts.push("..")
relParts.push(...down)
return relParts.join("/") || "."
}
interface WorktreeSelectorProps {
instanceId: string
sessionId: string
}
export default function WorktreeSelector(props: WorktreeSelectorProps) {
const [isOpen, setIsOpen] = createSignal(false)
const [createOpen, setCreateOpen] = createSignal(false)
const [createSlug, setCreateSlug] = createSignal("")
const [isCreating, setIsCreating] = createSignal(false)
const [deleteOpen, setDeleteOpen] = createSignal(false)
const [deleteTarget, setDeleteTarget] = createSignal<WorktreeOption & { kind: "worktree" } | null>(null)
const [forceDelete, setForceDelete] = createSignal(false)
const [isDeleting, setIsDeleting] = createSignal(false)
const session = createMemo(() => sessions().get(props.instanceId)?.get(props.sessionId))
const isChildSession = createMemo(() => Boolean(session()?.parentId))
const parentId = createMemo(() => getParentSessionId(props.instanceId, props.sessionId))
const currentSlug = createMemo(() => getWorktreeSlugForParentSession(props.instanceId, parentId()))
const gitRepoStatus = createMemo(() => getGitRepoStatus(props.instanceId))
const worktreesUnavailable = createMemo(() => gitRepoStatus() === false)
const dropdownDisabled = createMemo(() => isChildSession() || worktreesUnavailable())
const worktreeOptions = createMemo<WorktreeOption[]>(() => {
const list = getWorktrees(props.instanceId)
const mapped: WorktreeOption[] = list.map((wt) => ({
kind: "worktree",
key: wt.slug,
slug: wt.slug,
directory: wt.directory,
raw: wt,
}))
return [CREATE_OPTION, ...mapped]
})
const selectedOption = createMemo<WorktreeOption | undefined>(() => {
const slug = currentSlug()
const match = worktreeOptions().find((opt) => opt.kind === "worktree" && opt.slug === slug)
if (match) return match
// Fallback to root if mapped slug is missing.
return worktreeOptions().find((opt) => opt.kind === "worktree" && opt.slug === "root")
})
const openDeleteDialog = (opt: WorktreeOption & { kind: "worktree" }) => {
if (opt.slug === "root") return
setForceDelete(false)
setDeleteTarget(opt)
setDeleteOpen(true)
}
const repoRoot = createMemo(() => {
const list = getWorktrees(props.instanceId)
return list.find((wt) => wt.slug === "root")?.directory ?? ""
})
const displayPathFor = (directory: string) => {
const base = repoRoot()
if (!base) return directory
return relativePath(base, directory)
}
const handleCopyPath = async (directory: string) => {
try {
const ok = await copyToClipboard(directory)
showToastNotification({ message: ok ? "Copied worktree path" : "Failed to copy path", variant: ok ? "success" : "error" })
} catch (error) {
log.error("Failed to copy worktree path", error)
showToastNotification({ message: "Failed to copy path", variant: "error" })
}
}
const handleChange = async (value: WorktreeOption | null) => {
if (worktreesUnavailable()) return
if (!value) return
if (value.kind === "action") {
setIsOpen(false)
setCreateSlug("")
setCreateOpen(true)
return
}
await setWorktreeSlugForParentSession(props.instanceId, parentId(), value.slug)
}
return (
<div class="sidebar-selector">
<Select<WorktreeOption>
open={isOpen()}
onOpenChange={setIsOpen}
value={selectedOption() ?? null}
onChange={(value) => {
void handleChange(value).catch((error) => log.warn("Failed to change worktree", error))
}}
options={worktreeOptions()}
optionValue="key"
optionTextValue={(opt) => (opt.kind === "action" ? opt.label : opt.slug)}
placeholder="Worktree"
disabled={dropdownDisabled()}
itemComponent={(itemProps) => {
const opt = itemProps.item.rawValue
if (opt.kind === "action") {
return (
<Select.Item item={itemProps.item} class="selector-option worktree-selector-item">
<div class="selector-option-content w-full">
<Select.ItemLabel class="selector-option-label">{opt.label}</Select.ItemLabel>
<Select.ItemDescription class="selector-option-description">New from current branch</Select.ItemDescription>
</div>
</Select.Item>
)
}
return (
<Select.Item item={itemProps.item} class="selector-option worktree-selector-item">
<div class="flex flex-col gap-1 flex-1 min-w-0">
<div class="flex items-center gap-2">
<Select.ItemLabel class="selector-option-label flex-1 min-w-0 truncate">
{opt.slug === "root" ? "Workspace" : opt.slug}
</Select.ItemLabel>
<Show when={opt.slug !== "root"}>
<button
type="button"
class="session-item-close opacity-80 hover:opacity-100 hover:bg-surface-hover"
aria-label="Delete worktree"
title="Delete worktree"
onPointerDown={(event) => {
preventSelectPress(event)
setIsOpen(false)
openDeleteDialog(opt)
}}
onPointerUp={preventSelectPress}
onMouseDown={preventSelectPress}
onMouseUp={preventSelectPress}
onClick={preventSelectPress}
>
<Trash2 class="w-3 h-3" />
</button>
</Show>
</div>
<div class="flex items-center gap-2 min-w-0">
<span
class="selector-option-description flex-1 min-w-0 truncate font-mono"
title={opt.directory}
>
{displayPathFor(opt.directory)}
</span>
<button
type="button"
class="session-item-close opacity-80 hover:opacity-100 hover:bg-surface-hover"
aria-label="Copy path"
title="Copy path"
onPointerDown={(event) => {
preventSelectPress(event)
void (async () => {
await handleCopyPath(opt.directory)
setIsOpen(false)
})()
}}
onPointerUp={preventSelectPress}
onMouseDown={preventSelectPress}
onMouseUp={preventSelectPress}
onClick={preventSelectPress}
>
<Copy class="w-3 h-3" />
</button>
</div>
</div>
</Select.Item>
)
}}
>
<Select.Trigger class="selector-trigger">
<div class="flex-1 min-w-0">
<Select.Value<WorktreeOption>>
{(state) => {
if (worktreesUnavailable()) {
return (
<div class="selector-trigger-label selector-trigger-label--stacked">
<span class="selector-trigger-primary selector-trigger-primary--align-left">Worktree: Unavailable</span>
</div>
)
}
const value = state.selectedOption()
const label = value && value.kind === "worktree" ? (value.slug === "root" ? "Workspace" : value.slug) : "Workspace"
return (
<div class="selector-trigger-label selector-trigger-label--stacked">
<span class="selector-trigger-primary selector-trigger-primary--align-left">Worktree: {label}</span>
</div>
)
}}
</Select.Value>
</div>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content class="selector-popover max-h-80 overflow-auto p-1">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
<Dialog open={createOpen()} onOpenChange={(open) => !open && setCreateOpen(false)}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Create worktree</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">Creates a git worktree</Dialog.Description>
</div>
<div class="space-y-2">
<label class="text-xs font-medium text-muted uppercase tracking-wide">Name</label>
<input
class="form-input w-full"
value={createSlug()}
onInput={(e) => setCreateSlug(e.currentTarget.value)}
placeholder="worktree-name"
disabled={isCreating()}
spellcheck={false}
autocapitalize="off"
autocomplete="off"
/>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
class="selector-button selector-button-secondary"
onClick={() => setCreateOpen(false)}
disabled={isCreating()}
>
Cancel
</button>
<button
type="button"
class="selector-button selector-button-primary"
disabled={
isCreating() ||
!createSlug().trim() ||
createSlug().trim() === "root" ||
/[\x00-\x1F\x7F]/.test(createSlug())
}
onClick={() => {
const slug = createSlug().trim()
void (async () => {
setIsCreating(true)
await createWorktree(props.instanceId, slug)
await reloadWorktrees(props.instanceId)
await setWorktreeSlugForParentSession(props.instanceId, parentId(), slug)
setCreateOpen(false)
showToastNotification({ message: `Created worktree ${slug}`, variant: "success" })
})()
.catch((error) => {
log.warn("Failed to create worktree", error)
showToastNotification({
message: error instanceof Error ? error.message : "Failed to create worktree",
variant: "error",
})
})
.finally(() => {
setIsCreating(false)
})
}}
>
{isCreating() ? "Creating..." : "Create"}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
<Dialog open={deleteOpen()} onOpenChange={(open) => !open && setDeleteOpen(false)}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Delete worktree</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">Removes the git worktree checkout directory for this branch.</Dialog.Description>
</div>
<Show when={deleteTarget()}>
{(target) => (
<div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Worktree</p>
<p class="text-sm font-mono text-primary break-all">{target().slug}</p>
<p class="text-[11px] text-secondary mt-2 break-all font-mono">{target().directory}</p>
</div>
)}
</Show>
<label class="flex items-center gap-2 text-sm text-secondary">
<input
type="checkbox"
checked={forceDelete()}
onChange={(e) => setForceDelete(e.currentTarget.checked)}
disabled={isDeleting()}
/>
Force delete (discard local changes)
</label>
<div class="flex justify-end gap-2">
<button
type="button"
class="selector-button selector-button-secondary"
onClick={() => setDeleteOpen(false)}
disabled={isDeleting()}
>
Cancel
</button>
<button
type="button"
class="selector-button selector-button-primary"
disabled={isDeleting() || !deleteTarget()}
onClick={() => {
const target = deleteTarget()
if (!target) {
setDeleteOpen(false)
return
}
void (async () => {
setIsDeleting(true)
await deleteWorktree(props.instanceId, target.slug, { force: forceDelete() })
await reloadWorktrees(props.instanceId)
await reloadWorktreeMap(props.instanceId)
if (currentSlug() === target.slug) {
await setWorktreeSlugForParentSession(props.instanceId, parentId(), "root")
}
setDeleteOpen(false)
showToastNotification({ message: `Deleted worktree ${target.slug}`, variant: "success" })
})()
.catch((error) => {
log.warn("Failed to delete worktree", error)
showToastNotification({
message: error instanceof Error ? error.message : "Failed to delete worktree",
variant: "error",
})
})
.finally(() => {
setIsDeleting(false)
})
}}
>
{isDeleting() ? "Deleting..." : "Delete"}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
</div>
)
}

View File

@@ -20,12 +20,14 @@ import type {
WorkspaceLogEntry, WorkspaceLogEntry,
WorkspaceEventPayload, WorkspaceEventPayload,
WorkspaceEventType, WorkspaceEventType,
WorktreeListResponse,
WorktreeMap,
WorktreeCreateRequest,
} from "../../../server/src/api-types" } from "../../../server/src/api-types"
import { getLogger } from "./logger" import { getLogger } from "./logger"
const FALLBACK_API_BASE = "http://127.0.0.1:9898"
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? RUNTIME_BASE ?? FALLBACK_API_BASE : FALLBACK_API_BASE const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? RUNTIME_BASE : undefined
const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events" const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH) const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH)
@@ -127,6 +129,39 @@ export const serverApi = {
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> { fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
return request<WorkspaceDescriptor[]>("/api/workspaces") return request<WorkspaceDescriptor[]>("/api/workspaces")
}, },
fetchWorktrees(id: string): Promise<WorktreeListResponse> {
return request<WorktreeListResponse>(`/api/workspaces/${encodeURIComponent(id)}/worktrees`)
},
createWorktree(id: string, payload: WorktreeCreateRequest): Promise<{ slug: string; directory: string; branch?: string }> {
return request<{ slug: string; directory: string; branch?: string }>(`/api/workspaces/${encodeURIComponent(id)}/worktrees`, {
method: "POST",
body: JSON.stringify(payload),
})
},
deleteWorktree(id: string, slug: string, options?: { force?: boolean }): Promise<void> {
const params = new URLSearchParams()
if (options?.force) {
params.set("force", "true")
}
const suffix = params.toString() ? `?${params.toString()}` : ""
return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}${suffix}`, {
method: "DELETE",
})
},
readWorktreeMap(id: string): Promise<WorktreeMap> {
return request<WorktreeMap>(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`)
},
writeWorktreeMap(id: string, map: WorktreeMap): Promise<void> {
return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`, {
method: "PUT",
body: JSON.stringify(map),
})
},
createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceDescriptor> { createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceDescriptor> {
return request<WorkspaceDescriptor>("/api/workspaces", { return request<WorkspaceDescriptor>("/api/workspaces", {
method: "POST", method: "POST",

View File

@@ -31,6 +31,7 @@ export interface UseCommandsOptions {
toggleShowTimelineTools: () => void toggleShowTimelineTools: () => void
toggleUsageMetrics: () => void toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void toggleAutoCleanupBlankSessions: () => void
togglePromptSubmitOnEnter: () => void
setDiffViewMode: (mode: "split" | "unified") => void setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void setDiagnosticsExpansion: (mode: ExpansionPreference) => void
@@ -423,6 +424,18 @@ export function useCommands(options: UseCommandsOptions) {
}, },
}) })
commandRegistry.register({
id: "prompt-submit-shortcut",
label: () =>
options.preferences().promptSubmitOnEnter
? tGlobal("commands.promptSubmitShortcut.label.swapped")
: tGlobal("commands.promptSubmitShortcut.label.default"),
description: () => tGlobal("commands.promptSubmitShortcut.description"),
category: "Input & Focus",
keywords: () => splitKeywords("commands.promptSubmitShortcut.keywords"),
action: options.togglePromptSubmitOnEnter,
})
commandRegistry.register({ commandRegistry.register({
id: "thinking", id: "thinking",
label: () => tGlobal(options.preferences().showThinkingBlocks ? "commands.thinkingBlocks.label.hide" : "commands.thinkingBlocks.label.show"), label: () => tGlobal(options.preferences().showThinkingBlocks ? "commands.thinkingBlocks.label.hide" : "commands.thinkingBlocks.label.show"),

View File

@@ -29,4 +29,10 @@ export const appMessages = {
"releases.uiUpdated.title": "UI updated", "releases.uiUpdated.title": "UI updated",
"releases.uiUpdated.message": "UI is now updated to {version}.", "releases.uiUpdated.message": "UI is now updated to {version}.",
"theme.mode.system": "System",
"theme.mode.light": "Light",
"theme.mode.dark": "Dark",
"theme.toggle.title": "Theme: {mode}",
"theme.toggle.ariaLabel": "Theme: {mode}",
} as const } as const

View File

@@ -82,9 +82,14 @@ export const commandMessages = {
"commands.clearInput.description": "Clear the prompt textarea", "commands.clearInput.description": "Clear the prompt textarea",
"commands.clearInput.keywords": "clear, reset", "commands.clearInput.keywords": "clear, reset",
"commands.thinkingBlocks.label.show": "Show Thinking Blocks", "commands.promptSubmitShortcut.label.default": "Enter: New Line, Cmd/Ctrl+Enter: Submit Prompt",
"commands.thinkingBlocks.label.hide": "Hide Thinking Blocks", "commands.promptSubmitShortcut.label.swapped": "Enter: Submit Prompt, Cmd/Ctrl+Enter: New Line",
"commands.thinkingBlocks.description": "Show/hide AI thinking process", "commands.promptSubmitShortcut.description": "Swap Enter and Cmd/Ctrl+Enter behavior in the prompt input",
"commands.promptSubmitShortcut.keywords": "enter, cmd, ctrl, submit, send, newline, shortcut, keybind, prompt",
"commands.thinkingBlocks.label.show": "Show Thinking",
"commands.thinkingBlocks.label.hide": "Hide Thinking",
"commands.thinkingBlocks.description": "Show or hide AI thinking sections",
"commands.thinkingBlocks.keywords": "thinking, reasoning, toggle, show, hide", "commands.thinkingBlocks.keywords": "thinking, reasoning, toggle, show, hide",
"commands.timelineToolCalls.label.show": "Show Timeline Tool Calls", "commands.timelineToolCalls.label.show": "Show Timeline Tool Calls",
@@ -99,8 +104,8 @@ export const commandMessages = {
"commands.common.enabled": "Enabled", "commands.common.enabled": "Enabled",
"commands.common.disabled": "Disabled", "commands.common.disabled": "Disabled",
"commands.thinkingBlocksDefault.label": "Thinking Blocks Default · {state}", "commands.thinkingBlocksDefault.label": "Thinking View: {state}",
"commands.thinkingBlocksDefault.description": "Toggle whether thinking blocks start expanded", "commands.thinkingBlocksDefault.description": "Collapse / Expand AI thinking sections when shown",
"commands.thinkingBlocksDefault.keywords": "thinking, reasoning, expand, collapse, default", "commands.thinkingBlocksDefault.keywords": "thinking, reasoning, expand, collapse, default",
"commands.diffViewSplit.label": "Use Split Diff View", "commands.diffViewSplit.label": "Use Split Diff View",

View File

@@ -20,6 +20,9 @@ export const messagingMessages = {
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message", "messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
"messageSection.quote.addAsQuote": "Add as quote", "messageSection.quote.addAsQuote": "Add as quote",
"messageSection.quote.addAsCode": "Add as code", "messageSection.quote.addAsCode": "Add as code",
"messageSection.quote.copy": "Copy",
"messageSection.quote.copied": "Copied!",
"messageSection.quote.copyFailed": "Copy failed",
"messageTimeline.ariaLabel": "Message timeline", "messageTimeline.ariaLabel": "Message timeline",
"messageTimeline.segment.user.label": "You", "messageTimeline.segment.user.label": "You",
@@ -38,6 +41,11 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "Go to Session", "messageBlock.tool.goToSession.label": "Go to Session",
"messageBlock.tool.goToSession.title": "Go to session", "messageBlock.tool.goToSession.title": "Go to session",
"messageBlock.tool.goToSession.unavailableTitle": "Session not available yet", "messageBlock.tool.goToSession.unavailableTitle": "Session not available yet",
"messageBlock.tool.deletePart.label": "Delete",
"messageBlock.tool.deletePart.deleting": "Deleting...",
"messageBlock.tool.deletePart.title": "Delete this tool call output",
"messageBlock.tool.deletePart.failed.title": "Delete failed",
"messageBlock.tool.deletePart.failed.message": "Failed to delete tool call output",
"messageBlock.compaction.ariaLabel": "Session compaction", "messageBlock.compaction.ariaLabel": "Session compaction",
"messageBlock.compaction.autoLabel": "Session auto-compacted", "messageBlock.compaction.autoLabel": "Session auto-compacted",
@@ -73,6 +81,11 @@ export const messagingMessages = {
"messageItem.status.generating": "Generating...", "messageItem.status.generating": "Generating...",
"messageItem.status.sending": "Sending...", "messageItem.status.sending": "Sending...",
"messageItem.status.failedToSend": "Message failed to send", "messageItem.status.failedToSend": "Message failed to send",
"messagePart.actions.delete": "Delete",
"messagePart.actions.deleting": "Deleting...",
"messagePart.actions.deleteTitle": "Delete this item",
"messagePart.actions.deleteFailedTitle": "Delete failed",
"messagePart.actions.deleteFailedMessage": "Failed to delete item",
"messageItem.attachment.defaultName": "attachment", "messageItem.attachment.defaultName": "attachment",
"messageItem.attachment.downloadAriaLabel": "Download {name}", "messageItem.attachment.downloadAriaLabel": "Download {name}",
"messageItem.agentMeta.agentLabel": "Agent: {agent}", "messageItem.agentMeta.agentLabel": "Agent: {agent}",

View File

@@ -30,8 +30,27 @@ export const sessionMessages = {
"sessionList.copyId.success": "Session ID copied", "sessionList.copyId.success": "Session ID copied",
"sessionList.copyId.error": "Unable to copy session ID", "sessionList.copyId.error": "Unable to copy session ID",
"sessionList.delete.error": "Unable to delete session", "sessionList.delete.error": "Unable to delete session",
"sessionList.delete.title": "Delete session",
"sessionList.delete.confirmMessage": "Delete \"{label}\"? This cannot be undone.",
"sessionList.delete.confirmLabel": "Delete",
"sessionList.delete.cancelLabel": "Cancel",
"sessionList.rename.error": "Unable to rename session", "sessionList.rename.error": "Unable to rename session",
"sessionList.filter.placeholder": "Search sessions…",
"sessionList.filter.ariaLabel": "Search sessions",
"sessionList.selection.selectAllLabel": "Select all",
"sessionList.selection.selectAllAriaLabel": "Select all sessions",
"sessionList.selection.clearLabel": "Clear",
"sessionList.selection.clearAriaLabel": "Clear selection",
"sessionList.selection.checkboxAriaLabel": "Select session",
"sessionList.bulkDelete.button": "Delete {count}",
"sessionList.bulkDelete.ariaLabel": "Delete {count} selected sessions",
"sessionList.bulkDelete.title": "Delete sessions",
"sessionList.bulkDelete.confirmMessage": "Delete {count} selected sessions? This cannot be undone.",
"sessionList.bulkDelete.confirmLabel": "Delete",
"sessionList.bulkDelete.cancelLabel": "Cancel",
"sessionList.bulkDelete.error": "Unable to delete {count} sessions",
"sessionRenameDialog.title": "Rename Session", "sessionRenameDialog.title": "Rename Session",
"sessionRenameDialog.description.withLabel": "Update the title for \"{label}\".", "sessionRenameDialog.description.withLabel": "Update the title for \"{label}\".",
"sessionRenameDialog.description.default": "Set a new title for this session.", "sessionRenameDialog.description.default": "Set a new title for this session.",

View File

@@ -2,6 +2,9 @@ export const toolCallMessages = {
"toolCall.pending.waitingToRun": "Waiting to run...", "toolCall.pending.waitingToRun": "Waiting to run...",
"toolCall.error.label": "Error:", "toolCall.error.label": "Error:",
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.diff.label": "Diff", "toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Diff view mode", "toolCall.diff.viewMode.ariaLabel": "Diff view mode",

View File

@@ -82,9 +82,14 @@ export const commandMessages = {
"commands.clearInput.description": "Borrar el área de texto del prompt", "commands.clearInput.description": "Borrar el área de texto del prompt",
"commands.clearInput.keywords": "limpiar, reiniciar", "commands.clearInput.keywords": "limpiar, reiniciar",
"commands.thinkingBlocks.label.show": "Mostrar bloques de pensamiento", "commands.promptSubmitShortcut.label.default": "Enter: Nueva linea, Cmd/Ctrl+Enter: Enviar prompt",
"commands.thinkingBlocks.label.hide": "Ocultar bloques de pensamiento", "commands.promptSubmitShortcut.label.swapped": "Enter: Enviar prompt, Cmd/Ctrl+Enter: Nueva linea",
"commands.thinkingBlocks.description": "Mostrar/ocultar el proceso de pensamiento de la IA", "commands.promptSubmitShortcut.description": "Intercambiar el comportamiento de Enter y Cmd/Ctrl+Enter en la entrada de prompt",
"commands.promptSubmitShortcut.keywords": "enter, enviar, salto de linea, atajo, teclado, cmd, ctrl, prompt",
"commands.thinkingBlocks.label.show": "Mostrar pensamiento",
"commands.thinkingBlocks.label.hide": "Ocultar pensamiento",
"commands.thinkingBlocks.description": "Mostrar u ocultar secciones de pensamiento de IA",
"commands.thinkingBlocks.keywords": "pensamiento, razonamiento, alternar, mostrar, ocultar", "commands.thinkingBlocks.keywords": "pensamiento, razonamiento, alternar, mostrar, ocultar",
"commands.timelineToolCalls.label.show": "Mostrar llamadas de herramienta en la línea de tiempo", "commands.timelineToolCalls.label.show": "Mostrar llamadas de herramienta en la línea de tiempo",
@@ -99,8 +104,8 @@ export const commandMessages = {
"commands.common.enabled": "Activado", "commands.common.enabled": "Activado",
"commands.common.disabled": "Desactivado", "commands.common.disabled": "Desactivado",
"commands.thinkingBlocksDefault.label": "Bloques de pensamiento por defecto · {state}", "commands.thinkingBlocksDefault.label": "Vista de pensamiento: {state}",
"commands.thinkingBlocksDefault.description": "Alternar si los bloques de pensamiento empiezan expandidos", "commands.thinkingBlocksDefault.description": "Contraer / Expandir secciones de pensamiento de IA cuando se muestran",
"commands.thinkingBlocksDefault.keywords": "pensamiento, razonamiento, expandir, colapsar, por defecto", "commands.thinkingBlocksDefault.keywords": "pensamiento, razonamiento, expandir, colapsar, por defecto",
"commands.diffViewSplit.label": "Usar vista de diff dividida", "commands.diffViewSplit.label": "Usar vista de diff dividida",

View File

@@ -20,6 +20,9 @@ export const messagingMessages = {
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje", "messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",
"messageSection.quote.addAsQuote": "Añadir como cita", "messageSection.quote.addAsQuote": "Añadir como cita",
"messageSection.quote.addAsCode": "Añadir como código", "messageSection.quote.addAsCode": "Añadir como código",
"messageSection.quote.copy": "Copiar",
"messageSection.quote.copied": "¡Copiado!",
"messageSection.quote.copyFailed": "No se pudo copiar",
"messageTimeline.ariaLabel": "Línea de tiempo de mensajes", "messageTimeline.ariaLabel": "Línea de tiempo de mensajes",
"messageTimeline.segment.user.label": "Tú", "messageTimeline.segment.user.label": "Tú",
@@ -38,6 +41,11 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "Ir a sesión", "messageBlock.tool.goToSession.label": "Ir a sesión",
"messageBlock.tool.goToSession.title": "Ir a la sesión", "messageBlock.tool.goToSession.title": "Ir a la sesión",
"messageBlock.tool.goToSession.unavailableTitle": "La sesión aún no está disponible", "messageBlock.tool.goToSession.unavailableTitle": "La sesión aún no está disponible",
"messageBlock.tool.deletePart.label": "Eliminar",
"messageBlock.tool.deletePart.deleting": "Eliminando...",
"messageBlock.tool.deletePart.title": "Eliminar esta salida de herramienta",
"messageBlock.tool.deletePart.failed.title": "Error al eliminar",
"messageBlock.tool.deletePart.failed.message": "No se pudo eliminar la salida de herramienta",
"messageBlock.compaction.ariaLabel": "Compactación de sesión", "messageBlock.compaction.ariaLabel": "Compactación de sesión",
"messageBlock.compaction.autoLabel": "Sesión compactada automáticamente", "messageBlock.compaction.autoLabel": "Sesión compactada automáticamente",
@@ -73,6 +81,11 @@ export const messagingMessages = {
"messageItem.status.generating": "Generando...", "messageItem.status.generating": "Generando...",
"messageItem.status.sending": "Enviando...", "messageItem.status.sending": "Enviando...",
"messageItem.status.failedToSend": "No se pudo enviar el mensaje", "messageItem.status.failedToSend": "No se pudo enviar el mensaje",
"messagePart.actions.delete": "Eliminar",
"messagePart.actions.deleting": "Eliminando...",
"messagePart.actions.deleteTitle": "Eliminar este elemento",
"messagePart.actions.deleteFailedTitle": "Error al eliminar",
"messagePart.actions.deleteFailedMessage": "No se pudo eliminar el elemento",
"messageItem.attachment.defaultName": "adjunto", "messageItem.attachment.defaultName": "adjunto",
"messageItem.attachment.downloadAriaLabel": "Descargar {name}", "messageItem.attachment.downloadAriaLabel": "Descargar {name}",
"messageItem.agentMeta.agentLabel": "Agente: {agent}", "messageItem.agentMeta.agentLabel": "Agente: {agent}",

View File

@@ -30,8 +30,27 @@ export const sessionMessages = {
"sessionList.copyId.success": "ID de sesión copiado", "sessionList.copyId.success": "ID de sesión copiado",
"sessionList.copyId.error": "No se pudo copiar el ID de sesión", "sessionList.copyId.error": "No se pudo copiar el ID de sesión",
"sessionList.delete.error": "No se pudo eliminar la sesión", "sessionList.delete.error": "No se pudo eliminar la sesión",
"sessionList.delete.title": "Eliminar sesión",
"sessionList.delete.confirmMessage": "¿Eliminar \"{label}\"? Esto no se puede deshacer.",
"sessionList.delete.confirmLabel": "Eliminar",
"sessionList.delete.cancelLabel": "Cancelar",
"sessionList.rename.error": "No se pudo renombrar la sesión", "sessionList.rename.error": "No se pudo renombrar la sesión",
"sessionList.filter.placeholder": "Buscar sesiones…",
"sessionList.filter.ariaLabel": "Buscar sesiones",
"sessionList.selection.selectAllLabel": "Seleccionar todo",
"sessionList.selection.selectAllAriaLabel": "Seleccionar todas las sesiones",
"sessionList.selection.clearLabel": "Limpiar",
"sessionList.selection.clearAriaLabel": "Limpiar selección",
"sessionList.selection.checkboxAriaLabel": "Seleccionar sesión",
"sessionList.bulkDelete.button": "Eliminar {count}",
"sessionList.bulkDelete.ariaLabel": "Eliminar {count} sesiones seleccionadas",
"sessionList.bulkDelete.title": "Eliminar sesiones",
"sessionList.bulkDelete.confirmMessage": "¿Eliminar {count} sesiones seleccionadas? Esto no se puede deshacer.",
"sessionList.bulkDelete.confirmLabel": "Eliminar",
"sessionList.bulkDelete.cancelLabel": "Cancelar",
"sessionList.bulkDelete.error": "No se pudieron eliminar {count} sesiones",
"sessionRenameDialog.title": "Renombrar sesión", "sessionRenameDialog.title": "Renombrar sesión",
"sessionRenameDialog.description.withLabel": "Actualiza el título de \"{label}\".", "sessionRenameDialog.description.withLabel": "Actualiza el título de \"{label}\".",
"sessionRenameDialog.description.default": "Establece un nuevo título para esta sesión.", "sessionRenameDialog.description.default": "Establece un nuevo título para esta sesión.",

View File

@@ -2,6 +2,9 @@ export const toolCallMessages = {
"toolCall.pending.waitingToRun": "Esperando para ejecutar...", "toolCall.pending.waitingToRun": "Esperando para ejecutar...",
"toolCall.error.label": "Error:", "toolCall.error.label": "Error:",
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.diff.label": "Diff", "toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff", "toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff",

View File

@@ -82,9 +82,14 @@ export const commandMessages = {
"commands.clearInput.description": "Effacer la zone de texte du prompt", "commands.clearInput.description": "Effacer la zone de texte du prompt",
"commands.clearInput.keywords": "effacer, réinitialiser, prompt", "commands.clearInput.keywords": "effacer, réinitialiser, prompt",
"commands.thinkingBlocks.label.show": "Afficher les blocs de réflexion", "commands.promptSubmitShortcut.label.default": "Entree: Nouvelle ligne, Cmd/Ctrl+Entree: Envoyer le prompt",
"commands.thinkingBlocks.label.hide": "Masquer les blocs de réflexion", "commands.promptSubmitShortcut.label.swapped": "Entree: Envoyer le prompt, Cmd/Ctrl+Entree: Nouvelle ligne",
"commands.thinkingBlocks.description": "Afficher/masquer le processus de réflexion de l'IA", "commands.promptSubmitShortcut.description": "Echanger le comportement de Entree et Cmd/Ctrl+Entree dans la saisie du prompt",
"commands.promptSubmitShortcut.keywords": "entree, envoyer, nouvelle ligne, raccourci, cmd, ctrl, prompt",
"commands.thinkingBlocks.label.show": "Afficher la réflexion",
"commands.thinkingBlocks.label.hide": "Masquer la réflexion",
"commands.thinkingBlocks.description": "Afficher ou masquer les sections de réflexion de l'IA",
"commands.thinkingBlocks.keywords": "réflexion, raisonnement, basculer, afficher, masquer", "commands.thinkingBlocks.keywords": "réflexion, raisonnement, basculer, afficher, masquer",
"commands.timelineToolCalls.label.show": "Afficher les appels d'outil dans la timeline", "commands.timelineToolCalls.label.show": "Afficher les appels d'outil dans la timeline",
@@ -99,8 +104,8 @@ export const commandMessages = {
"commands.common.enabled": "Activé", "commands.common.enabled": "Activé",
"commands.common.disabled": "Désactivé", "commands.common.disabled": "Désactivé",
"commands.thinkingBlocksDefault.label": "Blocs de réflexion par défaut · {state}", "commands.thinkingBlocksDefault.label": "Vue de la réflexion: {state}",
"commands.thinkingBlocksDefault.description": "Choisir si les blocs de réflexion démarrent développés", "commands.thinkingBlocksDefault.description": "Réduire / Développer les sections de réflexion de l'IA lorsqu'elles sont affichées",
"commands.thinkingBlocksDefault.keywords": "réflexion, raisonnement, développer, réduire, défaut", "commands.thinkingBlocksDefault.keywords": "réflexion, raisonnement, développer, réduire, défaut",
"commands.diffViewSplit.label": "Utiliser la vue diff côte à côte", "commands.diffViewSplit.label": "Utiliser la vue diff côte à côte",

View File

@@ -20,6 +20,9 @@ export const messagingMessages = {
"messageSection.scroll.toLatestAriaLabel": "Aller au dernier message", "messageSection.scroll.toLatestAriaLabel": "Aller au dernier message",
"messageSection.quote.addAsQuote": "Ajouter en citation", "messageSection.quote.addAsQuote": "Ajouter en citation",
"messageSection.quote.addAsCode": "Ajouter en code", "messageSection.quote.addAsCode": "Ajouter en code",
"messageSection.quote.copy": "Copier",
"messageSection.quote.copied": "Copié !",
"messageSection.quote.copyFailed": "Impossible de copier",
"messageTimeline.ariaLabel": "Chronologie des messages", "messageTimeline.ariaLabel": "Chronologie des messages",
"messageTimeline.segment.user.label": "Vous", "messageTimeline.segment.user.label": "Vous",
@@ -38,6 +41,11 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "Aller à la session", "messageBlock.tool.goToSession.label": "Aller à la session",
"messageBlock.tool.goToSession.title": "Aller à la session", "messageBlock.tool.goToSession.title": "Aller à la session",
"messageBlock.tool.goToSession.unavailableTitle": "Session pas encore disponible", "messageBlock.tool.goToSession.unavailableTitle": "Session pas encore disponible",
"messageBlock.tool.deletePart.label": "Supprimer",
"messageBlock.tool.deletePart.deleting": "Suppression...",
"messageBlock.tool.deletePart.title": "Supprimer cette sortie d'outil",
"messageBlock.tool.deletePart.failed.title": "Échec de suppression",
"messageBlock.tool.deletePart.failed.message": "Impossible de supprimer la sortie d'outil",
"messageBlock.compaction.ariaLabel": "Compaction de la session", "messageBlock.compaction.ariaLabel": "Compaction de la session",
"messageBlock.compaction.autoLabel": "Session compactée automatiquement", "messageBlock.compaction.autoLabel": "Session compactée automatiquement",
@@ -73,6 +81,11 @@ export const messagingMessages = {
"messageItem.status.generating": "Génération...", "messageItem.status.generating": "Génération...",
"messageItem.status.sending": "Envoi...", "messageItem.status.sending": "Envoi...",
"messageItem.status.failedToSend": "Échec de l'envoi du message", "messageItem.status.failedToSend": "Échec de l'envoi du message",
"messagePart.actions.delete": "Supprimer",
"messagePart.actions.deleting": "Suppression...",
"messagePart.actions.deleteTitle": "Supprimer cet élément",
"messagePart.actions.deleteFailedTitle": "Échec de suppression",
"messagePart.actions.deleteFailedMessage": "Impossible de supprimer l'élément",
"messageItem.attachment.defaultName": "piece-jointe", "messageItem.attachment.defaultName": "piece-jointe",
"messageItem.attachment.downloadAriaLabel": "Télécharger {name}", "messageItem.attachment.downloadAriaLabel": "Télécharger {name}",
"messageItem.agentMeta.agentLabel": "Agent : {agent}", "messageItem.agentMeta.agentLabel": "Agent : {agent}",

View File

@@ -30,8 +30,27 @@ export const sessionMessages = {
"sessionList.copyId.success": "ID de session copié", "sessionList.copyId.success": "ID de session copié",
"sessionList.copyId.error": "Impossible de copier l'ID de session", "sessionList.copyId.error": "Impossible de copier l'ID de session",
"sessionList.delete.error": "Impossible de supprimer la session", "sessionList.delete.error": "Impossible de supprimer la session",
"sessionList.delete.title": "Supprimer la session",
"sessionList.delete.confirmMessage": "Supprimer \"{label}\" ? Cette action est irréversible.",
"sessionList.delete.confirmLabel": "Supprimer",
"sessionList.delete.cancelLabel": "Annuler",
"sessionList.rename.error": "Impossible de renommer la session", "sessionList.rename.error": "Impossible de renommer la session",
"sessionList.filter.placeholder": "Rechercher des sessions…",
"sessionList.filter.ariaLabel": "Rechercher des sessions",
"sessionList.selection.selectAllLabel": "Tout sélectionner",
"sessionList.selection.selectAllAriaLabel": "Sélectionner toutes les sessions",
"sessionList.selection.clearLabel": "Effacer",
"sessionList.selection.clearAriaLabel": "Effacer la sélection",
"sessionList.selection.checkboxAriaLabel": "Sélectionner la session",
"sessionList.bulkDelete.button": "Supprimer {count}",
"sessionList.bulkDelete.ariaLabel": "Supprimer {count} sessions sélectionnées",
"sessionList.bulkDelete.title": "Supprimer des sessions",
"sessionList.bulkDelete.confirmMessage": "Supprimer {count} sessions sélectionnées ? Cette action est irréversible.",
"sessionList.bulkDelete.confirmLabel": "Supprimer",
"sessionList.bulkDelete.cancelLabel": "Annuler",
"sessionList.bulkDelete.error": "Impossible de supprimer {count} sessions",
"sessionRenameDialog.title": "Renommer la session", "sessionRenameDialog.title": "Renommer la session",
"sessionRenameDialog.description.withLabel": "Mettre à jour le titre de \"{label}\".", "sessionRenameDialog.description.withLabel": "Mettre à jour le titre de \"{label}\".",
"sessionRenameDialog.description.default": "Définir un nouveau titre pour cette session.", "sessionRenameDialog.description.default": "Définir un nouveau titre pour cette session.",

View File

@@ -2,6 +2,9 @@ export const toolCallMessages = {
"toolCall.pending.waitingToRun": "En attente d'exécution...", "toolCall.pending.waitingToRun": "En attente d'exécution...",
"toolCall.error.label": "Erreur :", "toolCall.error.label": "Erreur :",
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.diff.label": "Diff", "toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff", "toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff",

View File

@@ -82,9 +82,14 @@ export const commandMessages = {
"commands.clearInput.description": "プロンプト入力欄をクリア", "commands.clearInput.description": "プロンプト入力欄をクリア",
"commands.clearInput.keywords": "クリア, リセット, clear, reset", "commands.clearInput.keywords": "クリア, リセット, clear, reset",
"commands.thinkingBlocks.label.show": "思考ブロックを表示", "commands.promptSubmitShortcut.label.default": "Enter: 改行, Cmd/Ctrl+Enter: プロンプト送信",
"commands.thinkingBlocks.label.hide": "思考ブロックを非表示", "commands.promptSubmitShortcut.label.swapped": "Enter: プロンプト送信, Cmd/Ctrl+Enter: 改行",
"commands.thinkingBlocks.description": "AI の思考過程を表示/非表示", "commands.promptSubmitShortcut.description": "プロンプト入力で Enter と Cmd/Ctrl+Enter の動作を入れ替え",
"commands.promptSubmitShortcut.keywords": "enter, 送信, 改行, ショートカット, cmd, ctrl, プロンプト",
"commands.thinkingBlocks.label.show": "思考を表示",
"commands.thinkingBlocks.label.hide": "思考を非表示",
"commands.thinkingBlocks.description": "AI の思考セクションを表示/非表示",
"commands.thinkingBlocks.keywords": "思考, 推論, 切り替え, 表示, 非表示, thinking, reasoning, toggle, show, hide", "commands.thinkingBlocks.keywords": "思考, 推論, 切り替え, 表示, 非表示, thinking, reasoning, toggle, show, hide",
"commands.timelineToolCalls.label.show": "タイムラインのツールコールを表示", "commands.timelineToolCalls.label.show": "タイムラインのツールコールを表示",
@@ -99,8 +104,8 @@ export const commandMessages = {
"commands.common.enabled": "有効", "commands.common.enabled": "有効",
"commands.common.disabled": "無効", "commands.common.disabled": "無効",
"commands.thinkingBlocksDefault.label": "思考ブロックの既定 · {state}", "commands.thinkingBlocksDefault.label": "思考ビュー: {state}",
"commands.thinkingBlocksDefault.description": "思考ブロックを既定で展開するか切り替え", "commands.thinkingBlocksDefault.description": "表示中は AI の思考セクションを折りたたみ / 展開",
"commands.thinkingBlocksDefault.keywords": "思考, 推論, 展開, 折りたたみ, 既定, thinking, reasoning, expand, collapse, default", "commands.thinkingBlocksDefault.keywords": "思考, 推論, 展開, 折りたたみ, 既定, thinking, reasoning, expand, collapse, default",
"commands.diffViewSplit.label": "分割 diff 表示を使用", "commands.diffViewSplit.label": "分割 diff 表示を使用",

View File

@@ -20,6 +20,9 @@ export const messagingMessages = {
"messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール", "messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール",
"messageSection.quote.addAsQuote": "引用として追加", "messageSection.quote.addAsQuote": "引用として追加",
"messageSection.quote.addAsCode": "コードとして追加", "messageSection.quote.addAsCode": "コードとして追加",
"messageSection.quote.copy": "コピー",
"messageSection.quote.copied": "コピーしました",
"messageSection.quote.copyFailed": "コピーできませんでした",
"messageTimeline.ariaLabel": "メッセージタイムライン", "messageTimeline.ariaLabel": "メッセージタイムライン",
"messageTimeline.segment.user.label": "あなた", "messageTimeline.segment.user.label": "あなた",
@@ -38,6 +41,11 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "セッションへ移動", "messageBlock.tool.goToSession.label": "セッションへ移動",
"messageBlock.tool.goToSession.title": "セッションへ移動", "messageBlock.tool.goToSession.title": "セッションへ移動",
"messageBlock.tool.goToSession.unavailableTitle": "セッションはまだ利用できません", "messageBlock.tool.goToSession.unavailableTitle": "セッションはまだ利用できません",
"messageBlock.tool.deletePart.label": "削除",
"messageBlock.tool.deletePart.deleting": "削除中...",
"messageBlock.tool.deletePart.title": "このツール出力を削除",
"messageBlock.tool.deletePart.failed.title": "削除に失敗しました",
"messageBlock.tool.deletePart.failed.message": "ツール出力の削除に失敗しました",
"messageBlock.compaction.ariaLabel": "セッションのコンパクト化", "messageBlock.compaction.ariaLabel": "セッションのコンパクト化",
"messageBlock.compaction.autoLabel": "セッションを自動でコンパクト化しました", "messageBlock.compaction.autoLabel": "セッションを自動でコンパクト化しました",
@@ -73,6 +81,11 @@ export const messagingMessages = {
"messageItem.status.generating": "生成中...", "messageItem.status.generating": "生成中...",
"messageItem.status.sending": "送信中...", "messageItem.status.sending": "送信中...",
"messageItem.status.failedToSend": "メッセージの送信に失敗しました", "messageItem.status.failedToSend": "メッセージの送信に失敗しました",
"messagePart.actions.delete": "削除",
"messagePart.actions.deleting": "削除中...",
"messagePart.actions.deleteTitle": "この項目を削除",
"messagePart.actions.deleteFailedTitle": "削除に失敗しました",
"messagePart.actions.deleteFailedMessage": "項目の削除に失敗しました",
"messageItem.attachment.defaultName": "添付ファイル", "messageItem.attachment.defaultName": "添付ファイル",
"messageItem.attachment.downloadAriaLabel": "{name} をダウンロード", "messageItem.attachment.downloadAriaLabel": "{name} をダウンロード",
"messageItem.agentMeta.agentLabel": "エージェント: {agent}", "messageItem.agentMeta.agentLabel": "エージェント: {agent}",

View File

@@ -30,8 +30,27 @@ export const sessionMessages = {
"sessionList.copyId.success": "セッション ID をコピーしました", "sessionList.copyId.success": "セッション ID をコピーしました",
"sessionList.copyId.error": "セッション ID をコピーできません", "sessionList.copyId.error": "セッション ID をコピーできません",
"sessionList.delete.error": "セッションを削除できません", "sessionList.delete.error": "セッションを削除できません",
"sessionList.delete.title": "セッションを削除",
"sessionList.delete.confirmMessage": "\"{label}\" を削除しますか?この操作は元に戻せません。",
"sessionList.delete.confirmLabel": "削除",
"sessionList.delete.cancelLabel": "キャンセル",
"sessionList.rename.error": "セッション名を変更できません", "sessionList.rename.error": "セッション名を変更できません",
"sessionList.filter.placeholder": "セッションを検索…",
"sessionList.filter.ariaLabel": "セッションを検索",
"sessionList.selection.selectAllLabel": "すべて選択",
"sessionList.selection.selectAllAriaLabel": "すべてのセッションを選択",
"sessionList.selection.clearLabel": "クリア",
"sessionList.selection.clearAriaLabel": "選択をクリア",
"sessionList.selection.checkboxAriaLabel": "セッションを選択",
"sessionList.bulkDelete.button": "{count} 件を削除",
"sessionList.bulkDelete.ariaLabel": "選択した {count} 件のセッションを削除",
"sessionList.bulkDelete.title": "セッションを削除",
"sessionList.bulkDelete.confirmMessage": "選択した {count} 件のセッションを削除しますか?この操作は元に戻せません。",
"sessionList.bulkDelete.confirmLabel": "削除",
"sessionList.bulkDelete.cancelLabel": "キャンセル",
"sessionList.bulkDelete.error": "{count} 件のセッションを削除できません",
"sessionRenameDialog.title": "セッション名を変更", "sessionRenameDialog.title": "セッション名を変更",
"sessionRenameDialog.description.withLabel": "\"{label}\" のタイトルを更新します。", "sessionRenameDialog.description.withLabel": "\"{label}\" のタイトルを更新します。",
"sessionRenameDialog.description.default": "このセッションの新しいタイトルを設定します。", "sessionRenameDialog.description.default": "このセッションの新しいタイトルを設定します。",

View File

@@ -2,6 +2,9 @@ export const toolCallMessages = {
"toolCall.pending.waitingToRun": "実行待ち...", "toolCall.pending.waitingToRun": "実行待ち...",
"toolCall.error.label": "エラー:", "toolCall.error.label": "エラー:",
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.diff.label": "Diff", "toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "diff 表示モード", "toolCall.diff.viewMode.ariaLabel": "diff 表示モード",

View File

@@ -82,9 +82,14 @@ export const commandMessages = {
"commands.clearInput.description": "Очистить поле prompt", "commands.clearInput.description": "Очистить поле prompt",
"commands.clearInput.keywords": "очистить, сброс", "commands.clearInput.keywords": "очистить, сброс",
"commands.thinkingBlocks.label.show": "Показать блоки размышлений", "commands.promptSubmitShortcut.label.default": "Enter: Новая строка, Cmd/Ctrl+Enter: Отправить промпт",
"commands.thinkingBlocks.label.hide": "Скрыть блоки размышлений", "commands.promptSubmitShortcut.label.swapped": "Enter: Отправить промпт, Cmd/Ctrl+Enter: Новая строка",
"commands.thinkingBlocks.description": "Показать/скрыть процесс рассуждений AI", "commands.promptSubmitShortcut.description": "Поменять местами Enter и Cmd/Ctrl+Enter в поле ввода промпта",
"commands.promptSubmitShortcut.keywords": "enter, отправить, новая строка, сочетание, cmd, ctrl, промпт",
"commands.thinkingBlocks.label.show": "Показать размышления",
"commands.thinkingBlocks.label.hide": "Скрыть размышления",
"commands.thinkingBlocks.description": "Показать или скрыть секции размышлений ИИ",
"commands.thinkingBlocks.keywords": "thinking, reasoning, переключить, показать, скрыть", "commands.thinkingBlocks.keywords": "thinking, reasoning, переключить, показать, скрыть",
"commands.timelineToolCalls.label.show": "Показать Tool Calls в таймлайне", "commands.timelineToolCalls.label.show": "Показать Tool Calls в таймлайне",
@@ -99,8 +104,8 @@ export const commandMessages = {
"commands.common.enabled": "Включено", "commands.common.enabled": "Включено",
"commands.common.disabled": "Выключено", "commands.common.disabled": "Выключено",
"commands.thinkingBlocksDefault.label": "Блоки размышлений по умолчанию · {state}", "commands.thinkingBlocksDefault.label": "Вид размышлений: {state}",
"commands.thinkingBlocksDefault.description": "Переключить, разворачивать ли блоки размышлений по умолчанию", "commands.thinkingBlocksDefault.description": "Свернуть / Развернуть секции размышлений ИИ, когда они показаны",
"commands.thinkingBlocksDefault.keywords": "thinking, reasoning, развернуть, свернуть, по умолчанию", "commands.thinkingBlocksDefault.keywords": "thinking, reasoning, развернуть, свернуть, по умолчанию",
"commands.diffViewSplit.label": "Раздельный просмотр diff", "commands.diffViewSplit.label": "Раздельный просмотр diff",

View File

@@ -20,6 +20,9 @@ export const messagingMessages = {
"messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению", "messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению",
"messageSection.quote.addAsQuote": "Добавить как цитату", "messageSection.quote.addAsQuote": "Добавить как цитату",
"messageSection.quote.addAsCode": "Добавить как код", "messageSection.quote.addAsCode": "Добавить как код",
"messageSection.quote.copy": "Копировать",
"messageSection.quote.copied": "Скопировано!",
"messageSection.quote.copyFailed": "Не удалось скопировать",
"messageTimeline.ariaLabel": "Таймлайн сообщений", "messageTimeline.ariaLabel": "Таймлайн сообщений",
"messageTimeline.segment.user.label": "Вы", "messageTimeline.segment.user.label": "Вы",
@@ -38,6 +41,11 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "Перейти к сессии", "messageBlock.tool.goToSession.label": "Перейти к сессии",
"messageBlock.tool.goToSession.title": "Перейти к сессии", "messageBlock.tool.goToSession.title": "Перейти к сессии",
"messageBlock.tool.goToSession.unavailableTitle": "Сессия пока недоступна", "messageBlock.tool.goToSession.unavailableTitle": "Сессия пока недоступна",
"messageBlock.tool.deletePart.label": "Удалить",
"messageBlock.tool.deletePart.deleting": "Удаление...",
"messageBlock.tool.deletePart.title": "Удалить этот вывод инструмента",
"messageBlock.tool.deletePart.failed.title": "Ошибка удаления",
"messageBlock.tool.deletePart.failed.message": "Не удалось удалить вывод инструмента",
"messageBlock.compaction.ariaLabel": "Компактация сессии", "messageBlock.compaction.ariaLabel": "Компактация сессии",
"messageBlock.compaction.autoLabel": "Сессия автоматически компактирована", "messageBlock.compaction.autoLabel": "Сессия автоматически компактирована",
@@ -73,6 +81,11 @@ export const messagingMessages = {
"messageItem.status.generating": "Генерация…", "messageItem.status.generating": "Генерация…",
"messageItem.status.sending": "Отправка…", "messageItem.status.sending": "Отправка…",
"messageItem.status.failedToSend": "Не удалось отправить сообщение", "messageItem.status.failedToSend": "Не удалось отправить сообщение",
"messagePart.actions.delete": "Удалить",
"messagePart.actions.deleting": "Удаление...",
"messagePart.actions.deleteTitle": "Удалить этот элемент",
"messagePart.actions.deleteFailedTitle": "Ошибка удаления",
"messagePart.actions.deleteFailedMessage": "Не удалось удалить элемент",
"messageItem.attachment.defaultName": "вложение", "messageItem.attachment.defaultName": "вложение",
"messageItem.attachment.downloadAriaLabel": "Скачать {name}", "messageItem.attachment.downloadAriaLabel": "Скачать {name}",
"messageItem.agentMeta.agentLabel": "Агент: {agent}", "messageItem.agentMeta.agentLabel": "Агент: {agent}",

View File

@@ -30,8 +30,27 @@ export const sessionMessages = {
"sessionList.copyId.success": "ID сессии скопирован", "sessionList.copyId.success": "ID сессии скопирован",
"sessionList.copyId.error": "Не удалось скопировать ID сессии", "sessionList.copyId.error": "Не удалось скопировать ID сессии",
"sessionList.delete.error": "Не удалось удалить сессию", "sessionList.delete.error": "Не удалось удалить сессию",
"sessionList.delete.title": "Удалить сессию",
"sessionList.delete.confirmMessage": "Удалить \"{label}\"? Это действие нельзя отменить.",
"sessionList.delete.confirmLabel": "Удалить",
"sessionList.delete.cancelLabel": "Отмена",
"sessionList.rename.error": "Не удалось переименовать сессию", "sessionList.rename.error": "Не удалось переименовать сессию",
"sessionList.filter.placeholder": "Поиск сессий…",
"sessionList.filter.ariaLabel": "Поиск сессий",
"sessionList.selection.selectAllLabel": "Выбрать все",
"sessionList.selection.selectAllAriaLabel": "Выбрать все сессии",
"sessionList.selection.clearLabel": "Очистить",
"sessionList.selection.clearAriaLabel": "Очистить выбор",
"sessionList.selection.checkboxAriaLabel": "Выбрать сессию",
"sessionList.bulkDelete.button": "Удалить {count}",
"sessionList.bulkDelete.ariaLabel": "Удалить {count} выбранных сессий",
"sessionList.bulkDelete.title": "Удалить сессии",
"sessionList.bulkDelete.confirmMessage": "Удалить {count} выбранных сессий? Это действие нельзя отменить.",
"sessionList.bulkDelete.confirmLabel": "Удалить",
"sessionList.bulkDelete.cancelLabel": "Отмена",
"sessionList.bulkDelete.error": "Не удалось удалить {count} сессий",
"sessionRenameDialog.title": "Переименовать сессию", "sessionRenameDialog.title": "Переименовать сессию",
"sessionRenameDialog.description.withLabel": "Обновите название для \"{label}\".", "sessionRenameDialog.description.withLabel": "Обновите название для \"{label}\".",
"sessionRenameDialog.description.default": "Установите новое название для этой сессии.", "sessionRenameDialog.description.default": "Установите новое название для этой сессии.",

View File

@@ -2,6 +2,9 @@ export const toolCallMessages = {
"toolCall.pending.waitingToRun": "Ожидание запуска…", "toolCall.pending.waitingToRun": "Ожидание запуска…",
"toolCall.error.label": "Ошибка:", "toolCall.error.label": "Ошибка:",
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.diff.label": "Diff", "toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff", "toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff",

View File

@@ -82,9 +82,14 @@ export const commandMessages = {
"commands.clearInput.description": "清空 prompt 输入框", "commands.clearInput.description": "清空 prompt 输入框",
"commands.clearInput.keywords": "clear, reset, 清空, 重置", "commands.clearInput.keywords": "clear, reset, 清空, 重置",
"commands.thinkingBlocks.label.show": "显示思考块", "commands.promptSubmitShortcut.label.default": "Enter: 换行, Cmd/Ctrl+Enter: 提交提示词",
"commands.thinkingBlocks.label.hide": "隐藏思考块", "commands.promptSubmitShortcut.label.swapped": "Enter: 提交提示词, Cmd/Ctrl+Enter: 换行",
"commands.thinkingBlocks.description": "显示/隐藏 AI 思考过程", "commands.promptSubmitShortcut.description": "在提示词输入框中交换 Enter 与 Cmd/Ctrl+Enter 的行为",
"commands.promptSubmitShortcut.keywords": "enter, 换行, 提交, 发送, 快捷键, cmd, ctrl, 提示词",
"commands.thinkingBlocks.label.show": "显示思考",
"commands.thinkingBlocks.label.hide": "隐藏思考",
"commands.thinkingBlocks.description": "显示或隐藏 AI 思考部分",
"commands.thinkingBlocks.keywords": "thinking, reasoning, toggle, show, hide, 思考, 推理, 切换, 显示, 隐藏", "commands.thinkingBlocks.keywords": "thinking, reasoning, toggle, show, hide, 思考, 推理, 切换, 显示, 隐藏",
"commands.timelineToolCalls.label.show": "显示时间轴工具调用", "commands.timelineToolCalls.label.show": "显示时间轴工具调用",
@@ -99,8 +104,8 @@ export const commandMessages = {
"commands.common.enabled": "启用", "commands.common.enabled": "启用",
"commands.common.disabled": "禁用", "commands.common.disabled": "禁用",
"commands.thinkingBlocksDefault.label": "思考块默认 · {state}", "commands.thinkingBlocksDefault.label": "思考视图: {state}",
"commands.thinkingBlocksDefault.description": "切换思考块是否默认展开", "commands.thinkingBlocksDefault.description": "在显示时折叠 / 展开 AI 思考部分",
"commands.thinkingBlocksDefault.keywords": "thinking, reasoning, expand, collapse, default, 思考, 推理, 展开, 折叠, 默认", "commands.thinkingBlocksDefault.keywords": "thinking, reasoning, expand, collapse, default, 思考, 推理, 展开, 折叠, 默认",
"commands.diffViewSplit.label": "使用分栏 Diff 视图", "commands.diffViewSplit.label": "使用分栏 Diff 视图",

View File

@@ -20,6 +20,9 @@ export const messagingMessages = {
"messageSection.scroll.toLatestAriaLabel": "滚动到最新消息", "messageSection.scroll.toLatestAriaLabel": "滚动到最新消息",
"messageSection.quote.addAsQuote": "作为引用添加", "messageSection.quote.addAsQuote": "作为引用添加",
"messageSection.quote.addAsCode": "作为代码添加", "messageSection.quote.addAsCode": "作为代码添加",
"messageSection.quote.copy": "复制",
"messageSection.quote.copied": "已复制!",
"messageSection.quote.copyFailed": "无法复制",
"messageTimeline.ariaLabel": "消息时间线", "messageTimeline.ariaLabel": "消息时间线",
"messageTimeline.segment.user.label": "你", "messageTimeline.segment.user.label": "你",
@@ -38,6 +41,11 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "前往会话", "messageBlock.tool.goToSession.label": "前往会话",
"messageBlock.tool.goToSession.title": "前往会话", "messageBlock.tool.goToSession.title": "前往会话",
"messageBlock.tool.goToSession.unavailableTitle": "会话尚不可用", "messageBlock.tool.goToSession.unavailableTitle": "会话尚不可用",
"messageBlock.tool.deletePart.label": "删除",
"messageBlock.tool.deletePart.deleting": "正在删除...",
"messageBlock.tool.deletePart.title": "删除此工具输出",
"messageBlock.tool.deletePart.failed.title": "删除失败",
"messageBlock.tool.deletePart.failed.message": "删除工具输出失败",
"messageBlock.compaction.ariaLabel": "会话压缩", "messageBlock.compaction.ariaLabel": "会话压缩",
"messageBlock.compaction.autoLabel": "会话已自动压缩", "messageBlock.compaction.autoLabel": "会话已自动压缩",
@@ -73,6 +81,11 @@ export const messagingMessages = {
"messageItem.status.generating": "正在生成...", "messageItem.status.generating": "正在生成...",
"messageItem.status.sending": "正在发送...", "messageItem.status.sending": "正在发送...",
"messageItem.status.failedToSend": "消息发送失败", "messageItem.status.failedToSend": "消息发送失败",
"messagePart.actions.delete": "删除",
"messagePart.actions.deleting": "正在删除...",
"messagePart.actions.deleteTitle": "删除此项",
"messagePart.actions.deleteFailedTitle": "删除失败",
"messagePart.actions.deleteFailedMessage": "删除失败",
"messageItem.attachment.defaultName": "附件", "messageItem.attachment.defaultName": "附件",
"messageItem.attachment.downloadAriaLabel": "下载 {name}", "messageItem.attachment.downloadAriaLabel": "下载 {name}",
"messageItem.agentMeta.agentLabel": "智能体:{agent}", "messageItem.agentMeta.agentLabel": "智能体:{agent}",

View File

@@ -30,8 +30,27 @@ export const sessionMessages = {
"sessionList.copyId.success": "已复制会话 ID", "sessionList.copyId.success": "已复制会话 ID",
"sessionList.copyId.error": "无法复制会话 ID", "sessionList.copyId.error": "无法复制会话 ID",
"sessionList.delete.error": "无法删除会话", "sessionList.delete.error": "无法删除会话",
"sessionList.delete.title": "删除会话",
"sessionList.delete.confirmMessage": "删除“{label}”?此操作无法撤销。",
"sessionList.delete.confirmLabel": "删除",
"sessionList.delete.cancelLabel": "取消",
"sessionList.rename.error": "无法重命名会话", "sessionList.rename.error": "无法重命名会话",
"sessionList.filter.placeholder": "搜索会话…",
"sessionList.filter.ariaLabel": "搜索会话",
"sessionList.selection.selectAllLabel": "全选",
"sessionList.selection.selectAllAriaLabel": "选择所有会话",
"sessionList.selection.clearLabel": "清除",
"sessionList.selection.clearAriaLabel": "清除选择",
"sessionList.selection.checkboxAriaLabel": "选择会话",
"sessionList.bulkDelete.button": "删除 {count}",
"sessionList.bulkDelete.ariaLabel": "删除已选择的 {count} 个会话",
"sessionList.bulkDelete.title": "删除会话",
"sessionList.bulkDelete.confirmMessage": "删除已选择的 {count} 个会话?此操作无法撤销。",
"sessionList.bulkDelete.confirmLabel": "删除",
"sessionList.bulkDelete.cancelLabel": "取消",
"sessionList.bulkDelete.error": "无法删除 {count} 个会话",
"sessionRenameDialog.title": "重命名会话", "sessionRenameDialog.title": "重命名会话",
"sessionRenameDialog.description.withLabel": "更新“{label}”的标题。", "sessionRenameDialog.description.withLabel": "更新“{label}”的标题。",
"sessionRenameDialog.description.default": "为此会话设置新标题。", "sessionRenameDialog.description.default": "为此会话设置新标题。",

View File

@@ -2,6 +2,9 @@ export const toolCallMessages = {
"toolCall.pending.waitingToRun": "等待运行...", "toolCall.pending.waitingToRun": "等待运行...",
"toolCall.error.label": "错误:", "toolCall.error.label": "错误:",
"toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.diff.label": "Diff", "toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Diff 视图模式", "toolCall.diff.viewMode.ariaLabel": "Diff 视图模式",

View File

@@ -91,7 +91,7 @@ async function getOrCreateHighlighter() {
// Create highlighter with no preloaded languages // Create highlighter with no preloaded languages
highlighterPromise = createHighlighter({ highlighterPromise = createHighlighter({
themes: ["github-light", "github-dark"], themes: ["github-light", "github-light-high-contrast", "github-dark"],
langs: [], langs: [],
}) })
@@ -242,9 +242,9 @@ async function runLanguageLoadQueue() {
} }
function setupRenderer(isDark: boolean) { function setupRenderer(isDark: boolean) {
if (!highlighter || rendererSetup) return
currentTheme = isDark ? "dark" : "light" currentTheme = isDark ? "dark" : "light"
if (!highlighter) return
if (rendererSetup) return
marked.setOptions({ marked.setOptions({
breaks: true, breaks: true,
@@ -296,10 +296,10 @@ function setupRenderer(isDark: boolean) {
// Use highlighting if language is loaded, otherwise fall back to plain code // Use highlighting if language is loaded, otherwise fall back to plain code
if (loadedLanguages.has(langKey)) { if (loadedLanguages.has(langKey)) {
try { try {
const html = highlighter!.codeToHtml(decodedCode, { const html = highlighter!.codeToHtml(decodedCode, {
lang: langKey, lang: langKey,
theme: currentTheme === "dark" ? "github-dark" : "github-light", theme: currentTheme === "dark" ? "github-dark" : "github-light-high-contrast",
}) })
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}${html}</div>` return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}${html}</div>`
} catch { } catch {
// Fall through to plain code if highlighting fails // Fall through to plain code if highlighting fails
@@ -329,6 +329,10 @@ export async function initMarkdown(isDark: boolean) {
isInitialized = true isInitialized = true
} }
export function setMarkdownTheme(isDark: boolean) {
currentTheme = isDark ? "dark" : "light"
}
export function isMarkdownReady(): boolean { export function isMarkdownReady(): boolean {
return isInitialized && highlighter !== null return isInitialized && highlighter !== null
} }

View File

@@ -0,0 +1,158 @@
import { runtimeEnv } from "../runtime-env"
import { getLogger } from "../logger"
const log = getLogger("actions")
let desired = false
let inFlight: Promise<boolean> | null = null
let applied = false
let webWakeLock: any = null
async function setWebWakeLock(enabled: boolean): Promise<boolean> {
if (typeof navigator === "undefined") return false
const api = (navigator as any).wakeLock
if (!api?.request) {
return false
}
try {
if (enabled) {
if (webWakeLock) {
return true
}
webWakeLock = await api.request("screen")
try {
webWakeLock.addEventListener?.("release", () => {
// If the lock is released by the UA (e.g., tab hidden), clear local state.
webWakeLock = null
if (desired) {
// Re-acquire best-effort.
queueMicrotask(() => {
void setWakeLockDesired(true)
})
}
})
} catch {
// optional
}
return true
}
if (webWakeLock) {
await webWakeLock.release?.()
}
webWakeLock = null
return false
} catch (error) {
log.log("[wake-lock] web wake lock failed", error)
webWakeLock = null
return false
}
}
function hasAnyWakeLockSupport(): boolean {
if (typeof window === "undefined") return false
if (runtimeEnv.host === "electron") {
const api = (window as any).electronAPI
if (api?.setWakeLock) return true
}
if (runtimeEnv.host === "tauri") {
// We'll attempt dynamic import; treat as potentially supported.
return true
}
return Boolean((navigator as any)?.wakeLock?.request)
}
async function setElectronWakeLock(enabled: boolean): Promise<boolean> {
const api = (window as typeof window & { electronAPI?: { setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }> } })
.electronAPI
if (!api?.setWakeLock) {
return false
}
try {
const result = await api.setWakeLock(Boolean(enabled))
return Boolean(result?.enabled)
} catch (error) {
log.log("[wake-lock] electron wake lock failed", error)
return false
}
}
async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
try {
const mod = await import("tauri-plugin-keepawake-api")
const start = (mod as any).start as ((config?: any) => Promise<void>) | undefined
const stop = (mod as any).stop as (() => Promise<void>) | undefined
if (!start || !stop) {
return false
}
if (enabled) {
// Plugin config supports toggling display/idle/sleep. Use a conservative
// default to keep both system + display awake.
await start({ display: true, idle: true, sleep: true })
return true
}
await stop()
return false
} catch (error) {
log.log("[wake-lock] tauri wake lock failed", error)
return false
}
}
async function applyWakeLock(enabled: boolean): Promise<boolean> {
if (typeof window === "undefined") return false
if (runtimeEnv.host === "electron") {
const ok = await setElectronWakeLock(enabled)
if (ok || !enabled) return ok
// fallback to web API if electron preload didn't expose it
}
if (runtimeEnv.host === "tauri") {
const ok = await setTauriWakeLock(enabled)
if (ok || !enabled) return ok
// fallback to web API if tauri command isn't available
}
return await setWebWakeLock(enabled)
}
export function setWakeLockDesired(nextDesired: boolean): Promise<boolean> {
desired = Boolean(nextDesired)
if (inFlight) {
// Coalesce: once the current request resolves, it will re-apply the latest desired state.
return inFlight
}
const target = desired
inFlight = (async () => {
try {
const ok = await applyWakeLock(target)
// Treat disable attempts as applied even if the underlying API doesn't exist.
applied = target
return ok
} finally {
inFlight = null
// If desired changed while in-flight, re-apply once.
if (desired !== applied) {
void setWakeLockDesired(desired)
}
// If we tried to enable but there is no support, avoid re-trying forever.
if (desired && !hasAnyWakeLockSupport()) {
applied = false
}
}
})()
return inFlight!
}

View File

@@ -0,0 +1,204 @@
import { isElectronHost, isTauriHost } from "./runtime-env"
import { getLogger } from "./logger"
export type OsNotificationPermission = "granted" | "denied" | "default" | "unsupported"
export type OsNotificationCapability = {
supported: boolean
permission: OsNotificationPermission
info?: string
}
export type OsNotificationPayload = {
title: string
body: string
}
const log = getLogger("actions")
function hasWebNotificationApi(): boolean {
return typeof window !== "undefined" && typeof (window as any).Notification !== "undefined"
}
function getWebPermission(): OsNotificationPermission {
if (!hasWebNotificationApi()) return "unsupported"
const permission = (window as any).Notification.permission as string
if (permission === "granted") return "granted"
if (permission === "denied") return "denied"
return "default"
}
async function requestWebPermission(): Promise<OsNotificationPermission> {
if (!hasWebNotificationApi()) return "unsupported"
try {
const next = await (window as any).Notification.requestPermission()
if (next === "granted") return "granted"
if (next === "denied") return "denied"
return "default"
} catch (error) {
log.warn("[os-notifications] requestPermission failed", error)
return getWebPermission()
}
}
async function sendWebNotification(payload: OsNotificationPayload): Promise<void> {
if (!hasWebNotificationApi()) {
throw new Error("Web notifications not supported")
}
// Browsers generally require permission prior to sending.
if (getWebPermission() !== "granted") {
throw new Error("Web notification permission not granted")
}
// eslint-disable-next-line no-new
new (window as any).Notification(payload.title, { body: payload.body })
}
function hasElectronNotifier(): boolean {
if (typeof window === "undefined") return false
const api = (window as Window & { electronAPI?: any }).electronAPI
return Boolean(api && typeof api.showNotification === "function")
}
export function isOsNotificationSupportedSync(): boolean {
if (typeof window === "undefined") return false
if (isElectronHost()) {
return hasElectronNotifier()
}
if (isTauriHost()) {
// The authoritative check requires async import; treat Tauri as supported and let the
// settings modal surface missing plugin/capability errors.
return true
}
return hasWebNotificationApi()
}
async function sendElectronNotification(payload: OsNotificationPayload): Promise<void> {
const api = (window as Window & { electronAPI?: any }).electronAPI
if (!api || typeof api.showNotification !== "function") {
throw new Error("Electron notification bridge unavailable")
}
await api.showNotification(payload)
}
async function getTauriNotificationModule(): Promise<any | null> {
try {
const mod = await import("@tauri-apps/plugin-notification")
return mod
} catch (error) {
log.info("[os-notifications] tauri notification plugin not available", error as any)
return null
}
}
async function getTauriPermission(): Promise<OsNotificationPermission> {
const mod = await getTauriNotificationModule()
if (!mod) return "unsupported"
try {
const granted = await mod.isPermissionGranted()
return granted ? "granted" : "default"
} catch (error) {
log.warn("[os-notifications] failed to check tauri notification permission", error)
return "default"
}
}
async function requestTauriPermission(): Promise<OsNotificationPermission> {
const mod = await getTauriNotificationModule()
if (!mod) return "unsupported"
try {
const result = await mod.requestPermission()
if (result === "granted") return "granted"
if (result === "denied") return "denied"
return "default"
} catch (error) {
log.warn("[os-notifications] failed to request tauri notification permission", error)
return await getTauriPermission()
}
}
async function sendTauriNotification(payload: OsNotificationPayload): Promise<void> {
const mod = await getTauriNotificationModule()
if (!mod) {
throw new Error("Tauri notification plugin unavailable")
}
await mod.sendNotification({ title: payload.title, body: payload.body })
}
export async function getOsNotificationCapability(): Promise<OsNotificationCapability> {
if (typeof window === "undefined") {
return { supported: false, permission: "unsupported", info: "Not available in this environment." }
}
if (isElectronHost()) {
if (!hasElectronNotifier()) {
return {
supported: false,
permission: "unsupported",
info: "Electron notification bridge is not available.",
}
}
// Electron notifications are controlled by OS-level settings; Electron doesn't expose a reliable permission probe.
return {
supported: true,
permission: "granted",
info: "Notifications are managed by your OS notification settings.",
}
}
if (isTauriHost()) {
const permission = await getTauriPermission()
const supported = permission !== "unsupported"
return {
supported,
permission,
info: supported ? undefined : "Tauri notification support is not available in this build.",
}
}
// Web
const permission = getWebPermission()
const supported = permission !== "unsupported"
return {
supported,
permission,
info: supported
? undefined
: "This browser does not support OS notifications (or notifications are blocked by the environment).",
}
}
export async function requestOsNotificationPermission(): Promise<OsNotificationPermission> {
if (typeof window === "undefined") return "unsupported"
if (isElectronHost()) {
// Electron permissions are handled by the OS. No explicit request mechanism.
return hasElectronNotifier() ? "granted" : "unsupported"
}
if (isTauriHost()) {
return await requestTauriPermission()
}
return await requestWebPermission()
}
export async function sendOsNotification(payload: OsNotificationPayload): Promise<void> {
if (typeof window === "undefined") {
return
}
if (isElectronHost()) {
await sendElectronNotification(payload)
return
}
if (isTauriHost()) {
await sendTauriNotification(payload)
return
}
await sendWebNotification(payload)
}

View File

@@ -4,8 +4,13 @@ import { CODENOMAD_API_BASE } from "./api-client"
class SDKManager { class SDKManager {
private clients = new Map<string, OpencodeClient>() private clients = new Map<string, OpencodeClient>()
createClient(instanceId: string, proxyPath: string): OpencodeClient { private key(instanceId: string, worktreeSlug: string): string {
const existing = this.clients.get(instanceId) return `${instanceId}:${worktreeSlug || "root"}`
}
createClient(instanceId: string, proxyPath: string, worktreeSlug = "root"): OpencodeClient {
const key = this.key(instanceId, worktreeSlug)
const existing = this.clients.get(key)
if (existing) { if (existing) {
return existing return existing
} }
@@ -13,17 +18,25 @@ class SDKManager {
const baseUrl = buildInstanceBaseUrl(proxyPath) const baseUrl = buildInstanceBaseUrl(proxyPath)
const client = createOpencodeClient({ baseUrl }) const client = createOpencodeClient({ baseUrl })
this.clients.set(instanceId, client) this.clients.set(key, client)
return client return client
} }
getClient(instanceId: string): OpencodeClient | null { getClient(instanceId: string, worktreeSlug = "root"): OpencodeClient | null {
return this.clients.get(instanceId) ?? null return this.clients.get(this.key(instanceId, worktreeSlug)) ?? null
} }
destroyClient(instanceId: string): void { destroyClient(instanceId: string, worktreeSlug = "root"): void {
this.clients.delete(instanceId) this.clients.delete(this.key(instanceId, worktreeSlug))
}
destroyClientsForInstance(instanceId: string): void {
for (const key of Array.from(this.clients.keys())) {
if (key === instanceId || key.startsWith(`${instanceId}:`)) {
this.clients.delete(key)
}
}
} }
destroyAll(): void { destroyAll(): void {

View File

@@ -3,22 +3,24 @@ import { createTheme, ThemeProvider as MuiThemeProvider } from "@suid/material/s
import CssBaseline from "@suid/material/CssBaseline" import CssBaseline from "@suid/material/CssBaseline"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
export type ThemeMode = "system" | "light" | "dark"
interface ThemeContextValue { interface ThemeContextValue {
isDark: () => boolean isDark: () => boolean
toggleTheme: () => void themeMode: () => ThemeMode
setTheme: (dark: boolean) => void setThemeMode: (mode: ThemeMode) => void
cycleThemeMode: () => void
} }
const ThemeContext = createContext<ThemeContextValue>() const ThemeContext = createContext<ThemeContextValue>()
function applyTheme(dark: boolean) { function applyThemeMode(mode: ThemeMode) {
if (typeof document === "undefined") return if (typeof document === "undefined") return
if (dark) { if (mode === "system") {
document.documentElement.setAttribute("data-theme", "dark") document.documentElement.removeAttribute("data-theme")
return return
} }
document.documentElement.setAttribute("data-theme", mode)
document.documentElement.removeAttribute("data-theme")
} }
interface ResolvedPaletteColors { interface ResolvedPaletteColors {
@@ -78,16 +80,31 @@ export function ThemeProvider(props: { children: JSX.Element }) {
const mediaQuery = typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null const mediaQuery = typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null
const { themePreference, setThemePreference } = useConfig() const { themePreference, setThemePreference } = useConfig()
const [isDark, setIsDarkSignal] = createSignal(true) const [isDark, setIsDarkSignal] = createSignal(true)
const [themeRevision, setThemeRevision] = createSignal(0)
const themeMode = () => themePreference() as ThemeMode
const resolveDarkTheme = () => { const resolveDarkTheme = () => {
themePreference() const mode = themeMode()
return true if (mode === "dark") return true
if (mode === "light") return false
return mediaQuery?.matches ?? false
} }
const applyResolvedTheme = () => { const applyResolvedTheme = () => {
const mode = themeMode()
const dark = resolveDarkTheme() const dark = resolveDarkTheme()
if (mode === "system") {
applyThemeMode("system")
} else {
applyThemeMode(mode)
}
setIsDarkSignal(dark) setIsDarkSignal(dark)
applyTheme(dark) if (typeof window !== "undefined") {
requestAnimationFrame(() => setThemeRevision((v) => v + 1))
} else {
setThemeRevision((v) => v + 1)
}
} }
createEffect(() => { createEffect(() => {
@@ -107,15 +124,18 @@ export function ThemeProvider(props: { children: JSX.Element }) {
} }
}) })
const setTheme = (_dark: boolean) => { const setThemeMode = (mode: ThemeMode) => {
setThemePreference("dark") setThemePreference(mode)
} }
const toggleTheme = () => { const cycleThemeMode = () => {
setTheme(true) const current = themeMode()
const next: ThemeMode = current === "system" ? "light" : current === "light" ? "dark" : "system"
setThemeMode(next)
} }
const muiTheme = createMemo(() => { const muiTheme = createMemo(() => {
themeRevision()
const paletteColors = resolvePaletteColors(isDark()) const paletteColors = resolvePaletteColors(isDark())
return createTheme({ return createTheme({
palette: { palette: {
@@ -144,21 +164,32 @@ export function ThemeProvider(props: { children: JSX.Element }) {
borderRadius: 8, borderRadius: 8,
}, },
components: { components: {
MuiIconButton: {
styleOverrides: {
root: {
color: "inherit",
"&.Mui-disabled": {
color: "var(--text-muted)",
opacity: 0.55,
},
},
},
},
MuiDrawer: { MuiDrawer: {
styleOverrides: { styleOverrides: {
paper: { paper: {
backgroundColor: paletteColors.backgroundPaper, backgroundColor: "var(--surface-secondary)",
color: paletteColors.textPrimary, color: "var(--text-primary)",
}, },
}, },
}, },
MuiAppBar: { MuiAppBar: {
styleOverrides: { styleOverrides: {
root: { root: {
backgroundColor: paletteColors.backgroundPaper, backgroundColor: "var(--surface-secondary)",
color: paletteColors.textPrimary, color: "var(--text-primary)",
boxShadow: "none", boxShadow: "none",
borderBottom: `1px solid ${paletteColors.divider}`, borderBottom: "1px solid var(--border-base)",
zIndex: 10, zIndex: 10,
}, },
}, },
@@ -175,7 +206,7 @@ export function ThemeProvider(props: { children: JSX.Element }) {
}) })
return ( return (
<ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}> <ThemeContext.Provider value={{ isDark, themeMode, setThemeMode, cycleThemeMode }}>
<MuiThemeProvider theme={muiTheme()}> <MuiThemeProvider theme={muiTheme()}>
<CssBaseline /> <CssBaseline />
{props.children} {props.children}

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