Compare commits

..

102 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
Shantur Rathore
67f5f830a3 Bump to v0.9.3 2026-01-29 22:37:34 +00:00
Shantur Rathore
81102cc6bf fix(ui): rename forked session to parent title 2026-01-29 22:34:30 +00:00
Shantur Rathore
afa7243eab feat(server): allow skipping internal auth
Add --dangerously-skip-auth / CODENOMAD_SKIP_AUTH for trusted-perimeter deployments so users behind SSO/VPN don't need a second login.
2026-01-29 20:38:05 +00:00
Shantur Rathore
37b7c1e53c fix(server): enforce workspace directory via x-opencode-directory 2026-01-28 23:41:32 +00:00
Shantur Rathore
ba61ab79e2 fix(tauri): prevent quit deadlock and exit loop 2026-01-28 20:19:57 +00:00
Shantur Rathore
37d075fbb3 fix(tauri): allow tauri.localhost internal navigation 2026-01-28 19:41:39 +00:00
Shantur Rathore
2961d41be3 fix(ui): open external toast links via system browser 2026-01-28 19:24:33 +00:00
Shantur Rathore
1bb5aedfdb chore(ui): widen left sidebar width limits 2026-01-28 18:50:05 +00:00
Shantur Rathore
0a793fb1c6 refactor(ui): consolidate sidebar selector shortcut hints 2026-01-28 18:03:20 +00:00
Shantur Rathore
a401eeec11 fix(ui): stabilize streaming message/tool rendering
Avoid remounting message blocks on part updates so tool call UI state persists. Render tool/message content from store and stabilize tool output scrolling during streaming.
2026-01-28 17:55:44 +00:00
Shantur Rathore
d9bcc66930 Merge pull request #102 from bizzkoot/fix/question-tool-ux-improvements
fix(ui): Improve Question Tool UX (Enter Key & Auto-focus)
2026-01-28 15:50:57 +00:00
bizzkoot
01921e3454 fix(ui): improve question tool UX (enter key & autofocus) 2026-01-28 21:01:49 +08:00
Shantur Rathore
158f6e25cf feat(ui): add favorite models to selector 2026-01-26 20:24:05 +00:00
Shantur Rathore
562c4b2637 feat(ui): add dismiss button to toasts 2026-01-26 13:42:58 +00:00
Shantur Rathore
51fd5d87f7 feat(ui): toast when UI updates 2026-01-26 13:36:36 +00:00
Shantur Rathore
28fb56bfa1 Minimum server 0.9.2 2026-01-26 13:23:14 +00:00
Shantur Rathore
c1052b36dc bump version to 0.9.2 2026-01-26 13:15:02 +00:00
Shantur Rathore
c62c9b1c78 feat(ui): add language selector
Adds a language dropdown to the folder picker using the shared selector UI and persists selection to preferences.locale.
2026-01-26 13:11:05 +00:00
Shantur Rathore
feccbd13bd feat(ui): add locales and split catalogs
Adds Spanish, French, Russian, Japanese, and Simplified Chinese catalogs and wires supported locales into the i18n layer.
2026-01-26 12:56:26 +00:00
Shantur Rathore
5b1e21345f feat(ui): localize UI strings
Converts hardcoded UI copy to i18n keys across the app, adds global translation for non-component modules, and splits the English catalog into feature modules with duplicate-key detection.
2026-01-26 12:26:12 +00:00
Shantur Rathore
33939f4096 feat(ui): add i18n scaffolding
Adds a minimal i18n provider with locale preference support and migrates folder selection copy to message keys.
2026-01-26 10:22:03 +00:00
Shantur Rathore
96f5a0ab44 Update min Server version to 0.9.1 2026-01-25 18:05:37 +00:00
Shantur Rathore
d9f7735c94 ui: show selector shortcuts inline 2026-01-25 17:55:46 +00:00
Shantur Rathore
4aae8ab720 feat(ui): add model thinking selector 2026-01-25 17:39:38 +00:00
Shantur Rathore
b83c69f002 chore(shutdown): log CLI kill timeout
Log when Electron/Tauri force-kill the CLI during shutdown so orphaned instance reports are easier to diagnose.
2026-01-25 11:03:16 +00:00
Shantur Rathore
c74e0b89f7 fix(shutdown): stop instances before app exit
Prevent desktop wrappers from SIGKILLing the CLI during shutdown, which could orphan OpenCode workspace processes. Shut down workspaces earlier/in parallel and increase the quit grace period.
2026-01-25 11:01:50 +00:00
Shantur Rathore
9ee7ff9509 feat(ui): move folder picker subtitle 2026-01-25 10:35:01 +00:00
Shantur Rathore
74a21d6418 Bump version to 0.9.1 for UI release 2026-01-25 00:27:37 +00:00
Shantur Rathore
15f390ade7 ci: allow manual release-ui on main/dev 2026-01-25 00:23:33 +00:00
Shantur Rathore
bb4e3815d1 feat(ui): show GitHub stars 2026-01-25 00:21:06 +00:00
Shantur Rathore
8fa0175b98 feat(ui): improve folder picker layout 2026-01-25 00:09:22 +00:00
Shantur Rathore
ee59622b98 Upgrade min version to 0.9.0 2026-01-24 19:23:01 +00:00
Shantur Rathore
a1452ad353 Add release notes command 2026-01-24 19:21:56 +00:00
Shantur Rathore
0c9284e57e Bump version to 0.9.0 2026-01-24 16:17:14 +00:00
Shantur Rathore
0766185ff6 fix(server): stop workspace process groups 2026-01-24 14:41:09 +00:00
Shantur Rathore
effb30d98e feat(ui): polish task steps section
Rename Tasks to Steps and remove list padding for a flush, compact steps view.
2026-01-24 10:35:15 +00:00
Shantur Rathore
4da69b5a20 feat(ui): show task model in headers
Task prompt/output headers now include provider/model metadata when available, alongside agent.
2026-01-24 10:29:02 +00:00
Shantur Rathore
3d3337c7b8 feat(ui): render task prompt/output panes
Task tool calls now show prompt, summary, and output with independent scroll; markdown rendering supports cache keys to avoid collisions.
2026-01-23 22:39:04 +00:00
Shantur Rathore
f0b43dbc68 feat(filesystem): add create-folder API for workspace picker
Adds a secure endpoint for creating a single subfolder in the current filesystem listing, and wires the non-native directory browser UI to create + enter the new folder.
2026-01-23 12:33:15 +00:00
264 changed files with 20925 additions and 2248 deletions

View File

@@ -12,8 +12,8 @@ env:
jobs:
release-ui:
# Automated via reusable call (main releases); manual runs allowed on dev.
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' }}
# Automated via reusable call (main releases); manual runs allowed on dev/main.
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }}
runs-on: ubuntu-24.04
steps:
- name: Checkout

View File

@@ -0,0 +1,7 @@
---
description: Creates release notes
agent: build
---
Check how I do prepare release notes here - https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/v0.7.0
Use the same format to create release notes from users perspective for new release by looking at changes from last tagged release to tip of branch

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.

4697
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -473,6 +473,14 @@ if (isMac) {
}
app.whenReady().then(() => {
// Required for Windows notifications / taskbar grouping.
// Keep in sync with desktop app identifier.
try {
app.setAppUserModelId("ai.neuralnomads.codenomad.client")
} catch {
// ignore
}
startCli()
if (isMac) {
@@ -505,7 +513,6 @@ app.on("before-quit", async (event) => {
})
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit()
}
// CodeNomad supports a single window; closing it should quit the app on all platforms.
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 { createRequire } from "module"
import { EventEmitter } from "events"
@@ -82,6 +82,7 @@ export class CliProcessManager extends EventEmitter {
private stdoutBuffer = ""
private stderrBuffer = ""
private bootstrapToken: string | null = null
private requestedStop = false
async start(options: StartOptions): Promise<CliStatus> {
if (this.child) {
@@ -91,6 +92,7 @@ export class CliProcessManager extends EventEmitter {
this.stdoutBuffer = ""
this.stderrBuffer = ""
this.bootstrapToken = null
this.requestedStop = false
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
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)}`)
: this.buildDirectSpawn(cliEntry, args)
const detached = process.platform !== "win32"
const child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
detached,
})
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
@@ -175,10 +179,90 @@ export class CliProcessManager extends EventEmitter {
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) => {
const killTimeout = setTimeout(() => {
child.kill("SIGKILL")
}, 4000)
console.warn(
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
)
sendStopSignal("SIGKILL")
}, 30000)
child.on("exit", () => {
clearTimeout(killTimeout)
@@ -188,7 +272,15 @@ export class CliProcessManager extends EventEmitter {
resolve()
})
child.kill("SIGTERM")
if (isAlreadyExited()) {
clearTimeout(killTimeout)
this.child = undefined
this.updateStatus({ state: "stopped" })
resolve()
return
}
sendStopSignal("SIGTERM")
})
}
@@ -202,7 +294,16 @@ export class CliProcessManager extends EventEmitter {
private handleTimeout() {
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.updateStatus({ state: "error", error: "CLI did not start in time" })
@@ -246,38 +347,27 @@ export class CliProcessManager extends EventEmitter {
console.info(`[cli][${stream}] ${trimmed}`)
this.emit("log", { stream, message: trimmed })
const port = this.extractPort(trimmed)
if (port && this.status.state === "starting") {
const url = `http://127.0.0.1:${port}`
console.info(`[cli] ready on ${url}`)
this.updateStatus({ state: "ready", port, url })
const localUrl = this.extractLocalUrl(trimmed)
if (localUrl && this.status.state === "starting") {
let port: number | undefined
try {
port = Number(new URL(localUrl).port) || undefined
} catch {
port = undefined
}
console.info(`[cli] ready on ${localUrl}`)
this.updateStatus({ state: "ready", port, url: localUrl })
this.emit("ready", this.status)
}
}
}
private extractPort(line: string): number | null {
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
if (readyMatch) {
return parseInt(readyMatch[1], 10)
private extractLocalUrl(line: string): string | null {
const match = line.match(/^Local\s+Connection\s+URL\s*:\s*(https?:\/\/\S+)\s*$/i)
if (!match) {
return null
}
if (line.toLowerCase().includes("http server listening")) {
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
if (httpMatch) {
return parseInt(httpMatch[1], 10)
}
try {
const parsed = JSON.parse(line)
if (typeof parsed.port === "number") {
return parsed.port
}
} catch {
// not JSON, ignore
}
}
return null
return match[1] ?? null
}
private updateStatus(patch: Partial<CliStatus>) {
@@ -286,7 +376,18 @@ export class CliProcessManager extends EventEmitter {
}
private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--port", "0", "--generate-token"]
const args = ["serve", "--host", host, "--generate-token"]
if (options.dev) {
// Dev: run plain HTTP + Vite dev server proxy.
args.push("--https", "false", "--http", "true")
// Avoid collisions with an already-running server (and dual-stack ::/0.0.0.0 quirks)
// by forcing an ephemeral port in dev.
args.push("--http-port", "0")
} else {
// Prod desktop: always keep loopback HTTP enabled.
args.push("--https", "true", "--http", "true")
}
if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
@@ -376,4 +477,3 @@ export class CliProcessManager extends EventEmitter {
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
}
}

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,11 @@ You can run CodeNomad directly without installing it:
npx @neuralnomads/codenomad --launch
```
On startup, CodeNomad prints two URLs:
- `Local Connection URL : ...` (used by desktop shells)
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
### Install Globally
Or install it globally to use the `codenomad` command:
@@ -44,15 +49,78 @@ You can configure the server using flags or environment variables:
| Flag | Env Variable | Description |
|------|--------------|-------------|
| `--port <number>` | `CLI_PORT` | HTTP port (default 9898) |
| `--https <enabled>` | `CLI_HTTPS` | Enable HTTPS listener (default `true`) |
| `--http <enabled>` | `CLI_HTTP` | Enable HTTP listener (default `false`) |
| `--https-port <number>` | `CLI_HTTPS_PORT` | HTTPS port (default `9898`, use `0` for auto) |
| `--http-port <number>` | `CLI_HTTP_PORT` | HTTP port (default `9899`, use `0` for auto) |
| `--tls-key <path>` | `CLI_TLS_KEY` | TLS private key (PEM). Requires `--tls-cert`. |
| `--tls-cert <path>` | `CLI_TLS_CERT` | TLS certificate (PEM). Requires `--tls-key`. |
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
| `--config <path>` | `CLI_CONFIG` | Config file location |
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
### HTTP vs HTTPS
- Default: `--https=true --http=false` (HTTPS only).
- To run plain HTTP only (useful for development):
```sh
codenomad --https=false --http=true
```
- To run both HTTPS (for remote) and HTTP loopback (for desktop):
```sh
codenomad --https=true --http=true
```
### Remote Access Binding Rules
- When remote access is enabled (bind host is non-loopback, e.g. `--host 0.0.0.0`):
- HTTP listens on `127.0.0.1` only.
- HTTPS listens on `--host` (LAN/all interfaces).
- When remote access is disabled (bind host is loopback, e.g. `--host 127.0.0.1`):
- Both HTTP and HTTPS listen on `127.0.0.1`.
### Self-Signed Certificates
If `--https=true` and you do not provide `--tls-key/--tls-cert`, CodeNomad generates a local certificate automatically under your config directory:
- `~/.config/codenomad/tls/ca-cert.pem`
- `~/.config/codenomad/tls/server-cert.pem`
Certificates are valid for about 30 days and rotate automatically on startup when needed. You can add extra SANs via:
```sh
codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
```
### Authentication
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
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.
### 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
- **Config**: `~/.config/codenomad/config.json`
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)

View File

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

View File

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

View File

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

View File

@@ -15,15 +15,25 @@ export interface AuthManagerInit {
username: string
password?: string
generateToken: boolean
dangerouslySkipAuth?: boolean
}
export class AuthManager {
private readonly authStore: AuthStore
private readonly authStore: AuthStore | null
private readonly tokenManager: TokenManager | null
private readonly sessionManager = new SessionManager()
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
private readonly authEnabled: boolean
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
if (!this.authEnabled) {
this.authStore = null
this.tokenManager = null
return
}
const authFilePath = resolveAuthFilePath(init.configPath)
this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }))
@@ -37,6 +47,10 @@ export class AuthManager {
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
}
isAuthEnabled(): boolean {
return this.authEnabled
}
getCookieName(): string {
return this.cookieName
}
@@ -56,19 +70,31 @@ export class AuthManager {
}
validateLogin(username: string, password: string): boolean {
return this.authStore.validateCredentials(username, password)
if (!this.authEnabled) {
return true
}
return this.requireAuthStore().validateCredentials(username, password)
}
createSession(username: string) {
if (!this.authEnabled) {
return { id: "auth-disabled", createdAt: Date.now(), username: this.init.username }
}
return this.sessionManager.createSession(username)
}
getStatus() {
return this.authStore.getStatus()
if (!this.authEnabled) {
return { username: this.init.username, passwordUserProvided: false }
}
return this.requireAuthStore().getStatus()
}
setPassword(password: string) {
return this.authStore.setPassword({ password, markUserProvided: true })
if (!this.authEnabled) {
throw new Error("Internal authentication is disabled")
}
return this.requireAuthStore().setPassword({ password, markUserProvided: true })
}
isLoopbackRequest(request: FastifyRequest): boolean {
@@ -76,6 +102,12 @@ export class AuthManager {
}
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
if (!this.authEnabled) {
// When auth is disabled, treat all requests as authenticated.
// We still return a stable username so callers can display it.
return { username: this.init.username, sessionId: "auth-disabled" }
}
const cookies = parseCookies(request.headers.cookie)
const sessionId = cookies[this.cookieName]
const session = this.sessionManager.getSession(sessionId)
@@ -87,9 +119,24 @@ export class AuthManager {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
}
setSessionCookieWithOptions(reply: FastifyReply, sessionId: string, options?: { secure?: boolean }) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId, options))
}
clearSessionCookie(reply: FastifyReply) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
}
clearSessionCookieWithOptions(reply: FastifyReply, options?: { secure?: boolean }) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0, ...options }))
}
private requireAuthStore(): AuthStore {
if (!this.authStore) {
throw new Error("Auth store is unavailable")
}
return this.authStore
}
}
function resolveAuthFilePath(configPath: string) {
@@ -104,8 +151,11 @@ function resolvePath(filePath: string) {
return path.resolve(filePath)
}
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number }) {
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number; secure?: boolean }) {
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
if (options?.secure) {
parts.push("Secure")
}
if (options?.maxAgeSeconds !== undefined) {
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
}

View File

@@ -12,15 +12,25 @@ const PreferencesSchema = z.object({
showThinkingBlocks: z.boolean().default(false),
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showTimelineTools: z.boolean().default(true),
promptSubmitOnEnter: z.boolean().default(false),
lastUsedBinary: z.string().optional(),
locale: z.string().optional(),
environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]),
modelFavorites: z.array(ModelPreferenceSchema).default([]),
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showUsageMetrics: z.boolean().default(true),
autoCleanupBlankSessions: z.boolean().default(true),
listeningMode: z.enum(["local", "all"]).default("local"),
// OS notifications
osNotificationsEnabled: z.boolean().default(false),
osNotificationsAllowWhenVisible: z.boolean().default(false),
notifyOnNeedsInput: z.boolean().default(true),
notifyOnIdle: z.boolean().default(true),
})
const RecentFolderSchema = z.object({

View File

@@ -2,6 +2,7 @@ import fs from "fs"
import os from "os"
import path from "path"
import {
FileSystemCreateFolderResponse,
FileSystemEntry,
FileSystemListResponse,
FileSystemListingMetadata,
@@ -56,6 +57,30 @@ export class FileSystemBrowser {
return this.listRestrictedWithMetadata(targetPath, includeFiles)
}
createFolder(parentPath: string | undefined, folderName: string): FileSystemCreateFolderResponse {
const name = this.normalizeFolderName(folderName)
if (this.unrestricted) {
const resolvedParent = this.resolveUnrestrictedPath(parentPath)
if (this.isWindows && resolvedParent === WINDOWS_DRIVES_ROOT) {
throw new Error("Cannot create folders at drive root")
}
this.assertDirectoryExists(resolvedParent)
const absolutePath = this.resolveAbsoluteChild(resolvedParent, name)
fs.mkdirSync(absolutePath)
return { path: absolutePath, absolutePath }
}
const normalizedParent = this.normalizeRelativePath(parentPath)
const parentAbsolute = this.toRestrictedAbsolute(normalizedParent)
this.assertDirectoryExists(parentAbsolute)
const relativePath = this.buildRelativePath(normalizedParent, name)
const absolutePath = this.toRestrictedAbsolute(relativePath)
fs.mkdirSync(absolutePath)
return { path: relativePath, absolutePath }
}
readFile(relativePath: string): string {
if (this.unrestricted) {
throw new Error("readFile is not available in unrestricted mode")
@@ -157,25 +182,58 @@ export class FileSystemBrowser {
return { entries, metadata }
}
private normalizeFolderName(input: string): string {
const name = input.trim()
if (!name) {
throw new Error("Folder name is required")
}
if (name === "." || name === "..") {
throw new Error("Invalid folder name")
}
if (name.startsWith("~")) {
throw new Error("Invalid folder name")
}
if (name.includes("/") || name.includes("\\")) {
throw new Error("Folder name must not include path separators")
}
if (name.includes("\u0000")) {
throw new Error("Invalid folder name")
}
return name
}
private assertDirectoryExists(directory: string) {
if (!fs.existsSync(directory)) {
throw new Error(`Directory does not exist: ${directory}`)
}
const stats = fs.statSync(directory)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${directory}`)
}
}
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
const dirents = fs.readdirSync(directory, { withFileTypes: true })
const results: FileSystemEntry[] = []
for (const entry of dirents) {
if (!options.includeFiles && !entry.isDirectory()) {
continue
}
const absoluteEntryPath = path.join(directory, entry.name)
let stats: fs.Stats
try {
// Use fs.statSync (not Dirent.isDirectory) so symlinks to directories
// are treated as directories in directory-only listings.
stats = fs.statSync(absoluteEntryPath)
} catch {
// Skip entries we cannot stat (insufficient permissions, etc.)
continue
}
const isDirectory = entry.isDirectory()
const isDirectory = stats.isDirectory()
if (!options.includeFiles && !isDirectory) {
continue
}

View File

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

View File

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

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

View File

@@ -11,6 +11,11 @@ const FilesystemQuerySchema = z.object({
includeFiles: z.coerce.boolean().optional(),
})
const FilesystemCreateFolderSchema = z.object({
parentPath: z.string().optional(),
name: z.string(),
})
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/filesystem", async (request, reply) => {
const query = FilesystemQuerySchema.parse(request.query ?? {})
@@ -24,4 +29,26 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps)
return { error: (error as Error).message }
}
})
app.post("/api/filesystem/folders", async (request, reply) => {
const body = FilesystemCreateFolderSchema.parse(request.body ?? {})
try {
const created = deps.fileSystemBrowser.createFolder(body.parentPath, body.name)
reply.code(201)
return created
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err?.code === "EEXIST") {
reply.code(409).type("text/plain").send("Folder already exists")
return
}
if (err?.code === "EACCES" || err?.code === "EPERM") {
reply.code(403).type("text/plain").send("Permission denied")
return
}
reply.code(400).type("text/plain").send((error as Error).message)
}
})
}

View File

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

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

View File

@@ -28,6 +28,8 @@ interface WorkspaceManagerOptions {
eventBus: EventBus
logger: Logger
getServerBaseUrl: () => string
/** Optional CA bundle path to trust CodeNomad HTTPS certs. */
nodeExtraCaCertsPath?: string
}
interface WorkspaceRecord extends WorkspaceDescriptor {}
@@ -91,7 +93,7 @@ export class WorkspaceManager {
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
const proxyPath = `/workspaces/${id}/instance`
const proxyPath = `/workspaces/${id}/worktrees/root/instance`
const descriptor: WorkspaceRecord = {
@@ -132,6 +134,7 @@ export class WorkspaceManager {
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
CODENOMAD_INSTANCE_ID: id,
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}),
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
}
@@ -187,16 +190,27 @@ export class WorkspaceManager {
async shutdown() {
this.options.logger.info("Shutting down all workspaces")
const stopTasks: Array<Promise<void>> = []
for (const [id, workspace] of this.workspaces) {
if (workspace.pid) {
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
await this.runtime.stop(id).catch((error) => {
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
})
} else {
if (!workspace.pid) {
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
continue
}
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
stopTasks.push(
this.runtime.stop(id).catch((error) => {
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
}),
)
}
if (stopTasks.length > 0) {
await Promise.allSettled(stopTasks)
}
this.workspaces.clear()
this.opencodeAuth.clear()
this.options.logger.info("All workspaces cleared")

View File

@@ -1,4 +1,4 @@
import { ChildProcess, spawn } from "child_process"
import { ChildProcess, spawn, spawnSync } from "child_process"
import { existsSync, statSync } from "fs"
import path from "path"
import { EventBus } from "../events/bus"
@@ -122,10 +122,12 @@ export class WorkspaceRuntime {
},
"Launching OpenCode process",
)
const detached = process.platform !== "win32"
const child = spawn(spec.command, spec.args, {
cwd: options.folder,
env,
stdio: ["ignore", "pipe", "pipe"],
detached,
...spec.options,
})
@@ -259,10 +261,96 @@ export class WorkspaceRuntime {
const child = managed.child
this.logger.info({ workspaceId }, "Stopping OpenCode process")
const pid = child.pid
if (!pid) {
this.logger.warn({ workspaceId }, "Workspace process missing PID; cannot stop")
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
}
this.logger.debug({ workspaceId, pid, err }, "Failed to signal POSIX process group")
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
}
this.logger.debug({ workspaceId, pid, err }, "Failed to signal workspace PID")
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") || combined.includes("process") && combined.includes("not")) {
return true
}
this.logger.debug({ workspaceId, pid, exitCode, stderr: result.stderr, stdout: result.stdout }, "taskkill failed")
return false
} catch (error) {
this.logger.debug({ workspaceId, pid, err: error }, "taskkill failed to execute")
return false
}
}
const sendStopSignal = (signal: NodeJS.Signals) => {
if (process.platform === "win32") {
// Best-effort: terminate the whole process tree rooted at pid.
// Use /F only for escalation.
tryTaskkill(signal === "SIGKILL")
return
}
// Prefer process-group signaling so wrapper launchers (bun/node) don't orphan the real server.
const groupOk = tryKillPosixGroup(signal)
if (!groupOk) {
// Fallback to direct PID kill.
tryKillSinglePid(signal)
}
}
await new Promise<void>((resolve, reject) => {
let escalationTimer: NodeJS.Timeout | null = null
const cleanup = () => {
child.removeListener("exit", onExit)
child.removeListener("error", onError)
if (escalationTimer) {
clearTimeout(escalationTimer)
escalationTimer = null
}
}
const onExit = () => {
@@ -274,32 +362,30 @@ export class WorkspaceRuntime {
reject(error)
}
const resolveIfAlreadyExited = () => {
if (child.exitCode !== null || child.signalCode !== null) {
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
cleanup()
resolve()
return true
}
return false
if (isAlreadyExited()) {
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
cleanup()
resolve()
return
}
child.once("exit", onExit)
child.once("error", onError)
if (resolveIfAlreadyExited()) {
return
}
this.logger.debug(
{ workspaceId, pid, detached: process.platform !== "win32" },
"Sending SIGTERM to workspace process (tree/group)",
)
sendStopSignal("SIGTERM")
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process")
child.kill("SIGTERM")
setTimeout(() => {
if (!child.killed) {
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing")
child.kill("SIGKILL")
} else {
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
escalationTimer = setTimeout(() => {
escalationTimer = null
if (isAlreadyExited()) {
this.logger.debug({ workspaceId, pid }, "Workspace exited before SIGKILL escalation")
return
}
this.logger.warn({ workspaceId, pid }, "Process did not stop after SIGTERM, escalating")
sendStopSignal("SIGKILL")
}, 2000)
})
}

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",
"version": "0.8.1",
"version": "0.10.2",
"private": true,
"license": "MIT",
"scripts": {
"dev": "tauri dev",
"dev:ui": "npm run dev --workspace @codenomad/ui",

View File

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

View File

@@ -3,7 +3,7 @@
"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:*"]
"urls": ["http://127.0.0.1:*", "http://localhost:*", "http://tauri.localhost/*", "https://tauri.localhost/*"]
},
"windows": ["main"],
"permissions": [
@@ -11,6 +11,10 @@
"core:menu:default",
"dialog:allow-open",
"opener:allow-default-urls",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
"notification:allow-show",
"core:webview:allow-set-webview-zoom"
]
}

File diff suppressed because one or more lines are too long

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

View File

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

View File

@@ -34,6 +34,8 @@ fn workspace_root() -> Option<PathBuf> {
const SESSION_COOKIE_NAME: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30;
fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") {
let mut display = url.to_string();
@@ -276,6 +278,7 @@ impl CliProcessManager {
pub fn stop(&self) -> anyhow::Result<()> {
let mut child_opt = self.child.lock();
if let Some(mut child) = child_opt.take() {
log_line(&format!("stopping CLI pid={}", child.id()));
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
@@ -290,7 +293,12 @@ impl CliProcessManager {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > Duration::from_secs(4) {
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
log_line(&format!(
"stop timed out after {}s; sending SIGKILL pid={}",
CLI_STOP_GRACE_SECS,
child.id()
));
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGKILL);
@@ -456,13 +464,33 @@ impl CliProcessManager {
let status_clone = status.clone();
let app_clone = app.clone();
thread::spawn(move || {
let code = {
let mut guard = child_holder.lock();
if let Some(child) = guard.as_mut() {
child.wait().ok()
} else {
None
// Do not hold the child mutex while waiting for process exit.
// Holding the lock across `wait()` deadlocks `stop()`, which needs the
// same lock to send SIGTERM/SIGKILL when the user quits the app.
let code = loop {
let maybe_exited = {
let mut guard = child_holder.lock();
if guard.is_none() {
return;
}
match guard
.as_mut()
.and_then(|child| child.try_wait().ok().flatten())
{
Some(status) => {
// Drop the handle after the process exits so other callers
// don't attempt to stop/kill a finished process.
*guard = None;
Some(status)
}
None => None,
}
};
if let Some(status) = maybe_exited {
break Some(status);
}
thread::sleep(Duration::from_millis(100));
};
let mut locked = status_clone.lock();
@@ -503,7 +531,7 @@ impl CliProcessManager {
bootstrap_token: &Arc<Mutex<Option<String>>>,
) {
let mut buffer = String::new();
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
@@ -531,12 +559,12 @@ impl CliProcessManager {
continue;
}
if let Some(port) = port_regex
if let Some(url) = local_url_regex
.as_ref()
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
.map(|m| m.as_str().to_string())
{
Self::mark_ready(app, status, ready, bootstrap_token, port);
Self::mark_ready(app, status, ready, bootstrap_token, url);
continue;
}
@@ -546,13 +574,13 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
{
Self::mark_ready(app, status, ready, bootstrap_token, port);
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{port}"));
continue;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
Self::mark_ready(app, status, ready, bootstrap_token, port as u16);
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{}", port));
continue;
}
}
@@ -569,12 +597,15 @@ impl CliProcessManager {
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>,
port: u16,
base_url: String,
) {
ready.store(true, Ordering::SeqCst);
let base_url = format!("http://127.0.0.1:{port}");
let port = Url::parse(&base_url)
.ok()
.and_then(|u| u.port_or_known_default())
.map(|p| p as u16);
let mut locked = status.lock();
locked.port = Some(port);
locked.port = port;
locked.url = Some(base_url.clone());
locked.state = CliState::Ready;
locked.error = None;
@@ -583,22 +614,29 @@ impl CliProcessManager {
let token = bootstrap_token.lock().take();
if let Some(token) = token {
match exchange_bootstrap_token(&base_url, &token) {
Ok(Some(session_id)) => {
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
log_line(&format!("failed to set session cookie: {err}"));
navigate_main(app, &format!("{base_url}/login"));
} else {
navigate_main(app, &base_url);
// Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS,
// skip the exchange and let the user authenticate normally.
let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string());
if scheme.as_deref() != Some("http") {
navigate_main(app, &base_url);
} else {
match exchange_bootstrap_token(&base_url, &token) {
Ok(Some(session_id)) => {
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
log_line(&format!("failed to set session cookie: {err}"));
navigate_main(app, &format!("{base_url}/login"));
} else {
navigate_main(app, &base_url);
}
}
Ok(None) => {
log_line("bootstrap token exchange failed (invalid token)");
navigate_main(app, &format!("{base_url}/login"));
}
Err(err) => {
log_line(&format!("bootstrap token exchange failed: {err}"));
navigate_main(app, &format!("{base_url}/login"));
}
}
Ok(None) => {
log_line("bootstrap token exchange failed (invalid token)");
navigate_main(app, &format!("{base_url}/login"));
}
Err(err) => {
log_line(&format!("bootstrap token exchange failed: {err}"));
navigate_main(app, &format!("{base_url}/login"));
}
}
} else {
@@ -681,19 +719,24 @@ impl CliEntry {
}
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
let mut args = vec![
"serve".to_string(),
"--host".to_string(),
host.to_string(),
"--port".to_string(),
"0".to_string(),
"--generate-token".to_string(),
];
let mut args = vec!["serve".to_string(), "--host".to_string(), host.to_string(), "--generate-token".to_string()];
if dev {
// Dev: plain HTTP + Vite dev server proxy.
args.push("--https".to_string());
args.push("false".to_string());
args.push("--http".to_string());
args.push("true".to_string());
args.push("--ui-dev-server".to_string());
args.push("http://localhost:3000".to_string());
args.push("--log-level".to_string());
args.push("debug".to_string());
} else {
// Prod desktop: always keep loopback HTTP enabled.
args.push("--https".to_string());
args.push("true".to_string());
args.push("--http".to_string());
args.push("true".to_string());
}
args
}

View File

@@ -4,6 +4,7 @@ mod cli_manager;
use cli_manager::{CliProcessManager, CliStatus};
use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview;
@@ -11,6 +12,8 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
use tauri_plugin_opener::OpenerExt;
use url::Url;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
#[derive(Clone)]
pub struct AppState {
pub manager: CliProcessManager,
@@ -32,6 +35,7 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
Ok(state.manager.status())
}
fn is_dev_mode() -> bool {
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
}
@@ -39,7 +43,10 @@ fn is_dev_mode() -> bool {
fn should_allow_internal(url: &Url) -> bool {
match url.scheme() {
"tauri" | "asset" | "file" => true,
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost")),
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
// This must be treated as an internal origin or the navigation guard will
// redirect it to the system browser and the app will appear blank.
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "tauri.localhost")),
_ => false,
}
}
@@ -67,6 +74,8 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_keepawake::init())
.plugin(tauri_plugin_notification::init())
.plugin(navigation_guard)
.manage(AppState {
manager: CliProcessManager::new(),
@@ -163,7 +172,13 @@ fn main() {
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| match event {
tauri::RunEvent::ExitRequested { .. } => {
tauri::RunEvent::ExitRequested { api, .. } => {
// `app_handle.exit(0)` triggers another `ExitRequested`. Without a guard, we can
// prevent exit forever and the app never quits (Cmd+Q / Quit menu appears stuck).
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
return;
}
api.prevent_exit();
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
@@ -173,18 +188,21 @@ fn main() {
});
}
tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::Destroyed,
event: tauri::WindowEvent::CloseRequested { api, .. },
..
} => {
if app_handle.webview_windows().len() <= 1 {
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
let _ = state.manager.stop();
}
app.exit(0);
});
// Ensure we have time to stop the CLI process before the app exits.
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
return;
}
api.prevent_close();
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
let _ = state.manager.stop();
}
app.exit(0);
});
}
_ => {}
});

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import InstanceShell from "./components/instance/instance-shell2"
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown"
import { initGithubStars } from "./stores/github-stars"
import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands"
@@ -17,6 +18,8 @@ import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger"
import { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n"
import { setWakeLockDesired } from "./lib/native/wake-lock"
import {
hasInstances,
isSelectingFolder,
@@ -46,10 +49,13 @@ import {
updateSessionModel,
} from "./stores/sessions"
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
const log = getLogger("actions")
const App: Component = () => {
const { isDark } = useTheme()
const { t } = useI18n()
const {
preferences,
recordWorkspaceLaunch,
@@ -57,6 +63,7 @@ const App: Component = () => {
toggleShowTimelineTools,
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -87,6 +94,26 @@ const App: Component = () => {
initReleaseNotifications()
})
const shouldHoldWakeLock = createMemo(() => {
const map = instances()
for (const id of map.keys()) {
const status = getInstanceSessionIndicatorStatus(id)
if (status !== "idle") {
return true
}
}
return false
})
createEffect(() => {
const hold = shouldHoldWakeLock()
void setWakeLockDesired(hold)
})
onCleanup(() => {
void setWakeLockDesired(false)
})
createEffect(() => {
instances()
hasInstances()
@@ -94,6 +121,7 @@ const App: Component = () => {
})
onMount(() => {
void initGithubStars()
updateInstanceTabBarHeight()
const handleResize = () => updateInstanceTabBarHeight()
window.addEventListener("resize", handleResize)
@@ -117,7 +145,7 @@ const App: Component = () => {
const formatLaunchErrorMessage = (error: unknown): string => {
if (!error) {
return "Failed to launch workspace"
return t("app.launchError.fallbackMessage")
}
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
try {
@@ -200,12 +228,12 @@ const App: Component = () => {
async function handleCloseInstance(instanceId: string) {
const confirmed = await showConfirmDialog(
"Stop OpenCode instance? This will stop the server.",
t("app.stopInstance.confirmMessage"),
{
title: "Stop instance",
title: t("app.stopInstance.title"),
variant: "warning",
confirmLabel: "Stop",
cancelLabel: "Keep running",
confirmLabel: t("app.stopInstance.confirmLabel"),
cancelLabel: t("app.stopInstance.cancelLabel"),
},
)
@@ -267,6 +295,7 @@ const App: Component = () => {
toggleShowThinkingBlocks,
toggleShowTimelineTools,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -325,40 +354,41 @@ const App: Component = () => {
<Dialog open={Boolean(launchError())} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
We couldn't start the selected OpenCode binary. Review the error output below or choose a different
binary from Advanced Settings.
</Dialog.Description>
</div>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-3xl p-6 flex flex-col gap-6 max-h-[80vh] min-h-0 overflow-hidden">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
{t("app.launchError.description")}
</Dialog.Description>
</div>
<div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Binary path</p>
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
</div>
<div class={`flex flex-col gap-4 ${launchErrorMessage() ? "flex-1 min-h-0" : ""}`}>
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex-shrink-0">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
</div>
<Show when={launchErrorMessage()}>
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex flex-col gap-2 flex-1 min-h-0">
<p class="text-xs font-medium text-muted uppercase tracking-wide">{t("app.launchError.errorOutputLabel")}</p>
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words overflow-auto flex-1 min-h-0">{launchErrorMessage()}</pre>
</div>
</Show>
</div>
<Show when={launchErrorMessage()}>
<div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Error output</p>
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
</div>
</Show>
<div class="flex justify-end gap-2">
<Show when={launchError()?.missingBinary}>
<button
type="button"
class="selector-button selector-button-secondary"
<div class="flex justify-end gap-2">
<Show when={launchError()?.missingBinary}>
<button
type="button"
class="selector-button selector-button-secondary"
onClick={handleLaunchErrorAdvanced}
>
Open Advanced Settings
{t("app.launchError.openAdvancedSettings")}
</button>
</Show>
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
Close
{t("app.launchError.close")}
</button>
</div>
</Dialog.Content>
@@ -428,7 +458,7 @@ const App: Component = () => {
clearLaunchError()
}}
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Close (Esc)"
title={t("app.launchError.closeTitle")}
>
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />

View File

@@ -2,6 +2,7 @@ import { Component } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import OpenCodeBinarySelector from "./opencode-binary-selector"
import EnvironmentVariablesEditor from "./environment-variables-editor"
import { useI18n } from "../lib/i18n"
interface AdvancedSettingsModalProps {
open: boolean
@@ -12,6 +13,8 @@ interface AdvancedSettingsModalProps {
}
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
const { t } = useI18n()
return (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
<Dialog.Portal>
@@ -19,7 +22,7 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-5xl 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">Advanced Settings</Dialog.Title>
<Dialog.Title class="text-xl font-semibold text-primary">{t("advancedSettings.title")}</Dialog.Title>
</header>
<div class="flex-1 overflow-y-auto p-6 space-y-6">
@@ -32,8 +35,8 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
<div class="panel">
<div class="panel-header">
<h3 class="panel-title">Environment Variables</h3>
<p class="panel-subtitle">Applied whenever a new OpenCode instance starts</p>
<h3 class="panel-title">{t("advancedSettings.environmentVariables.title")}</h3>
<p class="panel-subtitle">{t("advancedSettings.environmentVariables.subtitle")}</p>
</div>
<div class="panel-body">
<EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} />
@@ -47,7 +50,7 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
class="selector-button selector-button-secondary"
onClick={props.onClose}
>
Close
{t("advancedSettings.actions.close")}
</button>
</div>
</Dialog.Content>

View File

@@ -3,6 +3,7 @@ import { For, Show, createEffect, createMemo } from "solid-js"
import { agents, fetchAgents, sessions } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Agent } from "../types/session"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
@@ -15,6 +16,7 @@ interface AgentSelectorProps {
}
export default function AgentSelector(props: AgentSelectorProps) {
const { t } = useI18n()
const instanceAgents = () => agents().get(props.instanceId) || []
const session = createMemo(() => {
@@ -71,7 +73,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
options={availableAgents()}
optionValue="name"
optionTextValue="name"
placeholder="Select agent..."
placeholder={t("agentSelector.placeholder")}
itemComponent={(itemProps) => (
<Select.Item
item={itemProps.item}
@@ -81,7 +83,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
<Select.ItemLabel class="selector-option-label flex items-center gap-2">
<span>{itemProps.item.rawValue.name}</span>
<Show when={itemProps.item.rawValue.mode === "subagent"}>
<span class="neutral-badge">subagent</span>
<span class="neutral-badge">{t("agentSelector.badge.subagent")}</span>
</Show>
</Select.ItemLabel>
<Show when={itemProps.item.rawValue.description}>
@@ -99,15 +101,17 @@ export default function AgentSelector(props: AgentSelectorProps) {
data-agent-selector
class="selector-trigger"
>
<Select.Value<Agent>>
{(state) => (
<div class="selector-trigger-label">
<span class="selector-trigger-primary">
Agent: {state.selectedOption()?.name ?? "None"}
</span>
</div>
)}
</Select.Value>
<div class="flex-1 min-w-0">
<Select.Value<Agent>>
{(state) => (
<div class="selector-trigger-label selector-trigger-label--stacked">
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{t("agentSelector.trigger.primary", { agent: state.selectedOption()?.name ?? t("agentSelector.none") })}
</span>
</div>
)}
</Select.Value>
</div>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>

View File

@@ -2,28 +2,26 @@ import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect, createSignal } from "solid-js"
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
import { useI18n } from "../lib/i18n"
const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string; fallbackTitle: string }> = {
const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string }> = {
info: {
badgeBg: "var(--badge-neutral-bg)",
badgeBorder: "var(--border-base)",
badgeText: "var(--accent-primary)",
symbol: "i",
fallbackTitle: "Heads up",
},
warning: {
badgeBg: "rgba(255, 152, 0, 0.14)",
badgeBorder: "var(--status-warning)",
badgeText: "var(--status-warning)",
symbol: "!",
fallbackTitle: "Please review",
},
error: {
badgeBg: "var(--danger-soft-bg)",
badgeBorder: "var(--status-error)",
badgeText: "var(--status-error)",
symbol: "!",
fallbackTitle: "Something went wrong",
},
}
@@ -60,14 +58,22 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptVa
}
const AlertDialog: Component = () => {
const { t } = useI18n()
let primaryButtonRef: HTMLButtonElement | undefined
let promptInputRef: HTMLInputElement | undefined
createEffect(() => {
if (alertDialogState()) {
queueMicrotask(() => {
primaryButtonRef?.focus()
})
}
const state = alertDialogState()
if (!state) return
queueMicrotask(() => {
if (state.type === "prompt") {
promptInputRef?.focus()
promptInputRef?.select()
return
}
primaryButtonRef?.focus()
})
})
return (
@@ -75,11 +81,25 @@ const AlertDialog: Component = () => {
{(payload) => {
const variant = payload.variant ?? "info"
const accent = variantAccent[variant]
const title = payload.title || accent.fallbackTitle
const fallbackTitle =
variant === "warning"
? t("alertDialog.fallbackTitle.warning")
: variant === "error"
? t("alertDialog.fallbackTitle.error")
: t("alertDialog.fallbackTitle.info")
const title = payload.title || fallbackTitle
const isConfirm = payload.type === "confirm"
const isPrompt = payload.type === "prompt"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : isPrompt ? "Run" : "OK")
const cancelLabel = payload.cancelLabel || "Cancel"
const confirmLabel =
payload.confirmLabel ||
(isConfirm
? t("alertDialog.actions.confirm")
: isPrompt
? t("alertDialog.actions.run")
: t("alertDialog.actions.ok"))
const cancelLabel = payload.cancelLabel || t("alertDialog.actions.cancel")
const [inputValue, setInputValue] = createSignal(payload.inputDefaultValue ?? "")
@@ -118,25 +138,31 @@ const AlertDialog: Component = () => {
</div>
</div>
<Show when={isPrompt}>
<div class="mt-4">
<label class="text-xs font-medium text-muted uppercase tracking-wide">
{payload.inputLabel || "Arguments"}
</label>
<input
class="modal-search-input mt-2"
value={inputValue()}
placeholder={payload.inputPlaceholder || ""}
onInput={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
dismiss(true, payload, inputValue())
}
}}
/>
</div>
</Show>
<Show when={isPrompt}>
<div class="mt-4">
<label class="text-sm font-medium text-secondary">
{payload.inputLabel || t("alertDialog.prompt.inputLabel")}
</label>
<input
ref={(el) => {
promptInputRef = el
}}
class="form-input mt-2"
value={inputValue()}
placeholder={payload.inputPlaceholder || ""}
autocapitalize="off"
autocorrect="off"
spellcheck={false}
onInput={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
dismiss(true, payload, inputValue())
}
}}
/>
</div>
</Show>
<div class="mt-6 flex justify-end gap-3">
{(isConfirm || isPrompt) && (

View File

@@ -1,5 +1,6 @@
import { Component } from "solid-js"
import type { Attachment } from "../types/attachment"
import { useI18n } from "../lib/i18n"
interface AttachmentChipProps {
attachment: Attachment
@@ -7,6 +8,7 @@ interface AttachmentChipProps {
}
const AttachmentChip: Component<AttachmentChipProps> = (props) => {
const { t } = useI18n()
return (
<div
class="attachment-chip"
@@ -16,7 +18,7 @@ const AttachmentChip: Component<AttachmentChipProps> = (props) => {
<button
onClick={props.onRemove}
class="attachment-remove"
aria-label="Remove attachment"
aria-label={t("attachmentChip.removeAriaLabel")}
>
×
</button>

View File

@@ -3,6 +3,7 @@ import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import type { BackgroundProcess } from "../../../server/src/api-types"
import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client"
import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
import { useI18n } from "../lib/i18n"
interface BackgroundProcessOutputDialogProps {
open: boolean
@@ -12,6 +13,7 @@ interface BackgroundProcessOutputDialogProps {
}
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
const { t } = useI18n()
const [output, setOutput] = createSignal("")
const [outputHtml, setOutputHtml] = createSignal("")
const [ansiEnabled, setAnsiEnabled] = createSignal(false)
@@ -67,7 +69,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
})
.catch(() => {
if (!active) return
setRawOutput("Failed to load output.")
setRawOutput(t("backgroundProcessOutputDialog.loadErrorFallback"))
setAnsiEnabled(false)
setOutputHtml("")
})
@@ -121,7 +123,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
<div class="flex items-start justify-between px-6 py-4 border-b border-base gap-4">
<div class="flex-1 min-w-0">
<Dialog.Title class="text-lg font-semibold text-primary">Background Output</Dialog.Title>
<Dialog.Title class="text-lg font-semibold text-primary">{t("backgroundProcessOutputDialog.title")}</Dialog.Title>
<Show when={props.process}>
<span class="text-xs text-secondary block">
{props.process?.title} · {props.process?.id}
@@ -133,16 +135,16 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
</div>
<button type="button" class="button-tertiary flex-shrink-0" onClick={props.onClose}>
Close
{t("backgroundProcessOutputDialog.actions.close")}
</button>
</div>
<div class="flex-1 overflow-auto p-6">
<Show when={loading()}>
<p class="text-xs text-secondary">Loading output...</p>
<p class="text-xs text-secondary">{t("backgroundProcessOutputDialog.loading")}</p>
</Show>
<Show when={!loading()}>
<Show when={truncated()}>
<p class="text-xs text-secondary mb-2">Output truncated for display.</p>
<p class="text-xs text-secondary mb-2">{t("backgroundProcessOutputDialog.truncatedNotice")}</p>
</Show>
<Show
when={ansiEnabled()}

View File

@@ -0,0 +1,38 @@
import type { Component } from "solid-js"
type BrandIconProps = {
class?: string
title?: string
}
export const GitHubMarkIcon: Component<BrandIconProps> = (props) => (
<svg
viewBox="0 0 98 96"
xmlns="http://www.w3.org/2000/svg"
aria-hidden={props.title ? undefined : "true"}
role={props.title ? "img" : "presentation"}
class={props.class}
>
{props.title ? <title>{props.title}</title> : null}
<path
fill="currentColor"
d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 0 48.9043 0C21.8203 0 0 22.1074 0 49.1914C0 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z"
/>
</svg>
)
export const DiscordSymbolIcon: Component<BrandIconProps> = (props) => (
<svg
viewBox="0 0 64 48"
xmlns="http://www.w3.org/2000/svg"
aria-hidden={props.title ? undefined : "true"}
role={props.title ? "img" : "presentation"}
class={props.class}
>
{props.title ? <title>{props.title}</title> : null}
<path
fill="currentColor"
d="M40.575 0C39.9562 1.09866 39.4006 2.2352 38.8954 3.397C34.0967 2.67719 29.2096 2.67719 24.3982 3.397C23.9057 2.2352 23.3374 1.09866 22.7186 0C18.2104 0.770324 13.8157 2.12155 9.64839 4.02841C1.38951 16.2652 -0.845688 28.1863 0.265599 39.9432C5.10222 43.517 10.5197 46.2447 16.2909 47.9874C17.5916 46.2447 18.7407 44.3883 19.7257 42.4562C17.8568 41.7616 16.0509 40.8903 14.3208 39.88C14.7755 39.5517 15.2175 39.2107 15.6468 38.8824C25.7873 43.6559 37.5316 43.6559 47.6847 38.8824C48.1141 39.236 48.5561 39.577 49.0107 39.88C47.2806 40.9029 45.4748 41.7616 43.5931 42.4688C44.5781 44.4009 45.7273 46.2573 47.028 48C52.7991 46.2573 58.2167 43.5422 63.0533 39.9684C64.3666 26.3299 60.8055 14.5099 53.6452 4.04104C49.4905 2.13418 45.0959 0.782952 40.5876 0.0252565L40.575 0ZM21.1401 32.7072C18.0209 32.7072 15.4321 29.8785 15.4321 26.3804C15.4321 22.8824 17.9199 20.041 21.1275 20.041C24.3351 20.041 26.886 22.895 26.8354 26.3804C26.7849 29.8658 24.3224 32.7072 21.1401 32.7072ZM42.1788 32.7072C39.047 32.7072 36.4834 29.8785 36.4834 26.3804C36.4834 22.8824 38.9712 20.041 42.1788 20.041C45.3864 20.041 47.9246 22.895 47.8741 26.3804C47.8236 29.8658 45.3611 32.7072 42.1788 32.7072Z"
/>
</svg>
)

View File

@@ -3,6 +3,7 @@ import type { Highlighter } from "shiki/bundle/full"
import { useTheme } from "../lib/theme"
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"
const inlineLoadedLanguages = new Set<string>()
@@ -15,6 +16,7 @@ interface CodeBlockInlineProps {
}
export function CodeBlockInline(props: CodeBlockInlineProps) {
const { t } = useI18n()
const { isDark } = useTheme()
const [html, setHtml] = createSignal("")
const [copied, setCopied] = createSignal(false)
@@ -53,7 +55,7 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
const highlighted = highlighter.codeToHtml(props.code, {
lang: props.language as CodeToHtmlOptions["lang"],
theme: isDark() ? "github-dark" : "github-light",
theme: isDark() ? "github-dark" : "github-light-high-contrast",
})
setHtml(highlighted)
} catch {
@@ -97,8 +99,8 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">
<Show when={copied()} fallback="Copy">
Copied!
<Show when={copied()} fallback={t("codeBlockInline.actions.copy")}>
{t("codeBlockInline.actions.copied")}
</Show>
</span>
</button>

View File

@@ -1,7 +1,8 @@
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import type { Command } from "../lib/commands"
import { resolveResolvable, type Command } from "../lib/commands"
import Kbd from "./kbd"
import { useI18n } from "../lib/i18n"
interface CommandPaletteProps {
open: boolean
@@ -24,6 +25,7 @@ function buildShortcutString(shortcut: Command["shortcut"]): string {
}
const CommandPalette: Component<CommandPaletteProps> = (props) => {
const { t } = useI18n()
const [query, setQuery] = createSignal("")
const [selectedCommandId, setSelectedCommandId] = createSignal<string | null>(null)
const [isPointerSelecting, setIsPointerSelecting] = createSignal(false)
@@ -32,6 +34,27 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
const categoryOrder = ["Custom Commands", "Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const
const categoryLabel = (category: string) => {
switch (category) {
case "Custom Commands":
return t("commandPalette.category.customCommands")
case "Instance":
return t("commandPalette.category.instance")
case "Session":
return t("commandPalette.category.session")
case "Agent & Model":
return t("commandPalette.category.agentModel")
case "Input & Focus":
return t("commandPalette.category.inputFocus")
case "System":
return t("commandPalette.category.system")
case "Other":
return t("commandPalette.category.other")
default:
return category
}
}
type CommandGroup = { category: string; commands: Command[]; startIndex: number }
type ProcessedCommands = { groups: CommandGroup[]; ordered: Command[] }
@@ -41,18 +64,21 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
const filtered = q
? source.filter((cmd) => {
const label = typeof cmd.label === "function" ? cmd.label() : cmd.label
const label = resolveResolvable(cmd.label)
const description = resolveResolvable(cmd.description)
const keywords = cmd.keywords ? resolveResolvable(cmd.keywords) : undefined
const category = cmd.category ? resolveResolvable(cmd.category) : undefined
const labelMatch = label.toLowerCase().includes(q)
const descMatch = cmd.description.toLowerCase().includes(q)
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(q))
const categoryMatch = cmd.category?.toLowerCase().includes(q)
const descMatch = description.toLowerCase().includes(q)
const keywordMatch = keywords?.some((k) => k.toLowerCase().includes(q))
const categoryMatch = category?.toLowerCase().includes(q)
return labelMatch || descMatch || keywordMatch || categoryMatch
})
: source
const groupsMap = new Map<string, Command[]>()
for (const cmd of filtered) {
const category = cmd.category || "Other"
const category = (cmd.category ? resolveResolvable(cmd.category) : undefined) || "Other"
const list = groupsMap.get(category)
if (list) {
list.push(cmd)
@@ -189,12 +215,12 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
<Dialog.Content
class="modal-surface w-full max-w-2xl max-h-[60vh]"
onKeyDown={handleKeyDown}
>
<Dialog.Title class="sr-only">Command Palette</Dialog.Title>
<Dialog.Description class="sr-only">Search and execute commands</Dialog.Description>
<Dialog.Content
class="modal-surface w-full max-w-2xl max-h-[60vh]"
onKeyDown={handleKeyDown}
>
<Dialog.Title class="sr-only">{t("commandPalette.title")}</Dialog.Title>
<Dialog.Description class="sr-only">{t("commandPalette.description")}</Dialog.Description>
<div class="modal-search-container">
<div class="flex items-center gap-3">
@@ -214,7 +240,7 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
setQuery(e.currentTarget.value)
setSelectedCommandId(null)
}}
placeholder="Type a command or search..."
placeholder={t("commandPalette.searchPlaceholder")}
class="modal-search-input"
/>
</div>
@@ -228,13 +254,13 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
>
<Show
when={orderedCommands().length > 0}
fallback={<div class="modal-empty-state">No commands found for "{query()}"</div>}
fallback={<div class="modal-empty-state">{t("commandPalette.empty", { query: query() })}</div>}
>
<For each={groupedCommandList()}>
{(group) => (
<div class="py-2">
<div class="modal-section-header">
{group.category}
{categoryLabel(group.category)}
</div>
<For each={group.commands}>
{(command, localIndex) => {
@@ -257,10 +283,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
>
<div class="flex-1 min-w-0">
<div class="modal-item-label">
{typeof command.label === "function" ? command.label() : command.label}
{resolveResolvable(command.label)}
</div>
<div class="modal-item-description">
{command.description}
{resolveResolvable(command.description)}
</div>
</div>
<Show when={command.shortcut}>

View File

@@ -1,8 +1,10 @@
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid"
import { ArrowUpLeft, Folder as FolderIcon, FolderPlus, Loader2, X } from "lucide-solid"
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
import { useI18n } from "../lib/i18n"
function normalizePathKey(input?: string | null) {
if (!input || input === "." || input === "./") {
@@ -61,9 +63,11 @@ type FolderRow =
| { type: "folder"; entry: FileSystemEntry }
const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) => {
const { t } = useI18n()
const [rootPath, setRootPath] = createSignal("")
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [creatingFolder, setCreatingFolder] = createSignal(false)
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null)
@@ -108,7 +112,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const metadata = await loadDirectory()
applyMetadata(metadata)
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem"
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
setError(message)
} finally {
setLoading(false)
@@ -198,7 +202,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const metadata = await loadDirectory(path)
applyMetadata(metadata)
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem"
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
setError(message)
}
}
@@ -256,6 +260,52 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
props.onSelect(absolutePath)
}
async function handleCreateFolder() {
if (creatingFolder()) return
const metadata = currentMetadata()
if (!metadata || metadata.pathKind === "drives") {
return
}
const name =
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
title: t("directoryBrowser.createFolder.title"),
inputLabel: t("directoryBrowser.createFolder.inputLabel"),
inputPlaceholder: t("directoryBrowser.createFolder.inputPlaceholder"),
confirmLabel: t("directoryBrowser.createFolder.confirmLabel"),
cancelLabel: t("directoryBrowser.createFolder.cancelLabel"),
}))?.trim() ?? ""
if (!name) return
if (name === "." || name === ".." || name.startsWith("~") || name.includes("/") || name.includes("\\")) {
showAlertDialog(t("directoryBrowser.createFolder.invalidNameMessage"), {
variant: "warning",
detail: t("directoryBrowser.createFolder.invalidNameDetail"),
})
return
}
setCreatingFolder(true)
try {
const parentKey = normalizePathKey(metadata.currentPath)
metadataCache.delete(parentKey)
inFlightRequests.delete(parentKey)
setDirectoryChildren((prev) => {
const next = new Map(prev)
next.delete(parentKey)
return next
})
const created = await serverApi.createFileSystemFolder(metadata.currentPath, name)
await navigateTo(created.path)
} catch (err) {
const message = err instanceof Error ? err.message : t("directoryBrowser.createFolder.errorFallback")
showAlertDialog(message, { variant: "error", title: t("directoryBrowser.createFolder.errorFallback") })
} finally {
setCreatingFolder(false)
}
}
function isPathLoading(path: string) {
return loadingPaths().has(normalizePathKey(path))
}
@@ -275,10 +325,10 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
<div class="directory-browser-heading">
<h3 class="directory-browser-title">{props.title}</h3>
<p class="directory-browser-description">
{props.description || "Browse folders under the configured workspace root."}
{props.description || t("directoryBrowser.defaultDescription")}
</p>
</div>
<button type="button" class="directory-browser-close" aria-label="Close" onClick={props.onClose}>
<button type="button" class="directory-browser-close" aria-label={t("directoryBrowser.close")} onClick={props.onClose}>
<X class="w-5 h-5" />
</button>
</div>
@@ -287,22 +337,35 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
<Show when={rootPath()}>
<div class="directory-browser-current">
<div class="directory-browser-current-meta">
<span class="directory-browser-current-label">Current folder</span>
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
</div>
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
disabled={!canSelectCurrent()}
onClick={() => {
const absolute = currentAbsolutePath()
if (absolute) {
props.onSelect(absolute)
}
}}
>
Select Current
</button>
<div class="directory-browser-current-actions">
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
disabled={!canSelectCurrent() || creatingFolder()}
onClick={() => {
const absolute = currentAbsolutePath()
if (absolute) {
props.onSelect(absolute)
}
}}
>
{t("directoryBrowser.selectCurrent")}
</button>
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select"
disabled={!canSelectCurrent() || creatingFolder()}
onClick={() => void handleCreateFolder()}
>
<span class="inline-flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
{creatingFolder() ? t("directoryBrowser.creating") : t("directoryBrowser.newFolder")}
</span>
</button>
</div>
</div>
</Show>
<Show
@@ -312,7 +375,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
<Show when={loading()} fallback={<span class="text-red-500">{error()}</span>}>
<div class="directory-browser-loading">
<Loader2 class="w-5 h-5 animate-spin" />
<span>Loading folders</span>
<span>{t("directoryBrowser.loadingFolders")}</span>
</div>
</Show>
</div>
@@ -320,13 +383,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
>
<Show
when={folderRows().length > 0}
fallback={<div class="panel-empty-state flex-1">No folders available.</div>}
fallback={<div class="panel-empty-state flex-1">{t("directoryBrowser.noFolders")}</div>}
>
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto directory-browser-list" role="listbox">
<For each={folderRows()}>
{(item) => {
const isFolder = item.type === "folder"
const label = isFolder ? item.entry.name || item.entry.path : "Up one level"
const label = isFolder ? item.entry.name || item.entry.path : t("directoryBrowser.upOneLevel")
const navigate = () => (isFolder ? handleNavigateTo(item.entry.path) : handleNavigateUp())
return (
<div class="panel-list-item" role="option">
@@ -353,7 +416,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
handleEntrySelect(item.entry)
}}
>
Select
{t("directoryBrowser.select")}
</button>
) : null}
</div>

View File

@@ -1,5 +1,6 @@
import { Component } from "solid-js"
import { Loader2 } from "lucide-solid"
import { useI18n } from "../lib/i18n"
const codeNomadIcon = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -9,15 +10,19 @@ interface EmptyStateProps {
}
const EmptyState: Component<EmptyStateProps> = (props) => {
const { t } = useI18n()
const modifier = typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"
const shortcut = `${modifier}+N`
return (
<div class="flex h-full w-full items-center justify-center bg-surface-secondary">
<div class="max-w-[500px] px-8 py-12 text-center">
<div class="mb-8 flex justify-center">
<img src={codeNomadIcon} alt="CodeNomad logo" class="h-24 w-auto" loading="lazy" />
<img src={codeNomadIcon} alt={t("emptyState.logoAlt")} class="h-24 w-auto" loading="lazy" />
</div>
<h1 class="mb-3 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="mb-8 text-base text-secondary">Select a folder to start coding with AI</p>
<h1 class="mb-3 text-3xl font-semibold text-primary">{t("emptyState.brandTitle")}</h1>
<p class="mb-8 text-base text-secondary">{t("emptyState.tagline")}</p>
<button
@@ -28,20 +33,20 @@ const EmptyState: Component<EmptyStateProps> = (props) => {
{props.isLoading ? (
<>
<Loader2 class="h-4 w-4 animate-spin" />
Selecting...
{t("emptyState.actions.selecting")}
</>
) : (
"Select Folder"
t("emptyState.actions.selectFolder")
)}
</button>
<p class="text-sm text-muted">
Keyboard shortcut: {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+N
{t("emptyState.keyboardShortcut", { shortcut })}
</p>
<div class="mt-6 space-y-1 text-sm text-muted">
<p>Examples: ~/projects/my-app</p>
<p>You can have multiple instances of the same folder</p>
<p>{t("emptyState.examples", { example: "~/projects/my-app" })}</p>
<p>{t("emptyState.multipleInstances")}</p>
</div>
</div>
</div>

View File

@@ -1,12 +1,14 @@
import { Component, createSignal, For, Show } from "solid-js"
import { Plus, Trash2, Key, Globe } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import { useI18n } from "../lib/i18n"
interface EnvironmentVariablesEditorProps {
disabled?: boolean
}
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
const { t } = useI18n()
const {
preferences,
addEnvironmentVariable,
@@ -54,9 +56,11 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
<div class="space-y-3">
<div class="flex items-center gap-2 mb-3">
<Globe class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">Environment Variables</span>
<span class="text-sm font-medium text-secondary">{t("envEditor.title")}</span>
<span class="text-xs text-muted">
({entries().length} variable{entries().length !== 1 ? "s" : ""})
{entries().length === 1
? t("envEditor.count.one", { count: entries().length })
: t("envEditor.count.other", { count: entries().length })}
</span>
</div>
@@ -73,8 +77,8 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
value={key}
disabled={props.disabled}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-secondary border border-base rounded text-muted cursor-not-allowed"
placeholder="Variable name"
title="Variable name (read-only)"
placeholder={t("envEditor.fields.name.placeholder")}
title={t("envEditor.fields.name.readOnlyTitle")}
/>
<input
type="text"
@@ -82,14 +86,14 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
disabled={props.disabled}
onInput={(e) => handleUpdateVariable(key, e.currentTarget.value)}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Variable value"
placeholder={t("envEditor.fields.value.placeholder")}
/>
</div>
<button
onClick={() => handleRemoveVariable(key)}
disabled={props.disabled}
class="p-1.5 icon-muted icon-danger-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Remove variable"
title={t("envEditor.actions.remove.title")}
>
<Trash2 class="w-3.5 h-3.5" />
</button>
@@ -110,7 +114,7 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
onKeyPress={handleKeyPress}
disabled={props.disabled}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Variable name"
placeholder={t("envEditor.fields.name.placeholder")}
/>
<input
type="text"
@@ -119,14 +123,14 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
onKeyPress={handleKeyPress}
disabled={props.disabled}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Variable value"
placeholder={t("envEditor.fields.value.placeholder")}
/>
</div>
<button
onClick={handleAddVariable}
disabled={props.disabled || !newKey().trim()}
class="p-1.5 icon-muted icon-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Add variable"
title={t("envEditor.actions.add.title")}
>
<Plus class="w-3.5 h-3.5" />
</button>
@@ -134,12 +138,12 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
<Show when={entries().length === 0}>
<div class="text-xs text-muted text-center py-2">
No environment variables configured. Add variables above to customize the OpenCode environment.
{t("envEditor.empty")}
</div>
</Show>
<div class="text-xs text-muted mt-2">
These variables will be available in the OpenCode environment when starting instances.
{t("envEditor.help")}
</div>
</div>
)

View File

@@ -1,5 +1,6 @@
import { Show } from "solid-js"
import { Maximize2, Minimize2 } from "lucide-solid"
import { useI18n } from "../lib/i18n"
interface ExpandButtonProps {
expandState: () => "normal" | "expanded"
@@ -7,6 +8,8 @@ interface ExpandButtonProps {
}
export default function ExpandButton(props: ExpandButtonProps) {
const { t } = useI18n()
function handleClick() {
const current = props.expandState()
props.onToggleExpand(current === "normal" ? "expanded" : "normal")
@@ -17,7 +20,7 @@ export default function ExpandButton(props: ExpandButtonProps) {
type="button"
class="prompt-expand-button"
onClick={handleClick}
aria-label="Toggle chat input height"
aria-label={t("expandButton.toggleAriaLabel")}
>
<Show
when={props.expandState() === "normal"}

View File

@@ -3,6 +3,7 @@ import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger"
import { useI18n } from "../lib/i18n"
const log = getLogger("actions")
@@ -49,6 +50,7 @@ interface FileSystemBrowserDialogProps {
type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry }
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
const { t } = useI18n()
const [rootPath, setRootPath] = createSignal("")
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
@@ -135,7 +137,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
setRootPath(metadata.rootPath)
setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? [])
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem"
const message = err instanceof Error ? err.message : t("filesystemBrowser.errors.loadFilesystemFallback")
setError(message)
}
}
@@ -143,10 +145,10 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
function describeLoadingPath() {
const path = loadingPath()
if (!path) {
return "filesystem"
return t("filesystemBrowser.loading.filesystem")
}
if (path === ".") {
return rootPath() || "workspace root"
return rootPath() || t("filesystemBrowser.loading.workspaceRoot")
}
return resolveAbsolutePath(rootPath(), path)
}
@@ -176,7 +178,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
function handleNavigateTo(path: string) {
void fetchDirectory(path, true).catch((err) => {
log.error("Failed to open directory", err)
setError(err instanceof Error ? err.message : "Unable to open directory")
setError(err instanceof Error ? err.message : t("filesystemBrowser.errors.openDirectoryFallback"))
})
}
@@ -277,19 +279,21 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<div class="panel-header flex items-start justify-between gap-4">
<div>
<h3 class="panel-title">{props.title}</h3>
<p class="panel-subtitle">{props.description || "Search for a path under the configured workspace root."}</p>
<p class="panel-subtitle">{props.description || t("filesystemBrowser.descriptionFallback")}</p>
<Show when={rootPath()}>
<p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p>
<p class="text-xs text-muted mt-1 font-mono break-all">
{t("filesystemBrowser.rootLabel", { root: rootPath() })}
</p>
</Show>
</div>
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
<X class="w-4 h-4" />
Close
{t("filesystemBrowser.actions.close")}
</button>
</div>
<div class="panel-body">
<label class="w-full text-sm text-secondary mb-2 block">Filter</label>
<label class="w-full text-sm text-secondary mb-2 block">{t("filesystemBrowser.filterLabel")}</label>
<div class="selector-input-group">
<div class="flex items-center gap-2 px-3 text-muted">
<Search class="w-4 h-4" />
@@ -301,7 +305,11 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
type="text"
value={searchQuery()}
onInput={(event) => setSearchQuery(event.currentTarget.value)}
placeholder={props.mode === "directories" ? "Search for folders" : "Search for files"}
placeholder={
props.mode === "directories"
? t("filesystemBrowser.search.placeholder.directories")
: t("filesystemBrowser.search.placeholder.files")
}
class="selector-input"
/>
</div>
@@ -311,7 +319,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<div class="px-4 pb-2">
<div class="flex items-center justify-between gap-3 rounded-md border border-border-subtle px-4 py-3">
<div>
<p class="text-xs text-secondary uppercase tracking-wide">Current folder</p>
<p class="text-xs text-secondary uppercase tracking-wide">{t("filesystemBrowser.currentFolder.label")}</p>
<p class="text-sm font-mono text-primary break-all">{currentAbsolutePath()}</p>
</div>
<button
@@ -319,7 +327,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
class="selector-button selector-button-secondary whitespace-nowrap"
onClick={() => props.onSelect(currentAbsolutePath())}
>
Select Current
{t("filesystemBrowser.currentFolder.selectCurrent")}
</button>
</div>
</div>
@@ -336,7 +344,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
>
<div class="flex items-center gap-2">
<Loader2 class="w-4 h-4 animate-spin" />
<span>Loading {describeLoadingPath()}</span>
<span>{t("filesystemBrowser.loading.loadingWithPath", { path: describeLoadingPath() })}</span>
</div>
</Show>
</div>
@@ -345,16 +353,16 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<Show when={loadingPath()}>
<div class="flex items-center gap-2 px-4 py-2 text-xs text-secondary">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
<span>Loading {describeLoadingPath()}</span>
<span>{t("filesystemBrowser.loading.loadingWithPath", { path: describeLoadingPath() })}</span>
</div>
</Show>
<Show
when={folderRows().length > 0}
fallback={
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
<p>No entries found.</p>
<p>{t("filesystemBrowser.empty.noEntries")}</p>
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
Retry
{t("filesystemBrowser.actions.retry")}
</button>
</div>
}
@@ -370,7 +378,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<ArrowUpLeft class="w-4 h-4" />
</div>
<div class="directory-browser-row-text">
<span class="directory-browser-row-name">Up one level</span>
<span class="directory-browser-row-name">{t("filesystemBrowser.navigation.upOneLevel")}</span>
</div>
</button>
</div>
@@ -412,7 +420,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
selectEntry()
}}
>
Select
{t("filesystemBrowser.actions.select")}
</button>
</div>
</div>
@@ -428,15 +436,15 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>
<kbd class="kbd"></kbd>
<span>Navigate</span>
<span>{t("filesystemBrowser.hints.navigate")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Enter</kbd>
<span>Select</span>
<span>{t("filesystemBrowser.hints.select")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Esc</kbd>
<span>Close</span>
<span>{t("filesystemBrowser.hints.close")}</span>
</div>
</div>
</div>
@@ -448,4 +456,3 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
}
export default FileSystemBrowserDialog

View File

@@ -1,11 +1,17 @@
import { Select } from "@kobalte/core/select"
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp } from "lucide-solid"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
import { ThemeModeToggle } from "./theme-mode-toggle"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import VersionPill from "./version-pill"
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
import { githubStars } from "../stores/github-stars"
import { formatCompactCount } from "../lib/formatters"
import { useI18n, type Locale } from "../lib/i18n"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -20,13 +26,27 @@ interface FolderSelectionViewProps {
}
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences } = useConfig()
const { recentFolders, removeRecentFolder, preferences, updatePreferences } = useConfig()
const { t, locale } = useI18n()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
type LanguageOption = { value: Locale; label: string }
const languageOptions: LanguageOption[] = [
{ value: "en", label: "English" },
{ value: "es", label: "Español" },
{ value: "fr", label: "Français" },
{ value: "ru", label: "Русский" },
{ value: "ja", label: "日本語" },
{ value: "zh-Hans", label: "简体中文" },
]
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
const folders = () => recentFolders()
const isLoading = () => Boolean(props.isLoading)
@@ -57,6 +77,19 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function handleKeyDown(e: KeyboardEvent) {
let activeElement: HTMLElement | null = null
if (typeof document !== "undefined") {
activeElement = document.activeElement as HTMLElement | null
}
const insideModal = activeElement?.closest(".modal-surface") || activeElement?.closest("[role='dialog']")
const isEditingField =
activeElement &&
(["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName) || activeElement.isContentEditable || Boolean(insideModal))
if (isEditingField) {
return
}
const normalizedKey = e.key.toLowerCase()
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
const blockedKeys = [
@@ -165,16 +198,21 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return "just now"
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
return t("time.relative.justNow")
}
function handleFolderSelect(path: string) {
if (isLoading()) return
props.onSelectFolder(path, selectedBinary())
}
const openExternalLink = (url: string) => {
if (typeof window === "undefined") return
window.open(url, "_blank", "noopener,noreferrer")
}
async function handleBrowse() {
if (isLoading()) return
@@ -182,7 +220,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
if (nativeDialogsAvailable) {
const fallbackPath = folders()[0]?.path
const selected = await openNativeFolderDialog({
title: "Select Workspace",
title: t("folderSelection.dialog.title"),
defaultPath: fallbackPath,
})
if (selected) {
@@ -229,170 +267,282 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
style="background-color: var(--surface-secondary)"
>
<div
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"}
>
<Show when={props.onOpenRemoteAccess}>
<div class="absolute top-4 right-6">
<div class="absolute top-4 left-6">
<Select<LanguageOption>
value={selectedLanguageOption()}
onChange={(value) => {
if (!value) return
if (value.value === locale()) return
updatePreferences({ locale: value.value })
}}
options={languageOptions}
optionValue="value"
optionTextValue="label"
itemComponent={(itemProps) => (
<Select.Item item={itemProps.item} class="selector-option">
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
</Select.Item>
)}
>
<Select.Trigger
class="selector-trigger"
aria-label={t("folderSelection.language.ariaLabel")}
title={t("folderSelection.language.ariaLabel")}
>
<Languages class="w-4 h-4 icon-muted" aria-hidden="true" />
<div class="flex-1 min-w-0">
<Select.Value<LanguageOption>>
{(state) => (
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{state.selectedOption()?.label}
</span>
)}
</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 min-w-[180px]">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
</div>
<div class="absolute top-4 right-6 flex items-center gap-2">
<ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
<Show when={props.onOpenRemoteAccess}>
<button
type="button"
class="selector-button selector-button-secondary inline-flex items-center justify-center"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => props.onOpenRemoteAccess?.()}
>
<MonitorUp class="w-4 h-4" />
</button>
</div>
</Show>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
<div class="mt-2 flex justify-center">
<VersionPill />
</div>
</div>
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
when={folders().length > 0}
fallback={
<div class="panel panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">No Recent Folders</p>
<p class="panel-empty-state-description">Browse for a folder to get started</p>
</div>
}
>
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header">
<h2 class="panel-title">Recent Folders</h2>
<p class="panel-subtitle">
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
</p>
</div>
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto" ref={(el) => (recentListRef = el)}>
<For each={folders()}>
{(folder, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-folder-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{folder.path.split("/").pop()}
</span>
</div>
<div class="text-xs font-mono truncate pl-6 text-muted">
{getDisplayPath(folder.path)}
</div>
<div class="text-xs mt-1 pl-6 text-muted">
{formatRelativeTime(folder.lastAccessed)}
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title="Remove from recent"
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="panel shrink-0">
<div class="panel-header hidden sm:block">
<h2 class="panel-title">Browse for Folder</h2>
<p class="panel-subtitle">Select any folder on your computer</p>
</div>
<div class="panel-body">
<button
onClick={() => void handleBrowse()}
disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")}
>
<div class="flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2" />
</button>
</div>
{/* Advanced settings section */}
<div class="panel-section w-full">
<button
onClick={() => props.onAdvancedSettingsOpen?.()}
class="panel-section-header w-full justify-between"
>
<div class="flex items-center gap-2">
<Settings class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
</div>
<ChevronRight class="w-4 h-4 icon-muted" />
</button>
</div>
</div>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt={t("folderSelection.logoAlt")} class="h-32 w-auto sm:h-48" loading="lazy" />
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<div class="mt-3 flex justify-center gap-2">
<a
href="https://github.com/NeuralNomadsAI/CodeNomad"
target="_blank"
rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
aria-label={t("folderSelection.links.github")}
title={t("folderSelection.links.github")}
onClick={(event) => {
event.preventDefault()
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
}}
>
<GitHubMarkIcon class="w-4 h-4" />
</a>
<a
href="https://github.com/NeuralNomadsAI/CodeNomad"
target="_blank"
rel="noreferrer"
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
aria-label={t("folderSelection.links.githubStars")}
title={t("folderSelection.links.githubStars")}
onClick={(event) => {
event.preventDefault()
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
}}
>
<Star class="w-4 h-4" />
<Show when={githubStars() !== null}>
<span class="text-xs font-medium">{formatCompactCount(githubStars()!)}</span>
</Show>
</a>
<a
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
target="_blank"
rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
aria-label={t("folderSelection.links.discord")}
title={t("folderSelection.links.discord")}
onClick={(event) => {
event.preventDefault()
openExternalLink(
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
)
}}
>
<DiscordSymbolIcon class="w-4 h-4" />
</a>
</div>
<p class="mt-3 text-base text-secondary">{t("folderSelection.tagline")}</p>
</div>
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
<div class="panel-footer-hints">
<Show when={folders().length > 0}>
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>
<kbd class="kbd"></kbd>
<span>Navigate</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Enter</kbd>
<span>Select</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd>
<span>Remove</span>
<div class="flex-1 min-h-0 overflow-hidden flex flex-col gap-4">
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
{/* Right column: recent folders */}
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
<Show
when={folders().length > 0}
fallback={
<div class="panel panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
</div>
}
>
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header">
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
<p class="panel-subtitle">
{t(
folders().length === 1
? "folderSelection.recent.subtitle.one"
: "folderSelection.recent.subtitle.other",
{ count: folders().length },
)}
</p>
</div>
<div
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
ref={(el) => (recentListRef = el)}
>
<For each={folders()}>
{(folder, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-folder-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{folder.path.split("/").pop()}
</span>
</div>
<div class="text-xs font-mono truncate pl-6 text-muted">
{getDisplayPath(folder.path)}
</div>
<div class="text-xs mt-1 pl-6 text-muted">
{formatRelativeTime(folder.lastAccessed)}
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title={t("folderSelection.recent.remove")}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" />
<span>Browse</span>
</div>
{/* Left column: version + browse + advanced settings */}
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
<div class="panel shrink-0">
<div class="panel-header hidden sm:block">
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
</div>
<div class="panel-body">
<button
onClick={() => void handleBrowse()}
disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")}
>
<div class="flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
<span>
{props.isLoading
? t("folderSelection.browse.buttonOpening")
: t("folderSelection.browse.button")}
</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2" />
</button>
</div>
{/* Advanced settings section */}
<div class="panel-section w-full">
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
<div class="flex items-center gap-2">
<Settings class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
</div>
<ChevronRight class="w-4 h-4 icon-muted" />
</button>
</div>
</div>
<div class="panel shrink-0">
<div class="panel-body flex items-center justify-center">
<VersionPill />
</div>
</div>
</div>
</div>
<div class="panel panel-footer shrink-0 hidden sm:block">
<div class="panel-footer-hints">
<Show when={folders().length > 0}>
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>
<kbd class="kbd"></kbd>
<span>{t("folderSelection.hints.navigate")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Enter</kbd>
<span>{t("folderSelection.hints.select")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd>
<span>{t("folderSelection.hints.remove")}</span>
</div>
</Show>
<div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" />
<span>{t("folderSelection.hints.browse")}</span>
</div>
</div>
</div>
</div>
@@ -401,8 +551,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="folder-loading-overlay">
<div class="folder-loading-indicator">
<div class="spinner" />
<p class="folder-loading-text">Starting instance</p>
<p class="folder-loading-subtext">Hang tight while we prepare your workspace.</p>
<p class="folder-loading-text">{t("folderSelection.loading.title")}</p>
<p class="folder-loading-subtext">{t("folderSelection.loading.subtitle")}</p>
</div>
</div>
</Show>
@@ -418,8 +568,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<DirectoryBrowserDialog
open={isFolderBrowserOpen()}
title="Select Workspace"
description="Select workspace to start coding."
title={t("folderSelection.dialog.title")}
description={t("folderSelection.dialog.description")}
onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect}
/>

View File

@@ -2,6 +2,7 @@ import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, c
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { ChevronDown } from "lucide-solid"
import InstanceInfo from "./instance-info"
import { useI18n } from "../lib/i18n"
interface InfoViewProps {
instanceId: string
@@ -10,6 +11,7 @@ interface InfoViewProps {
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
const InfoView: Component<InfoViewProps> = (props) => {
const { t } = useI18n()
let scrollRef: HTMLDivElement | undefined
const savedState = logsScrollState.get(props.instanceId)
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
@@ -90,18 +92,18 @@ const InfoView: Component<InfoViewProps> = (props) => {
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
<div class="log-header">
<h2 class="panel-title">Server Logs</h2>
<h2 class="panel-title">{t("infoView.logs.title")}</h2>
<div class="flex items-center gap-2">
<Show
when={streamingEnabled()}
fallback={
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
Show server logs
{t("infoView.logs.actions.show")}
</button>
}
>
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
Hide server logs
{t("infoView.logs.actions.hide")}
</button>
</Show>
</div>
@@ -116,17 +118,17 @@ const InfoView: Component<InfoViewProps> = (props) => {
when={streamingEnabled()}
fallback={
<div class="log-paused-state">
<p class="log-paused-title">Server logs are paused</p>
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p>
<p class="log-paused-title">{t("infoView.logs.paused.title")}</p>
<p class="log-paused-description">{t("infoView.logs.paused.description")}</p>
<button type="button" class="button-primary" onClick={handleEnableLogs}>
Show server logs
{t("infoView.logs.actions.show")}
</button>
</div>
}
>
<Show
when={logs().length > 0}
fallback={<div class="log-empty-state">Waiting for server output...</div>}
fallback={<div class="log-empty-state">{t("infoView.logs.empty.waiting")}</div>}
>
<For each={logs()}>
{(entry) => (
@@ -148,7 +150,7 @@ const InfoView: Component<InfoViewProps> = (props) => {
class="scroll-to-bottom"
>
<ChevronDown class="w-4 h-4" />
Scroll to bottom
{t("infoView.logs.scrollToBottom")}
</button>
</Show>
</div>

View File

@@ -1,4 +1,5 @@
import { Dialog } from "@kobalte/core/dialog"
import { useI18n } from "../lib/i18n"
interface InstanceDisconnectedModalProps {
open: boolean
@@ -8,8 +9,10 @@ interface InstanceDisconnectedModalProps {
}
export default function InstanceDisconnectedModal(props: InstanceDisconnectedModalProps) {
const folderLabel = props.folder || "this workspace"
const reasonLabel = props.reason || "The server stopped responding"
const { t } = useI18n()
const folderLabel = () => props.folder || t("instanceDisconnected.folderFallback")
const reasonLabel = () => props.reason || t("instanceDisconnected.reasonFallback")
return (
<Dialog open={props.open} modal>
@@ -18,25 +21,25 @@ export default function InstanceDisconnectedModal(props: InstanceDisconnectedMod
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Instance Disconnected</Dialog.Title>
<Dialog.Title class="text-xl font-semibold text-primary">{t("instanceDisconnected.title")}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
{folderLabel} can no longer be reached. Close the tab to continue working.
{t("instanceDisconnected.description", { folder: folderLabel() })}
</Dialog.Description>
</div>
<div class="rounded-lg border border-base bg-surface-secondary p-4 text-sm text-secondary">
<p class="font-medium text-primary">Details</p>
<p class="mt-2 text-secondary">{reasonLabel}</p>
<p class="font-medium text-primary">{t("instanceDisconnected.details.title")}</p>
<p class="mt-2 text-secondary">{reasonLabel()}</p>
{props.folder && (
<p class="mt-2 text-secondary">
Folder: <span class="font-mono text-primary break-all">{props.folder}</span>
{t("instanceDisconnected.details.folderLabel")} <span class="font-mono text-primary break-all">{props.folder}</span>
</p>
)}
</div>
<div class="flex justify-end">
<button type="button" class="selector-button selector-button-primary" onClick={props.onClose}>
Close Instance
{t("instanceDisconnected.actions.closeInstance")}
</button>
</div>
</Dialog.Content>

View File

@@ -2,6 +2,7 @@ import { Component, For, Show, createMemo } from "solid-js"
import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status"
import { useI18n } from "../lib/i18n"
interface InstanceInfoProps {
instance: Instance
@@ -9,6 +10,7 @@ interface InstanceInfoProps {
}
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const { t } = useI18n()
const metadataContext = useOptionalInstanceMetadataContext()
const isLoadingMetadata = metadataContext?.isLoading ?? (() => false)
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
@@ -26,11 +28,11 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
return (
<div class="panel">
<div class="panel-header">
<h2 class="panel-title">Instance Information</h2>
<h2 class="panel-title">{t("instanceInfo.title")}</h2>
</div>
<div class="panel-body space-y-3">
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Folder</div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</div>
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
{currentInstance().folder}
</div>
@@ -41,7 +43,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
Project
{t("instanceInfo.labels.project")}
</div>
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
{project().id}
@@ -51,7 +53,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<Show when={project().vcs}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
Version Control
{t("instanceInfo.labels.versionControl")}
</div>
<div class="flex items-center gap-2 text-xs text-primary">
<svg
@@ -73,7 +75,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<Show when={binaryVersion()}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
OpenCode Version
{t("instanceInfo.labels.opencodeVersion")}
</div>
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
v{binaryVersion()}
@@ -84,7 +86,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<Show when={currentInstance().binaryPath}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
Binary Path
{t("instanceInfo.labels.binaryPath")}
</div>
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
{currentInstance().binaryPath}
@@ -95,7 +97,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<Show when={environmentEntries().length > 0}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
Environment Variables ({environmentEntries().length})
{t("instanceInfo.labels.environmentVariables", { count: environmentEntries().length })}
</div>
<div class="space-y-1">
<For each={environmentEntries()}>
@@ -127,24 +129,24 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Loading...
{t("instanceInfo.loading")}
</div>
</div>
</Show>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">Server</div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">{t("instanceInfo.server.title")}</div>
<div class="space-y-1 text-xs">
<div class="flex justify-between items-center">
<span class="text-secondary">Port:</span>
<span class="text-secondary">{t("instanceInfo.server.port")}</span>
<span class="text-primary font-mono">{currentInstance().port}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-secondary">PID:</span>
<span class="text-secondary">{t("instanceInfo.server.pid")}</span>
<span class="text-primary font-mono">{currentInstance().pid}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-secondary">Status:</span>
<span class="text-secondary">{t("instanceInfo.server.status")}</span>
<span class={`status-badge ${currentInstance().status}`}>
<div
class={`status-dot ${currentInstance().status === "ready" ? "ready" : currentInstance().status === "starting" ? "starting" : currentInstance().status === "error" ? "error" : "stopped"} ${currentInstance().status === "ready" || currentInstance().status === "starting" ? "animate-pulse" : ""}`}

View File

@@ -2,6 +2,7 @@ import { For, Show, createMemo, createSignal, type Component } from "solid-js"
import Switch from "@suid/material/Switch"
import type { Instance, RawMcpStatus } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
@@ -42,6 +43,7 @@ function parseMcpStatus(status?: RawMcpStatus): ParsedMcpStatus[] {
}
const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) => {
const { t } = useI18n()
const metadataContext = useOptionalInstanceMetadataContext()
const instance = metadataContext?.instance ?? (() => {
if (props.initialInstance) {
@@ -112,12 +114,12 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
<section class="space-y-1.5">
<Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide">
LSP Servers
{t("instanceServiceStatus.sections.lsp")}
</div>
</Show>
<Show
when={!isLspLoading() && lspServers().length > 0}
fallback={renderEmptyState(isLspLoading() ? "Loading LSP servers..." : "No LSP servers detected.")}
fallback={renderEmptyState(isLspLoading() ? t("instanceServiceStatus.lsp.loading") : t("instanceServiceStatus.lsp.empty"))}
>
<div class="space-y-1.5">
<For each={lspServers()}>
@@ -132,7 +134,11 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
<span>
{server.status === "connected"
? t("instanceServiceStatus.lsp.status.connected")
: t("instanceServiceStatus.lsp.status.error")}
</span>
</div>
</div>
</div>
@@ -147,12 +153,12 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
<section class="space-y-1.5">
<Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide">
MCP Servers
{t("instanceServiceStatus.sections.mcp")}
</div>
</Show>
<Show
when={!isMcpLoading() && mcpServers().length > 0}
fallback={renderEmptyState(isMcpLoading() ? "Loading MCP servers..." : "No MCP servers detected.")}
fallback={renderEmptyState(isMcpLoading() ? t("instanceServiceStatus.mcp.loading") : t("instanceServiceStatus.mcp.empty"))}
>
<div class="space-y-1.5">
<For each={mcpServers()}>
@@ -192,7 +198,7 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
disabled={switchDisabled()}
color="success"
size="small"
inputProps={{ "aria-label": `Toggle ${server.name} MCP server` }}
inputProps={{ "aria-label": t("instanceServiceStatus.mcp.toggleAriaLabel", { name: server.name }) }}
onChange={(_, checked) => {
if (switchDisabled()) return
void toggleMcpServer(server.name, Boolean(checked))
@@ -222,12 +228,12 @@ const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) =>
<section class="space-y-1.5">
<Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide">
Plugins
{t("instanceServiceStatus.sections.plugins")}
</div>
</Show>
<Show
when={!isPluginsLoading() && plugins().length > 0}
fallback={renderEmptyState(isPluginsLoading() ? "Loading plugins..." : "No plugins configured.")}
fallback={renderEmptyState(isPluginsLoading() ? t("instanceServiceStatus.plugins.loading") : t("instanceServiceStatus.plugins.empty"))}
>
<div class="space-y-1.5">
<For each={plugins()}>

View File

@@ -2,6 +2,7 @@ import { Component, createMemo } from "solid-js"
import type { Instance } from "../types/instance"
import { getInstanceSessionIndicatorStatus } from "../stores/session-status"
import { FolderOpen, ShieldAlert, X } from "lucide-solid"
import { useI18n } from "../lib/i18n"
interface InstanceTabProps {
instance: Instance
@@ -27,6 +28,7 @@ function formatFolderName(path: string, instances: Instance[], currentInstance:
}
const InstanceTab: Component<InstanceTabProps> = (props) => {
const { t } = useI18n()
const aggregatedStatus = createMemo(() => getInstanceSessionIndicatorStatus(props.instance.id))
const statusClassName = createMemo(() => {
const status = aggregatedStatus()
@@ -35,13 +37,13 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
const statusTitle = createMemo(() => {
switch (aggregatedStatus()) {
case "permission":
return "Waiting on permission"
return t("instanceTab.status.permission")
case "compacting":
return "Compacting"
return t("instanceTab.status.compacting")
case "working":
return "Working"
return t("instanceTab.status.working")
default:
return "Idle"
return t("instanceTab.status.idle")
}
})
@@ -61,7 +63,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
<span
class={`status-indicator session-status ml-auto ${statusClassName()}`}
title={statusTitle()}
aria-label={`Instance status: ${statusTitle()}`}
aria-label={t("instanceTab.status.ariaLabel", { status: statusTitle() })}
>
{aggregatedStatus() === "permission" ? (
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
@@ -77,7 +79,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
}}
role="button"
tabIndex={0}
aria-label="Close instance"
aria-label={t("instanceTab.actions.close.ariaLabel")}
>
<X class="w-3 h-3" />
</span>

View File

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

View File

@@ -9,6 +9,7 @@ import SessionRenameDialog from "./session-rename-dialog"
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
import { isMac } from "../lib/keyboard-utils"
import { showToastNotification } from "../lib/notifications"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
const log = getLogger("actions")
@@ -19,6 +20,7 @@ interface InstanceWelcomeViewProps {
}
const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
const { t } = useI18n()
const [isCreating, setIsCreating] = createSignal(false)
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
@@ -47,7 +49,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
ctrl: !isMac(),
},
handler: () => {},
description: "New Session",
description: t("instanceWelcome.shortcuts.newSession"),
context: "global",
}
})
@@ -248,10 +250,10 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return "just now"
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
return t("time.relative.justNow")
}
function formatTimestamp(timestamp: number): string {
@@ -291,7 +293,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
setRenameTarget(null)
} catch (error) {
log.error("Failed to rename session:", error)
showToastNotification({ message: "Unable to rename session", variant: "error" })
showToastNotification({ message: t("instanceWelcome.toasts.renameError"), variant: "error" })
} finally {
setIsRenaming(false)
}
@@ -333,11 +335,11 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
/>
</svg>
</div>
<p class="panel-empty-state-title">No Previous Sessions</p>
<p class="panel-empty-state-description">Create a new session below to get started</p>
<p class="panel-empty-state-title">{t("instanceWelcome.empty.title")}</p>
<p class="panel-empty-state-description">{t("instanceWelcome.empty.description")}</p>
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
<button type="button" class="button-tertiary mt-4 lg:hidden" onClick={openInstanceInfoOverlay}>
View Instance Info
{t("instanceWelcome.actions.viewInstanceInfo")}
</button>
</Show>
</div>
@@ -347,8 +349,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<div class="panel-empty-state-icon">
<Loader2 class="w-12 h-12 mx-auto animate-spin text-muted" />
</div>
<p class="panel-empty-state-title">Loading Sessions</p>
<p class="panel-empty-state-description">Fetching your previous sessions...</p>
<p class="panel-empty-state-title">{t("instanceWelcome.loading.title")}</p>
<p class="panel-empty-state-description">{t("instanceWelcome.loading.description")}</p>
</div>
</Show>
}
@@ -357,9 +359,11 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<div class="panel-header">
<div class="flex flex-row flex-wrap items-center gap-2 justify-between">
<div>
<h2 class="panel-title">Resume Session</h2>
<h2 class="panel-title">{t("instanceWelcome.resume.title")}</h2>
<p class="panel-subtitle">
{parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available
{parentSessions().length === 1
? t("instanceWelcome.resume.subtitle.one", { count: parentSessions().length })
: t("instanceWelcome.resume.subtitle.other", { count: parentSessions().length })}
</p>
</div>
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
@@ -368,7 +372,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
class="button-tertiary lg:hidden flex-shrink-0"
onClick={openInstanceInfoOverlay}
>
View Instance Info
{t("instanceWelcome.actions.viewInstanceInfo")}
</button>
</Show>
</div>
@@ -404,7 +408,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
"text-accent": isFocused(),
}}
>
{session.title || "Untitled Session"}
{session.title || t("instanceWelcome.session.untitled")}
</span>
</div>
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
@@ -421,7 +425,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<button
type="button"
class="p-1.5 rounded transition-colors text-muted hover:text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
title="Rename session"
title={t("instanceWelcome.actions.renameTitle")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
@@ -433,7 +437,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<button
type="button"
class="p-1.5 rounded transition-colors text-muted hover:text-red-500 dark:hover:text-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
title="Delete session"
title={t("instanceWelcome.actions.deleteTitle")}
disabled={isSessionDeleting(session.id)}
onClick={(event) => {
event.preventDefault()
@@ -470,8 +474,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<div class="panel flex-shrink-0">
<div class="panel-header">
<h2 class="panel-title">Start New Session</h2>
<p class="panel-subtitle">Well reuse your last agent/model automatically</p>
<h2 class="panel-title">{t("instanceWelcome.new.title")}</h2>
<p class="panel-subtitle">{t("instanceWelcome.new.subtitle")}</p>
</div>
<div class="panel-body">
<div class="space-y-3">
@@ -496,7 +500,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
)}
<span>Create Session</span>
<span>{t("instanceWelcome.new.createButton")}</span>
</div>
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
</button>
@@ -524,7 +528,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
>
<div class="flex justify-end">
<button type="button" class="button-tertiary" onClick={closeInstanceInfoOverlay}>
Close
{t("instanceWelcome.overlay.close")}
</button>
</div>
<div class="max-h-[85vh] overflow-y-auto pr-1">
@@ -541,25 +545,25 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>
<kbd class="kbd"></kbd>
<span>Navigate</span>
<span>{t("instanceWelcome.hints.navigate")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">PgUp</kbd>
<kbd class="kbd">PgDn</kbd>
<span>Jump</span>
<span>{t("instanceWelcome.hints.jump")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Home</kbd>
<kbd class="kbd">End</kbd>
<span>First/Last</span>
<span>{t("instanceWelcome.hints.firstLast")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Enter</kbd>
<span>Resume</span>
<span>{t("instanceWelcome.hints.resume")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd>
<span>Delete</span>
<span>{t("instanceWelcome.hints.delete")}</span>
</div>
</div>
</div>

View File

@@ -12,16 +12,14 @@ import {
} from "solid-js"
import type { ToolState } from "@opencode-ai/sdk"
import { Accordion } from "@kobalte/core"
import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import { ChevronDown, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import AppBar from "@suid/material/AppBar"
import Box from "@suid/material/Box"
import Divider from "@suid/material/Divider"
import Drawer from "@suid/material/Drawer"
import IconButton from "@suid/material/IconButton"
import Toolbar from "@suid/material/Toolbar"
import Typography from "@suid/material/Typography"
import useMediaQuery from "@suid/material/useMediaQuery"
import CloseIcon from "@suid/icons-material/Close"
import MenuIcon from "@suid/icons-material/Menu"
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
import PushPinIcon from "@suid/icons-material/PushPin"
@@ -37,6 +35,7 @@ import {
getSessionFamily,
getSessionInfo,
getSessionThreads,
loadMessages,
sessions,
setActiveParentSession,
setActiveSession,
@@ -48,15 +47,16 @@ import { clearSessionRenderCache } from "../message-block"
import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette"
import SessionList from "../session-list"
import KeyboardHint from "../keyboard-hint"
import Kbd from "../kbd"
import InstanceWelcomeView from "../instance-welcome-view"
import InfoView from "../info-view"
import InstanceServiceStatus from "../instance-service-status"
import AgentSelector from "../agent-selector"
import ModelSelector from "../model-selector"
import ThinkingSelector from "../thinking-selector"
import CommandPalette from "../command-palette"
import PermissionNotificationBanner from "../permission-notification-banner"
import PermissionApprovalModal from "../permission-approval-modal"
import Kbd from "../kbd"
import { TodoListView } from "../tool-call/renderers/todo"
import ContextUsagePanel from "../session/context-usage-panel"
import SessionView from "../session/session-view"
@@ -64,8 +64,10 @@ import { formatTokenTotal } from "../../lib/formatters"
import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger"
import { serverApi } from "../../lib/api-client"
import WorktreeSelector from "../worktree-selector"
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
import { useI18n } from "../../lib/i18n"
import {
SESSION_SIDEBAR_EVENT,
type SessionSidebarRequestAction,
@@ -86,14 +88,13 @@ interface InstanceShellProps {
tabBarOffset: number
}
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
const DEFAULT_SESSION_SIDEBAR_WIDTH = 340
const MIN_SESSION_SIDEBAR_WIDTH = 220
const MAX_SESSION_SIDEBAR_WIDTH = 360
const MAX_SESSION_SIDEBAR_WIDTH = 400
const RIGHT_DRAWER_WIDTH = 260
const MIN_RIGHT_DRAWER_WIDTH = 200
const MAX_RIGHT_DRAWER_WIDTH = 380
const SESSION_CACHE_LIMIT = 5
const APP_BAR_HEIGHT = 56
const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1"
@@ -120,6 +121,8 @@ function persistPinState(side: "left" | "right", value: boolean) {
}
const InstanceShell2: Component<InstanceShellProps> = (props) => {
const { t } = useI18n()
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(RIGHT_DRAWER_WIDTH)
const [leftPinned, setLeftPinned] = createSignal(true)
@@ -149,6 +152,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
// Worktree selector manages its own dialogs.
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
const desktopQuery = useMediaQuery("(min-width: 1280px)")
@@ -211,10 +217,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const host = drawerHost()
if (!host) return
const rect = host.getBoundingClientRect()
const toolbar = host.querySelector<HTMLElement>(".session-toolbar")
const toolbarHeight = toolbar?.offsetHeight ?? APP_BAR_HEIGHT
setFloatingDrawerTop(rect.top + toolbarHeight)
setFloatingDrawerHeight(Math.max(0, rect.height - toolbarHeight))
setFloatingDrawerTop(rect.top)
setFloatingDrawerHeight(Math.max(0, rect.height))
}
onMount(() => {
@@ -356,6 +360,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return "disconnected"
}
const connectionStatusLabel = () => {
const status = connectionStatus()
if (status === "connected") return t("instanceShell.connection.connected")
if (status === "connecting") return t("instanceShell.connection.connecting")
if (status === "error" || status === "disconnected") return t("instanceShell.connection.disconnected")
return t("instanceShell.connection.unknown")
}
const handleCommandPaletteClick = () => {
showCommandPalette(props.instance.id)
}
@@ -432,6 +444,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return true
}
const focusVariantSelectorControl = () => {
const input = leftDrawerContentEl()?.querySelector<HTMLInputElement>("[data-thinking-selector]")
if (!input) return false
input.focus()
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
return true
}
createEffect(() => {
const pending = pendingSidebarAction()
if (!pending) return
@@ -444,7 +464,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
setPendingSidebarAction(null)
return
}
const handled = action === "focus-agent-selector" ? focusAgentSelectorControl() : focusModelSelectorControl()
const handled =
action === "focus-agent-selector"
? focusAgentSelectorControl()
: action === "focus-model-selector"
? focusModelSelectorControl()
: focusVariantSelectorControl()
if (handled) {
setPendingSidebarAction(null)
}
@@ -593,7 +618,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return undefined
}
const fallbackDrawerTop = () => APP_BAR_HEIGHT + props.tabBarOffset
const fallbackDrawerTop = () => props.tabBarOffset
const floatingTop = () => {
const measured = floatingDrawerTop()
if (measured > 0) return measured
@@ -702,28 +727,22 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const leftAppBarButtonLabel = () => {
const state = leftDrawerState()
if (state === "pinned") return "Left drawer pinned"
if (state === "floating-closed") return "Open left drawer"
return "Close left drawer"
if (state === "pinned") return t("instanceShell.leftDrawer.toggle.pinned")
return t("instanceShell.leftDrawer.toggle.open")
}
const rightAppBarButtonLabel = () => {
const state = rightDrawerState()
if (state === "pinned") return "Right drawer pinned"
if (state === "floating-closed") return "Open right drawer"
return "Close right drawer"
if (state === "pinned") return t("instanceShell.rightDrawer.toggle.pinned")
return t("instanceShell.rightDrawer.toggle.open")
}
const leftAppBarButtonIcon = () => {
const state = leftDrawerState()
if (state === "floating-closed") return <MenuIcon fontSize="small" />
return <MenuOpenIcon fontSize="small" />
return <MenuIcon fontSize="small" />
}
const rightAppBarButtonIcon = () => {
const state = rightDrawerState()
if (state === "floating-closed") return <MenuIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
return <MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
return <MenuIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
}
@@ -771,29 +790,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const handleLeftAppBarButtonClick = () => {
const state = leftDrawerState()
if (state === "pinned") return
if (state === "floating-closed") {
setLeftOpen(true)
measureDrawerHost()
return
}
blurIfInside(leftDrawerContentEl())
setLeftOpen(false)
focusTarget(leftToggleButtonEl())
if (state !== "floating-closed") return
setLeftOpen(true)
measureDrawerHost()
}
const handleRightAppBarButtonClick = () => {
const state = rightDrawerState()
if (state === "pinned") return
if (state === "floating-closed") {
setRightOpen(true)
measureDrawerHost()
return
}
blurIfInside(rightDrawerContentEl())
setRightOpen(false)
focusTarget(rightToggleButtonEl())
if (state !== "floating-closed") return
setRightOpen(true)
measureDrawerHost()
}
@@ -839,37 +844,66 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const LeftDrawerContent = () => (
<div class="flex flex-col h-full min-h-0" ref={setLeftDrawerContentEl}>
<div class="flex items-start justify-between gap-2 px-4 py-3 border-b border-base">
<div class="flex flex-col gap-1">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
<div class="session-sidebar-shortcuts">
<Show when={keyboardShortcuts().length}>
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
</Show>
</div>
</div>
<div class="flex items-center gap-2">
<IconButton
size="small"
color="inherit"
aria-label="Instance Info"
title="Instance Info"
onClick={() => handleSessionSelect("info")}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
<Show when={!isPhoneLayout()}>
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
<div class="flex items-center justify-between gap-2">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
{t("instanceShell.leftPanel.sessionsTitle")}
</span>
<div class="flex items-center gap-2 text-primary">
<IconButton
size="small"
color="inherit"
aria-label={leftPinned() ? "Unpin left drawer" : "Pin left drawer"}
onClick={() => (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())}
aria-label={t("sessionList.filter.ariaLabel")}
title={t("sessionList.filter.ariaLabel")}
aria-pressed={showSessionSearch()}
onClick={() => setShowSessionSearch((current) => !current)}
sx={{
color: showSessionSearch() ? "var(--text-primary)" : "inherit",
backgroundColor: showSessionSearch() ? "var(--surface-hover)" : "transparent",
"&:hover": {
backgroundColor: "var(--surface-hover)",
},
}}
>
{leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
<Search class={showSessionSearch() ? "w-4 h-4" : "w-4 h-4 opacity-70"} />
</IconButton>
<IconButton
size="small"
color="inherit"
aria-label={t("instanceShell.leftPanel.instanceInfo")}
title={t("instanceShell.leftPanel.instanceInfo")}
onClick={() => handleSessionSelect("info")}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
<Show when={!isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={leftPinned() ? t("instanceShell.leftDrawer.unpin") : t("instanceShell.leftDrawer.pin")}
onClick={() => (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())}
>
{leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
<Show when={leftDrawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={t("instanceShell.leftDrawer.toggle.close")}
title={t("instanceShell.leftDrawer.toggle.close")}
onClick={closeLeftDrawer}
>
<MenuOpenIcon fontSize="small" />
</IconButton>
</Show>
</div>
</div>
<div class="session-sidebar-shortcuts">
<Show when={keyboardShortcuts().length}>
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
</Show>
</div>
</div>
<div class="session-sidebar flex flex-col flex-1 min-h-0">
@@ -884,31 +918,24 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
enableFilterBar={showSessionSearch()}
showHeader={false}
showFooter={false}
/>
<Divider />
<Show when={activeSessionForInstance()}>
{(activeSession) => (
<>
<ContextUsagePanel instanceId={props.instance.id} sessionId={activeSession().id} />
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
<AgentSelector
instanceId={props.instance.id}
sessionId={activeSession().id}
currentAgent={activeSession().agent}
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
/>
<div class="session-sidebar-separator" />
<Show when={activeSessionForInstance()}>
{(activeSession) => (
<>
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
<WorktreeSelector instanceId={props.instance.id} sessionId={activeSession().id} />
<div class="sidebar-selector-hints" aria-hidden="true">
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
<Kbd shortcut="cmd+shift+a" />
</span>
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
<Kbd shortcut="cmd+shift+m" />
</span>
</div>
<AgentSelector
instanceId={props.instance.id}
sessionId={activeSession().id}
currentAgent={activeSession().agent}
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
/>
<ModelSelector
instanceId={props.instance.id}
@@ -916,6 +943,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
currentModel={activeSession().model}
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
/>
<ThinkingSelector instanceId={props.instance.id} currentModel={activeSession().model} />
<div class="session-sidebar-selector-hints" aria-hidden="true">
<Kbd shortcut="cmd+shift+a" />
<Kbd shortcut="cmd+shift+m" />
<Kbd shortcut="cmd+shift+t" />
</div>
</div>
</>
)}
@@ -928,19 +963,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const renderPlanSectionContent = () => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") {
return <p class="text-xs text-secondary">Select a session to view plan.</p>
return <p class="text-xs text-secondary">{t("instanceShell.plan.noSessionSelected")}</p>
}
const todoState = latestTodoState()
if (!todoState) {
return <p class="text-xs text-secondary">Nothing planned yet.</p>
return <p class="text-xs text-secondary">{t("instanceShell.plan.empty")}</p>
}
return <TodoListView state={todoState} emptyLabel="Nothing planned yet." showStatusLabel={false} />
return <TodoListView state={todoState} emptyLabel={t("instanceShell.plan.empty")} showStatusLabel={false} />
}
const renderBackgroundProcesses = () => {
const processes = backgroundProcessList()
if (processes.length === 0) {
return <p class="text-xs text-secondary">No background processes.</p>
return <p class="text-xs text-secondary">{t("instanceShell.backgroundProcesses.empty")}</p>
}
return (
@@ -951,9 +986,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="flex flex-col gap-1">
<span class="text-xs font-semibold text-primary">{process.title}</span>
<div class="flex flex-wrap gap-2 text-[11px] text-secondary">
<span>Status: {process.status}</span>
<span>{t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
<Show when={typeof process.outputSizeBytes === "number"}>
<span>Output: {Math.round((process.outputSizeBytes ?? 0) / 1024)}KB</span>
<span>
{t("instanceShell.backgroundProcesses.output", {
sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024),
})}
</span>
</Show>
</div>
</div>
@@ -962,8 +1001,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
onClick={() => openBackgroundOutput(process)}
aria-label="Output"
title="Output"
aria-label={t("instanceShell.backgroundProcesses.actions.output")}
title={t("instanceShell.backgroundProcesses.actions.output")}
>
<TerminalSquare class="h-4 w-4" />
</button>
@@ -972,8 +1011,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
disabled={process.status !== "running"}
onClick={() => stopBackgroundProcess(process.id)}
aria-label="Stop"
title="Stop"
aria-label={t("instanceShell.backgroundProcesses.actions.stop")}
title={t("instanceShell.backgroundProcesses.actions.stop")}
>
<XOctagon class="h-4 w-4" />
</button>
@@ -981,8 +1020,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
onClick={() => terminateBackgroundProcess(process.id)}
aria-label="Terminate"
title="Terminate"
aria-label={t("instanceShell.backgroundProcesses.actions.terminate")}
title={t("instanceShell.backgroundProcesses.actions.terminate")}
>
<Trash2 class="h-4 w-4" />
</button>
@@ -997,17 +1036,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const sections = [
{
id: "plan",
label: "Plan",
labelKey: "instanceShell.rightPanel.sections.plan",
render: renderPlanSectionContent,
},
{
id: "background-processes",
label: "Background Shells",
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
render: renderBackgroundProcesses,
},
{
id: "mcp",
label: "MCP Servers",
labelKey: "instanceShell.rightPanel.sections.mcp",
render: () => (
<InstanceServiceStatus
initialInstance={props.instance}
@@ -1019,7 +1058,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
},
{
id: "lsp",
label: "LSP Servers",
labelKey: "instanceShell.rightPanel.sections.lsp",
render: () => (
<InstanceServiceStatus
initialInstance={props.instance}
@@ -1031,7 +1070,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
},
{
id: "plugins",
label: "Plugins",
labelKey: "instanceShell.rightPanel.sections.plugins",
render: () => (
<InstanceServiceStatus
initialInstance={props.instance}
@@ -1057,22 +1096,46 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return (
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
<div class="flex items-center justify-between px-4 py-2 border-b border-base">
<Typography variant="subtitle2" class="uppercase tracking-wide text-xs font-semibold">
Status Panel
</Typography>
<div class="flex items-center gap-2">
<Show when={!isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={rightPinned() ? "Unpin right drawer" : "Pin right drawer"}
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())}
>
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
<div class="border-b border-base text-primary">
<div class="relative flex items-center px-4 py-2">
<div class="flex items-center gap-2">
<Show when={rightDrawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={t("instanceShell.rightDrawer.toggle.close")}
title={t("instanceShell.rightDrawer.toggle.close")}
onClick={closeRightDrawer}
>
<MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
</IconButton>
</Show>
<Show when={!isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={rightPinned() ? t("instanceShell.rightDrawer.unpin") : t("instanceShell.rightDrawer.pin")}
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())}
>
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
</div>
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
{t("instanceShell.rightPanel.title")}
</span>
</div>
</div>
<Show when={activeSessionForInstance()}>
{(activeSession) => (
<ContextUsagePanel
instanceId={props.instance.id}
sessionId={activeSession().id}
class="border-t border-base"
/>
)}
</Show>
</div>
<div class="flex-1 overflow-y-auto">
<Accordion.Root
@@ -1090,7 +1153,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
>
<Accordion.Header>
<Accordion.Trigger class="w-full flex items-center justify-between gap-3 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide">
<span>{section.label}</span>
<span>{t(section.labelKey)}</span>
<ChevronDown
class={`h-4 w-4 transition-transform duration-150 ${isSectionExpanded(section.id) ? "rotate-180" : ""}`}
/>
@@ -1233,19 +1296,93 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const sessionLayout = (
<div
class="session-shell-panels flex flex-col flex-1 min-h-0 overflow-x-hidden"
class="session-shell-panels flex flex-1 min-h-0 overflow-x-hidden"
ref={(element) => {
setDrawerHost(element)
measureDrawerHost()
}}
>
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
<Show
when={!isPhoneLayout()}
fallback={
<div class="flex flex-col w-full gap-1.5">
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
{renderLeftPanel()}
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}>
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
<Show
when={!isPhoneLayout()}
fallback={
<div class="flex flex-col w-full gap-1.5">
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
<Show when={leftDrawerState() === "floating-closed"}>
<IconButton
ref={setLeftToggleButtonEl}
color="inherit"
onClick={handleLeftAppBarButtonClick}
aria-label={leftAppBarButtonLabel()}
size="small"
aria-expanded={leftDrawerState() !== "floating-closed"}
>
{leftAppBarButtonIcon()}
</IconButton>
</Show>
<div class="flex flex-wrap items-center gap-1 justify-center">
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
<button
type="button"
class="connection-status-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick}
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }}
>
{t("instanceShell.commandPalette.button")}
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
</span>
<span
class={`status-indicator ${connectionStatusClass()}`}
aria-label={t("instanceShell.connection.ariaLabel", { status: connectionStatusLabel() })}
>
<span class="status-dot" />
</span>
</div>
<Show when={rightDrawerState() === "floating-closed"}>
<IconButton
ref={setRightToggleButtonEl}
color="inherit"
onClick={handleRightAppBarButtonClick}
aria-label={rightAppBarButtonLabel()}
size="small"
aria-expanded={rightDrawerState() !== "floating-closed"}
>
{rightAppBarButtonIcon()}
</IconButton>
</Show>
</div>
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.usedLabel")}
</span>
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.availableLabel")}
</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div>
</div>
</div>
}
>
<div class="session-toolbar-left flex items-center gap-3 min-w-0">
<Show when={leftDrawerState() === "floating-closed"}>
<IconButton
ref={setLeftToggleButtonEl}
color="inherit"
@@ -1253,38 +1390,68 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
aria-label={leftAppBarButtonLabel()}
size="small"
aria-expanded={leftDrawerState() !== "floating-closed"}
disabled={leftDrawerState() === "pinned"}
>
{leftAppBarButtonIcon()}
</IconButton>
</Show>
<div class="flex flex-wrap items-center gap-1 justify-center">
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
<button
type="button"
class="connection-status-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick}
aria-label="Open command palette"
style={{ flex: "0 0 auto", width: "auto" }}
>
Command Palette
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
<Show when={!showingInfoView()}>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.usedLabel")}
</span>
<span
class={`status-indicator ${connectionStatusClass()}`}
aria-label={`Connection ${connectionStatus()}`}
>
<span class="status-dot" />
</span>
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.availableLabel")}
</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div>
</Show>
</div>
<div class="session-toolbar-center flex-1 flex items-center justify-center gap-2 min-w-[160px]">
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
<button
type="button"
class="connection-status-button px-2 py-0.5 text-xs"
onClick={handleCommandPaletteClick}
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }}
>
{t("instanceShell.commandPalette.button")}
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
</span>
</div>
<div class="session-toolbar-right flex items-center gap-3">
<div class="connection-status-meta flex items-center gap-3">
<Show when={connectionStatus() === "connected"}>
<span class="status-indicator connected">
<span class="status-dot" />
<span class="status-text">{t("instanceShell.connection.connected")}</span>
</span>
</Show>
<Show when={connectionStatus() === "connecting"}>
<span class="status-indicator connecting">
<span class="status-dot" />
<span class="status-text">{t("instanceShell.connection.connecting")}</span>
</span>
</Show>
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
<span class="status-indicator disconnected">
<span class="status-dot" />
<span class="status-text">{t("instanceShell.connection.disconnected")}</span>
</span>
</Show>
</div>
<Show when={rightDrawerState() === "floating-closed"}>
<IconButton
ref={setRightToggleButtonEl}
color="inherit"
@@ -1292,112 +1459,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
aria-label={rightAppBarButtonLabel()}
size="small"
aria-expanded={rightDrawerState() !== "floating-closed"}
disabled={rightDrawerState() === "pinned"}
>
{rightAppBarButtonIcon()}
</IconButton>
</div>
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
<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">Used</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">Avail</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">Used</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">Avail</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="Open command palette"
style={{ flex: "0 0 auto", width: "auto" }}
>
Command Palette
</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">Connected</span>
</span>
</Show>
<Show when={connectionStatus() === "connecting"}>
<span class="status-indicator connecting">
<span class="status-dot" />
<span class="status-text">Connecting...</span>
</span>
</Show>
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
<span class="status-indicator disconnected">
<span class="status-dot" />
<span class="status-text">Disconnected</span>
</span>
</Show>
</div>
<IconButton
ref={setRightToggleButtonEl}
color="inherit"
onClick={handleRightAppBarButtonClick}
aria-label={rightAppBarButtonLabel()}
size="small"
aria-expanded={rightDrawerState() !== "floating-closed"}
disabled={rightDrawerState() === "pinned"}
>
{rightAppBarButtonIcon()}
</IconButton>
</div>
</Show>
</Toolbar>
</AppBar>
<Box sx={{ display: "flex", flex: 1, minHeight: 0, overflowX: "hidden" }}>
{renderLeftPanel()}
</Show>
</Toolbar>
</AppBar>
<Box
component="main"
@@ -1412,8 +1481,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500 dark:text-gray-400">
<p class="mb-2">No session selected</p>
<p class="text-sm">Select a session to view messages</p>
<p class="mb-2">{t("instanceShell.empty.title")}</p>
<p class="text-sm">{t("instanceShell.empty.description")}</p>
</div>
</div>
}
@@ -1451,9 +1520,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</div>
</Show>
</Box>
{renderRightPanel()}
</Box>
{renderRightPanel()}
</div>
)

View File

@@ -1,6 +1,7 @@
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { ChevronDown } from "lucide-solid"
import { useI18n } from "../lib/i18n"
interface LogsViewProps {
instanceId: string
@@ -9,6 +10,7 @@ interface LogsViewProps {
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
const LogsView: Component<LogsViewProps> = (props) => {
const { t } = useI18n()
let scrollRef: HTMLDivElement | undefined
const savedState = logsScrollState.get(props.instanceId)
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
@@ -83,18 +85,18 @@ const LogsView: Component<LogsViewProps> = (props) => {
return (
<div class="log-container">
<div class="log-header">
<h3 class="text-sm font-medium" style="color: var(--text-secondary)">Server Logs</h3>
<h3 class="text-sm font-medium" style="color: var(--text-secondary)">{t("logsView.title")}</h3>
<div class="flex items-center gap-2">
<Show
when={streamingEnabled()}
fallback={
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
Show server logs
{t("logsView.actions.show")}
</button>
}
>
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
Hide server logs
{t("logsView.actions.hide")}
</button>
</Show>
</div>
@@ -103,7 +105,7 @@ const LogsView: Component<LogsViewProps> = (props) => {
<Show when={instance()?.environmentVariables && Object.keys(instance()?.environmentVariables!).length > 0}>
<div class="env-vars-container">
<div class="env-vars-title">
Environment Variables ({Object.keys(instance()?.environmentVariables!).length})
{t("logsView.envVars.title", { count: Object.keys(instance()?.environmentVariables!).length })}
</div>
<div class="space-y-1">
<For each={Object.entries(instance()?.environmentVariables!)}>
@@ -130,17 +132,17 @@ const LogsView: Component<LogsViewProps> = (props) => {
when={streamingEnabled()}
fallback={
<div class="log-paused-state">
<p class="log-paused-title">Server logs are paused</p>
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p>
<p class="log-paused-title">{t("logsView.paused.title")}</p>
<p class="log-paused-description">{t("logsView.paused.description")}</p>
<button type="button" class="button-primary" onClick={handleEnableLogs}>
Show server logs
{t("logsView.actions.show")}
</button>
</div>
}
>
<Show
when={logs().length > 0}
fallback={<div class="log-empty-state">Waiting for server output...</div>}
fallback={<div class="log-empty-state">{t("logsView.empty.waiting")}</div>}
>
<For each={logs()}>
{(entry) => (
@@ -160,7 +162,7 @@ const LogsView: Component<LogsViewProps> = (props) => {
class="scroll-to-bottom"
>
<ChevronDown class="w-4 h-4" />
Scroll to bottom
{t("logsView.scrollToBottom")}
</button>
</Show>
</div>

View File

@@ -1,9 +1,10 @@
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 type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"
const log = getLogger("session")
@@ -34,6 +35,7 @@ interface MarkdownProps {
}
export function Markdown(props: MarkdownProps) {
const { t } = useI18n()
const [html, setHtml] = createSignal("")
let containerRef: HTMLDivElement | undefined
let latestRequestedText = ""
@@ -70,6 +72,9 @@ export function Markdown(props: MarkdownProps) {
createEffect(async () => {
const { part, text, themeKey, highlightEnabled, version } = resolved()
// Ensure the markdown highlighter theme matches the active UI theme.
setMarkdownTheme(themeKey === "dark")
latestRequestedText = text
const cacheMatches = (cache: RenderCache | undefined) => {
@@ -145,14 +150,14 @@ export function Markdown(props: MarkdownProps) {
const copyText = copyButton.querySelector(".copy-text")
if (copyText) {
if (success) {
copyText.textContent = "Copied!"
copyText.textContent = t("markdown.codeBlock.copy.copied")
setTimeout(() => {
copyText.textContent = "Copy"
copyText.textContent = t("markdown.codeBlock.copy.label")
}, 2000)
} else {
copyText.textContent = "Failed"
copyText.textContent = t("markdown.codeBlock.copy.failed")
setTimeout(() => {
copyText.textContent = "Copy"
copyText.textContent = t("markdown.codeBlock.copy.label")
}, 2000)
}
}
@@ -169,6 +174,8 @@ export function Markdown(props: MarkdownProps) {
const { part, text, themeKey, version } = resolved()
setMarkdownTheme(themeKey === "dark")
if (latestRequestedText !== text) {
return
}

View File

@@ -1,5 +1,5 @@
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 ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -11,6 +11,9 @@ import { messageStoreBus } from "../stores/message-v2/bus"
import { formatTokenTotal } from "../lib/formatters"
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances"
import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions"
import { useI18n } from "../lib/i18n"
const TOOL_ICON = "🔧"
const USER_BORDER_COLOR = "var(--message-user-border)"
@@ -171,21 +174,256 @@ messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
interface ContentDisplayItem {
type: "content"
key: string
record: MessageRecord
parts: ClientPart[]
messageInfo?: MessageInfo
isQueued: boolean
showAgentMeta?: boolean
messageId: string
startPartId: string
}
interface ToolDisplayItem {
type: "tool"
key: string
toolPart: ToolCallPart
messageInfo?: MessageInfo
messageId: string
messageVersion: number
partVersion: number
partId: string
}
interface MessageContentItemProps {
instanceId: string
sessionId: string
store: () => InstanceMessageStore
messageId: string
startPartId: string
messageIndex: number
lastAssistantIndex: () => number
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
onContentRendered?: () => void
}
function MessageContentItem(props: MessageContentItemProps) {
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const isQueued = createMemo(() => {
const current = record()
if (!current) return false
if (current.role !== "user") return false
const lastAssistant = props.lastAssistantIndex()
return lastAssistant === -1 || props.messageIndex > lastAssistant
})
const parts = createMemo<ClientPart[]>(() => {
const current = record()
if (!current) return []
const ids = current.partIds
const startIndex = ids.indexOf(props.startPartId)
if (startIndex === -1) return []
const resolved: ClientPart[] = []
for (let idx = startIndex; idx < ids.length; idx++) {
const partId = ids[idx]
const part = current.parts[partId]?.data
if (!part) continue
if (
part.type === "tool" ||
part.type === "reasoning" ||
part.type === "compaction" ||
part.type === "step-start" ||
part.type === "step-finish"
) {
break
}
resolved.push(part)
}
return resolved
})
const showAgentMeta = createMemo(() => {
const current = record()
if (!current) return false
if (current.role !== "assistant") return false
const currentParts = parts()
if (!currentParts.some((part) => partHasRenderableText(part))) {
return false
}
const ids = current.partIds
const startIndex = ids.indexOf(props.startPartId)
if (startIndex === -1) return false
// Only show agent meta on the first content segment that contains renderable content.
for (let idx = 0; idx < startIndex; idx++) {
const partId = ids[idx]
const part = current.parts[partId]?.data
if (!part) continue
if (
part.type === "tool" ||
part.type === "reasoning" ||
part.type === "compaction" ||
part.type === "step-start" ||
part.type === "step-finish"
) {
continue
}
if (partHasRenderableText(part)) {
return false
}
}
return true
})
return (
<Show when={record()}>
{(resolvedRecord) => (
<MessageItem
record={resolvedRecord()}
messageInfo={messageInfo()}
parts={parts()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={isQueued()}
showAgentMeta={showAgentMeta()}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
/>
)}
</Show>
)
}
interface ToolCallItemProps {
instanceId: string
sessionId: string
store: () => InstanceMessageStore
messageId: string
partId: string
onContentRendered?: () => void
}
function ToolCallItem(props: ToolCallItemProps) {
const { t } = useI18n()
const [deleting, setDeleting] = createSignal(false)
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const partEntry = createMemo(() => record()?.parts?.[props.partId])
const toolPart = createMemo(() => {
const part = partEntry()?.data as ClientPart | undefined
if (!part || part.type !== "tool") return undefined
return part as ToolCallPart
})
const toolState = createMemo(() => toolPart()?.state as ToolState | undefined)
const toolName = createMemo(() => toolPart()?.tool || "")
const messageVersion = createMemo(() => record()?.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 state = toolState()
if (!state) return ""
if (!(isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))) {
return ""
}
return extractTaskSessionId(state)
})
const taskLocation = createMemo(() => {
const id = taskSessionId()
if (!id) return null
return findTaskSessionLocation(id, props.instanceId)
})
const handleGoToTaskSession = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const location = taskLocation()
if (!location) return
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 (
<Show when={toolPart()}>
{(resolvedToolPart) => (
<>
<div class="tool-call-header-label">
<div class="tool-call-header-meta">
<span class="tool-call-icon">{TOOL_ICON}</span>
<span>{t("messageBlock.tool.header")}</span>
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
</div>
<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
class="tool-call-header-button"
type="button"
disabled={deleteDisabled()}
onClick={handleDeleteToolPart}
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")}
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</div>
<ToolCall
toolCall={resolvedToolPart()}
toolCallId={props.partId}
messageId={props.messageId}
messageVersion={messageVersion()}
partVersion={partVersion()}
instanceId={props.instanceId}
sessionId={props.sessionId}
onContentRendered={props.onContentRendered}
/>
</>
)}
</Show>
)
}
interface StepDisplayItem {
@@ -203,6 +441,8 @@ type ReasoningDisplayItem = {
messageInfo?: MessageInfo
showAgentMeta?: boolean
defaultExpanded: boolean
messageId: string
partId: string
}
type CompactionDisplayItem = {
@@ -211,6 +451,8 @@ type CompactionDisplayItem = {
part: ClientPart
messageInfo?: MessageInfo
accentColor?: string
messageId: string
partId: string
}
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem
@@ -236,6 +478,7 @@ interface MessageBlockProps {
}
export default function MessageBlock(props: MessageBlockProps) {
const { t } = useI18n()
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
@@ -270,7 +513,6 @@ export default function MessageBlock(props: MessageBlockProps) {
const items: MessageBlockItem[] = []
const blockContentKeys: string[] = []
const blockToolKeys: string[] = []
let segmentIndex = 0
let pendingParts: ClientPart[] = []
let agentMetaAttached = current.role !== "assistant"
const defaultAccentColor = current.role === "user" ? USER_BORDER_COLOR : ASSISTANT_BORDER_COLOR
@@ -278,34 +520,28 @@ export default function MessageBlock(props: MessageBlockProps) {
const flushContent = () => {
if (pendingParts.length === 0) return
const segmentKey = `${current.id}:segment:${segmentIndex}`
segmentIndex += 1
const shouldShowAgentMeta =
current.role === "assistant" &&
!agentMetaAttached &&
pendingParts.some((part) => partHasRenderableText(part))
const startPartId = typeof (pendingParts[0] as any)?.id === "string" ? ((pendingParts[0] as any).id as string) : ""
if (!startPartId) {
pendingParts = []
return
}
if (!agentMetaAttached && pendingParts.some((part) => partHasRenderableText(part))) {
agentMetaAttached = true
}
const segmentKey = `${current.id}:content:${startPartId}`
let cached = sessionCache.messageItems.get(segmentKey)
if (!cached) {
cached = {
type: "content",
key: segmentKey,
record: current,
parts: pendingParts.slice(),
messageInfo: info,
isQueued,
showAgentMeta: shouldShowAgentMeta,
messageId: current.id,
startPartId,
}
sessionCache.messageItems.set(segmentKey, cached)
} else {
cached.record = current
cached.parts = pendingParts.slice()
cached.messageInfo = info
cached.isQueued = isQueued
cached.showAgentMeta = shouldShowAgentMeta
}
if (shouldShowAgentMeta) {
agentMetaAttached = true
}
items.push(cached)
blockContentKeys.push(segmentKey)
lastAccentColor = defaultAccentColor
@@ -315,28 +551,26 @@ export default function MessageBlock(props: MessageBlockProps) {
orderedParts.forEach((part, partIndex) => {
if (part.type === "tool") {
flushContent()
const partVersion = typeof (part as any).revision === "number" ? (part as any).revision : 0
const messageVersion = current.revision
const key = `${current.id}:${part.id ?? partIndex}`
const partId = part.id
if (!partId) {
// Tool parts are required to have ids; if one slips through, skip rendering
// to avoid unstable keys and accidental remount cascades.
return
}
const key = `${current.id}:${partId}`
let toolItem = sessionCache.toolItems.get(key)
if (!toolItem) {
toolItem = {
type: "tool",
key,
toolPart: part as ToolCallPart,
messageInfo: info,
messageId: current.id,
messageVersion,
partVersion,
partId,
}
sessionCache.toolItems.set(key, toolItem)
} else {
toolItem.key = key
toolItem.toolPart = part as ToolCallPart
toolItem.messageInfo = info
toolItem.messageId = current.id
toolItem.messageVersion = messageVersion
toolItem.partVersion = partVersion
toolItem.partId = partId
}
items.push(toolItem)
blockToolKeys.push(key)
@@ -346,7 +580,8 @@ export default function MessageBlock(props: MessageBlockProps) {
if (part.type === "compaction") {
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)
items.push({
type: "compaction",
@@ -354,6 +589,8 @@ export default function MessageBlock(props: MessageBlockProps) {
part,
messageInfo: info,
accentColor: isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR,
messageId: current.id,
partId,
})
lastAccentColor = isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR
return
@@ -378,7 +615,8 @@ export default function MessageBlock(props: MessageBlockProps) {
if (part.type === "reasoning") {
flushContent()
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
if (showAgentMeta) {
agentMetaAttached = true
@@ -390,6 +628,8 @@ export default function MessageBlock(props: MessageBlockProps) {
messageInfo: info,
showAgentMeta,
defaultExpanded: props.thinkingDefaultExpanded(),
messageId: current.id,
partId,
})
lastAccentColor = ASSISTANT_BORDER_COLOR
}
@@ -425,21 +665,21 @@ export default function MessageBlock(props: MessageBlockProps) {
})
return (
<Show when={block()} keyed>
<Show when={block()}>
{(resolvedBlock) => (
<div class="message-stream-block" data-message-id={resolvedBlock.record.id}>
<For each={resolvedBlock.items}>
<div class="message-stream-block" data-message-id={resolvedBlock().record.id}>
<For each={resolvedBlock().items}>
{(item) => (
<Switch>
<Match when={item.type === "content"}>
<MessageItem
record={(item as ContentDisplayItem).record}
messageInfo={(item as ContentDisplayItem).messageInfo}
parts={(item as ContentDisplayItem).parts}
<MessageContentItem
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={(item as ContentDisplayItem).isQueued}
showAgentMeta={(item as ContentDisplayItem).showAgentMeta}
store={props.store}
messageId={(item as ContentDisplayItem).messageId}
startPartId={(item as ContentDisplayItem).startPartId}
messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
@@ -448,46 +688,14 @@ export default function MessageBlock(props: MessageBlockProps) {
<Match when={item.type === "tool"}>
{(() => {
const toolItem = item as ToolDisplayItem
const toolState = toolItem.toolPart.state as ToolState | undefined
const hasToolState =
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId, props.instanceId) : null
const handleGoToTaskSession = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!taskLocation) return
navigateToTaskSession(taskLocation)
}
return (
<div class="tool-call-message" data-key={toolItem.key}>
<div class="tool-call-header-label">
<div class="tool-call-header-meta">
<span class="tool-call-icon">{TOOL_ICON}</span>
<span>Tool Call</span>
<span class="tool-name">{toolItem.toolPart.tool || "unknown"}</span>
</div>
<Show when={taskSessionId}>
<button
class="tool-call-header-button"
type="button"
disabled={!taskLocation}
onClick={handleGoToTaskSession}
title={!taskLocation ? "Session not available yet" : "Go to session"}
>
Go to Session
</button>
</Show>
</div>
<ToolCall
toolCall={toolItem.toolPart}
toolCallId={toolItem.toolPart.id}
messageId={toolItem.messageId}
messageVersion={toolItem.messageVersion}
partVersion={toolItem.partVersion}
<ToolCallItem
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageId={toolItem.messageId}
partId={toolItem.partId}
onContentRendered={props.onContentRendered}
/>
</div>
@@ -495,7 +703,12 @@ export default function MessageBlock(props: MessageBlockProps) {
})()}
</Match>
<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 when={item.type === "step-finish"}>
<StepCard
@@ -507,7 +720,15 @@ export default function MessageBlock(props: MessageBlockProps) {
/>
</Match>
<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 when={item.type === "reasoning"}>
<ReasoningCard
@@ -515,6 +736,8 @@ export default function MessageBlock(props: MessageBlockProps) {
messageInfo={(item as ReasoningDisplayItem).messageInfo}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={(item as ReasoningDisplayItem).messageId}
partId={(item as ReasoningDisplayItem).partId}
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
/>
@@ -537,21 +760,63 @@ interface StepCardProps {
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 [deleting, setDeleting] = createSignal(false)
const isAuto = () => Boolean((props.part as any)?.auto)
const label = () => (isAuto() ? "Session auto-compacted" : "Session compacted by you")
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 containerClass = () =>
`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 (
<div
class={containerClass()}
class={`${containerClass()} relative`}
style={{ "border-left": `4px solid ${borderColor()}` }}
role="status"
aria-label="Session compaction"
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">
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
<span class="message-compaction-label">{label()}</span>
@@ -561,6 +826,7 @@ function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; bo
}
function StepCard(props: StepCardProps) {
const { t } = useI18n()
const timestamp = () => {
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
const date = new Date(value)
@@ -605,14 +871,15 @@ function StepCard(props: StepCardProps) {
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
const entries = [
{ label: "Input", value: usage.input, formatter: formatTokenTotal },
{ label: "Output", value: usage.output, formatter: formatTokenTotal },
{ label: "Reasoning", value: usage.reasoning, formatter: formatTokenTotal },
{ label: "Cache Read", value: usage.cacheRead, formatter: formatTokenTotal },
{ label: "Cache Write", value: usage.cacheWrite, formatter: formatTokenTotal },
{ label: "Cost", value: usage.cost, formatter: formatCostValue },
{ label: t("messageBlock.usage.input"), value: usage.input, formatter: formatTokenTotal },
{ label: t("messageBlock.usage.output"), value: usage.output, formatter: formatTokenTotal },
{ label: t("messageBlock.usage.reasoning"), value: usage.reasoning, formatter: formatTokenTotal },
{ label: t("messageBlock.usage.cacheRead"), value: usage.cacheRead, formatter: formatTokenTotal },
{ label: t("messageBlock.usage.cacheWrite"), value: usage.cacheWrite, formatter: formatTokenTotal },
{ label: t("messageBlock.usage.cost"), value: usage.cost, formatter: formatCostValue },
]
return (
@@ -647,8 +914,8 @@ function StepCard(props: StepCardProps) {
<div class="message-step-title-left">
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
<Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show>
<Show when={modelIdentifier()}>{(value) => <span>{t("messageBlock.step.modelLabel", { model: value() })}</span>}</Show>
</span>
</Show>
</div>
@@ -670,12 +937,16 @@ interface ReasoningCardProps {
messageInfo?: MessageInfo
instanceId: string
sessionId: string
messageId: string
partId: string
showAgentMeta?: boolean
defaultExpanded?: boolean
}
function ReasoningCard(props: ReasoningCardProps) {
const { t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deleting, setDeleting] = createSignal(false)
createEffect(() => {
setExpanded(Boolean(props.defaultExpanded))
@@ -739,6 +1010,27 @@ function ReasoningCard(props: ReasoningCardProps) {
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 (
<div class="message-reasoning-card">
<button
@@ -746,19 +1038,48 @@ function ReasoningCard(props: ReasoningCardProps) {
class="message-reasoning-toggle"
onClick={toggle}
aria-expanded={expanded()}
aria-label={expanded() ? "Collapse thinking" : "Expand thinking"}
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
>
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
<span>Thinking</span>
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Agent: {value()}</span>}</Show>
<Show when={modelIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Model: {value()}</span>}</Show>
<Show when={agentIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span>
</Show>
</span>
<span class="message-reasoning-meta">
<span class="message-reasoning-indicator">{expanded() ? "Hide" : "View"}</span>
<span class="message-reasoning-indicator">
{expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")}
</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>
</button>
@@ -766,7 +1087,7 @@ function ReasoningCard(props: ReasoningCardProps) {
<Show when={expanded()}>
<div class="message-reasoning-expanded">
<div class="message-reasoning-body">
<div class="message-reasoning-output" role="region" aria-label="Reasoning details">
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
<pre class="message-reasoning-text">{reasoningText() || ""}</pre>
</div>
</div>

View File

@@ -1,9 +1,13 @@
import { For, Show, createSignal } from "solid-js"
import { Copy, Split, Trash2, Undo } from "lucide-solid"
import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part"
import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions"
interface MessageItemProps {
record: MessageRecord
@@ -19,7 +23,9 @@ interface MessageItemProps {
}
export default function MessageItem(props: MessageItemProps) {
const { t } = useI18n()
const [copied, setCopied] = createSignal(false)
const [deletingParts, setDeletingParts] = createSignal<Set<string>>(new Set())
const isUser = () => props.record.role === "user"
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
@@ -49,15 +55,15 @@ export default function MessageItem(props: MessageItemProps) {
}
const url = part.url || ""
if (url.startsWith("data:")) {
return "attachment"
return t("messageItem.attachment.defaultName")
}
try {
const parsed = new URL(url)
const segments = parsed.pathname.split("/")
return segments.pop() || "attachment"
return segments.pop() || t("messageItem.attachment.defaultName")
} catch (error) {
const fallback = url.split("/").pop()
return fallback && fallback.length > 0 ? fallback : "attachment"
return fallback && fallback.length > 0 ? fallback : t("messageItem.attachment.defaultName")
}
}
@@ -112,16 +118,16 @@ export default function MessageItem(props: MessageItemProps) {
const error = info.error
if (error.name === "ProviderAuthError") {
return error.data?.message || "Authentication error"
return error.data?.message || t("messageItem.errors.authenticationFallback")
}
if (error.name === "MessageOutputLengthError") {
return "Message output length exceeded"
return t("messageItem.errors.outputLengthExceeded")
}
if (error.name === "MessageAbortedError") {
return "Request was aborted"
return t("messageItem.errors.requestAborted")
}
if (error.name === "UnknownError") {
return error.data?.message || "Unknown error occurred"
return error.data?.message || t("messageItem.errors.unknownFallback")
}
return null
}
@@ -135,8 +141,17 @@ export default function MessageItem(props: MessageItemProps) {
}
const isGenerating = () => {
if (hasContent()) {
return false
}
// Prefer the local record status for streaming placeholders.
if (!isUser() && props.record.status === "streaming") {
return true
}
const info = props.messageInfo
return !hasContent() && info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0
return Boolean(info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0)
}
const handleRevert = () => {
@@ -145,6 +160,8 @@ export default function MessageItem(props: MessageItemProps) {
}
}
const copyLabel = () => (copied() ? t("messageItem.actions.copied") : t("messageItem.actions.copy"))
const getRawContent = () => {
return props.parts
.filter(part => part.type === "text")
@@ -161,7 +178,51 @@ export default function MessageItem(props: MessageItemProps) {
setTimeout(() => setCopied(false), 2000)
}
if (!isUser() && !hasContent()) {
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()) {
return null
}
@@ -170,7 +231,7 @@ export default function MessageItem(props: MessageItemProps) {
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
const speakerLabel = () => (isUser() ? "You" : "Assistant")
const speakerLabel = () => (isUser() ? t("messageItem.speaker.you") : t("messageItem.speaker.assistant"))
const agentIdentifier = () => {
if (isUser()) return ""
@@ -195,10 +256,10 @@ export default function MessageItem(props: MessageItemProps) {
const agent = agentIdentifier()
const model = modelIdentifier()
if (agent) {
segments.push(`Agent: ${agent}`)
segments.push(t("messageItem.agentMeta.agentLabel", { agent }))
}
if (model) {
segments.push(`Model: ${model}`)
segments.push(t("messageItem.agentMeta.modelLabel", { model }))
}
return segments.join(" • ")
}
@@ -220,45 +281,70 @@ export default function MessageItem(props: MessageItemProps) {
<button
class="message-action-button"
onClick={handleRevert}
title="Revert to this message"
aria-label="Revert to this message"
title={t("messageItem.actions.revert")}
aria-label={t("messageItem.actions.revert")}
>
Revert
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<Show when={props.onFork}>
<button
class="message-action-button"
onClick={() => props.onFork?.(props.record.id)}
title="Fork from this message"
aria-label="Fork from this message"
title={t("messageItem.actions.fork")}
aria-label={t("messageItem.actions.fork")}
>
Fork
<Split class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<button
class="message-action-button"
onClick={handleCopy}
title="Copy message"
aria-label="Copy message"
title={copyLabel()}
aria-label={copyLabel()}
>
<Show when={copied()} fallback="Copy">
Copied!
</Show>
<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>
</div>
</Show>
<Show when={!isUser()}>
<button
class="message-action-button"
onClick={handleCopy}
title="Copy message"
aria-label="Copy message"
>
<Show when={copied()} fallback="Copy">
Copied!
<div class="message-action-group">
<button
class="message-action-button"
onClick={handleCopy}
title={copyLabel()}
aria-label={copyLabel()}
>
<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>
</button>
</div>
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div>
@@ -269,7 +355,7 @@ export default function MessageItem(props: MessageItemProps) {
<Show when={props.isQueued && isUser()}>
<div class="message-queued-badge">QUEUED</div>
<div class="message-queued-badge">{t("messageItem.status.queued")}</div>
</Show>
<Show when={errorMessage()}>
@@ -278,7 +364,7 @@ export default function MessageItem(props: MessageItemProps) {
<Show when={isGenerating()}>
<div class="message-generating">
<span class="generating-spinner"></span> Generating...
<span class="generating-spinner"></span> {t("messageItem.status.generating")}
</div>
</Show>
@@ -319,13 +405,26 @@ export default function MessageItem(props: MessageItemProps) {
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={`Download ${name}`}
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
>
<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="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</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}>
<div class="attachment-chip-preview">
<img src={attachment.url} alt={name} />
@@ -340,12 +439,12 @@ export default function MessageItem(props: MessageItemProps) {
<Show when={props.record.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
<span class="generating-spinner"></span> {t("messageItem.status.sending")}
</div>
</Show>
<Show when={props.record.status === "error"}>
<div class="message-error"> Message failed to send</div>
<div class="message-error"> {t("messageItem.status.failedToSend")}</div>
</Show>
</div>
</div>

View File

@@ -1,8 +1,9 @@
import { Show } from "solid-js"
import Kbd from "./kbd"
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_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-primary/70"
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
interface MessageListHeaderProps {
usedTokens: number
@@ -17,6 +18,7 @@ interface MessageListHeaderProps {
}
export default function MessageListHeader(props: MessageListHeaderProps) {
const { t } = useI18n()
const hasAvailableTokens = () => typeof props.availableTokens === "number"
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
@@ -29,7 +31,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
type="button"
class="session-sidebar-menu-button"
onClick={() => props.onSidebarToggle?.()}
aria-label="Open session list"
aria-label={t("messageListHeader.sidebar.openSessionListAriaLabel")}
>
<span aria-hidden="true" class="session-sidebar-menu-icon"></span>
</button>
@@ -39,11 +41,11 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
<div class="connection-status-text connection-status-info">
<div class="connection-status-usage">
<div class={METRIC_CHIP_CLASS}>
<span class={METRIC_LABEL_CLASS}>Used</span>
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.usedLabel")}</span>
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
</div>
<div class={METRIC_CHIP_CLASS}>
<span class={METRIC_LABEL_CLASS}>Avail</span>
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.availableLabel")}</span>
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
</div>
</div>
@@ -51,8 +53,13 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
<div class="connection-status-text connection-status-shortcut">
<div class="connection-status-shortcut-action">
<button type="button" class="connection-status-button" onClick={props.onCommandPalette} aria-label="Open command palette">
Command Palette
<button
type="button"
class="connection-status-button"
onClick={props.onCommandPalette}
aria-label={t("messageListHeader.commandPalette.ariaLabel")}
>
{t("messageListHeader.commandPalette.button")}
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
@@ -64,19 +71,19 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
<Show when={props.connectionStatus === "connected"}>
<span class="status-indicator connected">
<span class="status-dot" />
<span class="status-text">Connected</span>
<span class="status-text">{t("messageListHeader.connection.connected")}</span>
</span>
</Show>
<Show when={props.connectionStatus === "connecting"}>
<span class="status-indicator connecting">
<span class="status-dot" />
<span class="status-text">Connecting...</span>
<span class="status-text">{t("messageListHeader.connection.connecting")}</span>
</span>
</Show>
<Show when={props.connectionStatus === "error" || props.connectionStatus === "disconnected"}>
<span class="status-indicator disconnected">
<span class="status-dot" />
<span class="status-text">Disconnected</span>
<span class="status-text">{t("messageListHeader.connection.disconnected")}</span>
</span>
</Show>
</div>

View File

@@ -15,7 +15,7 @@ interface MessagePartProps {
sessionId: string
onRendered?: () => void
}
export default function MessagePart(props: MessagePartProps) {
export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme()
const { preferences } = useConfig()
@@ -25,6 +25,14 @@ interface MessagePartProps {
const isAssistantMessage = () => props.messageType === "assistant"
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
const shouldHideTextPart = () => {
const part = props.part
if (!part || part.type !== "text") return false
// Keep optimistic user prompts visible; hide synthetic assistant text.
return Boolean((part as any).synthetic) && props.messageType !== "user"
}
const plainTextContent = () => {
const part = props.part
@@ -94,23 +102,23 @@ interface MessagePartProps {
return (
<Switch>
<Match when={partType() === "text"}>
<Show when={!(props.part.type === "text" && props.part.synthetic) && partHasRenderableText(props.part)}>
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div class={textContainerClass()}>
<Show
when={isAssistantMessage()}
fallback={<span>{plainTextContent()}</span>}
>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered}
/>
</Show>
<Show
when={isAssistantMessage()}
fallback={<span class="text-primary">{plainTextContent()}</span>}
>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered}
/>
</Show>
</div>
</div>
</Show>
</Match>

View File

@@ -6,6 +6,9 @@ import { useConfig } from "../stores/preferences"
import { getSessionInfo } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus"
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
import { useI18n } from "../lib/i18n"
import { copyToClipboard } from "../lib/clipboard"
import { showToastNotification } from "../lib/notifications"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
const SCROLL_SCOPE = "session"
@@ -31,6 +34,7 @@ export interface MessageSectionProps {
export default function MessageSection(props: MessageSectionProps) {
const { preferences } = useConfig()
const { t } = useI18n()
const showUsagePreference = () => preferences().showUsageMetrics ?? true
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
@@ -92,6 +96,9 @@ export default function MessageSection(props: MessageSectionProps) {
const seenTimelineMessageIds = new Set<string>()
const seenTimelineSegmentKeys = new Set<string>()
const timelinePartCountsByMessageId = new Map<string, number>()
let pendingTimelineMessagePartUpdates = new Set<string>()
let pendingTimelinePartUpdateFrame: number | null = null
function makeTimelineKey(segment: TimelineSegment) {
return `${segment.messageId}:${segment.id}:${segment.type}`
@@ -100,6 +107,7 @@ export default function MessageSection(props: MessageSectionProps) {
function seedTimeline() {
seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear()
timelinePartCountsByMessageId.clear()
const ids = untrack(messageIds)
const resolvedStore = untrack(store)
const segments: TimelineSegment[] = []
@@ -107,7 +115,8 @@ export default function MessageSection(props: MessageSectionProps) {
const record = resolvedStore.getMessage(messageId)
if (!record) return
seenTimelineMessageIds.add(messageId)
const built = buildTimelineSegments(props.instanceId, record)
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
const built = buildTimelineSegments(props.instanceId, record, t)
built.forEach((segment) => {
const key = makeTimelineKey(segment)
if (seenTimelineSegmentKeys.has(key)) return
@@ -121,7 +130,8 @@ export default function MessageSection(props: MessageSectionProps) {
function appendTimelineForMessage(messageId: string) {
const record = untrack(() => store().getMessage(messageId))
if (!record) return
const built = buildTimelineSegments(props.instanceId, record)
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
const built = buildTimelineSegments(props.instanceId, record, t)
if (built.length === 0) return
const newSegments: TimelineSegment[] = []
built.forEach((segment) => {
@@ -373,7 +383,9 @@ export default function MessageSection(props: MessageSectionProps) {
const anchorRect = rects.length > 0 ? rects[0] : range.getBoundingClientRect()
const shellRect = shell.getBoundingClientRect()
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)
setQuoteSelection({ text: limited, top: relativeTop, left: relativeLeft })
}
@@ -392,6 +404,24 @@ export default function MessageSection(props: MessageSectionProps) {
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() {
if (props.loading) {
@@ -466,8 +496,6 @@ export default function MessageSection(props: MessageSectionProps) {
})
let previousTimelineIds: string[] = []
let previousLastTimelineMessageId: string | null = null
let previousLastTimelinePartCount = 0
createEffect(() => {
const loading = Boolean(props.loading)
@@ -475,11 +503,15 @@ export default function MessageSection(props: MessageSectionProps) {
if (loading) {
previousTimelineIds = []
previousLastTimelineMessageId = null
previousLastTimelinePartCount = 0
setTimelineSegments([])
seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear()
timelinePartCountsByMessageId.clear()
pendingTimelineMessagePartUpdates.clear()
if (pendingTimelinePartUpdateFrame !== null) {
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
pendingTimelinePartUpdateFrame = null
}
return
}
@@ -521,6 +553,14 @@ export default function MessageSection(props: MessageSectionProps) {
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next
})
// Keep part count tracking in sync with id replacement.
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
if (existingPartCount !== undefined) {
timelinePartCountsByMessageId.delete(oldId)
timelinePartCountsByMessageId.set(newId, existingPartCount)
}
previousTimelineIds = ids.slice()
return
}
@@ -544,30 +584,95 @@ export default function MessageSection(props: MessageSectionProps) {
previousTimelineIds = ids.slice()
})
function clearPendingTimelinePartUpdateFrame() {
if (pendingTimelinePartUpdateFrame !== null) {
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
pendingTimelinePartUpdateFrame = null
}
}
function scheduleTimelinePartUpdateFlush() {
if (pendingTimelinePartUpdateFrame !== null) return
pendingTimelinePartUpdateFrame = requestAnimationFrame(() => {
pendingTimelinePartUpdateFrame = null
if (pendingTimelineMessagePartUpdates.size === 0) return
const changedIds = Array.from(pendingTimelineMessagePartUpdates)
pendingTimelineMessagePartUpdates = new Set<string>()
const ids = messageIds()
const resolvedStore = store()
setTimelineSegments((prev) => {
let next = prev
for (const changedId of changedIds) {
// Remove old segments for this message.
next = next.filter((segment) => segment.messageId !== changedId)
const record = resolvedStore.getMessage(changedId)
const rebuilt = record ? buildTimelineSegments(props.instanceId, record, t) : []
// Insert rebuilt segments in the correct place based on session message order.
if (rebuilt.length > 0) {
let insertAt = next.length
const changedIndex = ids.indexOf(changedId)
if (changedIndex >= 0) {
for (let i = changedIndex + 1; i < ids.length; i++) {
const followingId = ids[i]
const existingIndex = next.findIndex((segment) => segment.messageId === followingId)
if (existingIndex >= 0) {
insertAt = existingIndex
break
}
}
}
next = [...next.slice(0, insertAt), ...rebuilt, ...next.slice(insertAt)]
}
}
// Rebuild the segment key set since we may have removed/replaced segments.
seenTimelineSegmentKeys.clear()
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next
})
})
}
// Keep timeline segments in sync when message parts are added/removed.
// Part deletion does not remove message ids from the session, so we must
// explicitly replace segments for messages whose part count changed.
createEffect(() => {
if (props.loading) return
const ids = messageIds()
if (ids.length === 0) return
const lastId = ids[ids.length - 1]
if (!lastId) return
const record = store().getMessage(lastId)
if (!record) return
const partCount = record.partIds.length
if (lastId === previousLastTimelineMessageId && partCount === previousLastTimelinePartCount) {
return
const resolvedStore = store()
let hasChanges = false
for (const messageId of ids) {
const record = resolvedStore.getMessage(messageId)
const partCount = record?.partIds.length ?? 0
const previousCount = timelinePartCountsByMessageId.get(messageId)
if (previousCount === undefined) {
timelinePartCountsByMessageId.set(messageId, partCount)
continue
}
if (previousCount !== partCount) {
timelinePartCountsByMessageId.set(messageId, partCount)
pendingTimelineMessagePartUpdates.add(messageId)
hasChanges = true
}
}
previousLastTimelineMessageId = lastId
previousLastTimelinePartCount = partCount
const built = buildTimelineSegments(props.instanceId, record)
const newSegments: TimelineSegment[] = []
built.forEach((segment) => {
const key = makeTimelineKey(segment)
if (seenTimelineSegmentKeys.has(key)) return
seenTimelineSegmentKeys.add(key)
newSegments.push(segment)
})
if (newSegments.length > 0) {
setTimelineSegments((prev) => [...prev, ...newSegments])
// Drop tracking for ids that are no longer present.
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
if (!ids.includes(trackedId)) {
timelinePartCountsByMessageId.delete(trackedId)
}
}
if (hasChanges) {
scheduleTimelinePartUpdateFlush()
}
})
@@ -734,6 +839,7 @@ export default function MessageSection(props: MessageSectionProps) {
cancelAnimationFrame(pendingAnchorScroll)
}
clearScrollToBottomFrames()
clearPendingTimelinePartUpdateFrame()
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
}
@@ -753,19 +859,19 @@ export default function MessageSection(props: MessageSectionProps) {
<div class="empty-state">
<div class="empty-state-content">
<div class="flex flex-col items-center gap-3 mb-6">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
<img src={codeNomadLogo} alt={t("messageSection.empty.logoAlt")} class="h-48 w-auto" loading="lazy" />
<h1 class="text-3xl font-semibold text-primary">{t("messageSection.empty.brandTitle")}</h1>
</div>
<h3>Start a conversation</h3>
<p>Type a message below or open the Command Palette:</p>
<h3>{t("messageSection.empty.title")}</h3>
<p>{t("messageSection.empty.description")}</p>
<ul>
<li>
<span>Command Palette</span>
<span>{t("messageSection.empty.tips.commandPalette")}</span>
<Kbd shortcut="cmd+shift+p" class="ml-2" />
</li>
<li>Ask about your codebase</li>
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
<li>
Attach files with <code>@</code>
{t("messageSection.empty.tips.attachFilesPrefix")} <code>@</code>
</li>
</ul>
</div>
@@ -775,7 +881,7 @@ export default function MessageSection(props: MessageSectionProps) {
<Show when={props.loading}>
<div class="loading-state">
<div class="spinner" />
<p>Loading messages...</p>
<p>{t("messageSection.loading.messages")}</p>
</div>
</Show>
@@ -803,7 +909,7 @@ export default function MessageSection(props: MessageSectionProps) {
<Show when={showScrollTopButton() || showScrollBottomButton()}>
<div class="message-scroll-button-wrapper">
<Show when={showScrollTopButton()}>
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label="Scroll to first message">
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={t("messageSection.scroll.toFirstAriaLabel")}>
<span class="message-scroll-icon" aria-hidden="true"></span>
</button>
</Show>
@@ -812,7 +918,7 @@ export default function MessageSection(props: MessageSectionProps) {
type="button"
class="message-scroll-button"
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
aria-label="Scroll to latest message"
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
>
<span class="message-scroll-icon" aria-hidden="true"></span>
</button>
@@ -828,10 +934,13 @@ export default function MessageSection(props: MessageSectionProps) {
>
<div class="message-quote-button-group">
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("quote")}>
Add as quote
{t("messageSection.quote.addAsQuote")}
</button>
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
Add as code
{t("messageSection.quote.addAsCode")}
</button>
<button type="button" class="message-quote-button" onClick={() => void handleCopySelectionRequest()}>
{t("messageSection.quote.copy")}
</button>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import type { MessageRecord } from "../stores/message-v2/types"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getToolIcon } from "./tool-call/utils"
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
import { useI18n } from "../lib/i18n"
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
@@ -29,14 +30,6 @@ interface MessageTimelineProps {
showToolSegments?: boolean
}
const SEGMENT_LABELS: Record<TimelineSegmentType, string> = {
user: "You",
assistant: "Asst",
tool: "Tool",
compaction: "Compaction",
}
const TOOL_FALLBACK_LABEL = "Tool Call"
const MAX_TOOLTIP_LENGTH = 220
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -90,7 +83,7 @@ function collectReasoningText(part: ClientPart): string {
return ""
}
function collectTextFromPart(part: ClientPart): string {
function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string {
if (!part) return ""
if (typeof (part as any).text === "string") {
return (part as any).text as string
@@ -106,26 +99,28 @@ function collectTextFromPart(part: ClientPart): string {
}
if (part.type === "file") {
const filename = (part as any)?.filename
return typeof filename === "string" && filename.length > 0 ? `[File] ${filename}` : "Attachment"
return typeof filename === "string" && filename.length > 0
? t("messageTimeline.text.filePrefix", { filename })
: t("messageTimeline.text.attachment")
}
return ""
}
function getToolTitle(part: ToolCallPart): string {
function getToolTitle(part: ToolCallPart, t: (key: string, params?: Record<string, unknown>) => string): string {
const metadata = (((part as unknown as { state?: { metadata?: unknown } })?.state?.metadata) || {}) as { title?: unknown }
const title = typeof metadata.title === "string" && metadata.title.length > 0 ? metadata.title : undefined
if (title) return title
if (typeof part.tool === "string" && part.tool.length > 0) {
return part.tool
}
return TOOL_FALLBACK_LABEL
return t("messageTimeline.tool.fallbackLabel")
}
function getToolTypeLabel(part: ToolCallPart): string {
function getToolTypeLabel(part: ToolCallPart, t: (key: string, params?: Record<string, unknown>) => string): string {
if (typeof part.tool === "string" && part.tool.trim().length > 0) {
return part.tool.trim().slice(0, 4)
}
return TOOL_FALLBACK_LABEL.slice(0, 4)
return t("messageTimeline.tool.fallbackLabel").slice(0, 4)
}
function formatTextsTooltip(texts: string[], fallback: string): string {
@@ -139,20 +134,34 @@ function formatTextsTooltip(texts: string[], fallback: string): string {
return fallback
}
function formatToolTooltip(titles: string[]): string {
function formatToolTooltip(
titles: string[],
t: (key: string, params?: Record<string, unknown>) => string,
): string {
if (titles.length === 0) {
return TOOL_FALLBACK_LABEL
return t("messageTimeline.tool.fallbackLabel")
}
return truncateText(`${TOOL_FALLBACK_LABEL}: ${titles.join(", ")}`)
return truncateText(`${t("messageTimeline.tool.fallbackLabel")}: ${titles.join(", ")}`)
}
export function buildTimelineSegments(instanceId: string, record: MessageRecord): TimelineSegment[] {
export function buildTimelineSegments(
instanceId: string,
record: MessageRecord,
t: (key: string, params?: Record<string, unknown>) => string,
): TimelineSegment[] {
if (!record) return []
const { orderedParts } = buildRecordDisplayData(instanceId, record)
if (!orderedParts || orderedParts.length === 0) {
return []
}
const segmentLabel = (type: TimelineSegmentType) => {
if (type === "user") return t("messageTimeline.segment.user.label")
if (type === "assistant") return t("messageTimeline.segment.assistant.label")
if (type === "compaction") return t("messageTimeline.segment.compaction.label")
return t("messageTimeline.tool.fallbackLabel").slice(0, 4)
}
const result: TimelineSegment[] = []
let segmentIndex = 0
let pending: PendingSegment | null = null
@@ -164,14 +173,14 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
}
const isToolSegment = pending.type === "tool"
const label = isToolSegment
? pending.toolTypeLabels[0] || TOOL_FALLBACK_LABEL.slice(0, 4)
: SEGMENT_LABELS[pending.type]
? pending.toolTypeLabels[0] || segmentLabel("tool")
: segmentLabel(pending.type)
const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined
const tooltip = isToolSegment
? formatToolTooltip(pending.toolTitles)
? formatToolTooltip(pending.toolTitles, t)
: formatTextsTooltip(
[...pending.texts, ...pending.reasoningTexts],
pending.type === "user" ? "User message" : "Assistant response",
pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
)
result.push({
@@ -204,8 +213,8 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
if (part.type === "tool") {
const target = ensureSegment("tool")
const toolPart = part as ToolCallPart
target.toolTitles.push(getToolTitle(toolPart))
target.toolTypeLabels.push(getToolTypeLabel(toolPart))
target.toolTitles.push(getToolTitle(toolPart, t))
target.toolTypeLabels.push(getToolTypeLabel(toolPart, t))
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
if (typeof toolPart.id === "string" && toolPart.id.length > 0) {
target.toolPartIds.push(toolPart.id)
@@ -230,8 +239,8 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
id: `${record.id}:${segmentIndex}`,
messageId: record.id,
type: "compaction",
label: SEGMENT_LABELS.compaction,
tooltip: isAuto ? "Auto Compaction" : "User Compaction",
label: segmentLabel("compaction"),
tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
variant: isAuto ? "auto" : "manual",
})
segmentIndex += 1
@@ -242,7 +251,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
continue
}
const text = collectTextFromPart(part)
const text = collectTextFromPart(part, t)
if (text.trim().length === 0) continue
const target = ensureSegment(defaultContentType)
if (target) {
@@ -258,6 +267,7 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
}
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
const { t } = useI18n()
const buttonRefs = new Map<string, HTMLButtonElement>()
const store = () => messageStoreBus.getOrCreate(props.instanceId)
const [hoveredSegment, setHoveredSegment] = createSignal<TimelineSegment | null>(null)
@@ -266,6 +276,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
const [tooltipSize, setTooltipSize] = createSignal<{ width: number; height: number }>({ width: 360, height: 420 })
const [tooltipElement, setTooltipElement] = createSignal<HTMLDivElement | null>(null)
let hoverTimer: number | null = null
let closeTimer: number | null = null
const showTools = () => props.showToolSegments ?? true
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
@@ -282,10 +293,30 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
hoverTimer = null
}
}
const clearCloseTimer = () => {
if (closeTimer !== null && typeof window !== "undefined") {
window.clearTimeout(closeTimer)
closeTimer = null
}
}
const scheduleClose = () => {
if (typeof window === "undefined") return
clearHoverTimer()
clearCloseTimer()
// Small delay so the pointer can travel from the segment to the tooltip.
closeTimer = window.setTimeout(() => {
closeTimer = null
setHoveredSegment(null)
setHoverAnchorRect(null)
}, 160)
}
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
if (typeof window === "undefined") return
clearHoverTimer()
clearCloseTimer()
const target = event.currentTarget as HTMLButtonElement
hoverTimer = window.setTimeout(() => {
const rect = target.getBoundingClientRect()
@@ -295,9 +326,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
}
const handleMouseLeave = () => {
clearHoverTimer()
setHoveredSegment(null)
setHoverAnchorRect(null)
scheduleClose()
}
createEffect(() => {
@@ -316,7 +345,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
setTooltipCoords({ top: clampedTop, left: clampedLeft })
})
onCleanup(() => clearHoverTimer())
onCleanup(() => {
clearHoverTimer()
clearCloseTimer()
})
createEffect(() => {
const activeId = props.activeMessageId
@@ -360,7 +392,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
})
return (
<div class="message-timeline" role="navigation" aria-label="Message timeline">
<div class="message-timeline" role="navigation" aria-label={t("messageTimeline.ariaLabel")}>
<For each={props.segments}>
{(segment) => {
onCleanup(() => buttonRefs.delete(segment.id))
@@ -422,6 +454,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
ref={(element) => setTooltipElement(element)}
class="message-timeline-tooltip"
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
onMouseEnter={() => clearCloseTimer()}
onMouseLeave={() => scheduleClose()}
>
<MessagePreview
messageId={data().messageId}
@@ -438,4 +472,3 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
}
export default MessageTimeline

View File

@@ -1,9 +1,11 @@
import { Combobox } from "@kobalte/core/combobox"
import { createEffect, createMemo, createSignal } from "solid-js"
import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import { ChevronDown, Star } from "lucide-solid"
import type { Model } from "../types/session"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
const log = getLogger("session")
@@ -21,10 +23,22 @@ interface FlatModel extends Model {
}
export default function ModelSelector(props: ModelSelectorProps) {
const { t } = useI18n()
const instanceProviders = () => providers().get(props.instanceId) || []
const [isOpen, setIsOpen] = createSignal(false)
const [manualAll, setManualAll] = createSignal(false)
const [explicitFavorites, setExplicitFavorites] = createSignal(false)
const [autoFavoritesEligibleAtOpen, setAutoFavoritesEligibleAtOpen] = createSignal(false)
const [searchDirty, setSearchDirty] = createSignal(false)
const [initialQuery, setInitialQuery] = createSignal("")
const [initialQueryReady, setInitialQueryReady] = createSignal(false)
const [inputValue, setInputValue] = createSignal("")
let triggerRef!: HTMLButtonElement
let searchInputRef!: HTMLInputElement
let listboxRef!: HTMLUListElement
let suppressNextClose = false
let wasFavoritesOnlyEnabled = false
let wasCurrentModelFavorite = false
createEffect(() => {
if (instanceProviders().length === 0) {
@@ -43,61 +57,232 @@ export default function ModelSelector(props: ModelSelectorProps) {
),
)
const favoriteKeySet = createMemo(() => {
const result = new Set<string>()
for (const item of preferences().modelFavorites ?? []) {
if (item.providerId && item.modelId) {
result.add(`${item.providerId}/${item.modelId}`)
}
}
return result
})
const favoriteModels = createMemo<FlatModel[]>(() => {
const keys = favoriteKeySet()
if (keys.size === 0) return []
return allModels().filter((m) => keys.has(m.key))
})
const hasFavorites = createMemo(() => favoriteModels().length > 0)
const currentModelValue = createMemo(() =>
allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId),
)
const currentModelIsFavorite = createMemo(() => {
const current = props.currentModel
return favoriteKeySet().has(`${current.providerId}/${current.modelId}`)
})
const currentModelKey = createMemo(() => {
const current = props.currentModel
return `${current.providerId}/${current.modelId}`
})
const searchActive = createMemo(() => {
if (!searchDirty()) return false
const next = inputValue().trim()
return next.length > 0
})
const favoritesOnlyEnabled = createMemo(() => {
if (searchActive()) return false
if (manualAll()) return false
if (!hasFavorites()) return false
return explicitFavorites() || autoFavoritesEligibleAtOpen()
})
const visibleOptions = createMemo<FlatModel[]>(() => {
if (!favoritesOnlyEnabled()) {
return allModels()
}
return favoriteModels()
})
const handleChange = async (value: FlatModel | null) => {
if (!value) return
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
}
const customFilter = (option: FlatModel, inputValue: string) => {
return option.searchText.toLowerCase().includes(inputValue.toLowerCase())
const customFilter = (option: FlatModel, rawInput: string) => {
if (!searchDirty()) return true
return option.searchText.toLowerCase().includes(rawInput.toLowerCase())
}
createEffect(() => {
if (isOpen()) {
setManualAll(false)
setExplicitFavorites(false)
setAutoFavoritesEligibleAtOpen(hasFavorites() && currentModelIsFavorite())
setSearchDirty(false)
setInitialQuery("")
setInputValue("")
setInitialQueryReady(false)
setTimeout(() => {
const seeded = searchInputRef?.value ?? ""
setInitialQuery(seeded)
setInputValue(seeded)
setInitialQueryReady(true)
searchInputRef?.focus()
searchInputRef?.select()
}, 100)
} else {
setInitialQueryReady(false)
setSearchDirty(false)
setAutoFavoritesEligibleAtOpen(false)
}
})
createEffect(() => {
if (!isOpen()) {
wasFavoritesOnlyEnabled = favoritesOnlyEnabled()
wasCurrentModelFavorite = currentModelIsFavorite()
return
}
const nowFavoritesOnlyEnabled = favoritesOnlyEnabled()
const nowCurrentModelFavorite = currentModelIsFavorite()
if (wasFavoritesOnlyEnabled && !nowFavoritesOnlyEnabled && wasCurrentModelFavorite && !nowCurrentModelFavorite) {
setTimeout(() => {
const key = currentModelKey()
const target = listboxRef?.querySelector(`[data-key="${key}"]`) as HTMLElement | null
target?.scrollIntoView({ block: "nearest" })
}, 0)
}
wasFavoritesOnlyEnabled = nowFavoritesOnlyEnabled
wasCurrentModelFavorite = nowCurrentModelFavorite
})
const handleSearchInput = (event: InputEvent & { currentTarget: HTMLInputElement }) => {
const next = event.currentTarget.value
setInputValue(next)
if (!initialQueryReady()) return
if (searchDirty()) return
if (next !== initialQuery()) {
setSearchDirty(true)
}
}
const preventListboxPress = (event: PointerEvent | MouseEvent) => {
event.preventDefault()
event.stopImmediatePropagation?.()
event.stopPropagation()
suppressNextClose = true
setTimeout(() => {
suppressNextClose = false
}, 0)
}
const toggleFavoritesOnly = () => {
if (!hasFavorites()) return
if (searchActive()) return
if (favoritesOnlyEnabled()) {
setManualAll(true)
setExplicitFavorites(false)
setAutoFavoritesEligibleAtOpen(false)
return
}
setExplicitFavorites(true)
setManualAll(false)
}
const showAllModels = () => {
setManualAll(true)
setExplicitFavorites(false)
setAutoFavoritesEligibleAtOpen(false)
setTimeout(() => searchInputRef?.focus(), 0)
}
return (
<div class="sidebar-selector">
<Combobox<FlatModel>
open={isOpen()}
value={currentModelValue()}
onChange={handleChange}
onOpenChange={setIsOpen}
options={allModels()}
onOpenChange={(next) => {
if (!next && suppressNextClose) return
setIsOpen(next)
}}
options={visibleOptions()}
optionValue="key"
optionTextValue="searchText"
optionLabel="name"
placeholder="Search models..."
placeholder={t("modelSelector.placeholder.search")}
defaultFilter={customFilter}
allowsEmptyCollection
itemComponent={(itemProps) => (
<Combobox.Item
item={itemProps.item}
class="selector-option"
>
<div class="selector-option-content">
<Combobox.ItemLabel class="selector-option-label">
{itemProps.item.rawValue.name}
</Combobox.ItemLabel>
<Combobox.ItemDescription class="selector-option-description">
{itemProps.item.rawValue.providerName} {itemProps.item.rawValue.providerId}/
{itemProps.item.rawValue.id}
</Combobox.ItemDescription>
</div>
<Combobox.ItemIndicator class="selector-option-indicator">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</Combobox.ItemIndicator>
</Combobox.Item>
)}
itemComponent={(itemProps) => {
const isFavorite = () => favoriteKeySet().has(itemProps.item.rawValue.key)
return (
<Combobox.Item
item={itemProps.item}
class="selector-option"
>
<>
<div class="selector-option-content">
<Combobox.ItemLabel class="selector-option-label">{itemProps.item.rawValue.name}</Combobox.ItemLabel>
<Combobox.ItemDescription class="selector-option-description">
{itemProps.item.rawValue.providerName} {itemProps.item.rawValue.providerId}/{itemProps.item.rawValue.id}
</Combobox.ItemDescription>
</div>
<Combobox.ItemIndicator class="selector-option-indicator">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</Combobox.ItemIndicator>
<button
type="button"
class="selector-option-star"
data-active={isFavorite()}
aria-label={
isFavorite()
? t("modelSelector.favorite.remove")
: t("modelSelector.favorite.add")
}
onPointerDown={preventListboxPress}
onPointerUp={preventListboxPress}
onMouseDown={preventListboxPress}
onMouseUp={preventListboxPress}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
event.stopPropagation()
suppressNextClose = true
setTimeout(() => {
suppressNextClose = false
}, 0)
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleFavoriteModelPreference({
providerId: itemProps.item.rawValue.providerId,
modelId: itemProps.item.rawValue.id,
})
}}
>
<Star
class="w-4 h-4"
fill={isFavorite() ? "currentColor" : "none"}
/>
</button>
</>
</Combobox.Item>
)
}}
>
<Combobox.Control class="relative w-full" data-model-selector-control>
<Combobox.Input class="sr-only" data-model-selector />
@@ -105,11 +290,11 @@ export default function ModelSelector(props: ModelSelectorProps) {
ref={triggerRef}
class="selector-trigger"
>
<div class="selector-trigger-label selector-trigger-label--stacked">
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
<span class="selector-trigger-primary selector-trigger-primary--align-left">
Model: {currentModelValue()?.name ?? "None"}
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
</span>
{currentModelValue() && (
{currentModelValue() && (
<span class="selector-trigger-secondary">
{currentModelValue()!.providerId}/{currentModelValue()!.id}
</span>
@@ -124,13 +309,53 @@ export default function ModelSelector(props: ModelSelectorProps) {
<Combobox.Portal>
<Combobox.Content class="selector-popover">
<div class="selector-search-container">
<Combobox.Input
ref={searchInputRef}
class="selector-search-input"
placeholder="Search models..."
/>
<div class="selector-input-group">
<Combobox.Input
ref={searchInputRef}
class="selector-search-input flex-1 min-w-0"
placeholder={t("modelSelector.placeholder.search")}
onInput={handleSearchInput}
/>
<button
type="button"
class="selector-favorites-toggle"
aria-label={t("modelSelector.favoritesOnly.toggle.ariaLabel")}
aria-pressed={favoritesOnlyEnabled()}
disabled={!hasFavorites() || searchActive()}
data-active={favoritesOnlyEnabled()}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleFavoritesOnly()
}}
>
<Star class="w-4 h-4" fill={favoritesOnlyEnabled() ? "currentColor" : "none"} />
</button>
</div>
</div>
<Combobox.Listbox ref={listboxRef} class="selector-listbox" />
<div class="selector-footer">
<button
type="button"
class="selector-option selector-option-action w-full"
style={{ display: favoritesOnlyEnabled() && !searchActive() ? "flex" : "none" }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
showAllModels()
}}
>
<span class="selector-option-label">{t("modelSelector.favoritesOnly.showAll")}</span>
</button>
</div>
<Combobox.Listbox class="selector-listbox" />
</Combobox.Content>
</Combobox.Portal>
</Combobox>

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

@@ -4,6 +4,7 @@ import { useConfig } from "../stores/preferences"
import { serverApi } from "../lib/api-client"
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
const log = getLogger("actions")
@@ -23,6 +24,7 @@ interface OpenCodeBinarySelectorProps {
}
const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) => {
const { t } = useI18n()
const {
opencodeBinaries,
addOpenCodeBinary,
@@ -103,7 +105,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
}
if (validatingPaths().has(path)) {
return { valid: false, error: "Already validating" }
return { valid: false, error: t("opencodeBinarySelector.validation.alreadyValidating") }
}
try {
@@ -139,7 +141,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
setValidationError(null)
if (nativeDialogsAvailable) {
const selected = await openNativeFileDialog({
title: "Select OpenCode Binary",
title: t("opencodeBinarySelector.dialog.title"),
})
if (selected) {
setCustomPath(selected)
@@ -160,7 +162,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
setCustomPath("")
setValidationError(null)
} else {
setValidationError(validation.error || "Invalid OpenCode binary")
setValidationError(validation.error || t("opencodeBinarySelector.validation.invalidBinary"))
}
}
@@ -202,14 +204,14 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return "just now"
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
return t("time.relative.justNow")
}
function getDisplayName(path: string): string {
if (path === "opencode") return "opencode (system PATH)"
if (path === "opencode") return t("opencodeBinarySelector.display.systemPath", { name: "opencode" })
const parts = path.split(/[/\\]/)
return parts[parts.length - 1] ?? path
}
@@ -221,13 +223,13 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
<div class="panel">
<div class="panel-header flex items-center justify-between gap-3">
<div>
<h3 class="panel-title">OpenCode Binary</h3>
<p class="panel-subtitle">Choose which executable OpenCode should run</p>
<h3 class="panel-title">{t("opencodeBinarySelector.title")}</h3>
<p class="panel-subtitle">{t("opencodeBinarySelector.subtitle")}</p>
</div>
<Show when={validating()}>
<div class="selector-loading text-xs">
<Loader2 class="selector-loading-spinner" />
<span>Checking versions</span>
<span>{t("opencodeBinarySelector.status.checkingVersions")}</span>
</div>
</Show>
</div>
@@ -245,7 +247,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
}
}}
disabled={props.disabled}
placeholder="Enter path to opencode binary…"
placeholder={t("opencodeBinarySelector.customPath.placeholder")}
class="selector-input"
/>
<button
@@ -255,7 +257,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
class="selector-button selector-button-primary"
>
<Plus class="w-4 h-4" />
Add
{t("opencodeBinarySelector.actions.add")}
</button>
</div>
@@ -266,7 +268,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
>
<FolderOpen class="w-4 h-4" />
Browse for Binary
{t("opencodeBinarySelector.actions.browse")}
</button>
<Show when={validationError()}>
@@ -308,16 +310,16 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
</Show>
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
<Show when={versionLabel()}>
<span class="selector-badge-version">v{versionLabel()}</span>
<span class="selector-badge-version">{t("opencodeBinarySelector.versionLabel", { version: versionLabel() })}</span>
</Show>
<Show when={isPathValidating(binary.path)}>
<span class="selector-badge-time">Checking</span>
<span class="selector-badge-time">{t("opencodeBinarySelector.status.checking")}</span>
</Show>
<Show when={!isDefault && binary.lastUsed}>
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
</Show>
<Show when={isDefault}>
<span class="selector-badge-time">Use binary from system PATH</span>
<span class="selector-badge-time">{t("opencodeBinarySelector.badge.systemPath")}</span>
</Show>
</div>
</div>
@@ -328,7 +330,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
class="p-2 text-muted hover:text-primary"
onClick={(event) => handleRemoveBinary(binary.path, event)}
disabled={props.disabled}
title="Remove binary"
title={t("opencodeBinarySelector.actions.removeTitle")}
>
<Trash2 class="w-3.5 h-3.5" />
</button>
@@ -343,8 +345,8 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
<FileSystemBrowserDialog
open={isBinaryBrowserOpen()}
mode="files"
title="Select OpenCode Binary"
description="Browse files exposed by the CLI server."
title={t("opencodeBinarySelector.dialog.title")}
description={t("opencodeBinarySelector.dialog.description")}
onClose={() => setIsBinaryBrowserOpen(false)}
onSelect={handleBinaryBrowserSelect}
/>
@@ -353,4 +355,3 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
}
export default OpenCodeBinarySelector

View File

@@ -2,6 +2,7 @@ import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Comp
import type { PermissionRequestLike } from "../types/permission"
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
import { useI18n } from "../lib/i18n"
import {
activeInterruption,
getPermissionQueue,
@@ -130,6 +131,7 @@ function resolveToolCallFromQuestion(instanceId: string, request: QuestionReques
}
const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => {
const { t } = useI18n()
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
const [permissionSubmitting, setPermissionSubmitting] = createSignal<Set<string>>(new Set())
const [permissionError, setPermissionError] = createSignal<Map<string, string>>(new Map())
@@ -165,7 +167,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
const sessionId = getPermissionSessionId(permission) || ""
await sendPermissionResponse(props.instanceId, sessionId, permissionId, response)
} catch (error) {
setPermissionItemError(permissionId, error instanceof Error ? error.message : "Unable to update permission")
setPermissionItemError(
permissionId,
error instanceof Error ? error.message : t("permissionApproval.errors.unableToUpdatePermission"),
)
} finally {
setPermissionBusy(permissionId, false)
}
@@ -257,19 +262,24 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<div class="permission-center-modal-header">
<div class="permission-center-modal-title-row">
<h2 id="permission-center-title" class="permission-center-modal-title">
Requests
{t("permissionApproval.title")}
</h2>
<Show when={orderedQueue().length > 0}>
<span class="permission-center-modal-count">{orderedQueue().length}</span>
</Show>
</div>
<button type="button" class="permission-center-modal-close" onClick={props.onClose} aria-label="Close">
<button
type="button"
class="permission-center-modal-close"
onClick={props.onClose}
aria-label={t("permissionApproval.actions.closeAriaLabel")}
>
</button>
</div>
<div class="permission-center-modal-body">
<Show when={hasRequests()} fallback={<div class="permission-center-empty">No pending requests.</div>}>
<Show when={hasRequests()} fallback={<div class="permission-center-empty">{t("permissionApproval.empty")}</div>}>
<div class="permission-center-list" role="list">
<For each={orderedQueue()}>
{(item) => {
@@ -285,14 +295,17 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
const showFallback = () => !resolved()
const kindLabel = () => (item.kind === "permission" ? "Permission" : "Question")
const kindLabel = () =>
item.kind === "permission"
? t("permissionApproval.kind.permission")
: t("permissionApproval.kind.question")
const primaryTitle = () => {
if (item.kind === "permission") {
return getPermissionDisplayTitle(item.payload)
}
const first = item.payload.questions?.[0]?.question
return typeof first === "string" && first.trim().length > 0 ? first : "Question"
return typeof first === "string" && first.trim().length > 0 ? first : t("permissionApproval.kind.question")
}
const secondaryTitle = () => {
@@ -300,7 +313,9 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
return getPermissionKind(item.payload)
}
const count = item.payload.questions?.length ?? 0
return count === 1 ? "1 question" : `${count} questions`
return count === 1
? t("permissionApproval.questionCount.one", { count })
: t("permissionApproval.questionCount.other", { count })
}
return (
@@ -313,7 +328,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<span class={`permission-center-item-chip permission-center-item-chip-${item.kind}`}>{kindLabel()}</span>
<span class="permission-center-item-kind">{secondaryTitle()}</span>
<Show when={isActive()}>
<span class="permission-center-item-chip">Active</span>
<span class="permission-center-item-chip">{t("permissionApproval.status.active")}</span>
</Show>
</div>
@@ -326,7 +341,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
handleGoToSession(sessionId())
}}
>
Go to Session
{t("permissionApproval.actions.goToSession")}
</button>
<Show when={showFallback()}>
<button
@@ -338,7 +353,9 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
handleLoadSession(sessionId())
}}
>
{loadingSession() === sessionId() ? "Loading…" : "Load Session"}
{loadingSession() === sessionId()
? t("permissionApproval.actions.loadingSession")
: t("permissionApproval.actions.loadSession")}
</button>
</Show>
</div>
@@ -360,7 +377,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
disabled={permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "once")}
>
Allow Once
{t("permissionApproval.actions.allowOnce")}
</button>
<button
type="button"
@@ -368,7 +385,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
disabled={permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "always")}
>
Always Allow
{t("permissionApproval.actions.alwaysAllow")}
</button>
<button
type="button"
@@ -376,7 +393,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
disabled={permissionSubmitting().has(item.id)}
onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "reject")}
>
Deny
{t("permissionApproval.actions.deny")}
</button>
</div>
</div>
@@ -385,7 +402,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
</Show>
</Show>
<Show when={item.kind !== "permission"}>
<div class="permission-center-fallback-hint">Load session for more information.</div>
<div class="permission-center-fallback-hint">{t("permissionApproval.fallbackHint")}</div>
</Show>
</div>
}

View File

@@ -1,5 +1,6 @@
import { Show, createMemo, type Component } from "solid-js"
import { ShieldAlert } from "lucide-solid"
import { useI18n } from "../lib/i18n"
import { getPermissionQueueLength, getQuestionQueueLength } from "../stores/instances"
interface PermissionNotificationBannerProps {
@@ -8,17 +9,38 @@ interface PermissionNotificationBannerProps {
}
const PermissionNotificationBanner: Component<PermissionNotificationBannerProps> = (props) => {
const { t } = useI18n()
const permissionCount = createMemo(() => getPermissionQueueLength(props.instanceId))
const questionCount = createMemo(() => getQuestionQueueLength(props.instanceId))
const queueLength = createMemo(() => permissionCount() + questionCount())
const hasRequests = createMemo(() => queueLength() > 0)
const label = createMemo(() => {
const total = queueLength()
const pendingLabel = total === 1
? t("permissionBanner.pendingRequests.one", { count: total })
: t("permissionBanner.pendingRequests.other", { count: total })
const parts: string[] = []
if (permissionCount() > 0) parts.push(`${permissionCount()} permission${permissionCount() === 1 ? "" : "s"}`)
if (questionCount() > 0) parts.push(`${questionCount()} question${questionCount() === 1 ? "" : "s"}`)
const detail = parts.length ? ` (${parts.join(", ")})` : ""
return `${total} pending request${total === 1 ? "" : "s"}${detail}`
if (permissionCount() > 0) {
parts.push(
permissionCount() === 1
? t("permissionBanner.detail.permission.one", { count: permissionCount() })
: t("permissionBanner.detail.permission.other", { count: permissionCount() }),
)
}
if (questionCount() > 0) {
parts.push(
questionCount() === 1
? t("permissionBanner.detail.question.one", { count: questionCount() })
: t("permissionBanner.detail.question.other", { count: questionCount() }),
)
}
const detail = parts.length ? t("permissionBanner.detail.wrapper", { detail: parts.join(", ") }) : ""
return `${pendingLabel}${detail}`
})
return (

View File

@@ -14,7 +14,9 @@ import { getActiveInstance } from "../stores/instances"
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt, executeCustomCommand } from "../stores/sessions"
import { getCommands } from "../stores/commands"
import { showAlertDialog } from "../stores/alerts"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
import { preferences } from "../stores/preferences"
const log = getLogger("actions")
@@ -32,6 +34,7 @@ interface PromptInputProps {
}
export default function PromptInput(props: PromptInputProps) {
const { t } = useI18n()
const [prompt, setPromptInternal] = createSignal("")
const [history, setHistory] = createSignal<string[]>([])
const HISTORY_LIMIT = 100
@@ -53,9 +56,9 @@ export default function PromptInput(props: PromptInputProps) {
const getPlaceholder = () => {
if (mode() === "shell") {
return "Run a shell command (Esc to exit)..."
return t("promptInput.placeholder.shell")
}
return "Type your message, @file, @agent, or paste images and text..."
return t("promptInput.placeholder.default")
}
@@ -540,13 +543,46 @@ export default function PromptInput(props: PromptInputProps) {
}
}
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
if (showPicker()) {
handlePickerClose()
if (e.key === "Enter") {
const isModified = e.metaKey || e.ctrlKey
// If the picker is open, Enter should select from it.
if (!isModified && showPicker()) {
return
}
if (submitOnEnter()) {
// Swapped mode: Enter submits, Cmd/Ctrl+Enter inserts a newline.
if (isModified) {
e.preventDefault()
e.stopPropagation()
insertNewlineAtCursor()
return
}
// Shift+Enter always inserts a newline.
if (e.shiftKey) {
// If the picker is open, avoid selecting an item on Enter.
if (showPicker()) {
e.stopPropagation()
}
return
}
e.preventDefault()
handleSend()
return
}
// Default: Cmd/Ctrl+Enter submits.
if (isModified) {
e.preventDefault()
if (showPicker()) {
handlePickerClose()
}
handleSend()
return
}
handleSend()
return
}
if (e.key === "ArrowUp") {
@@ -642,8 +678,8 @@ export default function PromptInput(props: PromptInputProps) {
}
} catch (error) {
log.error("Failed to send message:", error)
showAlertDialog("Failed to send message", {
title: "Send failed",
showAlertDialog(t("promptInput.send.errorFallback"), {
title: t("promptInput.send.errorTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
@@ -1019,7 +1055,7 @@ export default function PromptInput(props: PromptInputProps) {
const blockquote = lines.map((line) => `> ${line}`).join("\n")
if (!blockquote) return
insertBlockContent(`${blockquote}\n\n`)
insertBlockContent(`${blockquote}\n`)
}
function insertCodeSelection(rawText: string) {
@@ -1048,8 +1084,30 @@ export default function PromptInput(props: PromptInputProps) {
return hasText || attachments().length > 0
}
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "Shell mode" })
const commandHint = () => ({ key: "/", text: "Commands" })
const shellHint = () =>
mode() === "shell"
? { key: "Esc", text: t("promptInput.hints.shell.exit") }
: { key: "!", text: t("promptInput.hints.shell.enable") }
const commandHint = () => ({ key: "/", text: t("promptInput.hints.commands") })
const submitOnEnter = () => preferences().promptSubmitOnEnter
function insertNewlineAtCursor() {
const textarea = textareaRef
const current = prompt()
const start = textarea ? textarea.selectionStart : current.length
const end = textarea ? textarea.selectionEnd : current.length
const nextValue = current.substring(0, start) + "\n" + current.substring(end)
const nextCursor = start + 1
setPrompt(nextValue)
setTimeout(() => {
if (!textareaRef) return
textareaRef.focus()
textareaRef.setSelectionRange(nextCursor, nextCursor)
}, 0)
}
const shouldShowOverlay = () => prompt().length === 0
@@ -1115,7 +1173,7 @@ export default function PromptInput(props: PromptInputProps) {
class="prompt-history-button"
onClick={() => selectPreviousHistory(true)}
disabled={!canHistoryGoPrevious()}
aria-label="Previous prompt"
aria-label={t("promptInput.history.previousAriaLabel")}
>
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
</button>
@@ -1124,7 +1182,7 @@ export default function PromptInput(props: PromptInputProps) {
class="prompt-history-button"
onClick={() => selectNextHistory(true)}
disabled={!canHistoryGoNext()}
aria-label="Next prompt"
aria-label={t("promptInput.history.nextAriaLabel")}
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
@@ -1137,10 +1195,22 @@ export default function PromptInput(props: PromptInputProps) {
fallback={
<>
<span class="prompt-overlay-text">
<Kbd>Enter</Kbd> New line <Kbd shortcut="cmd+enter" /> Send <Kbd>@</Kbd> Files/agents <Kbd></Kbd> History
<Show
when={submitOnEnter()}
fallback={
<>
<Kbd>Enter</Kbd> {t("promptInput.overlay.newLine")} <Kbd shortcut="cmd+enter" /> {t("promptInput.overlay.send")}
</>
}
>
<>
<Kbd>Enter</Kbd> {t("promptInput.overlay.send")} <Kbd shortcut="cmd+enter" /> {t("promptInput.overlay.newLine")}
</>
</Show>
{" "} <Kbd>@</Kbd> {t("promptInput.overlay.filesAgents")} <Kbd></Kbd> {t("promptInput.overlay.history")}
</span>
<Show when={attachments().length > 0}>
<span class="prompt-overlay-text prompt-overlay-muted"> {attachments().length} file(s) attached</span>
<span class="prompt-overlay-text prompt-overlay-muted">{t("promptInput.overlay.attachments", { count: attachments().length })}</span>
</Show>
<span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
@@ -1151,17 +1221,17 @@ export default function PromptInput(props: PromptInputProps) {
</span>
</Show>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
<span class="prompt-overlay-shell-active">{t("promptInput.overlay.shellModeActive")}</span>
</Show>
</>
}
>
<>
<span class="prompt-overlay-text prompt-overlay-warning">
Press <Kbd>Esc</Kbd> again to abort session
{t("promptInput.overlay.press")} <Kbd>Esc</Kbd> {t("promptInput.overlay.againToAbort")}
</span>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
<span class="prompt-overlay-shell-active">{t("promptInput.overlay.shellModeActive")}</span>
</Show>
</>
</Show>
@@ -1177,8 +1247,8 @@ export default function PromptInput(props: PromptInputProps) {
class="stop-button"
onClick={handleAbort}
disabled={!canStop()}
aria-label="Stop session"
title="Stop session"
aria-label={t("promptInput.stopSession.ariaLabel")}
title={t("promptInput.stopSession.title")}
>
<svg class="stop-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<rect x="4" y="4" width="12" height="12" rx="2" />
@@ -1189,7 +1259,7 @@ export default function PromptInput(props: PromptInputProps) {
class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`}
onClick={handleSend}
disabled={!canSend()}
aria-label="Send message"
aria-label={t("promptInput.send.ariaLabel")}
>
<Show
when={mode() === "shell"}

View File

@@ -9,6 +9,7 @@ import { restartCli } from "../lib/native/cli"
import { preferences, setListeningMode } from "../stores/preferences"
import { showConfirmDialog } from "../stores/alerts"
import { getLogger } from "../lib/logger"
import { useI18n } from "../lib/i18n"
const log = getLogger("actions")
@@ -18,6 +19,7 @@ interface RemoteAccessOverlayProps {
}
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const { t } = useI18n()
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
const [loading, setLoading] = createSignal(false)
@@ -35,10 +37,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const allowExternalConnections = createMemo(() => currentMode() === "all")
const displayAddresses = createMemo(() => {
const list = addresses()
if (allowExternalConnections()) {
return list.filter((address) => address.scope !== "loopback")
if (!allowExternalConnections()) {
return []
}
return list.filter((address) => address.scope === "loopback")
// Local URL is displayed separately; list only remote-friendly addresses.
return list.filter((address) => address.scope !== "loopback")
})
const refreshMeta = async () => {
@@ -85,11 +88,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
return
}
const confirmed = await showConfirmDialog("Restart to apply listening mode? This will stop all running instances.", {
title: allow ? "Open to other devices" : "Limit to this device",
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"),
variant: "warning",
confirmLabel: "Restart now",
cancelLabel: "Cancel",
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
})
if (!confirmed) {
@@ -100,7 +103,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
setListeningMode(targetMode)
const restarted = await restartCli()
if (!restarted) {
setError("Unable to restart automatically. Please restart the app to apply the change.")
setError(t("remoteAccess.restart.errorManual"))
} else {
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
}
@@ -123,12 +126,12 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const confirm = passwordConfirm()
if (next.trim().length < 8) {
setPasswordError("Password must be at least 8 characters.")
setPasswordError(t("remoteAccess.password.error.tooShort"))
return
}
if (next !== confirm) {
setPasswordError("Passwords do not match.")
setPasswordError(t("remoteAccess.password.error.mismatch"))
return
}
@@ -162,11 +165,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
<Dialog.Content class="modal-surface remote-panel" tabIndex={-1}>
<header class="remote-header">
<div>
<p class="remote-eyebrow">Remote handover</p>
<h2 class="remote-title">Connect to CodeNomad remotely</h2>
<p class="remote-subtitle">Use the addresses below to open CodeNomad from another device.</p>
<p class="remote-eyebrow">{t("remoteAccess.eyebrow")}</p>
<h2 class="remote-title">{t("remoteAccess.title")}</h2>
<p class="remote-subtitle">{t("remoteAccess.subtitle")}</p>
</div>
<button type="button" class="remote-close" onClick={props.onClose} aria-label="Close remote access">
<button type="button" class="remote-close" onClick={props.onClose} aria-label={t("remoteAccess.close")}>
×
</button>
</header>
@@ -177,13 +180,13 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
<div class="remote-section-title">
<Shield class="remote-icon" />
<div>
<p class="remote-label">Listening mode</p>
<p class="remote-help">Allow or limit remote handovers by binding to all interfaces or just localhost.</p>
<p class="remote-label">{t("remoteAccess.sections.listeningMode.label")}</p>
<p class="remote-help">{t("remoteAccess.sections.listeningMode.help")}</p>
</div>
</div>
<button class="remote-refresh" type="button" onClick={() => void refreshMeta()} disabled={loading()}>
<RefreshCw class={`remote-icon ${loading() ? "remote-spin" : ""}`} />
<span class="remote-refresh-label">Refresh</span>
<span class="remote-refresh-label">{t("remoteAccess.refresh")}</span>
</button>
</div>
@@ -196,19 +199,18 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
>
<Switch.Input />
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
<span class="remote-toggle-state">{allowExternalConnections() ? "On" : "Off"}</span>
<span class="remote-toggle-state">{allowExternalConnections() ? t("remoteAccess.toggle.on") : t("remoteAccess.toggle.off")}</span>
<Switch.Thumb class="remote-toggle-thumb" />
</Switch.Control>
<div class="remote-toggle-copy">
<span class="remote-toggle-title">Allow connections from other IPs</span>
<span class="remote-toggle-title">{t("remoteAccess.toggle.title")}</span>
<span class="remote-toggle-caption">
{allowExternalConnections() ? "Binding to 0.0.0.0" : "Binding to 127.0.0.1"}
{allowExternalConnections() ? t("remoteAccess.toggle.caption.all") : t("remoteAccess.toggle.caption.local")}
</span>
</div>
</Switch>
<p class="remote-toggle-note">
Changing this requires a restart and temporarily stops all active instances. Share the addresses below once the
server restarts.
{t("remoteAccess.toggle.note")}
</p>
</section>
@@ -217,22 +219,24 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
<div class="remote-section-title">
<Shield class="remote-icon" />
<div>
<p class="remote-label">Server password</p>
<p class="remote-help">Remote handovers require a password. Set a memorable one to enable logins from other devices.</p>
<p class="remote-label">{t("remoteAccess.sections.serverPassword.label")}</p>
<p class="remote-help">{t("remoteAccess.sections.serverPassword.help")}</p>
</div>
</div>
</div>
<Show
when={authStatus() && authStatus()!.authenticated}
fallback={<div class="remote-card">Authentication status unavailable.</div>}
fallback={<div class="remote-card">{t("remoteAccess.authStatus.unavailable")}</div>}
>
<div class="remote-card">
<p class="remote-help">Username: {authStatus()!.username ?? "codenomad"}</p>
<p class="remote-help">
{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}
</p>
<p class="remote-help">
{authStatus()!.passwordUserProvided
? "A password is set for remote access."
: "No memorable password is set yet. Set one to allow remote handover logins."}
? t("remoteAccess.password.status.set")
: t("remoteAccess.password.status.unset")}
</p>
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
@@ -245,26 +249,26 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
}}
>
{passwordFormOpen()
? "Cancel"
? t("remoteAccess.password.actions.cancel")
: authStatus()!.passwordUserProvided
? "Change password"
: "Set password"}
? t("remoteAccess.password.actions.change")
: t("remoteAccess.password.actions.set")}
</button>
</div>
<Show when={passwordFormOpen()}>
<div class="selector-input-group" style={{ "margin-top": "12px" }}>
<label class="text-sm font-medium text-secondary">New password</label>
<label class="text-sm font-medium text-secondary">{t("remoteAccess.password.form.newPassword")}</label>
<input
class="selector-input w-full"
type="password"
value={passwordValue()}
onInput={(event) => setPasswordValue(event.currentTarget.value)}
placeholder="At least 8 characters"
placeholder={t("remoteAccess.password.form.placeholder")}
/>
</div>
<div class="selector-input-group" style={{ "margin-top": "10px" }}>
<label class="text-sm font-medium text-secondary">Confirm password</label>
<label class="text-sm font-medium text-secondary">{t("remoteAccess.password.form.confirmPassword")}</label>
<input
class="selector-input w-full"
type="password"
@@ -284,7 +288,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
disabled={savingPassword()}
onClick={() => void handleSubmitPassword()}
>
{savingPassword() ? "Saving" : "Save password"}
{savingPassword() ? t("remoteAccess.password.save.saving") : t("remoteAccess.password.save.label")}
</button>
</div>
</Show>
@@ -298,49 +302,107 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
<div class="remote-section-title">
<Wifi class="remote-icon" />
<div>
<p class="remote-label">Reachable addresses</p>
<p class="remote-help">Launch or scan from another machine to hand over control.</p>
<p class="remote-label">{t("remoteAccess.sections.addresses.label")}</p>
<p class="remote-help">{t("remoteAccess.sections.addresses.help")}</p>
</div>
</div>
</div>
<Show when={!loading()} fallback={<div class="remote-card">Loading addresses</div>}>
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">No addresses available yet.</div>}>
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
<div class="remote-address-list">
<For each={displayAddresses()}>
{(address) => {
const expandedState = () => expandedUrl() === address.url
const qr = () => qrCodes()[address.url]
<Show when={meta()?.localUrl}>
{(url) => {
const value = () => url()
const expandedState = () => expandedUrl() === value()
const qr = () => qrCodes()[value()]
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{address.url}</p>
<p class="remote-address-meta">
{address.family.toUpperCase()} {address.scope === "external" ? "Network" : address.scope === "loopback" ? "Loopback" : "Internal"} {address.ip}
</p>
<p class="remote-address-url">{value()}</p>
<p class="remote-address-meta">{t("remoteAccess.address.scope.loopback")}</p>
</div>
<div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(address.url)}>
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(value())}>
<ExternalLink class="remote-icon" />
Open
{t("remoteAccess.address.open")}
</button>
<button
class="remote-pill"
type="button"
onClick={() => void toggleExpanded(address.url)}
onClick={() => void toggleExpanded(value())}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
{expandedState() ? "Hide QR" : "Show QR"}
{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={`QR for ${address.url}`} class="remote-qr-img" />}
{(dataUrl) => (
<img
src={dataUrl()}
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"
/>
)}
</Show>
</div>
</Show>

View File

@@ -2,11 +2,13 @@ import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCl
import type { SessionStatus } from "../types/session"
import type { SessionThread } from "../stores/session-state"
import { getSessionStatus } from "../stores/session-status"
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid"
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split } from "lucide-solid"
import KeyboardHint from "./keyboard-hint"
import SessionRenameDialog from "./session-rename-dialog"
import { keyboardRegistry } from "../lib/keyboard-registry"
import { showToastNotification } from "../lib/notifications"
import { useI18n } from "../lib/i18n"
import { showConfirmDialog } from "../stores/alerts"
import {
deleteSession,
ensureSessionParentExpanded,
@@ -18,6 +20,7 @@ import {
setActiveSessionFromList,
toggleSessionParentExpanded,
} from "../stores/sessions"
import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees"
import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
const log = getLogger("session")
@@ -34,23 +37,82 @@ interface SessionListProps {
showFooter?: boolean
headerContent?: JSX.Element
footerContent?: JSX.Element
enableFilterBar?: boolean
}
function formatSessionStatus(status: SessionStatus): string {
switch (status) {
case "working":
return "Working"
case "compacting":
return "Compacting"
default:
return "Idle"
}
return status
}
const SessionList: Component<SessionListProps> = (props) => {
const { t } = useI18n()
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
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 deleting = loading().deletingSession.get(props.instanceId)
return deleting ? deleting.has(sessionId) : false
@@ -59,9 +121,10 @@ const SessionList: Component<SessionListProps> = (props) => {
const selectSession = (sessionId: string) => {
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
const parentId = session?.parentId ?? session?.id
if (parentId) {
ensureSessionParentExpanded(props.instanceId, parentId)
// If the user selects a child session, make sure its parent thread is expanded.
// For parent sessions we don't force expansion; user can collapse/expand freely.
if (session?.parentId) {
ensureSessionParentExpanded(props.instanceId, session.parentId)
}
props.onSelect(sessionId)
@@ -73,13 +136,13 @@ const SessionList: Component<SessionListProps> = (props) => {
try {
const success = await copyToClipboard(sessionId)
if (success) {
showToastNotification({ message: "Session ID copied", variant: "success" })
showToastNotification({ message: t("sessionList.copyId.success"), variant: "success" })
} else {
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
showToastNotification({ message: t("sessionList.copyId.error"), variant: "error" })
}
} catch (error) {
log.error(`Failed to copy session ID ${sessionId}:`, error)
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
showToastNotification({ message: t("sessionList.copyId.error"), variant: "error" })
}
}
@@ -87,6 +150,17 @@ const SessionList: Component<SessionListProps> = (props) => {
event.stopPropagation()
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
let fallbackSessionId: string | undefined
@@ -127,7 +201,7 @@ const SessionList: Component<SessionListProps> = (props) => {
}
} catch (error) {
log.error(`Failed to delete session ${sessionId}:`, error)
showToastNotification({ message: "Unable to delete session", variant: "error" })
showToastNotification({ message: t("sessionList.delete.error"), variant: "error" })
}
}
@@ -152,11 +226,120 @@ const SessionList: Component<SessionListProps> = (props) => {
setRenameTarget(null)
} catch (error) {
log.error(`Failed to rename session ${target.id}:`, error)
showToastNotification({ message: "Unable to rename session", variant: "error" })
showToastNotification({ message: t("sessionList.rename.error"), variant: "error" })
} finally {
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<{
@@ -171,19 +354,68 @@ const SessionList: Component<SessionListProps> = (props) => {
if (!session()) {
return <></>
}
const worktreeSlug = createMemo(() => {
if (rowProps.isChild) return "root"
return getWorktreeSlugForParentSession(props.instanceId, rowProps.sessionId)
})
const showWorktreeBadge = createMemo(() => {
if (rowProps.isChild) return false
if (getGitRepoStatus(props.instanceId) === false) return false
const slug = worktreeSlug()
return Boolean(slug) && slug !== "root"
})
const isActive = () => props.activeSessionId === rowProps.sessionId
const title = () => session()?.title || "Untitled"
const title = () => session()?.title || t("sessionList.session.untitled")
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
const statusLabel = () => formatSessionStatus(status())
const statusLabel = () => {
switch (formatSessionStatus(status())) {
case "working":
return t("sessionList.status.working")
case "compacting":
return t("sessionList.status.compacting")
default:
return t("sessionList.status.idle")
}
}
const needsPermission = () => Boolean(session()?.pendingPermission)
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
const needsInput = () => needsPermission() || needsQuestion()
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`)
const statusText = () => (needsPermission() ? "Needs Permission" : needsQuestion() ? "Needs Input" : statusLabel())
const statusText = () =>
needsPermission()
? t("sessionList.status.needsPermission")
: needsQuestion()
? t("sessionList.status.needsInput")
: statusLabel()
return (
<div class="session-list-item group">
const isSelected = () => selectedSessionIds().has(rowProps.sessionId)
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
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}
@@ -195,11 +427,23 @@ const SessionList: Component<SessionListProps> = (props) => {
>
<div class="session-item-row session-item-header">
<div class="session-item-title-row">
{rowProps.isChild ? (
<Bot class="w-4 h-4 flex-shrink-0" />
) : (
<User class="w-4 h-4 flex-shrink-0" />
)}
<Show when={props.enableFilterBar}>
<input
ref={(el) => {
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>
</div>
</div>
@@ -207,9 +451,7 @@ const SessionList: Component<SessionListProps> = (props) => {
<div class="flex items-center gap-2 min-w-0">
<Show
when={rowProps.hasChildren && !rowProps.isChild}
fallback={
rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />
}
fallback={rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />}
>
<span
class={`session-item-expander opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
@@ -219,20 +461,24 @@ const SessionList: Component<SessionListProps> = (props) => {
}}
role="button"
tabIndex={0}
aria-label={rowProps.expanded ? "Collapse session" : "Expand session"}
title={rowProps.expanded ? "Collapse" : "Expand"}
aria-label={
rowProps.expanded ? t("sessionList.expand.collapseAriaLabel") : t("sessionList.expand.expandAriaLabel")
}
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"}`} />
</span>
</Show>
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
{needsInput() ? (
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
) : (
<span class="status-dot" />
)}
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{statusText()}
</span>
<Show when={showWorktreeBadge()}>
<span class="status-indicator session-status-list worktree-indicator" title={`Worktree: ${worktreeSlug()}`}>
<Split class="w-3.5 h-3.5" aria-hidden="true" />
<span class="worktree-indicator-label">{worktreeSlug()}</span>
</span>
</Show>
</div>
<div class="session-item-actions">
<span
@@ -240,8 +486,8 @@ const SessionList: Component<SessionListProps> = (props) => {
onClick={(event) => copySessionId(event, rowProps.sessionId)}
role="button"
tabIndex={0}
aria-label="Copy session ID"
title="Copy session ID"
aria-label={t("sessionList.actions.copyId.ariaLabel")}
title={t("sessionList.actions.copyId.title")}
>
<Copy class="w-3 h-3" />
</span>
@@ -253,8 +499,8 @@ const SessionList: Component<SessionListProps> = (props) => {
}}
role="button"
tabIndex={0}
aria-label="Rename session"
title="Rename session"
aria-label={t("sessionList.actions.rename.ariaLabel")}
title={t("sessionList.actions.rename.title")}
>
<Pencil class="w-3 h-3" />
</span>
@@ -263,8 +509,8 @@ const SessionList: Component<SessionListProps> = (props) => {
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
role="button"
tabIndex={0}
aria-label="Delete session"
title="Delete session"
aria-label={t("sessionList.actions.delete.ariaLabel")}
title={t("sessionList.actions.delete.title")}
>
<Show
when={!isSessionDeleting(rowProps.sessionId)}
@@ -300,6 +546,13 @@ const SessionList: Component<SessionListProps> = (props) => {
})
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()
if (!parentId) return
ensureSessionParentExpanded(props.instanceId, parentId)
@@ -356,11 +609,68 @@ const SessionList: Component<SessionListProps> = (props) => {
<div
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}>
<div class="session-list-header p-3 border-b border-base">
{props.headerContent ?? (
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-primary">Sessions</h3>
<h3 class="text-sm font-semibold text-primary">{t("sessionList.header.title")}</h3>
<KeyboardHint
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
/>
@@ -369,33 +679,33 @@ const SessionList: Component<SessionListProps> = (props) => {
</div>
</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}>
<div class="session-section">
<For each={props.threads}>
<Show when={filteredThreads().length > 0}>
<div class="session-section">
<For each={filteredThreads()}>
{(thread) => {
const expanded = () => isSessionParentExpanded(props.instanceId, thread.parent.id)
return (
<>
<SessionRow
sessionId={thread.parent.id}
hasChildren={thread.children.length > 0}
expanded={expanded()}
onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)}
/>
{(thread) => {
const expanded = () => (normalizedQuery() ? true : isSessionParentExpanded(props.instanceId, thread.parent.id))
return (
<>
<SessionRow
sessionId={thread.parent.id}
hasChildren={thread.children.length > 0}
expanded={expanded()}
onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)}
/>
<Show when={expanded() && thread.children.length > 0}>
<For each={thread.children}>
{(child, index) => (
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
)}
</For>
</Show>
</>
)
}}
<Show when={expanded() && thread.children.length > 0}>
<For each={thread.children}>
{(child, index) => (
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
)}
</For>
</Show>
</>
)
}}
</For>
</div>
</Show>
@@ -420,4 +730,3 @@ const SessionList: Component<SessionListProps> = (props) => {
}
export default SessionList

View File

@@ -5,6 +5,7 @@ import { getParentSessions, createSession, setActiveParentSession } from "../sto
import { instances, stopInstance } from "../stores/instances"
import { agents } from "../stores/sessions"
import { getLogger } from "../lib/logger"
import { useI18n } from "../lib/i18n"
const log = getLogger("session")
@@ -15,6 +16,7 @@ interface SessionPickerProps {
}
const SessionPicker: Component<SessionPickerProps> = (props) => {
const { t } = useI18n()
const [selectedAgent, setSelectedAgent] = createSignal<string>("")
const [isCreating, setIsCreating] = createSignal(false)
@@ -40,10 +42,10 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return "just now"
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
return t("time.relative.justNow")
}
async function handleSessionSelect(sessionId: string) {
@@ -74,19 +76,19 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
<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-lg p-6">
<Dialog.Title class="text-xl font-semibold text-primary mb-4">
OpenCode {instance()?.folder.split("/").pop()}
</Dialog.Title>
<Dialog.Content class="modal-surface w-full max-w-lg p-6">
<Dialog.Title class="text-xl font-semibold text-primary mb-4">
{t("sessionPicker.title", { folder: instance()?.folder.split("/").pop() })}
</Dialog.Title>
<div class="space-y-6">
<Show
when={parentSessions().length > 0}
fallback={<div class="text-center py-4 text-sm text-muted">No previous sessions</div>}
fallback={<div class="text-center py-4 text-sm text-muted">{t("sessionPicker.empty.noPrevious")}</div>}
>
<div>
<h3 class="text-sm font-medium text-secondary mb-2">
Resume a session ({parentSessions().length}):
{t("sessionPicker.resume.title", { count: parentSessions().length })}
</h3>
<div class="space-y-1 max-h-[400px] overflow-y-auto">
<For each={parentSessions()}>
@@ -98,7 +100,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
>
<div class="selector-option-content w-full">
<span class="selector-option-label truncate">
{session.title || "Untitled"}
{session.title || t("sessionPicker.session.untitled")}
</span>
</div>
<span class="selector-badge-time flex-shrink-0">
@@ -116,16 +118,16 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
<div class="w-full border-t border-base" />
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-surface-base text-muted">or</span>
<span class="px-2 bg-surface-base text-muted">{t("sessionPicker.divider.or")}</span>
</div>
</div>
<div>
<h3 class="text-sm font-medium text-secondary mb-2">Start new session:</h3>
<h3 class="text-sm font-medium text-secondary mb-2">{t("sessionPicker.new.title")}</h3>
<div class="space-y-3">
<Show
when={agentList().length > 0}
fallback={<div class="text-sm text-muted">Loading agents...</div>}
fallback={<div class="text-sm text-muted">{t("sessionPicker.agents.loading")}</div>}
>
<select
class="selector-input w-full"
@@ -161,9 +163,13 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
</Show>
<Show
when={!isCreating()}
fallback={<span>Creating...</span>}
fallback={<span>{t("sessionPicker.actions.creating")}</span>}
>
<span>{agentList().length === 0 ? "Loading agents..." : "Create Session"}</span>
<span>
{agentList().length === 0
? t("sessionPicker.agents.loading")
: t("sessionPicker.actions.createSession")}
</span>
</Show>
</div>
<kbd class="kbd ml-2">
@@ -180,7 +186,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
class="selector-button selector-button-secondary"
onClick={handleCancel}
>
Cancel
{t("sessionPicker.actions.cancel")}
</button>
</div>
</Dialog.Content>

View File

@@ -1,5 +1,6 @@
import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect, createSignal } from "solid-js"
import { useI18n } from "../lib/i18n"
interface SessionRenameDialogProps {
open: boolean
@@ -11,6 +12,7 @@ interface SessionRenameDialogProps {
}
const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
const { t } = useI18n()
const [title, setTitle] = createSignal("")
const inputId = `session-rename-${Math.random().toString(36).slice(2)}`
let inputRef: HTMLInputElement | undefined
@@ -40,9 +42,9 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
const description = () => {
if (props.sessionLabel && props.sessionLabel.trim()) {
return `Update the title for "${props.sessionLabel}".`
return t("sessionRenameDialog.description.withLabel", { label: props.sessionLabel })
}
return "Set a new title for this session."
return t("sessionRenameDialog.description.default")
}
return (
@@ -58,7 +60,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6" tabIndex={-1}>
<Dialog.Title class="text-lg font-semibold text-primary">Rename Session</Dialog.Title>
<Dialog.Title class="text-lg font-semibold text-primary">{t("sessionRenameDialog.title")}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1">
{description()}
</Dialog.Description>
@@ -66,7 +68,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
<form class="mt-4 space-y-4" onSubmit={handleRename}>
<div class="space-y-2">
<label class="text-sm font-medium text-secondary" for={inputId}>
Session name
{t("sessionRenameDialog.input.label")}
</label>
<input
id={inputId}
@@ -76,7 +78,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
type="text"
value={title()}
onInput={(event) => setTitle(event.currentTarget.value)}
placeholder="Enter a session name"
placeholder={t("sessionRenameDialog.input.placeholder")}
class="w-full px-3 py-2 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent"
/>
</div>
@@ -92,7 +94,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
}}
disabled={isSubmitting()}
>
Cancel
{t("sessionRenameDialog.actions.cancel")}
</button>
<button
type="submit"
@@ -111,11 +113,11 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>Renaming</span>
<span>{t("sessionRenameDialog.actions.renaming")}</span>
</>
}
>
Rename
{t("sessionRenameDialog.actions.rename")}
</Show>
</button>
</div>

View File

@@ -1,17 +1,19 @@
import { createMemo, type Component } from "solid-js"
import { getSessionInfo } from "../../stores/sessions"
import { formatTokenTotal } from "../../lib/formatters"
import { useI18n } from "../../lib/i18n"
interface ContextUsagePanelProps {
instanceId: string
sessionId: string
class?: string
}
const chipClass = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
const chipLabelClass = "uppercase text-[10px] tracking-wide text-primary/70"
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
const chipLabelClass = "uppercase text-[10px] tracking-wide text-muted"
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
const { t } = useI18n()
const info = createMemo(
() =>
getSessionInfo(props.instanceId, props.sessionId) ?? {
@@ -29,51 +31,29 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
const inputTokens = createMemo(() => info().inputTokens ?? 0)
const outputTokens = createMemo(() => info().outputTokens ?? 0)
const actualUsageTokens = createMemo(() => info().actualUsageTokens ?? 0)
const availableTokens = createMemo(() => info().contextAvailableTokens)
const outputLimit = createMemo(() => info().modelOutputLimit ?? 0)
const costValue = createMemo(() => {
const value = info().isSubscriptionModel ? 0 : info().cost
return value > 0 ? value : 0
})
const formatTokenValue = (value: number | null | undefined) => {
if (value === null || value === undefined) return "--"
return formatTokenTotal(value)
}
const costDisplay = createMemo(() => `$${costValue().toFixed(2)}`)
return (
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
<div class={headingClass}>Tokens</div>
<div class={`session-context-panel px-4 py-2 ${props.class ?? ""}`}>
<div class="flex flex-wrap items-center gap-2 text-xs text-primary">
<div class={chipClass}>
<span class={chipLabelClass}>Input</span>
<span class={chipLabelClass}>{t("contextUsagePanel.labels.input")}</span>
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
</div>
<div class={chipClass}>
<span class={chipLabelClass}>Output</span>
<span class={chipLabelClass}>{t("contextUsagePanel.labels.output")}</span>
<span class="font-semibold text-primary">{formatTokenTotal(outputTokens())}</span>
</div>
<div class={chipClass}>
<span class={chipLabelClass}>Cost</span>
<span class={chipLabelClass}>{t("contextUsagePanel.labels.cost")}</span>
<span class="font-semibold text-primary">{costDisplay()}</span>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
<div class={headingClass}>Context</div>
<div class={chipClass}>
<span class={chipLabelClass}>Used</span>
<span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span>
</div>
<div class={chipClass}>
<span class={chipLabelClass}>Avail</span>
<span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span>
</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 type { Session } from "../../types/session"
import type { Attachment } from "../../types/attachment"
@@ -9,11 +9,12 @@ import PromptInput from "../prompt-input"
import type { Attachment as PromptAttachment } from "../../types/attachment"
import { getAttachments, removeAttachment } from "../../stores/attachments"
import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
import { showAlertDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger"
import { requestData } from "../../lib/opencode-api"
import { useI18n } from "../../lib/i18n"
const log = getLogger("session")
@@ -34,6 +35,7 @@ interface SessionViewProps {
}
export const SessionView: Component<SessionViewProps> = (props) => {
const { t } = useI18n()
const session = () => props.activeSessions.get(props.sessionId)
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
@@ -110,6 +112,43 @@ export const SessionView: Component<SessionViewProps> = (props) => {
if (!props.isActive) return
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
createEffect(() => {
@@ -152,8 +191,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
log.info("Abort requested", { instanceId: props.instanceId, sessionId: currentSession.id })
} catch (error) {
log.error("Failed to abort session", error)
showAlertDialog("Failed to stop session", {
title: "Stop failed",
showAlertDialog(t("sessionView.alerts.abortFailed.message"), {
title: t("sessionView.alerts.abortFailed.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
@@ -201,8 +240,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
}
} catch (error) {
log.error("Failed to revert message", error)
showAlertDialog("Failed to revert to message", {
title: "Revert failed",
showAlertDialog(t("sessionView.alerts.revertFailed.message"), {
title: t("sessionView.alerts.revertFailed.title"),
variant: "error",
})
}
@@ -215,10 +254,15 @@ export const SessionView: Component<SessionViewProps> = (props) => {
}
const restoredText = getUserMessageText(messageId)
const parentTitle = (session()?.title ?? "").trim() || t("sessionList.session.untitled")
try {
const forkedSession = await forkSession(props.instanceId, props.sessionId, { messageId })
renameSession(props.instanceId, forkedSession.id, `Fork: ${parentTitle}`).catch((error) => {
log.error("Failed to rename forked session", error)
})
const parentToActivate = forkedSession.parentId ?? forkedSession.id
setActiveParentSession(props.instanceId, parentToActivate)
if (forkedSession.parentId) {
@@ -237,8 +281,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
}
} catch (error) {
log.error("Failed to fork session", error)
showAlertDialog("Failed to fork session", {
title: "Fork failed",
showAlertDialog(t("sessionView.alerts.forkFailed.message"), {
title: t("sessionView.alerts.forkFailed.title"),
variant: "error",
})
}
@@ -250,7 +294,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
when={session()}
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500">Session not found</div>
<div class="text-center text-gray-500">{t("sessionView.fallback.sessionNotFound")}</div>
</div>
}
>
@@ -296,8 +340,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
type="button"
class="attachment-expand"
onClick={() => handleExpandTextAttachment(attachment)}
aria-label="Expand pasted text"
title="Insert pasted text"
aria-label={t("sessionView.attachments.expandPastedTextAriaLabel")}
title={t("sessionView.attachments.insertPastedTextTitle")}
>
<Expand class="h-3 w-3" aria-hidden="true" />
</button>
@@ -306,7 +350,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
type="button"
class="attachment-remove"
onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
aria-label="Remove attachment"
aria-label={t("sessionView.attachments.removeAriaLabel")}
>
×
</button>

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

@@ -0,0 +1,109 @@
import { Combobox } from "@kobalte/core/combobox"
import { createEffect, createMemo } from "solid-js"
import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import { getLogger } from "../lib/logger"
import { getModelThinkingSelection, setModelThinkingSelection } from "../stores/preferences"
import { useI18n } from "../lib/i18n"
const log = getLogger("session")
interface ThinkingSelectorProps {
instanceId: string
currentModel: { providerId: string; modelId: string }
}
type ThinkingOption = {
key: string
label: string
value: string | undefined
}
export default function ThinkingSelector(props: ThinkingSelectorProps) {
const { t } = useI18n()
const instanceProviders = () => providers().get(props.instanceId) || []
createEffect(() => {
if (instanceProviders().length === 0) {
fetchProviders(props.instanceId).catch((error) => log.error("Failed to fetch providers", error))
}
})
const variantKeys = createMemo(() => {
const { providerId, modelId } = props.currentModel
const provider = instanceProviders().find((p) => p.id === providerId)
const model = provider?.models.find((m) => m.id === modelId)
return model?.variantKeys ?? []
})
const options = createMemo<ThinkingOption[]>(() => {
const keys = variantKeys()
return [
{ key: "__default__", label: t("thinkingSelector.variant.default"), value: undefined },
...keys.map((k) => ({ key: k, label: k, value: k })),
]
})
const currentValue = createMemo(() => {
const selected = getModelThinkingSelection(props.currentModel)
const keys = variantKeys()
if (selected && keys.includes(selected)) {
return options().find((opt) => opt.value === selected)
}
return options()[0]
})
const handleChange = (value: ThinkingOption | null) => {
if (!value) return
setModelThinkingSelection(props.currentModel, value.value)
}
const triggerPrimary = createMemo(() => {
const selected = currentValue()?.value
const variant = selected ?? t("thinkingSelector.variant.default")
return t("thinkingSelector.label", { variant })
})
return (
<div class="sidebar-selector">
<Combobox<ThinkingOption>
value={currentValue()}
onChange={handleChange}
options={options()}
optionValue="key"
optionLabel="label"
placeholder={t("thinkingSelector.label", { variant: t("thinkingSelector.variant.default") })}
itemComponent={(itemProps) => (
<Combobox.Item item={itemProps.item} class="selector-option">
<div class="selector-option-content">
<Combobox.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Combobox.ItemLabel>
</div>
<Combobox.ItemIndicator class="selector-option-indicator">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</Combobox.ItemIndicator>
</Combobox.Item>
)}
>
<Combobox.Control class="relative w-full" data-thinking-selector-control>
<Combobox.Input class="sr-only" data-thinking-selector />
<Combobox.Trigger class="selector-trigger">
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
<span class="selector-trigger-primary selector-trigger-primary--align-left">{triggerPrimary()}</span>
</div>
<Combobox.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Combobox.Icon>
</Combobox.Trigger>
</Combobox.Control>
<Combobox.Portal>
<Combobox.Content class="selector-popover">
<Combobox.Listbox class="selector-listbox" />
</Combobox.Content>
</Combobox.Portal>
</Combobox>
</div>
)
}

View File

@@ -1,12 +1,15 @@
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
import { Copy } from "lucide-solid"
import { messageStoreBus } from "../stores/message-v2/bus"
import { useTheme } from "../lib/theme"
import { useGlobalCache } from "../lib/hooks/use-global-cache"
import { useConfig } from "../stores/preferences"
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
import { copyToClipboard } from "../lib/clipboard"
import type { PermissionRequestLike } from "../types/permission"
import { getPermissionSessionId } from "../types/permission"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { useI18n } from "../lib/i18n"
import { resolveToolRenderer } from "./tool-call/renderers"
import { QuestionToolBlock } from "./tool-call/question-block"
import { PermissionToolBlock } from "./tool-call/permission-block"
@@ -58,6 +61,11 @@ interface ToolCallProps {
instanceId: string
sessionId: string
onContentRendered?: () => void
/**
* When true, tool call starts collapsed regardless of user preferences.
* Users can still expand/collapse manually.
*/
forceCollapsed?: boolean
}
@@ -67,6 +75,7 @@ interface ToolCallProps {
export default function ToolCall(props: ToolCallProps) {
const { preferences, setDiffViewMode } = useConfig()
const { isDark } = useTheme()
const { t } = useI18n()
const toolCallMemo = createMemo(() => props.toolCall)
const toolName = createMemo(() => toolCallMemo()?.tool || "")
const toolCallIdentifier = createMemo(() => {
@@ -140,6 +149,9 @@ export default function ToolCall(props: ToolCallProps) {
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
const defaultExpandedForTool = createMemo(() => {
if (props.forceCollapsed) {
return false
}
const prefExpanded = toolOutputDefaultExpanded()
const toolName = toolCallMemo()?.tool || ""
if (toolName === "read") {
@@ -233,12 +245,16 @@ export default function ToolCall(props: ToolCallProps) {
requestAnimationFrame(() => {
restoreScrollPosition(autoScroll())
if (!expanded()) return
scheduleAnchorScroll()
scheduleAnchorScroll(true)
})
}
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
scrollContainerRef = element || undefined
const next = element || undefined
if (next === scrollContainerRef) {
return
}
scrollContainerRef = next
setScrollContainer(scrollContainerRef)
if (scrollContainerRef) {
restoreScrollPosition(autoScroll())
@@ -442,7 +458,7 @@ export default function ToolCall(props: ToolCallProps) {
return row.map((value) => value.trim()).filter((value) => value.length > 0)
})
if (normalized.some((item) => (item?.length ?? 0) === 0)) {
setQuestionError("Please answer all questions before submitting.")
setQuestionError(t("toolCall.question.validation.answerAll"))
return
}
@@ -453,7 +469,7 @@ export default function ToolCall(props: ToolCallProps) {
await sendQuestionReply(props.instanceId, sessionId, request.id, normalized)
} catch (error) {
log.error("Failed to send question reply", error)
setQuestionError(error instanceof Error ? error.message : "Unable to reply")
setQuestionError(error instanceof Error ? error.message : t("toolCall.question.errors.unableToReply"))
} finally {
setQuestionSubmitting(false)
}
@@ -471,7 +487,7 @@ export default function ToolCall(props: ToolCallProps) {
await sendQuestionReject(props.instanceId, sessionId, request.id)
} catch (error) {
log.error("Failed to reject question", error)
setQuestionError(error instanceof Error ? error.message : "Unable to dismiss")
setQuestionError(error instanceof Error ? error.message : t("toolCall.question.errors.unableToDismiss"))
} finally {
setQuestionSubmitting(false)
}
@@ -545,6 +561,7 @@ export default function ToolCall(props: ToolCallProps) {
preferences,
setDiffViewMode,
isDark,
t,
diffCache,
permissionDiffCache,
scrollHelpers,
@@ -568,11 +585,29 @@ export default function ToolCall(props: ToolCallProps) {
toolCall: toolCallMemo,
toolState,
toolName,
instanceId: props.instanceId,
sessionId: props.sessionId,
t,
messageVersion: messageVersionAccessor,
partVersion: partVersionAccessor,
renderMarkdown: renderMarkdownContent,
renderAnsi: renderAnsiContent,
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,
}
@@ -589,7 +624,7 @@ export default function ToolCall(props: ToolCallProps) {
return
}
previousPartVersion = version
scheduleAnchorScroll()
scheduleAnchorScroll(true)
})
createEffect(() => {
@@ -626,6 +661,19 @@ export default function ToolCall(props: ToolCallProps) {
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 = () => {
return renderer().renderBody(rendererContext)
}
@@ -639,7 +687,7 @@ export default function ToolCall(props: ToolCallProps) {
await sendPermissionResponse(props.instanceId, sessionId, permission.id, response)
} catch (error) {
log.error("Failed to send permission response", error)
setPermissionError(error instanceof Error ? error.message : "Unable to update permission")
setPermissionError(error instanceof Error ? error.message : t("toolCall.permission.errors.unableToUpdate"))
} finally {
setPermissionSubmitting(false)
}
@@ -651,7 +699,7 @@ export default function ToolCall(props: ToolCallProps) {
if (state.status === "error" && state.error) {
return (
<div class="tool-call-error-content">
<strong>Error:</strong> {state.error}
<strong>{t("toolCall.error.label")}</strong> {state.error}
</div>
)
}
@@ -729,16 +777,32 @@ export default function ToolCall(props: ToolCallProps) {
}}
class={`tool-call ${combinedStatusClass()}`}
>
<button
class="tool-call-header"
onClick={toggle}
aria-expanded={expanded()}
data-status-icon={statusIcon()}
>
<span class="tool-call-summary" data-tool-icon={getToolIcon(toolName())}>
{renderToolTitle()}
<div class="tool-call-header">
<button
type="button"
class="tool-call-header-toggle"
onClick={toggle}
aria-expanded={expanded()}
>
<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>
</button>
</div>
{expanded() && (
<div class="tool-call-details">
@@ -752,7 +816,7 @@ export default function ToolCall(props: ToolCallProps) {
<Show when={status() === "pending" && !pendingPermission()}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
<span>Waiting to run...</span>
<span>{t("toolCall.pending.waitingToRun")}</span>
</div>
</Show>
</div>
@@ -761,6 +825,7 @@ export default function ToolCall(props: ToolCallProps) {
<Show when={diagnosticsEntries().length}>
{renderDiagnosticsSection(
t,
diagnosticsEntries(),
diagnosticsExpanded(),
() => setDiagnosticsOverride((prev) => {

View File

@@ -87,7 +87,7 @@ export function createAnsiContentRenderer(params: {
}
return (
<div class={messageClass} ref={(element) => params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}>
<div class={messageClass} ref={params.scrollHelpers.registerContainer} onScroll={params.scrollHelpers.handleScroll}>
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
{params.scrollHelpers.renderSentinel()}
</div>

View File

@@ -2,6 +2,7 @@ import { For, Show } from "solid-js"
import type { DiagnosticEntry } from "./diagnostics"
export function renderDiagnosticsSection(
t: (key: string, params?: Record<string, unknown>) => string,
entries: DiagnosticEntry[],
expanded: boolean,
toggle: () => void,
@@ -22,13 +23,13 @@ export function renderDiagnosticsSection(
<span class="tool-call-emoji" aria-hidden="true">
🛠
</span>
<span class="tool-call-summary">Diagnostics</span>
<span class="tool-call-summary">{t("toolCall.diagnostics.title")}</span>
<span class="tool-call-diagnostics-file" title={fileLabel}>
{fileLabel}
</span>
</button>
<Show when={expanded}>
<div class="tool-call-diagnostics" role="region" aria-label="Diagnostics">
<div class="tool-call-diagnostics" role="region" aria-label={t("toolCall.diagnostics.ariaLabel")}>
<div class="tool-call-diagnostics-body" role="list">
<For each={entries}>
{(entry) => (

View File

@@ -1,5 +1,6 @@
import type { ToolState } from "@opencode-ai/sdk"
import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils"
import { tGlobal } from "../../lib/i18n"
interface LspRangePosition {
line?: number
@@ -40,9 +41,9 @@ function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
}
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
return { label: "INFO", icon: "i", rank: 2 }
if (tone === "error") return { label: tGlobal("toolCall.diagnostics.severity.error.short"), icon: "!", rank: 0 }
if (tone === "warning") return { label: tGlobal("toolCall.diagnostics.severity.warning.short"), icon: "!", rank: 1 }
return { label: tGlobal("toolCall.diagnostics.severity.info.short"), icon: "i", rank: 2 }
}
export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {

View File

@@ -19,19 +19,32 @@ export function createDiffContentRenderer(params: {
preferences: Accessor<DiffPrefs>
setDiffViewMode: (mode: DiffViewMode) => void
isDark: Accessor<boolean>
t: (key: string, params?: Record<string, unknown>) => string
diffCache: CacheHandle
permissionDiffCache: CacheHandle
scrollHelpers: ToolScrollHelpers
handleScrollRendered: () => void
onContentRendered?: () => void
}) {
const registerTracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element)
}
const registerUntracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element, { disableTracking: true })
}
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null {
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
const toolbarLabel = options?.label || (relativePath
? params.t("toolCall.diff.label.withPath", { path: relativePath })
: params.t("toolCall.diff.label"))
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
const themeKey = params.isDark() ? "dark" : "light"
const disableScrollTracking = Boolean(options?.disableScrollTracking)
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
const baseEntryParams = cacheHandle.params() as any
const cacheEntryParams = (() => {
@@ -55,7 +68,7 @@ export function createDiffContentRenderer(params: {
}
const handleDiffRendered = () => {
if (!options?.disableScrollTracking) {
if (!disableScrollTracking) {
params.handleScrollRendered()
}
params.onContentRendered?.()
@@ -64,10 +77,10 @@ export function createDiffContentRenderer(params: {
return (
<div
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })}
onScroll={options?.disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
ref={registerRef}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
<div class="tool-call-diff-toolbar" role="group" aria-label={params.t("toolCall.diff.viewMode.ariaLabel")}>
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
<div class="tool-call-diff-toggle">
<button
@@ -76,7 +89,7 @@ export function createDiffContentRenderer(params: {
aria-pressed={diffMode() === "split"}
onClick={() => handleModeChange("split")}
>
Split
{params.t("toolCall.diff.viewMode.split")}
</button>
<button
type="button"
@@ -84,7 +97,7 @@ export function createDiffContentRenderer(params: {
aria-pressed={diffMode() === "unified"}
onClick={() => handleModeChange("unified")}
>
Unified
{params.t("toolCall.diff.viewMode.unified")}
</button>
</div>
</div>
@@ -97,7 +110,7 @@ export function createDiffContentRenderer(params: {
cacheEntryParams={cacheEntryParams as any}
onRendered={handleDiffRendered}
/>
{params.scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div>
)
}

View File

@@ -15,6 +15,14 @@ export function createMarkdownContentRenderer(params: {
handleScrollRendered: () => void
onContentRendered?: () => void
}) {
const registerTracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element)
}
const registerUntracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element, { disableTracking: true })
}
function renderMarkdownContent(options: MarkdownRenderOptions): JSXElement | null {
if (!options.content) {
return null
@@ -23,20 +31,27 @@ export function createMarkdownContentRenderer(params: {
const size = options.size || "default"
const disableHighlight = options.disableHighlight || false
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
const disableScrollTracking = options.disableScrollTracking || false
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
const state = params.toolState()
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
if (shouldDeferMarkdown) {
return (
<div class={messageClass} ref={(element) => params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}>
<div
class={messageClass}
ref={registerRef}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
{params.scrollHelpers.renderSentinel()}
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div>
)
}
const cacheKey = typeof options.cacheKey === "string" && options.cacheKey.length > 0 ? options.cacheKey : undefined
const markdownPart: TextPart = {
id: params.partId(),
id: cacheKey ? `${params.partId()}:${cacheKey}` : params.partId(),
type: "text",
text: options.content,
version: params.partVersion?.(),
@@ -48,7 +63,11 @@ export function createMarkdownContentRenderer(params: {
}
return (
<div class={messageClass} ref={(element) => params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}>
<div
class={messageClass}
ref={registerRef}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<Markdown
part={markdownPart}
instanceId={params.instanceId}
@@ -57,7 +76,7 @@ export function createMarkdownContentRenderer(params: {
disableHighlight={disableHighlight}
onRendered={handleMarkdownRendered}
/>
{params.scrollHelpers.renderSentinel()}
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { Show, type Accessor, type JSXElement } from "solid-js"
import type { PermissionRequestLike } from "../../types/permission"
import { getPermissionDisplayTitle, getPermissionKind } from "../../types/permission"
import { getPermissionSessionId } from "../../types/permission"
import { useI18n } from "../../lib/i18n"
import type { DiffPayload, DiffRenderOptions } from "./types"
import { getRelativePath } from "./utils"
@@ -18,6 +19,8 @@ export type PermissionToolBlockProps = {
}
export function PermissionToolBlock(props: PermissionToolBlockProps) {
const { t } = useI18n()
const diffPayload = () => {
const permission = props.permission()
if (!permission) return null
@@ -48,7 +51,9 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
{(permission) => (
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
<div class="tool-call-permission-header">
<span class="tool-call-permission-label">{props.active() ? "Permission Required" : "Permission Queued"}</span>
<span class="tool-call-permission-label">
{props.active() ? t("toolCall.permission.status.required") : t("toolCall.permission.status.queued")}
</span>
<span class="tool-call-permission-type">{getPermissionKind(permission())}</span>
</div>
<div class="tool-call-permission-body">
@@ -62,14 +67,14 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
variant: "permission-diff",
disableScrollTracking: true,
label: payload().filePath
? `Requested diff · ${getRelativePath(payload().filePath || "")}`
: "Requested diff",
? t("toolCall.permission.requestedDiff.withPath", { path: getRelativePath(payload().filePath || "") })
: t("toolCall.permission.requestedDiff.label"),
})}
</div>
)}
</Show>
<Show when={!props.active()}>
<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>
<p class="tool-call-permission-queued-text">{t("toolCall.permission.queuedText")}</p>
</Show>
<div class="tool-call-permission-actions">
<div class="tool-call-permission-buttons">
@@ -79,7 +84,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
disabled={props.submitting()}
onClick={() => respond("once")}
>
Allow Once
{t("toolCall.permission.actions.allowOnce")}
</button>
<button
type="button"
@@ -87,7 +92,7 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
disabled={props.submitting()}
onClick={() => respond("always")}
>
Always Allow
{t("toolCall.permission.actions.alwaysAllow")}
</button>
<button
type="button"
@@ -95,17 +100,17 @@ export function PermissionToolBlock(props: PermissionToolBlockProps) {
disabled={props.submitting()}
onClick={() => respond("reject")}
>
Deny
{t("toolCall.permission.actions.deny")}
</button>
</div>
<Show when={props.active()}>
<div class="tool-call-permission-shortcuts">
<kbd class="kbd">Enter</kbd>
<span>Allow once</span>
<span>{t("toolCall.permission.shortcuts.allowOnce")}</span>
<kbd class="kbd">A</kbd>
<span>Always allow</span>
<span>{t("toolCall.permission.shortcuts.alwaysAllow")}</span>
<kbd class="kbd">D</kbd>
<span>Deny</span>
<span>{t("toolCall.permission.shortcuts.deny")}</span>
</div>
</Show>
</div>

View File

@@ -1,6 +1,7 @@
import { createMemo, Show, For, type Accessor } from "solid-js"
import { createMemo, Show, For, createEffect, type Accessor } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { useI18n } from "../../lib/i18n"
type QuestionOption = { label: string; description: string }
@@ -26,6 +27,15 @@ export type QuestionToolBlockProps = {
}
export function QuestionToolBlock(props: QuestionToolBlockProps) {
const { t } = useI18n()
let firstInputRef: HTMLInputElement | undefined
createEffect(() => {
if (props.active() && firstInputRef) {
firstInputRef.focus()
}
})
const requestId = createMemo(() => {
const state = props.toolState()
const request = props.request()
@@ -70,6 +80,19 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
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[]) => {
if (!props.active()) return
props.setDraftAnswers((prev) => {
@@ -109,9 +132,16 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
if (!multi) {
// When switching a radio to custom, clear existing selection first.
updateAnswer(questionIndex, [])
toggleOption(questionIndex, value)
return
}
toggleOption(questionIndex, value)
// For multi-select, focusing the input should never toggle an existing custom value off.
// Ensure the current input value is selected; removal is handled by unchecking Custom.
const existing = answers()[questionIndex] ?? []
if (!existing.includes(value)) {
updateAnswer(questionIndex, [...existing, value])
}
}
const clearCustomAnswer = (questionIndex: number, valuesToRemove: string[]) => {
@@ -160,16 +190,11 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
return (
<Show when={isVisible() && questions().length > 0}>
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
<div class="tool-call-permission-header">
<span class="tool-call-permission-label">
{props.active() ? "Question Required" : props.request() ? "Question Queued" : "Questions"}
</span>
<span class="tool-call-permission-type">{questions().length === 1 ? "Question" : "Questions"}</span>
</div>
<div
class={`tool-call-permission p-0 gap-2 ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"} ${hasFinalAnswers() ? "tool-call-permission-answered" : ""}`}
>
<div class="tool-call-permission-body">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<For each={questions()}>
{(q, index) => {
const i = () => index()
@@ -183,21 +208,21 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
const customChecked = () => customValue().length > 0
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="text-xs">
Q{i() + 1}: <span class="font-semibold">{q?.header}</span>
<div class="text-sm text-primary">
{t("toolCall.question.number", { number: i() + 1 })} <span class="font-semibold">{q?.header}</span>
</div>
<Show when={multi()}>
<div class="text-xs text-muted">Multiple</div>
<div class="text-xs text-muted">{t("toolCall.question.multiple")}</div>
</Show>
</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">
<For each={q?.options ?? []}>
{(opt) => {
{(opt, optIndex) => {
const checked = () => selected().includes(opt.label)
return (
<label
@@ -205,14 +230,18 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
title={opt.description}
>
<input
ref={(el) => {
if (i() === 0 && optIndex() === 0) firstInputRef = el
}}
type={inputType()}
name={groupName()}
class="mt-0.5 accent-[var(--accent-primary)]"
checked={checked()}
disabled={!props.active() || props.submitting()}
onChange={() => toggleOption(i(), opt.label)}
/>
<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>
</label>
@@ -222,11 +251,15 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
<label
class={`mt-2 flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
title="Type a custom answer"
title={t("toolCall.question.custom.title")}
>
<input
ref={(el) => {
if (i() === 0 && (q?.options?.length ?? 0) === 0) firstInputRef = el
}}
type={inputType()}
name={groupName()}
class="mt-0.5 accent-[var(--accent-primary)]"
checked={customChecked()}
disabled={!props.active() || props.submitting()}
onChange={(e) => {
@@ -244,19 +277,26 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
}}
/>
<div class="flex flex-1 flex-col gap-2">
<div class="text-sm leading-tight">Custom answer</div>
<div class="text-sm leading-tight text-primary">{t("toolCall.question.custom.label")}</div>
<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"
placeholder="Type your own answer"
disabled={!props.active() || props.submitting()}
value={customValue()}
placeholder={t("toolCall.question.custom.placeholder")}
disabled={!props.active() || props.submitting()}
value={customValue()}
onFocus={(e) => {
if (!props.active()) return
// Keep the radio/checkbox selected while editing.
toggleFromCustomInput(i(), e.currentTarget)
}}
onInput={(e) => handleCustomTyping(i(), e.currentTarget)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.isComposing) {
if (!submitDisabled()) {
props.onSubmit()
}
}
}}
/>
</div>
</label>
@@ -267,7 +307,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
</For>
<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">
<button
type="button"
@@ -275,7 +315,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
disabled={submitDisabled()}
onClick={() => props.onSubmit()}
>
Submit
{t("toolCall.question.actions.submit")}
</button>
<button
type="button"
@@ -283,15 +323,15 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
disabled={props.submitting()}
onClick={() => props.onDismiss()}
>
Dismiss
{t("toolCall.question.actions.dismiss")}
</button>
</div>
<div class="tool-call-permission-shortcuts">
<kbd class="kbd">Enter</kbd>
<span>Submit</span>
<span>{t("toolCall.question.shortcuts.submit")}</span>
<kbd class="kbd">Esc</kbd>
<span>Dismiss</span>
<span>{t("toolCall.question.shortcuts.dismiss")}</span>
</div>
<Show when={props.error()}>
@@ -301,7 +341,7 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
</Show>
<Show when={!props.active() && props.request()}>
<p class="tool-call-permission-queued-text">Waiting for earlier responses.</p>
<p class="tool-call-permission-queued-text px-3 pb-3">{t("toolCall.question.queuedText")}</p>
</Show>
</div>
</div>

View File

@@ -35,10 +35,10 @@ function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
return "info"
}
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
return { label: "INFO", icon: "i", rank: 2 }
function getSeverityMeta(tone: DiagnosticEntry["tone"], t: (key: string, params?: Record<string, unknown>) => string) {
if (tone === "error") return { label: t("toolCall.diagnostics.severity.error.short"), icon: "!", rank: 0 }
if (tone === "warning") return { label: t("toolCall.diagnostics.severity.warning.short"), icon: "!", rank: 1 }
return { label: t("toolCall.diagnostics.severity.info.short"), icon: "i", rank: 2 }
}
function resolveDiagnosticsKey(
@@ -69,6 +69,7 @@ function resolveDiagnosticsKey(
function buildDiagnostics(
diagnostics: Record<string, LspDiagnostic[] | undefined>,
file: ApplyPatchFile,
t: (key: string, params?: Record<string, unknown>) => string,
): DiagnosticEntry[] {
const key = resolveDiagnosticsKey(diagnostics, file)
if (!key) return []
@@ -82,7 +83,7 @@ function buildDiagnostics(
if (!diagnostic || typeof diagnostic.message !== "string") continue
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
const severityMeta = getSeverityMeta(tone)
const severityMeta = getSeverityMeta(tone, t)
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
@@ -103,11 +104,14 @@ function buildDiagnostics(
return entries.sort((a, b) => a.severity - b.severity)
}
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string }) {
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string; t: (key: string, params?: Record<string, unknown>) => string }) {
return (
<Show when={props.entries.length > 0}>
<div class="tool-call-diagnostics-wrapper">
<div class="tool-call-diagnostics" role="region" aria-label={`Diagnostics ${props.label}`}
<div
class="tool-call-diagnostics"
role="region"
aria-label={props.t("toolCall.diagnostics.ariaLabel.withLabel", { label: props.label })}
>
<div class="tool-call-diagnostics-body" role="list">
<For each={props.entries}>
@@ -134,19 +138,22 @@ function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string })
export const applyPatchRenderer: ToolRenderer = {
tools: ["apply_patch"],
getAction: () => "Preparing apply_patch...",
getTitle({ toolState }) {
getAction: ({ t }) => t("toolCall.applyPatch.action.preparing"),
getTitle({ toolState, t }) {
const state = toolState()
if (!state) return undefined
if (state.status === "pending") return getToolName("apply_patch")
const { metadata } = readToolStatePayload(state)
const files = Array.isArray((metadata as any).files) ? ((metadata as any).files as ApplyPatchFile[]) : []
if (files.length > 0) {
return `${getToolName("apply_patch")} (${files.length} file${files.length === 1 ? "" : "s"})`
const tool = getToolName("apply_patch")
return files.length === 1
? t("toolCall.applyPatch.title.withFileCount.one", { tool, count: files.length })
: t("toolCall.applyPatch.title.withFileCount.other", { tool, count: files.length })
}
return getToolName("apply_patch")
},
renderBody({ toolState, renderDiff, renderMarkdown }) {
renderBody({ toolState, renderDiff, renderMarkdown, t }) {
const state = toolState()
if (!state || state.status === "pending") return null
@@ -170,10 +177,10 @@ export const applyPatchRenderer: ToolRenderer = {
<div class="tool-call-apply-patch">
<For each={files()}>
{(file, index) => {
const labelBase = file.relativePath || file.filePath || `File ${index() + 1}`
const labelBase = file.relativePath || file.filePath || t("toolCall.applyPatch.fileFallback", { number: index() + 1 })
const diffText = typeof file.diff === "string" ? file.diff : ""
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file))
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file, t))
return (
<div class="tool-call-apply-patch-file">
@@ -181,12 +188,12 @@ export const applyPatchRenderer: ToolRenderer = {
{renderDiff(
{ diffText, filePath },
{
label: `Diff · ${getRelativePath(labelBase)}`,
label: t("toolCall.diff.label.withPath", { path: getRelativePath(labelBase) }),
cacheKey: `apply_patch:${labelBase}:${index()}`,
},
)}
</Show>
<DiagnosticsInline entries={entries()} label={labelBase} />
<DiagnosticsInline entries={entries()} label={labelBase} t={t} />
</div>
)
}}

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