Compare commits

...

38 Commits

Author SHA1 Message Date
Shantur Rathore
03ed3d3b2c Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-04-16 08:43:33 +01:00
Shantur Rathore
a111de1af8 Minimum version to 0.14.0 2026-04-16 08:43:16 +01:00
Shantur Rathore
8a3b162be9 Bump version to 0.14.0 2026-04-16 08:42:33 +01:00
Shantur Rathore
c62cb3ce4a fix(server): share voice mode state across listeners 2026-04-13 21:36:49 +01:00
Shantur Rathore
d9811e735d fix(server): reject stale voice mode enables 2026-04-13 20:37:31 +01:00
Pascal André
1ce58b9dd9 fix(tauri): own Windows CLI subtree with a job object (#320)
## Summary
- Follow-up to #240 to make Windows desktop shutdown reliable this time,
even when the tracked CLI wrapper PID exits before its descendants
- Attach the spawned CLI process to a Windows Job Object with
`KILL_ON_JOB_CLOSE`, so the desktop app owns the whole subtree instead
of relying only on `taskkill /PID <wrapper> /T`
- Keep the current graceful-then-force shutdown path, but add a robust
OS-level fallback that reaps orphaned workspace processes when the
wrapper is already gone

## Root Cause
The previous Windows shutdown logic still depended on the PID tracked by
Tauri. In practice that PID can be a short-lived Node wrapper. Once that
wrapper exits, `taskkill` can report success or PID-not-found while
descendants remain alive, and the desktop app no longer has a reliable
handle to reap them.

## Validation
- `cargo check --manifest-path packages/tauri-app/src-tauri/Cargo.toml`
- `cargo build --release --manifest-path
packages/tauri-app/src-tauri/Cargo.toml`
- Manual local test: orphaned processes are cleaned up after desktop
shutdown
2026-04-12 21:10:15 +01:00
Pascal André
1907a4da03 perf(ui): virtualize message timeline rendering, #274 follow-up ( BIG SPEED IMPROVEMENT ) (#291)
## Summary
- virtualize MessageTimeline so large session histories stop rendering
the full timeline sidebar at once.
- keep the existing full render path in selection mode so xray/selection
behavior stays intact.
- route active-segment scrolling through the virtualizer so timeline
navigation still follows the selected message.

## Benefit
- prompt field was very laggy in cession with big history and timeline
had many bugs, this is fixed.
- the session with big history now load as fast as a new session .
2026-04-11 22:52:00 +01:00
Shantur Rathore
abf4c67fcc fix(ui): separate dictated prompt text 2026-04-11 20:34:53 +01:00
Shantur Rathore
bc130ceb5b fix(ui): portal timeline preview tooltip 2026-04-11 19:53:25 +01:00
Shantur Rathore
8505a43b16 fix(ui): add toggle for holding long assistant replies 2026-04-11 19:47:57 +01:00
Shantur Rathore
2a3329b5ed fix(ui): hold auto-follow on oversized assistant replies 2026-04-11 19:28:27 +01:00
VooDisss
c9c1cf21f0 fix(ui): stop forced auto-follow during streaming (#309)
# PR Draft: Fix sticky auto-scroll during streaming chat responses

Fixes #308

## Summary

This change makes chat auto-scroll easier to escape while assistant
output is still streaming.

The goal is to stop the viewport from repeatedly pulling the user back
toward the bottom once they begin scrolling upward to inspect earlier
content.

## Why

Before this change, streaming updates could keep reasserting
bottom-follow behavior during active rendering. That made auto-scroll
feel sticky and forced users to scroll repeatedly or forcefully just to
review earlier parts of an in-progress response.

The intended behavior is simpler: once the user scrolls upward to leave
follow mode, the UI should respect that decision instead of fighting it
during subsequent stream updates.

## What Changed

1. Removed render-time force-bottom behavior from the shared
follow-scroll helper path.
2. Updated streamed reasoning output to restore scroll without forcing
the viewport back to the bottom.
3. Updated streamed tool-call output to use the same non-forcing restore
behavior.

## Scope Boundaries

Included:

- Sticky auto-scroll behavior during streamed chat output
- Shared follow-scroll behavior used by streamed nested panes
- Reasoning and tool-call streaming paths that reused the same forced
follow behavior

Not included:

- A full rewrite of the virtualized message list follow model
- Broader scroll UX changes outside the streaming follow/escape behavior
- Unrelated UI or plugin configuration changes in the worktree

## Technical Notes

The core problem was not basic auto-scroll itself, but a render-time
path that could keep forcing bottom-follow behavior while new streamed
content was arriving.

That meant a user's attempt to scroll upward could be overridden
repeatedly by subsequent stream updates, which is why the auto-scroll
felt sticky. The fix removes that override and keeps render-time
restoration dependent on the current follow state instead.

## Files Changed

- `packages/ui/src/lib/follow-scroll.tsx`
- `packages/ui/src/components/message-block.tsx`
- `packages/ui/src/components/tool-call.tsx`

## Verification

Performed:

1. Reproduced the sticky auto-scroll behavior with a long multi-line
streaming response.
2. Verified that scrolling upward during streaming now disengages follow
more naturally in the affected streamed panes.
3. Ran `npm run typecheck --workspace @codenomad/ui`.
4. Ran `npm run build --workspace @codenomad/ui`.

Build note:

- The UI typecheck passes.
- The UI build succeeds.
- The build still emits existing third-party and chunk-size warnings
unrelated to this change.

## Risks and Follow-up

1. The broader scroll-follow model is still more heuristic-heavy than
ideal, so there may be future follow-up work to simplify it further.
2. This PR intentionally applies the smallest targeted fix to the known
snap-back path instead of rewriting the full chat scroll system.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-10 16:26:33 +01:00
Shantur Rathore
c7d4f99e48 fix(ui): prevent settings modal overflow on phones 2026-04-09 21:00:17 +01:00
Shantur Rathore
d50c00afb4 revert: remove debouncing and transparent window from zoom fix
Reverted debouncing logic and transparent window mode that were causing issues.
Kept the zoom step reduction from 0.2 to 0.1 for finer control.
2026-04-09 16:23:45 +01:00
Shantur Rathore
0ef57df3bc fix(ui): show token stats and simplify context window calculation
- Track messageInfoVersion in cache signature to rebuild when tokens arrive via SSE
- Read tokens from step-finish part directly (embedded in SSE events)
- Simplify available tokens to show full context window when no explicit input limit
2026-04-08 22:19:10 +01:00
Shantur Rathore
0739ec857c Reapply "fix(ui): support unified diff patch format in session changes viewer"
This reverts commit af6429162f.
2026-04-08 20:57:23 +01:00
Shantur Rathore
b060ab45ff Revert "feat(tauri): add zip bundle target for macOS and Windows"
This reverts commit 197898c01c.
2026-04-08 20:57:23 +01:00
Shantur Rathore
af6429162f Revert "fix(ui): support unified diff patch format in session changes viewer"
This reverts commit 2e9ee2cde6.
2026-04-08 20:57:12 +01:00
Shantur Rathore
2e9ee2cde6 fix(ui): support unified diff patch format in session changes viewer
Session diffs now use a compact patch field instead of storing full
before/after content. Added parsePatchToBeforeAfter utility to extract
before/after from unified diff format, and updated MonacoDiffViewer to
accept patch prop as alternative to before/after strings.
2026-04-08 20:48:13 +01:00
Shantur Rathore
d45c0b9367 fix(tauri): prevent Windows zoom freeze with debouncing and transparent window
- Add 50ms debounce to zoom operations to prevent WebView2 IPC bottleneck
- Enable transparent window mode for better Windows resize/zoom performance
- Reduce zoom step from 0.2 to 0.1 for finer control
2026-04-08 20:47:49 +01:00
Shantur Rathore
197898c01c feat(tauri): add zip bundle target for macOS and Windows
- Add build scripts for platform-specific builds with zip bundles
- Update CI workflow to use --bundles flag for explicit target selection
- macOS: use app,zip (removed dmg)
- Windows: use nsis,zip
- Linux: use appimage,deb,rpm
2026-04-08 20:34:08 +01:00
Shantur Rathore
0c0cfd2d22 fix(ui): keep speech input chained and scrolled to bottom 2026-04-08 19:02:06 +01:00
Shantur Rathore
5107ac207e feat(ui): show background process notify state 2026-04-08 16:09:17 +01:00
Shantur Rathore
1130066a33 feat(background-process): notify sessions when tasks end
Send synthetic session notifications when background processes finish, fail, stop, or terminate so the originating agent can react without polling. Hide synthetic text-only prompts from the UI stream so operational notifications stay out of the visible transcript.
2026-04-08 15:48:50 +01:00
Shantur Rathore
403a3ff189 Scroll fixes - Improve scroll to bottom handling for reasoning, bash and task tools (#288)
Fixes #286 and more
2026-04-04 15:11:45 +01:00
codenomadbot[bot]
7996e514c4 fix(ui): preserve prompt text when dismissing mention picker (#285)
## Summary
- preserve the current prompt text when dismissing the `@` mention/file
picker with `Esc`
- let `Enter` fall back to normal prompt submission when the mention
picker is open but there is no selectable result

## Verification
- source inspection of the prompt input and picker flow
- local `npm run typecheck --workspace @codenomad/ui` is blocked in this
environment because workspace dependencies are not installed

--
Yours,
[CodeNomadBot](https://github.com/NeuralNomadsAI/CodeNomad)

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-04 00:48:37 +01:00
Pascal André
141be2cde0 perf(ui): fix O(n²) reactive subscriptions in timeline effects (HUGE SPEED IMPROVEMENT) (#274)
## Summary

- Wraps store-proxied array iteration in `untrack()` in two
`createEffect` blocks and one `createMemo` in `message-section.tsx` to
prevent SolidJS from creating O(n) per-element reactive subscriptions on
every run
- Replaces `ids.includes()` with `Set.has()` for O(1) cleanup lookups in
the part-count tracking effect

## Problem

Two `createEffect` blocks in `message-section.tsx` iterate the
`messageIds()` store proxy array inside a tracked reactive context. This
causes SolidJS to create **O(n) per-element subscriptions** on every
run. When any element changes, all n subscriptions fire, re-running the
entire effect — resulting in **O(n²) total work**.

Additionally, the cleanup loop in the part-count tracking effect uses
`ids.includes(trackedId)` which is O(n) per tracked ID, compounding to
O(n²).

For long-running sessions with large message history (e.g. 7569
messages), this caused **~4.8 seconds of input latency** when sending a
new prompt.

## Fix

1. **Timeline sync effect (~line 738):** Wrap entire body in
`untrack()`, replace `ids.slice()` with `[...ids]` to snapshot without
proxy tracking
2. **Part-count tracking effect (~line 891):** Wrap iteration in
`untrack()`, replace `ids.includes()` with `new Set(ids).has()` for O(1)
lookups
3. **`lastAssistantIndex` memo:** Read message records via `untrack()`
to avoid O(n) subscriptions on part-level updates

## Result

On a 7569-message session: prompt input latency reduced from **~4.8s to
~42ms** (114x improvement).
2026-04-03 23:01:13 +01:00
codenomadbot[bot]
259d457209 fix(desktop): launch server with unrestricted root (#283)
## Summary
- launch the Electron-managed server with `--unrestricted-root` by
default
- launch the Tauri-managed server with `--unrestricted-root` by default
- stop relying on the server's `process.cwd()` fallback for desktop
filesystem browsing

--
Yours,
[CodeNomadBot](https://github.com/NeuralNomadsAI/CodeNomad)

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-03 16:47:34 +01:00
Shantur Rathore
d0a0325d7e feat(sidecars): add proxied sidecar tabs (#279)
## Summary
- add SideCar support across the server and UI, including proxied tabs,
picker/settings flows, and websocket-aware proxying
- unify top-level tab handling so workspace instances and SideCars share
the same tab model and navigation flows
- limit SideCars to port-based services only, removing server-managed
process control from the final API and UI

---------

Co-authored-by: Shantur <shantur@Mac.home>
Co-authored-by: Shantur <shantur@Shanturs-MacBook-Pro-M5.local>
2026-04-02 23:00:17 +01:00
Shantur Rathore
19a4c3df16 add remote server launcher flow (#277)
## Summary
- add a remote CodeNomad server launcher flow in the home screen,
including saved server profiles, probe-before-connect behavior, and
desktop bridge APIs for opening remote windows
- add Electron support for remote server windows with per-window origin
handling and self-signed certificate bypass, plus Tauri support for
remote windows with clearer self-signed guidance
- fix Tauri dev server resolution and window shutdown behavior so dev
mode prefers the source server entry and the app only exits after the
last window closes
2026-04-02 21:29:19 +01:00
Shantur Rathore
10506920ac fix electron remote tls exception scoping 2026-04-02 18:46:16 +01:00
Shantur Rathore
92c029d744 fix remote server keyboard and reconnect flows 2026-04-02 18:20:17 +01:00
Shantur Rathore
6eb3246d37 update tauri self-signed guidance 2026-04-02 17:18:23 +01:00
Shantur Rathore
5c90de84de fix tauri window shutdown behavior 2026-04-02 17:15:25 +01:00
Shantur Rathore
455a59f693 fix tauri dev server resolution 2026-04-02 17:10:10 +01:00
Shantur Rathore
a89da02d6b fix(tauri): stabilize dev CLI shell startup 2026-04-02 17:01:10 +01:00
Shantur Rathore
69d9e95bee add remote server launcher flow 2026-04-02 16:08:54 +01:00
bluelovers
893d5f9296 Add log level configuration support (#272)
Add log level configuration support via config.yaml and UI settings.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-02 11:12:33 +01:00
124 changed files with 5869 additions and 1168 deletions

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.13.3", "version": "0.14.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.13.3", "version": "0.14.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -12068,7 +12068,7 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.13.3", "version": "0.14.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
@@ -12105,7 +12105,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.13.3", "version": "0.14.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
@@ -12147,7 +12147,7 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.13.3", "version": "0.14.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
@@ -12155,7 +12155,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.13.3", "version": "0.14.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",

View File

@@ -1,6 +1,6 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.13.3", "version": "0.14.0",
"private": true, "private": true,
"description": "CodeNomad monorepo workspace", "description": "CodeNomad monorepo workspace",
"license": "MIT", "license": "MIT",

View File

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

View File

@@ -117,6 +117,28 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }), async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
) )
ipcMain.handle(
"remote:openWindow",
async (
_event,
payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean },
): Promise<{ ok: boolean }> => {
const opener = (mainWindow as BrowserWindow & {
__codenomadOpenRemoteWindow?: (payload: {
id: string
name: string
baseUrl: string
skipTlsVerify: boolean
}) => Promise<void>
}).__codenomadOpenRemoteWindow
if (!opener) {
throw new Error("Remote window opening is not available")
}
await opener(payload)
return { ok: true }
},
)
ipcMain.handle( ipcMain.handle(
"notifications:show", "notifications:show",
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => { async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {

View File

@@ -1,7 +1,7 @@
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron" import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
import http from "node:http" import http from "node:http"
import https from "node:https" import https from "node:https"
import { existsSync } from "fs" import { existsSync, mkdirSync } from "fs"
import { dirname, join } from "path" import { dirname, join } from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu" import { createApplicationMenu } from "./menu"
@@ -14,6 +14,31 @@ const mainDirname = dirname(mainFilename)
const isMac = process.platform === "darwin" const isMac = process.platform === "darwin"
function configureDevStoragePaths() {
if (app.isPackaged) {
return
}
const appName = "CodeNomad"
try {
app.setName(appName)
const userDataPath = join(app.getPath("appData"), appName)
const sessionDataPath = join(userDataPath, "session-data")
mkdirSync(userDataPath, { recursive: true })
mkdirSync(sessionDataPath, { recursive: true })
app.setPath("userData", userDataPath)
app.setPath("sessionData", sessionDataPath)
} catch (error) {
console.warn("[cli] failed to configure dev storage paths", error)
}
}
configureDevStoragePaths()
const cliManager = new CliProcessManager() const cliManager = new CliProcessManager()
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
let currentCliUrl: string | null = null let currentCliUrl: string | null = null
@@ -21,6 +46,8 @@ let pendingCliUrl: string | null = null
let pendingBootstrapToken: string | null = null let pendingBootstrapToken: string | null = null
let showingLoadingScreen = false let showingLoadingScreen = false
let preloadingView: BrowserView | null = null let preloadingView: BrowserView | null = null
const remoteWindowOrigins = new Map<number, Set<string>>()
const insecureWindowOrigins = new Map<number, Set<string>>()
if (isMac) { if (isMac) {
app.commandLine.appendSwitch("disable-spell-checking") app.commandLine.appendSwitch("disable-spell-checking")
@@ -93,8 +120,13 @@ function loadLoadingScreen(window: BrowserWindow) {
}) })
} }
function getAllowedRendererOrigins(): string[] { function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
const origins = new Set<string>() const origins = new Set<string>()
if (window) {
for (const origin of remoteWindowOrigins.get(window.id) ?? []) {
origins.add(origin)
}
}
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL] const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
for (const candidate of rendererCandidates) { for (const candidate of rendererCandidates) {
if (!candidate) { if (!candidate) {
@@ -109,13 +141,13 @@ function getAllowedRendererOrigins(): string[] {
return Array.from(origins) return Array.from(origins)
} }
function shouldOpenExternally(url: string): boolean { function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean {
try { try {
const parsed = new URL(url) const parsed = new URL(url)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return true return true
} }
const allowedOrigins = getAllowedRendererOrigins() const allowedOrigins = getAllowedRendererOrigins(window)
return !allowedOrigins.includes(parsed.origin) return !allowedOrigins.includes(parsed.origin)
} catch { } catch {
return false return false
@@ -128,7 +160,7 @@ function setupNavigationGuards(window: BrowserWindow) {
} }
window.webContents.setWindowOpenHandler(({ url }) => { window.webContents.setWindowOpenHandler(({ url }) => {
if (shouldOpenExternally(url)) { if (shouldOpenExternally(url, window)) {
handleExternal(url) handleExternal(url)
return { action: "deny" } return { action: "deny" }
} }
@@ -136,13 +168,54 @@ function setupNavigationGuards(window: BrowserWindow) {
}) })
window.webContents.on("will-navigate", (event, url) => { window.webContents.on("will-navigate", (event, url) => {
if (shouldOpenExternally(url)) { if (shouldOpenExternally(url, window)) {
event.preventDefault() event.preventDefault()
handleExternal(url) handleExternal(url)
} }
}) })
} }
function setWindowAllowedOrigin(window: BrowserWindow, url: string) {
try {
const origin = new URL(url).origin
remoteWindowOrigins.set(window.id, new Set([origin]))
} catch (error) {
console.warn("[cli] failed to store allowed origin", url, error)
}
}
function clearWindowAllowedOrigin(window: BrowserWindow) {
remoteWindowOrigins.delete(window.id)
}
function addWindowInsecureOrigin(window: BrowserWindow, url: string) {
try {
const origin = new URL(url).origin
insecureWindowOrigins.set(window.id, new Set([origin]))
} catch (error) {
console.warn("[cli] failed to store insecure origin", url, error)
}
}
function clearWindowInsecureOrigin(window: BrowserWindow) {
insecureWindowOrigins.delete(window.id)
}
function isInsecureOriginAllowed(url: string) {
try {
const targetOrigin = new URL(url).origin
for (const origins of insecureWindowOrigins.values()) {
if (origins.has(targetOrigin)) {
return true
}
}
} catch {
return false
}
return false
}
let cachedPreloadPath: string | null = null let cachedPreloadPath: string | null = null
function getPreloadPath() { function getPreloadPath() {
if (cachedPreloadPath && existsSync(cachedPreloadPath)) { if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
@@ -207,25 +280,30 @@ function createWindow() {
}, },
}) })
setupNavigationGuards(mainWindow) const window = mainWindow
setupNavigationGuards(window)
if (isMac) { if (isMac) {
mainWindow.webContents.session.setSpellCheckerEnabled(false) window.webContents.session.setSpellCheckerEnabled(false)
} }
showingLoadingScreen = true showingLoadingScreen = true
currentCliUrl = null currentCliUrl = null
loadLoadingScreen(mainWindow) clearWindowAllowedOrigin(window)
loadLoadingScreen(window)
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools({ mode: "detach" }) window.webContents.openDevTools({ mode: "detach" })
} }
createApplicationMenu(mainWindow) createApplicationMenu(window)
setupCliIPC(mainWindow, cliManager) setupCliIPC(window, cliManager)
mainWindow.on("closed", () => { window.on("closed", () => {
destroyPreloadingView() destroyPreloadingView()
clearWindowAllowedOrigin(window)
clearWindowInsecureOrigin(window)
mainWindow = null mainWindow = null
currentCliUrl = null currentCliUrl = null
pendingCliUrl = null pendingCliUrl = null
@@ -322,10 +400,66 @@ function finalizeCliSwap(url: string) {
return return
} }
const window = mainWindow
showingLoadingScreen = false showingLoadingScreen = false
currentCliUrl = url currentCliUrl = url
setWindowAllowedOrigin(window, url)
pendingCliUrl = null pendingCliUrl = null
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error)) window.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
}
function buildRemoteWindowTitle(name: string, baseUrl: string) {
try {
const parsed = new URL(baseUrl)
return `${name} - ${parsed.host}`
} catch {
return `${name} - ${baseUrl}`
}
}
function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) {
const escapedName = name.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
return `<!doctype html><html><head><meta charset="utf-8" /><title>${escapedName}</title><style>body{margin:0;background:#111827;color:#f9fafb;font-family:Inter,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}main{max-width:560px;width:100%;background:rgba(17,24,39,.88);border:1px solid rgba(255,255,255,.08);border-radius:20px;padding:28px;box-shadow:0 25px 60px rgba(0,0,0,.45)}h1{margin:0 0 10px;font-size:1.5rem}p{margin:0 0 10px;color:#cbd5e1;line-height:1.5}code{display:block;margin-top:16px;padding:12px 14px;border-radius:12px;background:#0f172a;color:#bfdbfe;overflow:auto}</style></head><body><main><h1>${escapedName}</h1><p>Could not connect to the remote server.</p><p>${escapedMessage}</p><code>${escapedUrl}</code></main></body></html>`
}
async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }) {
const targetUrl = new URL(payload.baseUrl)
const title = buildRemoteWindowTitle(payload.name, payload.baseUrl)
const window = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
backgroundColor: "#1a1a1a",
icon: getIconPath(),
title,
webPreferences: {
preload: getPreloadPath(),
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
},
})
setWindowAllowedOrigin(window, targetUrl.toString())
if (payload.skipTlsVerify) {
addWindowInsecureOrigin(window, targetUrl.toString())
}
setupNavigationGuards(window)
window.on("closed", () => {
clearWindowAllowedOrigin(window)
clearWindowInsecureOrigin(window)
})
try {
await window.loadURL(targetUrl.toString())
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildRemoteErrorHtml(payload.name, payload.baseUrl, message))}`)
}
} }
let bootstrapExchangeInFlight = false let bootstrapExchangeInFlight = false
@@ -504,6 +638,17 @@ app.whenReady().then(() => {
} }
createWindow() createWindow()
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
if (isInsecureOriginAllowed(url)) {
event.preventDefault()
console.warn("[cli] allowing insecure remote certificate for", url, error)
callback(true)
return
}
callback(false)
})
app.on("activate", () => { app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {

View File

@@ -539,7 +539,7 @@ export class CliProcessManager extends EventEmitter {
} }
private buildCliArgs(options: StartOptions, host: string): string[] { private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName] const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"]
if (options.dev) { if (options.dev) {
// Dev: run plain HTTP + Vite dev server proxy. // Dev: run plain HTTP + Vite dev server proxy.

View File

@@ -23,6 +23,7 @@ const electronAPI = {
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"), requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)), setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload), showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
} }
contextBridge.exposeInMainWorld("electronAPI", electronAPI) contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.13.3", "version": "0.14.0",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"license": "MIT", "license": "MIT",
"author": { "author": {

View File

@@ -13,6 +13,11 @@ type BackgroundProcess = {
outputSizeBytes?: number outputSizeBytes?: number
} }
type BackgroundProcessNotificationRequest = {
sessionID: string
directory: string
}
type BackgroundProcessOptions = { type BackgroundProcessOptions = {
baseDir: string baseDir: string
} }
@@ -36,12 +41,19 @@ export function createBackgroundProcessTools(config: CodeNomadConfig, options: B
args: { args: {
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"), title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
command: tool.schema.string().describe("Shell command to run in the workspace"), command: tool.schema.string().describe("Shell command to run in the workspace"),
notify: tool.schema.boolean().optional().describe("Notify the current session when the process ends"),
}, },
async execute(args) { async execute(args, context) {
assertCommandWithinBase(args.command, options.baseDir) assertCommandWithinBase(args.command, options.baseDir)
const notification: BackgroundProcessNotificationRequest | undefined = args.notify
? {
sessionID: context.sessionID,
directory: context.directory,
}
: undefined
const process = await request<BackgroundProcess>("", { const process = await request<BackgroundProcess>("", {
method: "POST", method: "POST",
body: JSON.stringify({ title: args.title, command: args.command }), body: JSON.stringify({ title: args.title, command: args.command, notify: args.notify, notification }),
}) })
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}` return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`

View File

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

View File

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

View File

@@ -170,6 +170,24 @@ export interface InstanceStreamEvent {
[key: string]: unknown [key: string]: unknown
} }
export type SideCarKind = "port"
export type SideCarPrefixMode = "strip" | "preserve"
export type SideCarStatus = "running" | "stopped"
export interface SideCar {
id: string
kind: SideCarKind
name: string
port: number
insecure: boolean
prefixMode: SideCarPrefixMode
status: SideCarStatus
createdAt: string
updatedAt: string
}
export interface BinaryRecord { export interface BinaryRecord {
id: string id: string
path: string path: string
@@ -244,12 +262,40 @@ export interface VoiceModeStateResponse {
enabled: boolean enabled: boolean
} }
export interface RemoteServerProfile {
id: string
name: string
baseUrl: string
skipTlsVerify: boolean
createdAt: string
updatedAt: string
lastConnectedAt?: string
}
export interface RemoteServerProbeRequest {
baseUrl: string
skipTlsVerify?: boolean
}
export interface RemoteServerProbeResponse {
ok: boolean
reachable: boolean
normalizedUrl: string
skipTlsVerify: boolean
requiresAuth: boolean
authenticated: boolean
error?: string
errorCode?: string
}
export type WorkspaceEventType = export type WorkspaceEventType =
| "workspace.created" | "workspace.created"
| "workspace.started" | "workspace.started"
| "workspace.error" | "workspace.error"
| "workspace.stopped" | "workspace.stopped"
| "workspace.log" | "workspace.log"
| "sidecar.updated"
| "sidecar.removed"
| "storage.configChanged" | "storage.configChanged"
| "storage.stateChanged" | "storage.stateChanged"
| "instance.dataChanged" | "instance.dataChanged"
@@ -262,6 +308,8 @@ export type WorkspaceEventPayload =
| { type: "workspace.error"; workspace: WorkspaceDescriptor } | { type: "workspace.error"; workspace: WorkspaceDescriptor }
| { type: "workspace.stopped"; workspaceId: string } | { type: "workspace.stopped"; workspaceId: string }
| { type: "workspace.log"; entry: WorkspaceLogEntry } | { type: "workspace.log"; entry: WorkspaceLogEntry }
| { type: "sidecar.updated"; sidecar: SideCar }
| { type: "sidecar.removed"; sidecarId: string }
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData } | { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
@@ -328,6 +376,8 @@ export interface ServerMeta {
export type BackgroundProcessStatus = "running" | "stopped" | "error" export type BackgroundProcessStatus = "running" | "stopped" | "error"
export type BackgroundProcessTerminalReason = "finished" | "failed" | "user_stopped" | "user_terminated"
export interface BackgroundProcess { export interface BackgroundProcess {
id: string id: string
workspaceId: string workspaceId: string
@@ -340,6 +390,8 @@ export interface BackgroundProcess {
stoppedAt?: string stoppedAt?: string
exitCode?: number exitCode?: number
outputSizeBytes?: number outputSizeBytes?: number
terminalReason?: BackgroundProcessTerminalReason
notifyEnabled?: boolean
} }
export interface BackgroundProcessListResponse { export interface BackgroundProcessListResponse {

View File

@@ -104,13 +104,18 @@ export class AuthManager {
} }
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null { getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
return this.getSessionFromHeaders(request.headers)
}
getSessionFromHeaders(headers: { cookie?: string | string[] | undefined }): { username: string; sessionId: string } | null {
if (!this.authEnabled) { if (!this.authEnabled) {
// When auth is disabled, treat all requests as authenticated. // When auth is disabled, treat all requests as authenticated.
// We still return a stable username so callers can display it. // We still return a stable username so callers can display it.
return { username: this.init.username, sessionId: "auth-disabled" } return { username: this.init.username, sessionId: "auth-disabled" }
} }
const cookies = parseCookies(request.headers.cookie) const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie.join("; ") : headers.cookie
const cookies = parseCookies(cookieHeader)
const sessionId = cookies[this.cookieName] const sessionId = cookies[this.cookieName]
const session = this.sessionManager.getSession(sessionId) const session = this.sessionManager.getSession(sessionId)
if (!session) return null if (!session) return null

View File

@@ -5,7 +5,7 @@ import { randomBytes } from "crypto"
import type { EventBus } from "../events/bus" import type { EventBus } from "../events/bus"
import type { WorkspaceManager } from "../workspaces/manager" import type { WorkspaceManager } from "../workspaces/manager"
import type { Logger } from "../logger" import type { Logger } from "../logger"
import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types" import type { BackgroundProcess, BackgroundProcessStatus, BackgroundProcessTerminalReason } from "../api-types"
const ROOT_DIR = ".codenomad/background_processes" const ROOT_DIR = ".codenomad/background_processes"
const INDEX_FILE = "index.json" const INDEX_FILE = "index.json"
@@ -27,6 +27,31 @@ interface RunningProcess {
outputPath: string outputPath: string
exitPromise: Promise<void> exitPromise: Promise<void>
workspaceId: string workspaceId: string
completion?: ProcessCompletion
}
interface ProcessCompletion {
reason: BackgroundProcessTerminalReason
endContext: "normal" | "workspace_cleanup"
removeAfterFinalize?: boolean
}
interface BackgroundProcessNotificationState {
sessionID: string
directory: string
sentAt?: string
}
interface PersistedBackgroundProcess extends BackgroundProcess {
notify?: BackgroundProcessNotificationState
}
interface StartOptions {
notify?: boolean
notification?: {
sessionID: string
directory: string
}
} }
export class BackgroundProcessManager { export class BackgroundProcessManager {
@@ -41,14 +66,14 @@ export class BackgroundProcessManager {
const records = await this.readIndex(workspaceId) const records = await this.readIndex(workspaceId)
const enriched = await Promise.all( const enriched = await Promise.all(
records.map(async (record) => ({ records.map(async (record) => ({
...record, ...this.toPublicProcess(record),
outputSizeBytes: await this.getOutputSize(workspaceId, record.id), outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
})), })),
) )
return enriched return enriched
} }
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> { async start(workspaceId: string, title: string, command: string, options: StartOptions = {}): Promise<BackgroundProcess> {
const workspace = this.deps.workspaceManager.get(workspaceId) const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) { if (!workspace) {
throw new Error("Workspace not found") throw new Error("Workspace not found")
@@ -73,8 +98,7 @@ export class BackgroundProcessManager {
this.killProcessTree(child, "SIGTERM") this.killProcessTree(child, "SIGTERM")
}) })
const record: BackgroundProcess = { const record: PersistedBackgroundProcess = {
id, id,
workspaceId, workspaceId,
title, title,
@@ -84,6 +108,20 @@ export class BackgroundProcessManager {
pid: child.pid, pid: child.pid,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
outputSizeBytes: 0, outputSizeBytes: 0,
notify: options.notify && options.notification
? {
sessionID: options.notification.sessionID,
directory: options.notification.directory,
}
: undefined,
}
const runningState: RunningProcess = {
id,
child,
outputPath,
exitPromise: Promise.resolve(),
workspaceId,
} }
const exitPromise = new Promise<void>((resolve) => { const exitPromise = new Promise<void>((resolve) => {
@@ -91,18 +129,21 @@ export class BackgroundProcessManager {
await new Promise<void>((resolve) => outputStream.end(resolve)) await new Promise<void>((resolve) => outputStream.end(resolve))
this.running.delete(id) this.running.delete(id)
record.status = this.statusFromExit(code) const completion = runningState.completion ?? this.completionFromExit(code)
record.terminalReason = completion.reason
record.status = this.statusFromReason(completion.reason)
record.exitCode = code === null ? undefined : code record.exitCode = code === null ? undefined : code
record.stoppedAt = new Date().toISOString() record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record) await this.finalizeRecord(workspaceId, record, completion)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
resolve() resolve()
}) })
}) })
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId }) runningState.exitPromise = exitPromise
this.running.set(id, runningState)
let lastPublishAt = 0 let lastPublishAt = 0
const maybePublishSize = () => { const maybePublishSize = () => {
@@ -128,7 +169,7 @@ export class BackgroundProcessManager {
await this.upsertIndex(workspaceId, record) await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id) record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record) this.publishUpdate(workspaceId, record)
return record return this.toPublicProcess(record)
} }
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> { async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
@@ -139,19 +180,21 @@ export class BackgroundProcessManager {
const running = this.running.get(processId) const running = this.running.get(processId)
if (running?.child && !running.child.killed) { if (running?.child && !running.child.killed) {
running.completion = { reason: "user_stopped", endContext: "normal" }
this.killProcessTree(running.child, "SIGTERM") this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running) await this.waitForExit(running)
const updated = await this.findProcess(workspaceId, processId)
return updated ? this.toPublicProcess(updated) : this.toPublicProcess(record)
} }
if (record.status === "running") { if (record.status === "running") {
record.status = "stopped" record.status = "stopped"
record.terminalReason = "user_stopped"
record.stoppedAt = new Date().toISOString() record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record) await this.finalizeRecord(workspaceId, record, { reason: "user_stopped", endContext: "normal" })
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
} }
return record return this.toPublicProcess(record)
} }
async terminate(workspaceId: string, processId: string): Promise<void> { async terminate(workspaceId: string, processId: string): Promise<void> {
@@ -160,17 +203,19 @@ export class BackgroundProcessManager {
const running = this.running.get(processId) const running = this.running.get(processId)
if (running?.child && !running.child.killed) { if (running?.child && !running.child.killed) {
running.completion = { reason: "user_terminated", endContext: "normal", removeAfterFinalize: true }
this.killProcessTree(running.child, "SIGTERM") this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running) await this.waitForExit(running)
return
} }
await this.removeFromIndex(workspaceId, processId) record.status = "stopped"
await this.removeProcessDir(workspaceId, processId) record.terminalReason = "user_terminated"
record.stoppedAt = new Date().toISOString()
this.deps.eventBus.publish({ await this.finalizeRecord(workspaceId, record, {
type: "instance.event", reason: "user_terminated",
instanceId: workspaceId, endContext: "normal",
event: { type: "background.process.removed", properties: { processId } }, removeAfterFinalize: true,
}) })
} }
@@ -266,6 +311,11 @@ export class BackgroundProcessManager {
private async cleanupWorkspace(workspaceId: string) { private async cleanupWorkspace(workspaceId: string) {
for (const [, running] of this.running.entries()) { for (const [, running] of this.running.entries()) {
if (running.workspaceId !== workspaceId) continue if (running.workspaceId !== workspaceId) continue
running.completion = {
reason: "user_terminated",
endContext: "workspace_cleanup",
removeAfterFinalize: true,
}
this.killProcessTree(running.child, "SIGTERM") this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running) await this.waitForExit(running)
} }
@@ -356,10 +406,17 @@ export class BackgroundProcessManager {
return args return args
} }
private statusFromExit(code: number | null): BackgroundProcessStatus { private completionFromExit(code: number | null): ProcessCompletion {
if (code === null) return "stopped" if (code === 0) {
if (code === 0) return "stopped" return { reason: "finished", endContext: "normal" }
return "error" }
return { reason: "failed", endContext: "normal" }
}
private statusFromReason(reason: BackgroundProcessTerminalReason): BackgroundProcessStatus {
if (reason === "failed") return "error"
return "stopped"
} }
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> { private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
@@ -423,25 +480,25 @@ export class BackgroundProcessManager {
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE) return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
} }
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> { private async findProcess(workspaceId: string, processId: string): Promise<PersistedBackgroundProcess | null> {
const records = await this.readIndex(workspaceId) const records = await this.readIndex(workspaceId)
return records.find((entry) => entry.id === processId) ?? null return records.find((entry) => entry.id === processId) ?? null
} }
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> { private async readIndex(workspaceId: string): Promise<PersistedBackgroundProcess[]> {
const indexPath = await this.getIndexPath(workspaceId) const indexPath = await this.getIndexPath(workspaceId)
if (!existsSync(indexPath)) return [] if (!existsSync(indexPath)) return []
try { try {
const raw = await fs.readFile(indexPath, "utf-8") const raw = await fs.readFile(indexPath, "utf-8")
const parsed = JSON.parse(raw) const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : [] return Array.isArray(parsed) ? (parsed as PersistedBackgroundProcess[]) : []
} catch { } catch {
return [] return []
} }
} }
private async upsertIndex(workspaceId: string, record: BackgroundProcess) { private async upsertIndex(workspaceId: string, record: PersistedBackgroundProcess) {
const records = await this.readIndex(workspaceId) const records = await this.readIndex(workspaceId)
const index = records.findIndex((entry) => entry.id === record.id) const index = records.findIndex((entry) => entry.id === record.id)
if (index >= 0) { if (index >= 0) {
@@ -458,7 +515,7 @@ export class BackgroundProcessManager {
await this.writeIndex(workspaceId, next) await this.writeIndex(workspaceId, next)
} }
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) { private async writeIndex(workspaceId: string, records: PersistedBackgroundProcess[]) {
const indexPath = await this.getIndexPath(workspaceId) const indexPath = await this.getIndexPath(workspaceId)
await fs.mkdir(path.dirname(indexPath), { recursive: true }) await fs.mkdir(path.dirname(indexPath), { recursive: true })
await fs.writeFile(indexPath, JSON.stringify(records, null, 2)) await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
@@ -503,14 +560,139 @@ export class BackgroundProcessManager {
} }
} }
private publishUpdate(workspaceId: string, record: BackgroundProcess) { private publishUpdate(workspaceId: string, record: PersistedBackgroundProcess) {
this.deps.eventBus.publish({ this.deps.eventBus.publish({
type: "instance.event", type: "instance.event",
instanceId: workspaceId, instanceId: workspaceId,
event: { type: "background.process.updated", properties: { process: record } }, event: { type: "background.process.updated", properties: { process: this.toPublicProcess(record) } },
}) })
} }
private toPublicProcess(record: PersistedBackgroundProcess): BackgroundProcess {
return {
id: record.id,
workspaceId: record.workspaceId,
title: record.title,
command: record.command,
cwd: record.cwd,
status: record.status,
pid: record.pid,
startedAt: record.startedAt,
stoppedAt: record.stoppedAt,
exitCode: record.exitCode,
outputSizeBytes: record.outputSizeBytes,
terminalReason: record.terminalReason,
notifyEnabled: Boolean(record.notify),
}
}
private async finalizeRecord(workspaceId: string, record: PersistedBackgroundProcess, completion: ProcessCompletion) {
if (this.shouldSendCompletionPrompt(record, completion)) {
try {
await this.sendCompletionPrompt(workspaceId, record)
if (record.notify) {
record.notify.sentAt = new Date().toISOString()
}
} catch (error) {
this.deps.logger.warn({ err: error, workspaceId, processId: record.id }, "Failed to send background process completion prompt")
}
}
if (completion.removeAfterFinalize) {
await this.removeFromIndex(workspaceId, record.id)
await this.removeProcessDir(workspaceId, record.id)
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.removed", properties: { processId: record.id } },
})
return
}
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
}
private shouldSendCompletionPrompt(record: PersistedBackgroundProcess, completion: ProcessCompletion) {
if (completion.endContext === "workspace_cleanup") return false
if (!record.notify) return false
return !record.notify.sentAt
}
private async sendCompletionPrompt(workspaceId: string, record: PersistedBackgroundProcess) {
const notify = record.notify
if (!notify || !record.terminalReason) return
if (!this.deps.workspaceManager.get(workspaceId)) {
throw new Error("Workspace not found")
}
const port = this.deps.workspaceManager.getInstancePort(workspaceId)
if (!port) {
throw new Error("Workspace instance is not ready")
}
const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async`
const headers: Record<string, string> = {
"content-type": "application/json",
"x-opencode-directory": /[^\x00-\x7F]/.test(notify.directory) ? encodeURIComponent(notify.directory) : notify.directory,
}
const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
if (authorization) {
headers.authorization = authorization
}
const response = await fetch(targetUrl, {
method: "POST",
headers,
body: JSON.stringify({
parts: [
{
type: "text",
text: this.buildSyntheticCompletionPrompt(record),
synthetic: true,
},
],
}),
})
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Prompt request failed with ${response.status}`)
}
}
private buildCompletionPrompt(record: PersistedBackgroundProcess): string {
const ref = `Background process "${record.title}" (${record.id})`
switch (record.terminalReason) {
case "finished":
return `${ref} finished successfully.`
case "failed":
return record.exitCode === undefined ? `${ref} failed.` : `${ref} failed with exit code ${record.exitCode}.`
case "user_stopped":
return `${ref} was stopped by user.`
case "user_terminated":
return `${ref} was terminated by user.`
}
return `${ref} ended.`
}
private buildSyntheticCompletionPrompt(record: PersistedBackgroundProcess): string {
return `<system-message>${this.escapeTaggedText(this.buildCompletionPrompt(record))}</system-message>`
}
private escapeTaggedText(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
private generateId(): string { private generateId(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15) const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
const random = randomBytes(3).toString("hex") const random = randomBytes(3).toString("hex")

View File

@@ -26,6 +26,7 @@ const PreferencesSchema = z
showUsageMetrics: z.boolean().default(true), showUsageMetrics: z.boolean().default(true),
autoCleanupBlankSessions: z.boolean().default(true), autoCleanupBlankSessions: z.boolean().default(true),
listeningMode: z.enum(["local", "all"]).default("local"), listeningMode: z.enum(["local", "all"]).default("local"),
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
// OS notifications // OS notifications
osNotificationsEnabled: z.boolean().default(false), osNotificationsEnabled: z.boolean().default(false),

View File

@@ -24,6 +24,8 @@ export class EventBus extends EventEmitter {
this.on("workspace.error", handler) this.on("workspace.error", handler)
this.on("workspace.stopped", handler) this.on("workspace.stopped", handler)
this.on("workspace.log", handler) this.on("workspace.log", handler)
this.on("sidecar.updated", handler)
this.on("sidecar.removed", handler)
this.on("storage.configChanged", handler) this.on("storage.configChanged", handler)
this.on("storage.stateChanged", handler) this.on("storage.stateChanged", handler)
this.on("instance.dataChanged", handler) this.on("instance.dataChanged", handler)
@@ -35,6 +37,8 @@ export class EventBus extends EventEmitter {
this.off("workspace.error", handler) this.off("workspace.error", handler)
this.off("workspace.stopped", handler) this.off("workspace.stopped", handler)
this.off("workspace.log", handler) this.off("workspace.log", handler)
this.off("sidecar.updated", handler)
this.off("sidecar.removed", handler)
this.off("storage.configChanged", handler) this.off("storage.configChanged", handler)
this.off("storage.stateChanged", handler) this.off("storage.stateChanged", handler)
this.off("instance.dataChanged", handler) this.off("instance.dataChanged", handler)

View File

@@ -24,6 +24,10 @@ import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses" import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service" import { SpeechService } from "./speech/service"
import { SideCarManager } from "./sidecars/manager"
import { ClientConnectionManager } from "./clients/connection-manager"
import { PluginChannelManager } from "./plugins/channel"
import { VoiceModeManager } from "./plugins/voice-mode"
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
@@ -315,6 +319,11 @@ async function main() {
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore(configLocation.instancesDir) const instanceStore = new InstanceStore(configLocation.instancesDir)
const speechService = new SpeechService(settings, logger.child({ component: "speech" })) const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
const sidecarManager = new SideCarManager({
settings,
eventBus,
logger: logger.child({ component: "sidecars" }),
})
const instanceEventBridge = new InstanceEventBridge({ const instanceEventBridge = new InstanceEventBridge({
workspaceManager, workspaceManager,
eventBus, eventBus,
@@ -372,6 +381,14 @@ async function main() {
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host) const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }))
const voiceModeManager = new VoiceModeManager({
connections: clientConnectionManager,
channel: pluginChannel,
logger: logger.child({ component: "voice-mode" }),
})
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT) const 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 httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
@@ -400,7 +417,11 @@ async function main() {
serverMeta, serverMeta,
instanceStore, instanceStore,
speechService, speechService,
sidecarManager,
authManager, authManager,
clientConnectionManager,
pluginChannel,
voiceModeManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl, uiDevServerUrl: uiResolution.uiDevServerUrl,
logger, logger,
@@ -421,7 +442,11 @@ async function main() {
serverMeta, serverMeta,
instanceStore, instanceStore,
speechService, speechService,
sidecarManager,
authManager, authManager,
clientConnectionManager,
pluginChannel,
voiceModeManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined, uiDevServerUrl: undefined,
logger, logger,
@@ -520,6 +545,18 @@ async function main() {
logger.warn({ err: error }, "Instance event bridge shutdown failed") logger.warn({ err: error }, "Instance event bridge shutdown failed")
} }
try {
await sidecarManager.shutdown()
} catch (error) {
logger.error({ err: error }, "SideCar manager shutdown failed")
}
try {
clientConnectionManager.shutdown()
} catch (error) {
logger.warn({ err: error }, "Client connection manager shutdown failed")
}
try { try {
await workspaceManager.shutdown() await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete") logger.info("Workspace manager shutdown complete")

View File

@@ -19,13 +19,13 @@ export class VoiceModeManager {
}) })
} }
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): void { setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): boolean {
if (enabled && !this.options.connections.isConnected(connection)) { if (enabled && !this.options.connections.isConnected(connection)) {
this.options.logger.debug( this.options.logger.debug(
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId }, { instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
"Ignoring voice mode enable for disconnected client connection", "Ignoring voice mode enable for disconnected client connection",
) )
return return false
} }
const key = getConnectionKey(connection) const key = getConnectionKey(connection)
@@ -44,6 +44,7 @@ export class VoiceModeManager {
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection") this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
this.publishIfChanged(instanceId) this.publishIfChanged(instanceId)
return true
} }
syncInstance(instanceId: string): void { syncInstance(instanceId: string): void {
@@ -76,7 +77,10 @@ export class VoiceModeManager {
this.aggregateByInstance.delete(instanceId) this.aggregateByInstance.delete(instanceId)
} }
this.options.logger.debug({ instanceId, enabled }, "Broadcasting aggregate voice mode") this.options.logger.debug(
{ instanceId, enabled },
"Broadcasting aggregate voice mode",
)
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled)) this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
} }
} }

View File

@@ -3,7 +3,9 @@ import cors from "@fastify/cors"
import fastifyStatic from "@fastify/static" import fastifyStatic from "@fastify/static"
import replyFrom from "@fastify/reply-from" import replyFrom from "@fastify/reply-from"
import fs from "fs" import fs from "fs"
import { connect as connectTcp, type Socket } from "net"
import path from "path" import path from "path"
import { connect as connectTls, type TLSSocket } from "tls"
import { fetch } from "undici" import { fetch } from "undici"
import type { Logger } from "../logger" import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager" import { WorkspaceManager } from "../workspaces/manager"
@@ -22,6 +24,8 @@ import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes" import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees" import { registerWorktreeRoutes } from "./routes/worktrees"
import { registerSpeechRoutes } from "./routes/speech" import { registerSpeechRoutes } from "./routes/speech"
import { registerRemoteServerRoutes } from "./routes/remote-servers"
import { registerSideCarRoutes } from "./routes/sidecars"
import { ServerMeta } from "../api-types" import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store" import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager" import { BackgroundProcessManager } from "../background-processes/manager"
@@ -32,6 +36,7 @@ import type { SpeechService } from "../speech/service"
import { ClientConnectionManager } from "../clients/connection-manager" import { ClientConnectionManager } from "../clients/connection-manager"
import { PluginChannelManager } from "../plugins/channel" import { PluginChannelManager } from "../plugins/channel"
import { VoiceModeManager } from "../plugins/voice-mode" import { VoiceModeManager } from "../plugins/voice-mode"
import type { SideCarManager } from "../sidecars/manager"
interface HttpServerDeps { interface HttpServerDeps {
bindHost: string bindHost: string
@@ -47,7 +52,11 @@ interface HttpServerDeps {
serverMeta: ServerMeta serverMeta: ServerMeta
instanceStore: InstanceStore instanceStore: InstanceStore
speechService: SpeechService speechService: SpeechService
sidecarManager: SideCarManager
authManager: AuthManager authManager: AuthManager
clientConnectionManager: ClientConnectionManager
pluginChannel: PluginChannelManager
voiceModeManager: VoiceModeManager
uiStaticDir: string uiStaticDir: string
uiDevServerUrl?: string uiDevServerUrl?: string
logger: Logger logger: Logger
@@ -176,13 +185,6 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus, eventBus: deps.eventBus,
logger: deps.logger.child({ component: "background-processes" }), logger: deps.logger.child({ component: "background-processes" }),
}) })
const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" }))
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
const voiceModeManager = new VoiceModeManager({
connections: clientConnectionManager,
channel: pluginChannel,
logger: deps.logger.child({ component: "voice-mode" }),
})
registerAuthRoutes(app, { authManager: deps.authManager }) registerAuthRoutes(app, { authManager: deps.authManager })
@@ -203,7 +205,7 @@ export function createHttpServer(deps: HttpServerDeps) {
const session = deps.authManager.getSessionFromRequest(request) const session = deps.authManager.getSessionFromRequest(request)
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/")
if (requiresAuthForApi && !session) { if (requiresAuthForApi && !session) {
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth. // Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/) const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
@@ -262,7 +264,7 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus, eventBus: deps.eventBus,
registerClient: registerSseClient, registerClient: registerSseClient,
logger: sseLogger, logger: sseLogger,
connectionManager: clientConnectionManager, connectionManager: deps.clientConnectionManager,
}) })
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager }) registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
registerStorageRoutes(app, { registerStorageRoutes(app, {
@@ -270,13 +272,21 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus, eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager, workspaceManager: deps.workspaceManager,
}) })
registerRemoteServerRoutes(app, { logger: apiLogger })
registerSpeechRoutes(app, { speechService: deps.speechService }) registerSpeechRoutes(app, { speechService: deps.speechService })
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
setupSideCarWebSocketProxy(app, {
sidecarManager: deps.sidecarManager,
authManager: deps.authManager,
logger: proxyLogger,
})
registerPluginRoutes(app, { registerPluginRoutes(app, {
workspaceManager: deps.workspaceManager, workspaceManager: deps.workspaceManager,
eventBus: deps.eventBus, eventBus: deps.eventBus,
logger: proxyLogger, logger: proxyLogger,
channel: pluginChannel, channel: deps.pluginChannel,
voiceModeManager, voiceModeManager: deps.voiceModeManager,
}) })
registerBackgroundProcessRoutes(app, { backgroundProcessManager }) registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
@@ -342,7 +352,6 @@ export function createHttpServer(deps: HttpServerDeps) {
}, },
stop: () => { stop: () => {
closeSseClients() closeSseClients()
clientConnectionManager.shutdown()
return app.close() return app.close()
}, },
} }
@@ -353,6 +362,68 @@ interface InstanceProxyDeps {
logger: Logger logger: Logger
} }
interface SideCarProxyDeps {
sidecarManager: SideCarManager
logger: Logger
}
interface SideCarWebSocketProxyDeps extends SideCarProxyDeps {
authManager: AuthManager
}
function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps) {
const proxyBaseHandler = async (
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) => {
await proxySideCarRequest({
request,
reply,
sidecarManager: deps.sidecarManager,
logger: deps.logger,
pathSuffix: "",
})
}
const proxyWildcardHandler = async (
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
reply: FastifyReply,
) => {
await proxySideCarRequest({
request,
reply,
sidecarManager: deps.sidecarManager,
logger: deps.logger,
pathSuffix: request.params["*"] ?? "",
})
}
app.all("/sidecars/:id", proxyBaseHandler)
app.all("/sidecars/:id/*", proxyWildcardHandler)
}
function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocketProxyDeps) {
app.server.on("upgrade", (request, socket, head) => {
const rawUrl = request.url ?? "/"
const parsed = parseSideCarUpgradePath(rawUrl)
if (!parsed) {
return
}
void proxySideCarWebSocketUpgrade({
request,
socket: socket as Socket,
head,
sidecarId: parsed.sidecarId,
incomingPath: parsed.pathname,
search: parsed.search,
sidecarManager: deps.sidecarManager,
authManager: deps.authManager,
logger: deps.logger,
})
})
}
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) { function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
app.register(async (instance) => { app.register(async (instance) => {
instance.removeAllContentTypeParsers() instance.removeAllContentTypeParsers()
@@ -837,3 +908,281 @@ function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, s
} }
return result return result
} }
async function proxySideCarRequest(args: {
request: FastifyRequest
reply: FastifyReply
sidecarManager: SideCarManager
logger: Logger
pathSuffix?: string
}) {
const sidecarId = (args.request.params as { id?: string }).id ?? ""
const sidecar = await args.sidecarManager.get(sidecarId)
if (!sidecar) {
args.reply.code(404).send({ error: "SideCar not found" })
return
}
const pathname = (args.request.raw.url ?? args.request.url ?? "").split("?")[0] ?? ""
const queryIndex = (args.request.raw.url ?? args.request.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (args.request.raw.url ?? args.request.url ?? "").slice(queryIndex) : ""
const pathSuffix = args.pathSuffix ?? ""
const requestPath = pathSuffix ? `${args.sidecarManager.buildProxyBasePath(sidecarId)}/${pathSuffix.replace(/^\/+/, "")}` : args.sidecarManager.buildProxyBasePath(sidecarId)
const targetPath = args.sidecarManager.buildTargetPath(sidecarId, requestPath, search)
const targetOrigin = args.sidecarManager.buildTargetOrigin(sidecar)
const targetUrl = `${targetOrigin}${targetPath}`
args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar")
await args.reply.from(targetUrl, {
rewriteRequestHeaders: (_originalRequest, headers) =>
sanitizeSideCarProxyRequestHeaders(headers as Record<string, string | string[] | undefined>, targetOrigin),
rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode),
onError: (reply, { error }) => {
args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request")
if (!reply.sent) {
reply.code(502).send({ error: "SideCar proxy failed" })
}
},
})
}
function parseSideCarUpgradePath(rawUrl: string): { sidecarId: string; pathname: string; search: string } | null {
let parsed: URL
try {
parsed = new URL(rawUrl, "http://localhost")
} catch {
return null
}
const match = parsed.pathname.match(/^\/sidecars\/([^/]+)(?:\/.*)?$/)
if (!match) {
return null
}
try {
return {
sidecarId: decodeURIComponent(match[1] ?? ""),
pathname: parsed.pathname,
search: parsed.search,
}
} catch {
return null
}
}
async function proxySideCarWebSocketUpgrade(args: {
request: import("http").IncomingMessage
socket: Socket
head: Buffer
sidecarId: string
incomingPath: string
search: string
sidecarManager: SideCarManager
authManager: AuthManager
logger: Logger
}) {
const { request, socket, head, sidecarId, incomingPath, search, sidecarManager, authManager, logger } = args
if (!isWebSocketUpgradeRequest(request)) {
rejectUpgrade(socket, 400, "Bad Request")
return
}
const session = authManager.getSessionFromHeaders(request.headers)
if (!session) {
rejectUpgrade(socket, 401, "Unauthorized")
return
}
const sidecar = await sidecarManager.get(sidecarId)
if (!sidecar) {
rejectUpgrade(socket, 404, "Not Found")
return
}
const targetOrigin = sidecarManager.buildTargetOrigin(sidecar)
const targetPath = sidecarManager.buildTargetPath(sidecarId, incomingPath, search)
const targetUrl = new URL(`${targetOrigin}${targetPath}`)
logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar")
const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl)
const closeBoth = () => {
if (!socket.destroyed) {
socket.destroy()
}
if (!upstream.destroyed) {
upstream.destroy()
}
}
upstream.once("error", (error) => {
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket")
rejectUpgrade(socket, 502, "Bad Gateway")
if (!upstream.destroyed) {
upstream.destroy()
}
})
socket.once("error", (error) => {
logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored")
if (!upstream.destroyed) {
upstream.destroy()
}
})
upstream.once(readyEvent, () => {
try {
upstream.write(buildSideCarWebSocketRequest(request, targetUrl))
if (head.length > 0) {
upstream.write(head)
}
upstream.pipe(socket)
socket.pipe(upstream)
} catch (error) {
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade")
closeBoth()
}
})
upstream.once("close", () => {
if (!socket.destroyed) {
socket.end()
}
})
socket.once("close", () => {
if (!upstream.destroyed) {
upstream.end()
}
})
}
function createSideCarUpstreamSocket(targetUrl: URL): { socket: Socket | TLSSocket; readyEvent: "connect" | "secureConnect" } {
const port = Number(targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80))
if (targetUrl.protocol === "https:") {
return {
socket: connectTls({
host: targetUrl.hostname,
port,
servername: targetUrl.hostname,
}),
readyEvent: "secureConnect",
}
}
return {
socket: connectTcp(port, targetUrl.hostname),
readyEvent: "connect",
}
}
function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, targetUrl: URL): string {
const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}`
const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n`
const headerLines: string[] = []
const rawHeaders = request.rawHeaders ?? []
const blockedHeaders = getBlockedSideCarRequestHeaders()
for (let index = 0; index < rawHeaders.length; index += 2) {
const key = rawHeaders[index]
const value = rawHeaders[index + 1]
if (!key || value === undefined) continue
const lower = key.toLowerCase()
if (blockedHeaders.has(lower)) continue
if (lower === "origin") {
headerLines.push(`Origin: ${targetUrl.origin}\r\n`)
continue
}
headerLines.push(`${key}: ${value}\r\n`)
}
const hostValue = targetUrl.port ? `${targetUrl.hostname}:${targetUrl.port}` : targetUrl.hostname
headerLines.push(`Host: ${hostValue}\r\n`)
headerLines.push("\r\n")
return requestLine + headerLines.join("")
}
function isWebSocketUpgradeRequest(request: import("http").IncomingMessage): boolean {
const upgrade = request.headers.upgrade
if (typeof upgrade !== "string" || upgrade.toLowerCase() !== "websocket") {
return false
}
const connection = request.headers.connection
const connectionValue = Array.isArray(connection) ? connection.join(",") : connection ?? ""
return connectionValue.toLowerCase().split(",").map((part) => part.trim()).includes("upgrade")
}
function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) {
if (socket.destroyed) {
return
}
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
socket.destroy()
}
function rewriteSideCarResponseHeaders(
headers: Record<string, string | string[] | undefined>,
sidecarId: string,
targetOrigin: string,
prefixMode: "strip" | "preserve",
) {
if (prefixMode === "preserve") {
return headers
}
const next = { ...headers }
const locationHeader = next.location
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader
if (!location) {
return next
}
const publicBase = `/sidecars/${encodeURIComponent(sidecarId)}`
if (location.startsWith("/")) {
next.location = `${publicBase}${location}`
return next
}
try {
const parsed = new URL(location)
if (parsed.origin === targetOrigin) {
next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}`
}
} catch {
// Relative redirects should continue to resolve against the public sidecar path.
}
return next
}
function sanitizeSideCarProxyRequestHeaders(
headers: Record<string, string | string[] | undefined>,
targetOrigin: string,
): Record<string, string | string[] | undefined> {
const blockedHeaders = getBlockedSideCarRequestHeaders()
const next: Record<string, string | string[] | undefined> = {}
for (const [key, value] of Object.entries(headers)) {
if (!value) continue
if (blockedHeaders.has(key.toLowerCase())) continue
next[key] = value
}
next.origin = targetOrigin
return next
}
function getBlockedSideCarRequestHeaders(): Set<string> {
return new Set([
"host",
"authorization",
"proxy-authorization",
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-port",
"x-forwarded-proto",
])
}

View File

@@ -9,6 +9,21 @@ interface RouteDeps {
const StartSchema = z.object({ const StartSchema = z.object({
title: z.string().trim().min(1), title: z.string().trim().min(1),
command: z.string().trim().min(1), command: z.string().trim().min(1),
notify: z.boolean().optional(),
notification: z
.object({
sessionID: z.string().trim().min(1),
directory: z.string().trim().min(1),
})
.optional(),
}).superRefine((value, ctx) => {
if (value.notify && !value.notification) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Notification metadata is required when notify is enabled",
path: ["notification"],
})
}
}) })
const OutputQuerySchema = z.object({ const OutputQuerySchema = z.object({
@@ -27,7 +42,10 @@ export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: Rout
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => { app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
const payload = StartSchema.parse(request.body ?? {}) const payload = StartSchema.parse(request.body ?? {})
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command) const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command, {
notify: payload.notify,
notification: payload.notification,
})
reply.code(201) reply.code(201)
return process return process
}) })

View File

@@ -66,11 +66,17 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
} }
const payload = VoiceModeStateSchema.parse(request.body ?? {}) const payload = VoiceModeStateSchema.parse(request.body ?? {})
deps.voiceModeManager.setEnabled( const applied = deps.voiceModeManager.setEnabled(
request.params.id, request.params.id,
{ clientId: payload.clientId, connectionId: payload.connectionId }, { clientId: payload.clientId, connectionId: payload.connectionId },
payload.enabled, payload.enabled,
) )
if (payload.enabled && !applied) {
reply.code(409).send({ error: "Client connection not active for voice mode enable" })
return
}
return { enabled: payload.enabled } return { enabled: payload.enabled }
}) })

View File

@@ -0,0 +1,166 @@
import { Agent, fetch } from "undici"
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { Logger } from "../../logger"
import type { RemoteServerProbeResponse } from "../../api-types"
interface RouteDeps {
logger: Logger
}
const ProbeSchema = z.object({
baseUrl: z.string().min(1),
skipTlsVerify: z.boolean().optional(),
})
const PROBE_TIMEOUT_MS = 8_000
export function registerRemoteServerRoutes(app: FastifyInstance, deps: RouteDeps) {
app.post("/api/remote-servers/probe", async (request, reply) => {
try {
const body = ProbeSchema.parse(request.body ?? {})
return await probeRemoteServer(body.baseUrl, Boolean(body.skipTlsVerify))
} catch (error) {
deps.logger.warn({ err: error }, "Failed to probe remote server")
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid request" }
}
})
}
async function probeRemoteServer(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteServerProbeResponse> {
const normalizedUrl = normalizeBaseUrl(baseUrl)
const probeUrl = new URL("./api/auth/status", `${normalizedUrl}/`)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
try {
const response = await fetch(probeUrl, {
method: "GET",
dispatcher,
signal: controller.signal,
headers: {
Accept: "application/json",
},
})
if (!response.ok) {
return {
ok: false,
reachable: true,
normalizedUrl,
skipTlsVerify,
requiresAuth: false,
authenticated: false,
error: `Remote server returned HTTP ${response.status}`,
errorCode: "http_error",
}
}
const payload = (await response.json()) as { authenticated?: unknown }
if (typeof payload?.authenticated !== "boolean") {
return {
ok: false,
reachable: true,
normalizedUrl,
skipTlsVerify,
requiresAuth: false,
authenticated: false,
error: "Remote server did not return a valid CodeNomad auth response",
errorCode: "invalid_server",
}
}
return {
ok: true,
reachable: true,
normalizedUrl,
skipTlsVerify,
requiresAuth: !payload.authenticated,
authenticated: payload.authenticated,
}
} catch (error) {
const message = describeProbeError(error)
return {
ok: false,
reachable: false,
normalizedUrl,
skipTlsVerify,
requiresAuth: false,
authenticated: false,
error: message.message,
errorCode: message.code,
}
} finally {
clearTimeout(timeout)
await dispatcher?.close().catch(() => {})
}
}
function normalizeBaseUrl(input: string): string {
const parsed = new URL(input.trim())
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Server URL must use http:// or https://")
}
parsed.hash = ""
parsed.search = ""
parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/"
const value = parsed.toString()
return parsed.pathname === "/" ? value.replace(/\/$/, "") : value.replace(/\/$/, "")
}
function describeProbeError(error: unknown): { code: string; message: string } {
const chain = unwrapErrorChain(error)
const detailed =
chain.find((entry) => {
const code = (entry?.code ?? "").toString()
return Boolean(code) && code !== "UND_ERR_RESPONSE_STATUS_CODE"
}) ?? chain[0]
const code = (detailed?.code ?? "").toString()
const exactMessage = detailed?.message?.trim() || chain.find((entry) => entry.message?.trim())?.message?.trim()
if (code === "DEPTH_ZERO_SELF_SIGNED_CERT" || code === "SELF_SIGNED_CERT_IN_CHAIN" || code === "CERT_HAS_EXPIRED") {
return {
code: "tls_error",
message: "Certificate check failed while connecting to the remote server.",
}
}
return {
code:
code === "ERR_INVALID_URL"
? "invalid_url"
: code === "ECONNREFUSED"
? "connection_refused"
: code === "ENOTFOUND"
? "dns_error"
: code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR"
? "timeout"
: code
? code.toLowerCase()
: "probe_failed",
message: exactMessage || "Failed to connect to the remote server.",
}
}
function unwrapErrorChain(error: unknown): Array<{ code?: unknown; message?: string }> {
const results: Array<{ code?: unknown; message?: string }> = []
let current: unknown = error
const seen = new Set<unknown>()
while (current && typeof current === "object" && !seen.has(current)) {
seen.add(current)
const entry = current as { code?: unknown; message?: string; cause?: unknown }
results.push({ code: entry.code, message: entry.message })
current = entry.cause
}
if (results.length === 0 && error instanceof Error) {
results.push({ message: error.message })
}
return results
}

View File

@@ -0,0 +1,56 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import type { SideCarManager } from "../../sidecars/manager"
interface RouteDeps {
sidecarManager: SideCarManager
}
const SideCarCreateSchema = z.object({
kind: z.literal("port").default("port"),
name: z.string().trim().min(1),
port: z.number().int().min(1).max(65535),
insecure: z.boolean().default(false),
prefixMode: z.enum(["strip", "preserve"]).default("strip"),
})
const SideCarUpdateSchema = SideCarCreateSchema.omit({ kind: true }).partial().refine((value) => Object.keys(value).length > 0, {
message: "At least one field is required",
})
export function registerSideCarRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/sidecars", async () => {
return { sidecars: await deps.sidecarManager.list() }
})
app.post("/api/sidecars", async (request, reply) => {
try {
const body = SideCarCreateSchema.parse(request.body ?? {})
const sidecar = await deps.sidecarManager.create(body)
reply.code(201)
return sidecar
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to create SideCar" }
}
})
app.put<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
try {
const body = SideCarUpdateSchema.parse(request.body ?? {})
return await deps.sidecarManager.update(request.params.id, body)
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to update SideCar" }
}
})
app.delete<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
const removed = await deps.sidecarManager.delete(request.params.id)
if (!removed) {
reply.code(404)
return { error: "SideCar not found" }
}
reply.code(204)
})
}

View File

@@ -107,6 +107,10 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
if (typeof listeningMode === "string") { if (typeof listeningMode === "string") {
serverConfig.listeningMode = listeningMode serverConfig.listeningMode = listeningMode
} }
const logLevel = preferences.logLevel
if (typeof logLevel === "string") {
serverConfig.logLevel = logLevel
}
const lastUsedBinary = preferences.lastUsedBinary const lastUsedBinary = preferences.lastUsedBinary
if (typeof lastUsedBinary === "string") { if (typeof lastUsedBinary === "string") {
serverConfig.opencodeBinary = lastUsedBinary serverConfig.opencodeBinary = lastUsedBinary
@@ -135,6 +139,7 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
const moved = new Set([ const moved = new Set([
"environmentVariables", "environmentVariables",
"listeningMode", "listeningMode",
"logLevel",
"lastUsedBinary", "lastUsedBinary",
"modelRecents", "modelRecents",
"modelFavorites", "modelFavorites",

View File

@@ -1,6 +1,7 @@
import type { Logger } from "../logger" import type { Logger } from "../logger"
import type { EventBus } from "../events/bus" import type { EventBus } from "../events/bus"
import type { ConfigLocation } from "../config/location" import type { ConfigLocation } from "../config/location"
import { z } from "zod"
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store" import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
import { migrateSettingsLayout } from "./migrate" import { migrateSettingsLayout } from "./migrate"
import type { WorkspaceEventPayload } from "../api-types" import type { WorkspaceEventPayload } from "../api-types"
@@ -8,6 +9,54 @@ import { sanitizeConfigOwner } from "./public-config"
export type DocKind = "config" | "state" export type DocKind = "config" | "state"
const CanonicalLogLevelSchema = z.preprocess(
(value) => (typeof value === "string" ? value.trim().toUpperCase() : value),
z.enum(["DEBUG", "INFO", "WARN", "ERROR"]),
)
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function isDeepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
try {
return JSON.stringify(a) === JSON.stringify(b)
} catch {
return false
}
}
function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc {
if (!isPlainObject(value)) {
return {}
}
const next: SettingsDoc = { ...value }
const parsedLogLevel = CanonicalLogLevelSchema.safeParse(next.logLevel)
if (parsedLogLevel.success) {
next.logLevel = parsedLogLevel.data
} else if (next.logLevel !== undefined) {
next.logLevel = "DEBUG"
}
return next
}
function normalizeConfigDoc(doc: SettingsDoc): SettingsDoc {
if (!isPlainObject(doc)) {
return {}
}
if (!isPlainObject(doc.server)) {
return doc
}
return {
...doc,
server: normalizeServerConfigOwner(doc.server as SettingsDoc),
}
}
export class SettingsService { export class SettingsService {
private readonly configStore: YamlDocStore private readonly configStore: YamlDocStore
private readonly stateStore: YamlDocStore private readonly stateStore: YamlDocStore
@@ -23,22 +72,44 @@ export class SettingsService {
} }
getDoc(kind: DocKind): SettingsDoc { getDoc(kind: DocKind): SettingsDoc {
return kind === "config" ? this.configStore.get() : this.stateStore.get() if (kind !== "config") {
return this.stateStore.get()
}
const current = this.configStore.get()
const normalized = normalizeConfigDoc(current)
if (!isDeepEqual(current, normalized)) {
this.configStore.replace(normalized)
}
return normalized
} }
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc { mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch) const updated =
kind === "config"
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
: this.stateStore.mergePatch(patch)
this.publish(kind, "*") this.publish(kind, "*")
return updated return updated
} }
getOwner(kind: DocKind, owner: string): SettingsDoc { getOwner(kind: DocKind, owner: string): SettingsDoc {
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner) if (kind !== "config") {
return this.stateStore.getOwner(owner)
}
return owner === "server"
? normalizeServerConfigOwner(this.getDoc("config").server as SettingsDoc)
: this.getDoc("config")[owner] as SettingsDoc
} }
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc { mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
const updated = const updated =
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch) kind === "config"
? owner === "server"
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
: this.configStore.mergePatchOwner(owner, patch)
: this.stateStore.mergePatchOwner(owner, patch)
this.publish(kind, owner, updated) this.publish(kind, owner, updated)
return updated return updated
} }

View File

@@ -0,0 +1,256 @@
import { connect } from "net"
import type { EventBus } from "../events/bus"
import type { Logger } from "../logger"
import type { SettingsService } from "../settings/service"
import type { SideCar, SideCarKind, SideCarPrefixMode, SideCarStatus } from "../api-types"
interface SideCarManagerOptions {
settings: SettingsService
eventBus: EventBus
logger: Logger
}
interface SideCarConfigRecord {
id: string
kind: SideCarKind
name: string
port: number
insecure: boolean
prefixMode: SideCarPrefixMode
createdAt: string
updatedAt: string
}
interface SideCarRuntimeRecord {
status: SideCarStatus
}
export class SideCarManager {
private readonly configs = new Map<string, SideCarConfigRecord>()
private readonly runtime = new Map<string, SideCarRuntimeRecord>()
constructor(private readonly options: SideCarManagerOptions) {
for (const record of this.loadConfiguredSideCars()) {
this.configs.set(record.id, record)
this.runtime.set(record.id, { status: "stopped" })
}
queueMicrotask(() => {
for (const record of this.configs.values()) {
void this.refreshPortSideCar(record.id).catch((error) => {
this.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port")
})
}
})
}
async list(): Promise<SideCar[]> {
await this.refreshPortStatuses()
return Array.from(this.configs.values()).map((record) => this.toSideCar(record))
}
async get(id: string): Promise<SideCar | undefined> {
if (!this.configs.has(id)) return undefined
await this.refreshPortSideCar(id)
return this.toSideCar(this.requireConfig(id))
}
async create(input: {
kind: SideCarKind
name: string
port: number
insecure: boolean
prefixMode: SideCarPrefixMode
}): Promise<SideCar> {
const normalizedName = input.name.trim()
const id = this.buildSideCarId(normalizedName)
if (this.configs.has(id)) {
throw new Error(`SideCar '${id}' already exists`)
}
const now = new Date().toISOString()
const record: SideCarConfigRecord = {
id,
kind: input.kind,
name: normalizedName,
port: input.port,
insecure: input.insecure,
prefixMode: input.prefixMode,
createdAt: now,
updatedAt: now,
}
this.configs.set(record.id, record)
this.runtime.set(record.id, { status: "stopped" })
this.persistConfigs()
await this.refreshPortSideCar(record.id)
return this.toSideCar(record)
}
async update(
id: string,
input: Partial<{
name: string
port: number
insecure: boolean
prefixMode: SideCarPrefixMode
}>,
): Promise<SideCar> {
const record = this.requireConfig(id)
record.name = typeof input.name === "string" ? input.name.trim() : record.name
record.port = typeof input.port === "number" ? input.port : record.port
record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure
record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode
record.updatedAt = new Date().toISOString()
this.persistConfigs()
await this.refreshPortSideCar(id)
return this.toSideCar(record)
}
async delete(id: string): Promise<boolean> {
const record = this.configs.get(id)
if (!record) return false
this.configs.delete(id)
this.runtime.delete(id)
this.persistConfigs()
this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id })
return true
}
async shutdown() {
return
}
buildTargetOrigin(sidecar: Pick<SideCar, "port" | "insecure">): string {
const protocol = sidecar.insecure ? "http" : "https"
return `${protocol}://127.0.0.1:${sidecar.port}`
}
buildProxyBasePath(id: string): string {
return `/sidecars/${encodeURIComponent(id)}`
}
buildTargetPath(id: string, incomingPath: string, search = ""): string {
const record = this.requireConfig(id)
const publicBase = this.buildProxyBasePath(id)
const normalizedPath = incomingPath || publicBase
if (record.prefixMode === "preserve") {
return `${normalizedPath}${search}`
}
let stripped = normalizedPath.startsWith(publicBase) ? normalizedPath.slice(publicBase.length) : normalizedPath
if (!stripped || stripped === "/") {
stripped = "/"
} else if (!stripped.startsWith("/")) {
stripped = `/${stripped}`
}
return `${stripped}${search}`
}
private async refreshPortStatuses() {
await Promise.all(Array.from(this.configs.values()).map((record) => this.refreshPortSideCar(record.id)))
}
private async refreshPortSideCar(id: string) {
const record = this.configs.get(id)
if (!record) return
const isAvailable = await this.isPortAvailable(record.port)
const current = this.runtime.get(id)
const nextStatus: SideCarStatus = isAvailable ? "running" : "stopped"
if (current?.status === nextStatus) {
return
}
this.runtime.set(id, { status: nextStatus })
record.updatedAt = new Date().toISOString()
this.publish(id)
}
private publish(id: string) {
const record = this.configs.get(id)
if (!record) return
this.options.eventBus.publish({ type: "sidecar.updated", sidecar: this.toSideCar(record) })
}
private toSideCar(record: SideCarConfigRecord): SideCar {
const runtime = this.runtime.get(record.id)
return {
id: record.id,
kind: record.kind,
name: record.name,
port: record.port,
insecure: record.insecure,
prefixMode: record.prefixMode,
status: runtime?.status ?? "stopped",
createdAt: record.createdAt,
updatedAt: record.updatedAt,
}
}
private requireConfig(id: string): SideCarConfigRecord {
const record = this.configs.get(id)
if (!record) {
throw new Error("SideCar not found")
}
return record
}
private persistConfigs() {
const sidecars = Array.from(this.configs.values()).map((record) => ({ ...record }))
this.options.settings.mergePatchOwner("config", "server", { sidecars })
}
private loadConfiguredSideCars(): SideCarConfigRecord[] {
const serverConfig = this.options.settings.getOwner("config", "server") as { sidecars?: unknown }
const list = Array.isArray(serverConfig?.sidecars) ? serverConfig.sidecars : []
const records: SideCarConfigRecord[] = []
for (const item of list) {
if (!item || typeof item !== "object") continue
const record = item as Record<string, unknown>
const kind = record.kind === "port" ? "port" : null
const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : null
const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : null
const port = typeof record.port === "number" && Number.isInteger(record.port) ? record.port : null
if (!kind || !id || !name || !port) continue
const insecure = record.insecure === true
const prefixMode = record.prefixMode === "preserve" ? "preserve" : "strip"
const createdAt = typeof record.createdAt === "string" && record.createdAt ? record.createdAt : new Date().toISOString()
const updatedAt = typeof record.updatedAt === "string" && record.updatedAt ? record.updatedAt : createdAt
records.push({ id, kind, name, port, insecure, prefixMode, createdAt, updatedAt })
}
return records
}
private isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = connect({ port, host: "127.0.0.1" }, () => {
socket.end()
resolve(true)
})
socket.once("error", () => {
socket.destroy()
resolve(false)
})
})
}
private buildSideCarId(name: string): string {
const normalized = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-|-$/g, "")
if (!normalized) {
throw new Error("SideCar name must include letters or numbers")
}
return normalized
}
}

View File

@@ -142,12 +142,15 @@ export class WorkspaceManager {
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword, [OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
} }
const logLevel = (serverConfig as any)?.logLevel
try { try {
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({ const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
workspaceId: id, workspaceId: id,
folder: workspacePath, folder: workspacePath,
binaryPath: resolvedBinaryPath, binaryPath: resolvedBinaryPath,
environment, environment,
logLevel,
onExit: (info) => this.handleProcessExit(info.workspaceId, info), onExit: (info) => this.handleProcessExit(info.workspaceId, info),
}) })

View File

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

View File

@@ -458,7 +458,7 @@ dependencies = [
[[package]] [[package]]
name = "codenomad-tauri" name = "codenomad-tauri"
version = "0.13.3" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dirs 5.0.1", "dirs 5.0.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.13.3", "version": "0.14.0",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "codenomad-tauri" name = "codenomad-tauri"
version = "0.13.3" version = "0.14.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
@@ -28,4 +28,4 @@ url = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] } windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }

View File

@@ -2378,6 +2378,72 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
"type": "string",
"const": "global-shortcut:default",
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
},
{
"description": "Enables the is_registered command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-is-registered",
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
},
{
"description": "Enables the register command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-register",
"markdownDescription": "Enables the register command without any pre-configured scope."
},
{
"description": "Enables the register_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-register-all",
"markdownDescription": "Enables the register_all command without any pre-configured scope."
},
{
"description": "Enables the unregister command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-unregister",
"markdownDescription": "Enables the unregister command without any pre-configured scope."
},
{
"description": "Enables the unregister_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-unregister-all",
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
},
{
"description": "Denies the is_registered command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-is-registered",
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
},
{
"description": "Denies the register command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-register",
"markdownDescription": "Denies the register command without any pre-configured scope."
},
{
"description": "Denies the register_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-register-all",
"markdownDescription": "Denies the register_all command without any pre-configured scope."
},
{
"description": "Denies the unregister command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-unregister",
"markdownDescription": "Denies the unregister command without any pre-configured scope."
},
{
"description": "Denies the unregister_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-unregister-all",
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
},
{ {
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", "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", "type": "string",

View File

@@ -5,9 +5,13 @@ use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::env; use std::env;
#[cfg(windows)]
use std::ffi::c_void;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fs; use std::fs;
use std::io::{BufRead, BufReader, Read, Write}; use std::io::{BufRead, BufReader, Read, Write};
#[cfg(windows)]
use std::mem::{size_of, zeroed};
use std::net::TcpStream; use std::net::TcpStream;
#[cfg(unix)] #[cfg(unix)]
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
@@ -19,12 +23,95 @@ use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url}; use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
#[cfg(windows)]
use std::os::windows::io::AsRawHandle;
#[cfg(windows)] #[cfg(windows)]
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
#[cfg(windows)]
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
#[cfg(windows)]
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
#[cfg(windows)] #[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000; const CREATE_NO_WINDOW: u32 = 0x08000000;
#[cfg(windows)]
#[derive(Debug)]
struct WindowsJobObject {
// The desktop wrapper may observe only a short-lived Node wrapper PID while the real
// server and workspace descendants continue running below it. KILL_ON_JOB_CLOSE gives
// Tauri an OS-owned handle for the whole subtree instead of relying on a single PID.
handle: HANDLE,
}
#[cfg(windows)]
impl WindowsJobObject {
fn create() -> anyhow::Result<Self> {
let handle = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) };
if handle.is_null() {
return Err(anyhow::anyhow!(
"CreateJobObjectW failed: {}",
std::io::Error::last_os_error()
));
}
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let ok = unsafe {
SetInformationJobObject(
handle,
JobObjectExtendedLimitInformation,
&mut info as *mut _ as *mut c_void,
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
};
if ok == 0 {
let err = std::io::Error::last_os_error();
unsafe {
CloseHandle(handle);
}
return Err(anyhow::anyhow!("SetInformationJobObject failed: {}", err));
}
Ok(Self { handle })
}
fn assign_child(&self, child: &Child) -> anyhow::Result<()> {
let process_handle = child.as_raw_handle() as HANDLE;
let ok = unsafe { AssignProcessToJobObject(self.handle, process_handle) };
if ok == 0 {
return Err(anyhow::anyhow!(
"AssignProcessToJobObject failed: {}",
std::io::Error::last_os_error()
));
}
Ok(())
}
}
#[cfg(windows)]
impl Drop for WindowsJobObject {
fn drop(&mut self) {
if !self.handle.is_null() {
unsafe {
CloseHandle(self.handle);
}
}
}
}
#[cfg(windows)]
unsafe impl Send for WindowsJobObject {}
#[cfg(windows)]
unsafe impl Sync for WindowsJobObject {}
fn log_line(message: &str) { fn log_line(message: &str) {
println!("[tauri-cli] {message}"); println!("[tauri-cli] {message}");
} }
@@ -363,6 +450,8 @@ impl Default for CliStatus {
pub struct CliProcessManager { pub struct CliProcessManager {
status: Arc<Mutex<CliStatus>>, status: Arc<Mutex<CliStatus>>,
child: Arc<Mutex<Option<Child>>>, child: Arc<Mutex<Option<Child>>>,
#[cfg(windows)]
job: Arc<Mutex<Option<WindowsJobObject>>>,
ready: Arc<AtomicBool>, ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>, bootstrap_token: Arc<Mutex<Option<String>>>,
} }
@@ -372,6 +461,8 @@ impl CliProcessManager {
Self { Self {
status: Arc::new(Mutex::new(CliStatus::default())), status: Arc::new(Mutex::new(CliStatus::default())),
child: Arc::new(Mutex::new(None)), child: Arc::new(Mutex::new(None)),
#[cfg(windows)]
job: Arc::new(Mutex::new(None)),
ready: Arc::new(AtomicBool::new(false)), ready: Arc::new(AtomicBool::new(false)),
bootstrap_token: Arc::new(Mutex::new(None)), bootstrap_token: Arc::new(Mutex::new(None)),
} }
@@ -394,6 +485,8 @@ impl CliProcessManager {
let status_arc = self.status.clone(); let status_arc = self.status.clone();
let child_arc = self.child.clone(); let child_arc = self.child.clone();
#[cfg(windows)]
let job_arc = self.job.clone();
let ready_flag = self.ready.clone(); let ready_flag = self.ready.clone();
let token_arc = self.bootstrap_token.clone(); let token_arc = self.bootstrap_token.clone();
thread::spawn(move || { thread::spawn(move || {
@@ -401,6 +494,8 @@ impl CliProcessManager {
app.clone(), app.clone(),
status_arc.clone(), status_arc.clone(),
child_arc, child_arc,
#[cfg(windows)]
job_arc,
ready_flag, ready_flag,
token_arc, token_arc,
dev, dev,
@@ -420,11 +515,12 @@ impl CliProcessManager {
} }
pub fn stop(&self) -> anyhow::Result<()> { pub fn stop(&self) -> anyhow::Result<()> {
#[cfg(windows)]
let _job = self.job.lock().take();
let mut child_opt = self.child.lock(); let mut child_opt = self.child.lock();
if let Some(mut child) = child_opt.take() { if let Some(mut child) = child_opt.take() {
log_line(&format!("stopping CLI pid={}", child.id())); log_line(&format!("stopping CLI pid={}", child.id()));
#[cfg(windows)]
let mut forced_tree_shutdown = false;
#[cfg(unix)] #[cfg(unix)]
unsafe { unsafe {
let pid = child.id() as i32; let pid = child.id() as i32;
@@ -446,18 +542,16 @@ impl CliProcessManager {
Ok(Some(_)) => break, Ok(Some(_)) => break,
Ok(None) => { Ok(None) => {
#[cfg(windows)] #[cfg(windows)]
if !forced_tree_shutdown if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) {
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
{
log_line(&format!( log_line(&format!(
"regular Windows shutdown still running after {}ms; escalating pid={}", "regular Windows shutdown still running after {}ms; escalating pid={}",
CLI_WINDOWS_FORCE_GRACE_MS, CLI_WINDOWS_FORCE_GRACE_MS,
child.id() child.id()
)); ));
forced_tree_shutdown = true;
if !kill_process_tree_windows(child.id(), true) { if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill(); let _ = child.kill();
} }
break;
} }
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) { if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
@@ -476,11 +570,7 @@ impl CliProcessManager {
} }
#[cfg(windows)] #[cfg(windows)]
{ {
if !forced_tree_shutdown if !kill_process_tree_windows(child.id(), true) {
&& !kill_process_tree_windows(child.id(), true)
{
let _ = child.kill();
} else if forced_tree_shutdown {
let _ = child.kill(); let _ = child.kill();
} }
} }
@@ -491,6 +581,9 @@ impl CliProcessManager {
Err(_) => break, Err(_) => break,
} }
} }
} else {
#[cfg(windows)]
log_line("tracked CLI process already exited; dropping Windows job object to reap descendants");
} }
let mut status = self.status.lock(); let mut status = self.status.lock();
@@ -511,6 +604,7 @@ impl CliProcessManager {
app: AppHandle, app: AppHandle,
status: Arc<Mutex<CliStatus>>, status: Arc<Mutex<CliStatus>>,
child_holder: Arc<Mutex<Option<Child>>>, child_holder: Arc<Mutex<Option<Child>>>,
#[cfg(windows)] job_holder: Arc<Mutex<Option<WindowsJobObject>>>,
ready: Arc<AtomicBool>, ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>, bootstrap_token: Arc<Mutex<Option<String>>>,
dev: bool, dev: bool,
@@ -534,7 +628,9 @@ impl CliProcessManager {
log_line(&format!("using cwd={}", c.display())); log_line(&format!("using cwd={}", c.display()));
} }
let command_info = if supports_user_shell() { let use_user_shell = supports_user_shell();
let command_info = if use_user_shell {
log_line("spawning via user shell"); log_line("spawning via user shell");
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?) ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
} else { } else {
@@ -545,7 +641,7 @@ impl CliProcessManager {
}) })
}; };
if !supports_user_shell() { if !use_user_shell {
if which::which(&resolution.node_binary).is_err() { if which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Node binary not found. Make sure Node.js is installed." "Node binary not found. Make sure Node.js is installed."
@@ -559,6 +655,8 @@ impl CliProcessManager {
let mut c = Command::new(&cmd.shell); let mut c = Command::new(&cmd.shell);
c.args(&cmd.args) c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1") .env("ELECTRON_RUN_AS_NODE", "1")
.env_remove("npm_config_prefix")
.env_remove("NPM_CONFIG_PREFIX")
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()); .stderr(Stdio::piped());
configure_spawn(&mut c); configure_spawn(&mut c);
@@ -588,6 +686,22 @@ impl CliProcessManager {
let pid = child.id(); let pid = child.id();
log_line(&format!("spawned pid={pid}")); log_line(&format!("spawned pid={pid}"));
#[cfg(windows)]
match WindowsJobObject::create().and_then(|job| {
job.assign_child(&child)?;
Ok(job)
}) {
Ok(job) => {
log_line(&format!("attached pid={pid} to Windows job object"));
*job_holder.lock() = Some(job);
}
Err(err) => {
log_line(&format!(
"failed to attach pid={pid} to Windows job object; falling back to taskkill-only cleanup: {err}"
));
}
}
{ {
let mut locked = status.lock(); let mut locked = status.lock();
locked.pid = Some(pid); locked.pid = Some(pid);
@@ -619,26 +733,41 @@ impl CliProcessManager {
.map(BufReader::new); .map(BufReader::new);
if let Some(reader) = stdout { if let Some(reader) = stdout {
Self::process_stream( let app = app_clone.clone();
reader, let status = status_clone.clone();
"stdout", let ready = ready_clone.clone();
&app_clone, let token = token_clone.clone();
&status_clone, let auth_cookie_name = auth_cookie_name_clone.clone();
&ready_clone, thread::spawn(move || {
&token_clone, Self::process_stream(
auth_cookie_name_clone.as_str(), reader,
); "stdout",
&app,
&status,
&ready,
&token,
auth_cookie_name.as_str(),
);
});
} }
if let Some(reader) = stderr { if let Some(reader) = stderr {
Self::process_stream( let app = app_clone.clone();
reader, let status = status_clone.clone();
"stderr", let ready = ready_clone.clone();
&app_clone, let token = token_clone.clone();
&status_clone, let auth_cookie_name = auth_cookie_name_clone.clone();
&ready_clone, thread::spawn(move || {
&token_clone, Self::process_stream(
auth_cookie_name_clone.as_str(), reader,
); "stderr",
&app,
&status,
&ready,
&token,
auth_cookie_name.as_str(),
);
});
} }
}); });
@@ -646,6 +775,8 @@ impl CliProcessManager {
let status_clone = status.clone(); let status_clone = status.clone();
let ready_clone = ready.clone(); let ready_clone = ready.clone();
let child_holder_clone = child_holder.clone(); let child_holder_clone = child_holder.clone();
#[cfg(windows)]
let job_holder_clone = job_holder.clone();
thread::spawn(move || { thread::spawn(move || {
let timeout = Duration::from_secs(60); let timeout = Duration::from_secs(60);
thread::sleep(timeout); thread::sleep(timeout);
@@ -700,6 +831,10 @@ impl CliProcessManager {
// Drop the handle after the process exits so other callers // Drop the handle after the process exits so other callers
// don't attempt to stop/kill a finished process. // don't attempt to stop/kill a finished process.
*guard = None; *guard = None;
#[cfg(windows)]
{
let _ = job_holder_clone.lock().take();
}
Some(status) Some(status)
} }
None => None, None => None,
@@ -757,8 +892,8 @@ impl CliProcessManager {
auth_cookie_name: &str, auth_cookie_name: &str,
) { ) {
let mut buffer = String::new(); let mut buffer = String::new();
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok(); let local_url_regex =
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok(); Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:"; let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
loop { loop {
@@ -800,38 +935,6 @@ impl CliProcessManager {
); );
continue; continue;
} }
if line.to_lowercase().contains("http server listening") {
if let Some(port) = http_regex
.as_ref()
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
{
Self::mark_ready(
app,
status,
ready,
bootstrap_token,
auth_cookie_name,
format!("http://localhost:{port}"),
);
continue;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
Self::mark_ready(
app,
status,
ready,
bootstrap_token,
auth_cookie_name,
format!("http://localhost:{}", port),
);
continue;
}
}
}
} }
} }
Err(_) => break, Err(_) => break,
@@ -976,6 +1079,7 @@ impl CliEntry {
"--auth-cookie-name".to_string(), "--auth-cookie-name".to_string(),
auth_cookie_name.to_string(), auth_cookie_name.to_string(),
"--generate-token".to_string(), "--generate-token".to_string(),
"--unrestricted-root".to_string(),
]; ];
if dev { if dev {
@@ -1031,27 +1135,58 @@ impl CliEntry {
} }
fn resolve_tsx(_app: &AppHandle) -> Option<String> { fn resolve_tsx(_app: &AppHandle) -> Option<String> {
let candidates = vec![ let cwd = std::env::current_dir().ok();
std::env::current_dir() let workspace = workspace_root();
.ok() let mut candidates = vec![
cwd.as_ref()
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
cwd.as_ref()
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
cwd.as_ref()
.map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
cwd.as_ref()
.map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
cwd.as_ref()
.map(|p| p.join("../node_modules/tsx/dist/cli.js")),
cwd.as_ref()
.map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
cwd.as_ref()
.map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
cwd.as_ref()
.map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
workspace
.as_ref()
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
workspace
.as_ref()
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
workspace
.as_ref()
.map(|p| p.join("node_modules/tsx/dist/cli.js")), .map(|p| p.join("node_modules/tsx/dist/cli.js")),
std::env::current_exe().ok().and_then(|ex| {
ex.parent()
.map(|p| p.join("../node_modules/tsx/dist/cli.js"))
}),
]; ];
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.mjs")));
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.cjs")));
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.js")));
}
}
first_existing(candidates) first_existing(candidates)
} }
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> { fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
let cwd = std::env::current_dir().ok();
let workspace = workspace_root();
let candidates = vec![ let candidates = vec![
std::env::current_dir() workspace
.ok() .as_ref()
.map(|p| p.join("packages/server/src/index.ts")), .map(|p| p.join("packages/server/src/index.ts")),
std::env::current_dir() cwd.as_ref().map(|p| p.join("packages/server/src/index.ts")),
.ok() cwd.as_ref().map(|p| p.join("../server/src/index.ts")),
.map(|p| p.join("../server/src/index.ts")), cwd.as_ref().map(|p| p.join("../../server/src/index.ts")),
]; ];
first_existing(candidates) first_existing(candidates)
@@ -1153,11 +1288,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
.unwrap_or("") .unwrap_or("")
.to_lowercase(); .to_lowercase();
if shell_name.contains("zsh") { let _ = shell_name;
vec!["-l".into(), "-i".into(), "-c".into(), command.into()] vec!["-l".into(), "-c".into(), command.into()]
} else {
vec!["-l".into(), "-c".into(), command.into()]
}
} }
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> { fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {

View File

@@ -6,13 +6,16 @@ use cli_manager::{CliProcessManager, CliStatus};
use keepawake::KeepAwake; use keepawake::KeepAwake;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview; use tauri::webview::Webview;
use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry}; use tauri::{
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
};
use tauri_plugin_global_shortcut::{ use tauri_plugin_global_shortcut::{
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState, Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
}; };
@@ -30,7 +33,7 @@ use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false); static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
const DEFAULT_ZOOM_LEVEL: f64 = 1.0; const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
const ZOOM_STEP: f64 = 0.2; const ZOOM_STEP: f64 = 0.1;
const MIN_ZOOM_LEVEL: f64 = 0.2; const MIN_ZOOM_LEVEL: f64 = 0.2;
const MAX_ZOOM_LEVEL: f64 = 5.0; const MAX_ZOOM_LEVEL: f64 = 5.0;
@@ -41,6 +44,16 @@ pub struct AppState {
pub manager: CliProcessManager, pub manager: CliProcessManager,
pub wake_lock: Mutex<Option<KeepAwake>>, pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>, pub zoom_level: Mutex<f64>,
pub remote_origins: Mutex<HashMap<String, String>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RemoteWindowPayload {
id: String,
name: String,
base_url: String,
skip_tls_verify: bool,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
@@ -118,11 +131,32 @@ fn should_allow_internal(url: &Url) -> bool {
} }
} }
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool { fn should_allow_window_origin<R: Runtime>(
app_handle: &AppHandle<R>,
window_label: &str,
url: &Url,
) -> bool {
if should_allow_internal(url) { if should_allow_internal(url) {
return true; return true;
} }
let state = app_handle.state::<AppState>();
let Ok(allowed) = state.remote_origins.lock() else {
return false;
};
if let Some(origin) = allowed.get(window_label) {
return origin == &url.origin().ascii_serialization();
}
false
}
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
let window_label = webview.label().to_string();
if should_allow_window_origin(&webview.app_handle(), &window_label, url) {
return true;
}
if let Err(err) = webview if let Err(err) = webview
.app_handle() .app_handle()
.opener() .opener()
@@ -133,6 +167,58 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
false false
} }
#[tauri::command]
fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
if payload.skip_tls_verify && payload.base_url.starts_with("https://") {
return Err(
"Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect, or use the CodeNomad Electron app."
.to_string(),
);
}
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
let label = format!("remote-{}", payload.id);
let title = format!(
"{} - {}",
payload.name,
parsed.host_str().unwrap_or(payload.base_url.as_str())
);
if let Some(existing) = app.get_webview_window(&label) {
let _ = existing.navigate(parsed.clone());
let _ = existing.set_title(&title);
let _ = existing.show();
let _ = existing.unminimize();
let _ = existing.set_focus();
return Ok(());
}
app.state::<AppState>()
.remote_origins
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), parsed.origin().ascii_serialization());
let window =
WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
.title(title)
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.build()
.map_err(|err| err.to_string())?;
let app_handle = app.clone();
window.on_window_event(move |event| {
if let WindowEvent::Destroyed = event {
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
origins.remove(&label);
}
}
});
Ok(())
}
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> { fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
paths paths
.iter() .iter()
@@ -286,6 +372,7 @@ fn main() {
manager: CliProcessManager::new(), manager: CliProcessManager::new(),
wake_lock: Mutex::new(None), wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL), zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
remote_origins: Mutex::new(HashMap::new()),
}) })
.setup(|app| { .setup(|app| {
set_windows_app_user_model_id(); set_windows_app_user_model_id();
@@ -323,7 +410,8 @@ fn main() {
cli_get_status, cli_get_status,
cli_restart, cli_restart,
wake_lock_start, wake_lock_start,
wake_lock_stop wake_lock_stop,
open_remote_window
]) ])
.on_menu_event(|app_handle, event| { .on_menu_event(|app_handle, event| {
match event.id().0.as_str() { match event.id().0.as_str() {
@@ -455,11 +543,24 @@ fn main() {
event: tauri::WindowEvent::CloseRequested { api, .. }, event: tauri::WindowEvent::CloseRequested { api, .. },
.. ..
} => { } => {
// Ensure we have time to stop the CLI process before the app exits. // Let windows close normally. App shutdown is handled only after the
// last window is actually gone so remote windows can outlive `main`.
let _ = api;
}
tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::Destroyed,
..
} => {
if !app_handle.webview_windows().is_empty() {
return;
}
// Stop the CLI only when the final window is gone and the app is
// truly exiting.
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) { if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
return; return;
} }
api.prevent_close();
let app = app_handle.clone(); let app = app_handle.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() { if let Some(state) = app.try_state::<AppState>() {

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad", "productName": "CodeNomad",
"version": "0.13.3", "version": "0.14.0",
"identifier": "ai.neuralnomads.codenomad.client", "identifier": "ai.neuralnomads.codenomad.client",
"build": { "build": {
"beforeDevCommand": "npm run dev:bootstrap", "beforeDevCommand": "npm run dev:bootstrap",

View File

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

View File

@@ -10,7 +10,10 @@ import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal" import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell2" import InstanceShell from "./components/instance/instance-shell2"
import { SettingsScreen } from "./components/settings-screen" import { SettingsScreen } from "./components/settings-screen"
import { SideCarPickerDialog } from "./components/sidecar-picker-dialog"
import { SideCarView } from "./components/sidecar-view"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context" import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { showAlertDialog } from "./stores/alerts"
import { initGithubStars } from "./stores/github-stars" import { initGithubStars } from "./stores/github-stars"
import { useCommands } from "./lib/hooks/use-commands" import { useCommands } from "./lib/hooks/use-commands"
@@ -23,7 +26,6 @@ import { runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n" import { useI18n } from "./lib/i18n"
import { setWakeLockDesired } from "./lib/native/wake-lock" import { setWakeLockDesired } from "./lib/native/wake-lock"
import { import {
hasInstances,
isSelectingFolder, isSelectingFolder,
setIsSelectingFolder, setIsSelectingFolder,
showFolderSelection, showFolderSelection,
@@ -33,10 +35,7 @@ import { useConfig } from "./stores/preferences"
import { import {
createInstance, createInstance,
instances, instances,
activeInstanceId,
setActiveInstanceId,
stopInstance, stopInstance,
getActiveInstance,
disconnectedInstance, disconnectedInstance,
acknowledgeDisconnectedInstance, acknowledgeDisconnectedInstance,
} from "./stores/instances" } from "./stores/instances"
@@ -53,6 +52,22 @@ import {
import { getInstanceSessionIndicatorStatus } from "./stores/session-status" import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
import { openSettings } from "./stores/settings-screen" import { openSettings } from "./stores/settings-screen"
import {
closeSidecarTab,
ensureSidecarsLoaded,
openSidecarTab,
} from "./stores/sidecars"
import {
activeAppTab,
activeAppTabId,
appTabs,
ensureActiveAppTab,
getAdjacentAppTabId,
getAppTabById,
selectAppTab,
selectInstanceTab,
selectSidecarTab,
} from "./stores/app-tabs"
const log = getLogger("actions") const log = getLogger("actions")
@@ -77,6 +92,7 @@ const App: Component = () => {
} = useConfig() } = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false)
const phoneQuery = useMediaQuery("(max-width: 767px)") const phoneQuery = useMediaQuery("(max-width: 767px)")
const isPhoneLayout = createMemo(() => phoneQuery()) const isPhoneLayout = createMemo(() => phoneQuery())
@@ -206,8 +222,7 @@ const App: Component = () => {
}) })
createEffect(() => { createEffect(() => {
instances() appTabs()
hasInstances()
requestAnimationFrame(() => updateInstanceTabBarHeight()) requestAnimationFrame(() => updateInstanceTabBarHeight())
}) })
@@ -219,7 +234,15 @@ const App: Component = () => {
onCleanup(() => window.removeEventListener("resize", handleResize)) onCleanup(() => window.removeEventListener("resize", handleResize))
}) })
const activeInstance = createMemo(() => getActiveInstance()) createEffect(() => {
appTabs()
ensureActiveAppTab()
})
const activeInstance = createMemo(() => {
const tab = activeAppTab()
return tab?.kind === "instance" ? tab.instance : null
})
const activeSessionIdForInstance = createMemo(() => { const activeSessionIdForInstance = createMemo(() => {
const instance = activeInstance() const instance = activeInstance()
if (!instance) return null if (!instance) return null
@@ -244,6 +267,7 @@ const App: Component = () => {
recordWorkspaceLaunch(folderPath, selectedBinary) recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError() clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary) const instanceId = await createInstance(folderPath, selectedBinary)
selectInstanceTab(instanceId)
setShowFolderSelection(false) setShowFolderSelection(false)
log.info("Created instance", { log.info("Created instance", {
@@ -270,8 +294,27 @@ const App: Component = () => {
} }
function handleNewInstanceRequest() { function handleNewInstanceRequest() {
if (hasInstances()) { setShowFolderSelection(true)
setShowFolderSelection(true) }
function handleOpenSidecarPicker() {
setSidecarPickerOpen(true)
void ensureSidecarsLoaded()
}
async function handleOpenSidecar(sidecarId: string) {
try {
const tab = await openSidecarTab(sidecarId)
selectSidecarTab(tab.token)
setShowFolderSelection(false)
setSidecarPickerOpen(false)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
showAlertDialog(message, {
variant: "error",
title: t("sidecars.open.errorTitle"),
})
log.error("Failed to open SideCar", error)
} }
} }
@@ -332,6 +375,23 @@ const App: Component = () => {
} }
} }
async function handleCloseAppTab(tabId: string) {
const tab = getAppTabById(tabId)
if (!tab) return
const fallbackTabId = activeAppTabId() === tabId ? getAdjacentAppTabId(tabId) : activeAppTabId()
if (tab.kind === "instance") {
await handleCloseInstance(tab.instance.id)
} else {
closeSidecarTab(tab.sidecarTab.token)
}
if (!getAppTabById(tabId)) {
ensureActiveAppTab(fallbackTabId)
}
}
const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => { const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => {
if (!instanceId || !sessionId || sessionId === "info") return if (!instanceId || !sessionId || sessionId === "info") return
await updateSessionAgent(instanceId, sessionId, agent) await updateSessionAgent(instanceId, sessionId, agent)
@@ -361,6 +421,7 @@ const App: Component = () => {
setThinkingBlocksExpansion, setThinkingBlocksExpansion,
setToolInputsVisibility, setToolInputsVisibility,
handleNewInstanceRequest, handleNewInstanceRequest,
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
handleCloseInstance, handleCloseInstance,
handleNewSession, handleNewSession,
handleCloseSession, handleCloseSession,
@@ -371,6 +432,7 @@ const App: Component = () => {
useAppLifecycle({ useAppLifecycle({
setEscapeInDebounce, setEscapeInDebounce,
handleNewInstanceRequest, handleNewInstanceRequest,
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
handleCloseInstance, handleCloseInstance,
handleNewSession, handleNewSession,
handleCloseSession, handleCloseSession,
@@ -470,52 +532,60 @@ const App: Component = () => {
</div> </div>
</Show> </Show>
<Show <Show
when={!hasInstances()} when={appTabs().length === 0}
fallback={ fallback={
<> <>
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}> <Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
<InstanceTabs <InstanceTabs
instances={instances()} tabs={appTabs()}
activeInstanceId={activeInstanceId()} activeTabId={activeAppTabId()}
onSelect={setActiveInstanceId} onSelect={selectAppTab}
onClose={handleCloseInstance} onClose={(tabId) => void handleCloseAppTab(tabId)}
onNew={handleNewInstanceRequest} onNew={handleNewInstanceRequest}
/> />
</Show> </Show>
<For each={Array.from(instances().values())}> <For each={appTabs()}>
{(instance) => { {(tab) => {
const isActiveInstance = () => activeInstanceId() === instance.id const isVisible = () => activeAppTabId() === tab.id && !showFolderSelection()
const isVisible = () => isActiveInstance() && !showFolderSelection() return tab.kind === "instance" ? (
return ( <div
<div class="flex-1 min-h-0 overflow-hidden"
class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}
style={{ display: isVisible() ? "flex" : "none" }} data-instance-id={tab.instance.id}
data-instance-id={instance.id} data-tab-id={tab.id}
data-instance-active={isActiveInstance() ? "true" : "false"} data-tab-kind={tab.kind}
data-instance-visible={isVisible() ? "true" : "false"} data-tab-visible={isVisible() ? "true" : "false"}
> >
<InstanceMetadataProvider instance={instance}> <InstanceMetadataProvider instance={tab.instance}>
<InstanceShell <InstanceShell
instance={instance} instance={tab.instance}
isActiveInstance={isActiveInstance()} isActiveInstance={isVisible()}
escapeInDebounce={escapeInDebounce()} escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands} paletteCommands={paletteCommands}
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)} onCloseSession={(sessionId) => handleCloseSession(tab.instance.id, sessionId)}
onNewSession={() => handleNewSession(instance.id)} onNewSession={() => handleNewSession(tab.instance.id)}
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(tab.instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(tab.instance.id, sessionId, model)}
onExecuteCommand={executeCommand} onExecuteCommand={executeCommand}
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()} tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()} mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
onEnterMobileFullscreen={() => void enterMobileFullscreen()} onEnterMobileFullscreen={() => void enterMobileFullscreen()}
onExitMobileFullscreen={() => void exitMobileFullscreen()} onExitMobileFullscreen={() => void exitMobileFullscreen()}
/> />
</InstanceMetadataProvider> </InstanceMetadataProvider>
</div>
</div> ) : (
) <div
class="flex-1 min-h-0 overflow-hidden"
style={{ display: isVisible() ? "flex" : "none" }}
data-tab-id={tab.id}
data-tab-kind={tab.kind}
data-tab-visible={isVisible() ? "true" : "false"}
>
<SideCarView tab={tab.sidecarTab} />
</div>
)
}} }}
</For> </For>
@@ -525,6 +595,7 @@ const App: Component = () => {
<FolderSelectionView <FolderSelectionView
onSelectFolder={handleSelectFolder} onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()} isLoading={isSelectingFolder()}
onOpenSidecar={handleOpenSidecarPicker}
/> />
</Show> </Show>
@@ -534,6 +605,7 @@ const App: Component = () => {
<FolderSelectionView <FolderSelectionView
onSelectFolder={handleSelectFolder} onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()} isLoading={isSelectingFolder()}
onOpenSidecar={handleOpenSidecarPicker}
onClose={() => { onClose={() => {
setShowFolderSelection(false) setShowFolderSelection(false)
clearLaunchError() clearLaunchError()
@@ -544,6 +616,7 @@ const App: Component = () => {
</Show> </Show>
<SettingsScreen /> <SettingsScreen />
<SideCarPickerDialog open={sidecarPickerOpen()} onClose={() => setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} />
<AlertDialog /> <AlertDialog />

View File

@@ -1,15 +1,17 @@
import { createEffect, createSignal, onCleanup, onMount } from "solid-js" import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { loadMonaco } from "../../lib/monaco/setup" import { loadMonaco } from "../../lib/monaco/setup"
import { getOrCreateTextModel } from "../../lib/monaco/model-cache" import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
import { inferMonacoLanguageId } from "../../lib/monaco/language" import { inferMonacoLanguageId } from "../../lib/monaco/language"
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup" import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
import { useTheme } from "../../lib/theme" import { useTheme } from "../../lib/theme"
import { parsePatchToBeforeAfter } from "../../lib/diff-utils"
interface MonacoDiffViewerProps { interface MonacoDiffViewerProps {
scopeKey: string scopeKey: string
path: string path: string
before: string patch?: string
after: string before?: string
after?: string
viewMode?: "split" | "unified" viewMode?: "split" | "unified"
contextMode?: "expanded" | "collapsed" contextMode?: "expanded" | "collapsed"
wordWrap?: "on" | "off" wordWrap?: "on" | "off"
@@ -23,6 +25,16 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
let monaco: any = null let monaco: any = null
const [ready, setReady] = createSignal(false) const [ready, setReady] = createSignal(false)
const resolvedContent = createMemo(() => {
if (props.patch !== undefined && props.patch !== null) {
return parsePatchToBeforeAfter(props.patch)
}
return {
before: props.before ?? "",
after: props.after ?? "",
}
})
const disposeEditor = () => { const disposeEditor = () => {
try { try {
diffEditor?.setModel(null as any) diffEditor?.setModel(null as any)
@@ -115,11 +127,12 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
createEffect(() => { createEffect(() => {
if (!ready() || !monaco || !diffEditor) return if (!ready() || !monaco || !diffEditor) return
const languageId = inferMonacoLanguageId(monaco, props.path) const languageId = inferMonacoLanguageId(monaco, props.path)
const { before, after } = resolvedContent()
const beforeKey = `${props.scopeKey}:diff:${props.path}:before` const beforeKey = `${props.scopeKey}:diff:${props.path}:before`
const afterKey = `${props.scopeKey}:diff:${props.path}:after` const afterKey = `${props.scopeKey}:diff:${props.path}:after`
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId }) const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: before, languageId })
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId }) const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: after, languageId })
diffEditor.setModel({ original, modified }) diffEditor.setModel({ original, modified })
void ensureMonacoLanguageLoaded(languageId).then(() => { void ensureMonacoLanguageLoaded(languageId).then(() => {

View File

@@ -1,6 +1,7 @@
import { Dialog } from "@kobalte/core/dialog"
import { Select } from "@kobalte/core/select" import { Select } from "@kobalte/core/select"
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js" import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid" import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2 } from "lucide-solid"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import DirectoryBrowserDialog from "./directory-browser-dialog" import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd" import Kbd from "./kbd"
@@ -14,25 +15,48 @@ import { useI18n, type Locale } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { openSettings, settingsOpen } from "../stores/settings-screen" import { openSettings, settingsOpen } from "../stores/settings-screen"
import { openExternalUrl } from "../lib/external-url" import { openExternalUrl } from "../lib/external-url"
import { serverApi } from "../lib/api-client"
import { openRemoteServerWindow } from "../lib/native/remote-window"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad" const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945" const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
type HomeTab = "local" | "servers"
interface FolderSelectionViewProps { interface FolderSelectionViewProps {
onSelectFolder: (folder: string, binaryPath?: string) => void onSelectFolder: (folder: string, binaryPath?: string) => void
onOpenSidecar?: () => void
isLoading?: boolean isLoading?: boolean
onClose?: () => void onClose?: () => void
} }
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => { const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig() const {
recentFolders,
removeRecentFolder,
preferences,
updatePreferences,
serverSettings,
remoteServers,
saveRemoteServerProfile,
markRemoteServerConnected,
removeRemoteServerProfile,
} = useConfig()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const [selectedIndex, setSelectedIndex] = createSignal(0) const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false) const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const [activeTab, setActiveTab] = createSignal<HomeTab>("local")
const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false)
const [serverName, setServerName] = createSignal("")
const [serverUrl, setServerUrl] = createSignal("")
const [skipTlsVerify, setSkipTlsVerify] = createSignal(false)
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
const [isSavingServer, setIsSavingServer] = createSignal(false)
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
const nativeDialogsAvailable = supportsNativeDialogs() const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined let recentListRef: HTMLDivElement | undefined
@@ -51,8 +75,13 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0] const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
const folders = () => recentFolders() const folders = () => recentFolders()
const serverList = () => remoteServers()
const isLoading = () => Boolean(props.isLoading) const isLoading = () => Boolean(props.isLoading)
function getActiveListLength() {
return activeTab() === "local" ? folders().length : serverList().length
}
// Update selected binary when preferences change // Update selected binary when preferences change
createEffect(() => { createEffect(() => {
const lastUsed = serverSettings().opencodeBinary const lastUsed = serverSettings().opencodeBinary
@@ -64,7 +93,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function scrollToIndex(index: number) { function scrollToIndex(index: number) {
const container = recentListRef const container = recentListRef
if (!container) return if (!container) return
const element = container.querySelector(`[data-folder-index="${index}"]`) as HTMLElement | null const element = container.querySelector(`[data-list-index="${index}"]`) as HTMLElement | null
if (!element) return if (!element) return
const containerRect = container.getBoundingClientRect() const containerRect = container.getBoundingClientRect()
@@ -113,19 +142,18 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
return return
} }
const folderList = folders()
if (isBrowseShortcut) { if (isBrowseShortcut) {
e.preventDefault() e.preventDefault()
void handleBrowse() void handleBrowse()
return return
} }
if (folderList.length === 0) return const listLength = getActiveListLength()
if (listLength === 0) return
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault() e.preventDefault()
const newIndex = Math.min(selectedIndex() + 1, folderList.length - 1) const newIndex = Math.min(selectedIndex() + 1, listLength - 1)
setSelectedIndex(newIndex) setSelectedIndex(newIndex)
setFocusMode("recent") setFocusMode("recent")
scrollToIndex(newIndex) scrollToIndex(newIndex)
@@ -138,7 +166,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
} else if (e.key === "PageDown") { } else if (e.key === "PageDown") {
e.preventDefault() e.preventDefault()
const pageSize = 5 const pageSize = 5
const newIndex = Math.min(selectedIndex() + pageSize, folderList.length - 1) const newIndex = Math.min(selectedIndex() + pageSize, listLength - 1)
setSelectedIndex(newIndex) setSelectedIndex(newIndex)
setFocusMode("recent") setFocusMode("recent")
scrollToIndex(newIndex) scrollToIndex(newIndex)
@@ -156,7 +184,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
scrollToIndex(0) scrollToIndex(0)
} else if (e.key === "End") { } else if (e.key === "End") {
e.preventDefault() e.preventDefault()
const newIndex = folderList.length - 1 const newIndex = listLength - 1
setSelectedIndex(newIndex) setSelectedIndex(newIndex)
setFocusMode("recent") setFocusMode("recent")
scrollToIndex(newIndex) scrollToIndex(newIndex)
@@ -165,10 +193,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
handleEnterKey() handleEnterKey()
} else if (e.key === "Backspace" || e.key === "Delete") { } else if (e.key === "Backspace" || e.key === "Delete") {
e.preventDefault() e.preventDefault()
if (folderList.length > 0 && focusMode() === "recent") { if (listLength > 0 && focusMode() === "recent") {
const folder = folderList[selectedIndex()] if (activeTab() === "local") {
if (folder) { const folder = folders()[selectedIndex()]
handleRemove(folder.path) if (folder) {
handleRemove(folder.path)
}
} else {
const server = serverList()[selectedIndex()]
if (server) {
removeRemoteServerProfile(server.id)
}
} }
} }
} }
@@ -177,15 +212,40 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function handleEnterKey() { function handleEnterKey() {
if (isLoading()) return if (isLoading()) return
const folderList = folders()
const index = selectedIndex() const index = selectedIndex()
const folder = folderList[index] if (activeTab() === "local") {
if (folder) { const folder = folders()[index]
handleFolderSelect(folder.path) if (folder) {
handleFolderSelect(folder.path)
}
return
}
const server = serverList()[index]
if (server) {
void handleConnectSavedServer(server.id)
} }
} }
createEffect(() => {
activeTab()
setSelectedIndex(0)
setFocusMode("recent")
})
createEffect(() => {
const length = getActiveListLength()
if (length === 0) {
setSelectedIndex(0)
return
}
if (selectedIndex() >= length) {
setSelectedIndex(length - 1)
}
})
onMount(() => { onMount(() => {
window.addEventListener("keydown", handleKeyDown) window.addEventListener("keydown", handleKeyDown)
@@ -236,6 +296,87 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
props.onSelectFolder(path, selectedBinary()) props.onSelectFolder(path, selectedBinary())
} }
function resetServerDialog() {
setServerName("")
setServerUrl("")
setSkipTlsVerify(false)
setServerDialogError(null)
}
function openServerDialog() {
resetServerDialog()
setIsServerDialogOpen(true)
}
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
const trimmedName = input.name.trim()
const trimmedUrl = input.baseUrl.trim()
if (!trimmedName || !trimmedUrl) {
throw new Error(t("folderSelection.servers.dialog.errorRequired"))
}
const probe = await serverApi.probeRemoteServer({
baseUrl: trimmedUrl,
skipTlsVerify: input.skipTlsVerify,
})
if (!probe.ok) {
throw new Error(probe.error || t("folderSelection.servers.dialog.errorConnect"))
}
const profile = await saveRemoteServerProfile({
id: input.id,
name: trimmedName,
baseUrl: probe.normalizedUrl,
skipTlsVerify: input.skipTlsVerify,
})
if (openWindow) {
await openRemoteServerWindow(profile)
await markRemoteServerConnected(profile.id)
}
return profile
}
async function handleSaveServer(openWindow: boolean) {
if (isSavingServer()) return
setIsSavingServer(true)
setServerDialogError(null)
try {
await probeAndOpenServer(
{
name: serverName(),
baseUrl: serverUrl(),
skipTlsVerify: skipTlsVerify(),
},
openWindow,
)
setIsServerDialogOpen(false)
resetServerDialog()
} catch (error) {
setServerDialogError(error instanceof Error ? error.message : String(error))
} finally {
setIsSavingServer(false)
}
}
async function handleConnectSavedServer(id: string) {
const target = remoteServers().find((entry) => entry.id === id)
if (!target || connectingServerId()) return
setConnectingServerId(id)
try {
await probeAndOpenServer(target, true)
} catch (error) {
showAlertDialog(error instanceof Error ? error.message : String(error), {
title: t("folderSelection.servers.errorTitle"),
variant: "warning",
})
} finally {
setConnectingServerId(null)
}
}
async function handleBrowse() { async function handleBrowse() {
if (isLoading()) return if (isLoading()) return
setFocusMode("new") setFocusMode("new")
@@ -476,90 +617,223 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4"> <div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
{/* Right column: recent folders */} {/* Right column: recent folders */}
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden"> <div class="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 flex flex-col flex-1 min-h-0">
<div class="panel-header"> <div class="panel-header !gap-0 !p-0">
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2> <div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
<p class="panel-subtitle"> <button
{t( type="button"
folders().length === 1 class="border-r border-base px-4 py-3 text-left transition-colors"
? "folderSelection.recent.subtitle.one" classList={{
: "folderSelection.recent.subtitle.other", "text-primary": activeTab() === "local",
{ count: folders().length }, "text-muted hover:text-secondary": activeTab() !== "local",
)} }}
</p> style={{
</div> "background-color": "var(--surface-secondary)",
<div }}
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto" onClick={() => setActiveTab("local")}
ref={(el) => (recentListRef = el)} >
>
<For each={folders()}>
{(folder, index) => (
<div <div
class="panel-list-item" class="panel-title text-base"
classList={{ style={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(), color: activeTab() === "local" ? "var(--text-primary)" : "var(--text-secondary)",
"panel-list-item-disabled": isLoading(),
}} }}
> >
<div class="flex items-center gap-2 w-full px-1"> {t("folderSelection.recent.title")}
</div>
<p
class="panel-subtitle mt-1"
style={{
color: activeTab() === "local" ? "var(--text-muted)" : "var(--text-secondary)",
}}
>
{t(
folders().length === 1
? "folderSelection.recent.subtitle.one"
: "folderSelection.recent.subtitle.other",
{ count: folders().length },
)}
</p>
</button>
<button
type="button"
class="px-4 py-3 text-left transition-colors"
classList={{
"text-primary": activeTab() === "servers",
"text-muted hover:text-secondary": activeTab() !== "servers",
}}
style={{
"background-color": "var(--surface-secondary)",
}}
onClick={() => setActiveTab("servers")}
>
<div
class="panel-title text-base"
style={{
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
}}
>
{t("folderSelection.tabs.servers")}
</div>
<p
class="panel-subtitle mt-1"
style={{
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
}}
>
{t("folderSelection.servers.count", { count: remoteServers().length })}
</p>
</button>
</div>
</div>
<Show
when={activeTab() === "local"}
fallback={
<Show
when={remoteServers().length > 0}
fallback={
<div class="panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Globe class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
<button <button
data-folder-index={index()} type="button"
class="panel-list-item-content flex-1" class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
disabled={isLoading()} onClick={openServerDialog}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
> >
<div class="flex items-center justify-between gap-3 w-full"> <Globe class="w-4 h-4" />
<div class="flex-1 min-w-0"> <span>{t("folderSelection.actions.connectButton")}</span>
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{splitFolderPath(folder.path).baseName}
</span>
</div>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">
{getDisplayPath(folder.path)}
</span>
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</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> </button>
</div> </div>
}
>
<div
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
ref={(el) => (recentListRef = el)}
>
<For each={remoteServers()}>
{(server, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-list-index={index()}
class="panel-list-item-content flex-1"
onClick={() => void handleConnectSavedServer(server.id)}
onMouseEnter={() => {
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0 text-left">
<div class="flex items-center gap-2 mb-1">
<Globe class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">{server.name}</span>
</div>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">{server.baseUrl}</span>
</div>
</div>
<Show when={connectingServerId() === server.id} fallback={<Show when={focusMode() === "recent" && selectedIndex() === index()}><kbd class="kbd"></kbd></Show>}>
<Loader2 class="w-4 h-4 animate-spin icon-muted" />
</Show>
</div>
</button>
<button
onClick={() => removeRemoteServerProfile(server.id)}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title={t("folderSelection.servers.remove")}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
)}
</For>
</div> </div>
)} </Show>
</For> }
</div> >
<Show
when={folders().length > 0}
fallback={
<div class="panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
</div>
}
>
<div
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
ref={(el) => (recentListRef = el)}
>
<For each={folders()}>
{(folder, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-list-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{splitFolderPath(folder.path).baseName}
</span>
</div>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">
{getDisplayPath(folder.path)}
</span>
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</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>
</Show>
</Show>
</div> </div>
</Show>
</div> </div>
@@ -567,11 +841,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0"> <div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
<div class="panel shrink-0"> <div class="panel shrink-0">
<div class="panel-header hidden sm:block"> <div class="panel-header hidden sm:block">
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2> <h2 class="panel-title">{t("folderSelection.actions.title")}</h2>
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p> <p class="panel-subtitle">{t("folderSelection.actions.subtitle")}</p>
</div> </div>
<div class="panel-body"> <div class="panel-body flex flex-col gap-3">
<button <button
onClick={() => void handleBrowse()} onClick={() => void handleBrowse()}
disabled={props.isLoading} disabled={props.isLoading}
@@ -588,6 +862,27 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div> </div>
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" /> <Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
</button> </button>
<button
type="button"
onClick={() => props.onOpenSidecar?.()}
class="button-primary mt-3 w-full flex items-center justify-center text-sm"
>
<div class="flex items-center gap-2">
<MonitorUp class="w-4 h-4" />
<span>{t("folderSelection.sidecars.button")}</span>
</div>
</button>
<button
onClick={openServerDialog}
class="button-primary w-full flex items-center justify-center text-sm"
>
<div class="flex items-center gap-2">
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</div>
</button>
</div> </div>
{/* OpenCode settings section */} {/* OpenCode settings section */}
@@ -663,6 +958,82 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onClose={() => setIsFolderBrowserOpen(false)} onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect} onSelect={handleBrowserSelect}
/> />
<Dialog open={isServerDialogOpen()} onOpenChange={(open) => !open && setIsServerDialogOpen(false)}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-[1300] flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-lg p-6 flex flex-col gap-5" tabIndex={-1}>
<div>
<Dialog.Title class="text-xl font-semibold text-primary">
{t("folderSelection.servers.dialog.title")}
</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
{t("folderSelection.servers.dialog.description")}
</Dialog.Description>
</div>
<label class="flex flex-col gap-2 text-sm text-secondary">
<span>{t("folderSelection.servers.dialog.name")}</span>
<input
class="selector-input w-full"
value={serverName()}
onInput={(event) => setServerName(event.currentTarget.value)}
placeholder={t("folderSelection.servers.dialog.namePlaceholder")}
/>
</label>
<label class="flex flex-col gap-2 text-sm text-secondary">
<span>{t("folderSelection.servers.dialog.url")}</span>
<input
class="selector-input w-full"
value={serverUrl()}
onInput={(event) => setServerUrl(event.currentTarget.value)}
placeholder={t("folderSelection.servers.dialog.urlPlaceholder")}
/>
</label>
<label class="flex items-start gap-3 text-sm text-secondary">
<input
type="checkbox"
checked={skipTlsVerify()}
onChange={(event) => setSkipTlsVerify(event.currentTarget.checked)}
/>
<span>{t("folderSelection.servers.dialog.skipTls")}</span>
</label>
<Show when={serverDialogError()}>
{(message) => <p class="text-sm text-red-500 break-words">{message()}</p>}
</Show>
<div class="flex items-center justify-end gap-3">
<button class="selector-button selector-button-secondary w-auto px-4" onClick={() => setIsServerDialogOpen(false)}>
{t("folderSelection.servers.dialog.cancel")}
</button>
<button
class="selector-button selector-button-secondary w-auto px-4"
disabled={isSavingServer()}
onClick={() => void handleSaveServer(false)}
>
{t("folderSelection.servers.dialog.save")}
</button>
<button
class="selector-button selector-button-secondary w-auto px-4"
disabled={isSavingServer()}
onClick={() => void handleSaveServer(true)}
>
<Show when={isSavingServer()} fallback={<span>{t("folderSelection.servers.dialog.connect")}</span>}>
<span class="inline-flex items-center gap-2">
<Loader2 class="w-4 h-4 animate-spin" />
{t("folderSelection.servers.dialog.connecting")}
</span>
</Show>
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
</> </>
) )
} }

View File

@@ -1,6 +1,5 @@
import { Component, For, Show, createMemo } from "solid-js" import { Component, For, Show, createMemo } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import type { Instance } from "../types/instance"
import InstanceTab from "./instance-tab" import InstanceTab from "./instance-tab"
import KeyboardHint from "./keyboard-hint" import KeyboardHint from "./keyboard-hint"
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid" import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
@@ -9,12 +8,13 @@ import { useI18n } from "../lib/i18n"
import { isOsNotificationSupportedSync } from "../lib/os-notifications" import { isOsNotificationSupportedSync } from "../lib/os-notifications"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import { openSettings } from "../stores/settings-screen" import { openSettings } from "../stores/settings-screen"
import type { AppTabRecord } from "../stores/app-tabs"
interface InstanceTabsProps { interface InstanceTabsProps {
instances: Map<string, Instance> tabs: AppTabRecord[]
activeInstanceId: string | null activeTabId: string | null
onSelect: (instanceId: string) => void onSelect: (tabId: string) => void
onClose: (instanceId: string) => void onClose: (tabId: string) => void
onNew: () => void onNew: () => void
} }
@@ -42,15 +42,25 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
<div class="tab-scroll"> <div class="tab-scroll">
<div class="tab-strip"> <div class="tab-strip">
<div class="tab-strip-tabs"> <div class="tab-strip-tabs">
<For each={Array.from(props.instances.entries())}> <For each={props.tabs}>
{([id, instance]) => ( {(tab) =>
<InstanceTab tab.kind === "instance" ? (
instance={instance} <InstanceTab
active={id === props.activeInstanceId} instance={tab.instance}
onSelect={() => props.onSelect(id)} active={tab.id === props.activeTabId}
onClose={() => props.onClose(id)} onSelect={() => props.onSelect(tab.id)}
/> onClose={() => props.onClose(tab.id)}
)} />
) : (
<div class={`tab-pill ${tab.id === props.activeTabId ? "tab-pill-active" : ""}`}>
<button class="tab-pill-button" onClick={() => props.onSelect(tab.id)}>
<span class="truncate max-w-[180px]">{tab.sidecarTab.name}</span>
</button>
<button class="tab-pill-close" onClick={() => props.onClose(tab.id)} aria-label={tab.sidecarTab.name}>
×
</button>
</div>
)}
</For> </For>
<button <button
class="new-tab-button" class="new-tab-button"
@@ -62,7 +72,7 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
</button> </button>
</div> </div>
<div class="tab-strip-spacer" /> <div class="tab-strip-spacer" />
<Show when={Array.from(props.instances.entries()).length > 1}> <Show when={props.tabs.length > 1}>
<div class="tab-shortcuts"> <div class="tab-shortcuts">
<KeyboardHint <KeyboardHint
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter( shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(

View File

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

View File

@@ -4,7 +4,7 @@ import { Accordion } from "@kobalte/core"
import { Tooltip } from "@kobalte/core/tooltip" import { Tooltip } from "@kobalte/core/tooltip"
import Switch from "@suid/material/Switch" import Switch from "@suid/material/Switch"
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import { BellRing, ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import type { Instance } from "../../../../../types/instance" import type { Instance } from "../../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../../server/src/api-types" import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
@@ -187,6 +187,24 @@ const StatusTab: Component<StatusTabProps> = (props) => {
<div class="status-process-header"> <div class="status-process-header">
<span class="status-process-title">{process.title}</span> <span class="status-process-title">{process.title}</span>
<div class="status-process-meta"> <div class="status-process-meta">
<span
classList={{
"text-success": Boolean(process.notifyEnabled),
"text-tertiary": !process.notifyEnabled,
}}
aria-label={props.t(
process.notifyEnabled
? "instanceShell.backgroundProcesses.notify.enabled"
: "instanceShell.backgroundProcesses.notify.disabled",
)}
title={props.t(
process.notifyEnabled
? "instanceShell.backgroundProcesses.notify.enabled"
: "instanceShell.backgroundProcesses.notify.disabled",
)}
>
<BellRing class="h-3.5 w-3.5" />
</span>
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span> <span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
<Show when={typeof process.outputSizeBytes === "number"}> <Show when={typeof process.outputSizeBytes === "number"}>
<span> <span>

View File

@@ -1,21 +1,22 @@
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js" import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid" import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
import MessageItem from "./message-item" import MessageItem from "./message-item"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, MessageInfo } from "../types/message" import type { ClientPart, MessageInfo } from "../types/message"
import { partHasRenderableText } from "../types/message" import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache" import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import { formatTokenTotal } from "../lib/formatters" import { formatTokenTotal } from "../lib/formatters"
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances" import { selectInstanceTab } from "../stores/app-tabs"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions" import { deleteMessage } from "../stores/session-actions"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover" import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech" import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button" import SpeechActionButton from "./speech-action-button"
import { createFollowScroll } from "../lib/follow-scroll"
function DeleteUpToIcon() { function DeleteUpToIcon() {
return ( return (
@@ -29,6 +30,7 @@ const TOOL_ICON = "🔧"
const USER_BORDER_COLOR = "var(--message-user-border)" const USER_BORDER_COLOR = "var(--message-user-border)"
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)" const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const TOOL_BORDER_COLOR = "var(--message-tool-border)" const TOOL_BORDER_COLOR = "var(--message-tool-border)"
const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
const LazyToolCall = lazy(() => import("./tool-call")) const LazyToolCall = lazy(() => import("./tool-call"))
@@ -130,7 +132,7 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string
} }
function navigateToTaskSession(location: TaskSessionLocation) { function navigateToTaskSession(location: TaskSessionLocation) {
setActiveInstanceId(location.instanceId) selectInstanceTab(location.instanceId)
const parentToActivate = location.parentId ?? location.sessionId const parentToActivate = location.parentId ?? location.sessionId
setActiveParentSession(location.instanceId, parentToActivate) setActiveParentSession(location.instanceId, parentToActivate)
if (location.parentId) { if (location.parentId) {
@@ -229,6 +231,12 @@ function isContentPartType(type: unknown): boolean {
return type === "text" || type === "file" return type === "text" || type === "file"
} }
function isVisibleContentPart(part: ClientPart): boolean {
if (!part || !isContentPartType((part as any).type)) return false
if (isHiddenSyntheticTextPart(part)) return false
return partHasRenderableText(part)
}
function MessageContentItem(props: MessageContentItemProps) { function MessageContentItem(props: MessageContentItemProps) {
const record = createMemo(() => props.store().getMessage(props.messageId)) const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
@@ -262,13 +270,15 @@ function MessageContentItem(props: MessageContentItemProps) {
return resolved return resolved
}) })
const visibleParts = createMemo(() => parts().filter((part) => isVisibleContentPart(part)))
const showAgentMeta = createMemo(() => { const showAgentMeta = createMemo(() => {
const current = record() const current = record()
if (!current) return false if (!current) return false
if (current.role !== "assistant") return false if (current.role !== "assistant") return false
const currentParts = parts() const currentParts = parts()
if (!currentParts.some((part) => partHasRenderableText(part))) { if (visibleParts().length === 0) {
return false return false
} }
@@ -284,10 +294,10 @@ function MessageContentItem(props: MessageContentItemProps) {
if (!isSupportedPartType(part)) continue if (!isSupportedPartType(part)) continue
if (!isContentPartType((part as any).type)) continue if (!isContentPartType((part as any).type)) continue
if (partHasRenderableText(part)) { if (isVisibleContentPart(part)) {
return false return false
}
} }
}
return true return true
}) })
@@ -298,7 +308,7 @@ function MessageContentItem(props: MessageContentItemProps) {
<MessageItem <MessageItem
record={resolvedRecord()} record={resolvedRecord()}
messageInfo={messageInfo()} messageInfo={messageInfo()}
parts={parts()} parts={visibleParts()}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
isQueued={isQueued()} isQueued={isQueued()}
@@ -619,13 +629,12 @@ export default function MessageBlock(props: MessageBlockProps) {
const lastAssistantIdx = props.lastAssistantIndex() const lastAssistantIdx = props.lastAssistantIndex()
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx) const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
// Intentionally untracked: messageInfoVersion updates should not trigger const messageInfoVersion = props.store().state.messageInfoVersion[current.id] ?? 0
// a full message block rebuild; record revision is the invalidation key.
const info = untrack(messageInfo)
const cacheSignature = [ const cacheSignature = [
current.id, current.id,
current.revision, current.revision,
messageInfoVersion,
isQueued ? 1 : 0, isQueued ? 1 : 0,
props.showThinking() ? 1 : 0, props.showThinking() ? 1 : 0,
props.thinkingDefaultExpanded() ? 1 : 0, props.thinkingDefaultExpanded() ? 1 : 0,
@@ -637,6 +646,9 @@ export default function MessageBlock(props: MessageBlockProps) {
return cachedBlock.block return cachedBlock.block
} }
// Only capture info after cache check fails - ensures fresh data on version bump
const info = untrack(messageInfo)
const { orderedParts } = buildRecordDisplayData(props.instanceId, current) const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
const items: MessageBlockItem[] = [] const items: MessageBlockItem[] = []
const blockContentKeys: string[] = [] const blockContentKeys: string[] = []
@@ -803,19 +815,19 @@ export default function MessageBlock(props: MessageBlockProps) {
data-message-id={resolvedBlock().record.id} data-message-id={resolvedBlock().record.id}
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined} data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
> >
<For each={resolvedBlock().items}> <Index each={resolvedBlock().items}>
{(item, index) => ( {(item, index) => (
<Switch> <Switch>
<Match when={item.type === "content"}> <Match when={item().type === "content"}>
<MessageContentItem <MessageContentItem
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
store={props.store} store={props.store}
messageId={(item as ContentDisplayItem).messageId} messageId={(item() as ContentDisplayItem).messageId}
startPartId={(item as ContentDisplayItem).startPartId} startPartId={(item() as ContentDisplayItem).startPartId}
messageIndex={props.messageIndex} messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex} lastAssistantIndex={props.lastAssistantIndex}
showDeleteMessage={index() === 0} showDeleteMessage={index === 0}
onDeleteHoverChange={props.onDeleteHoverChange} onDeleteHoverChange={props.onDeleteHoverChange}
onRevert={props.onRevert} onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
@@ -825,18 +837,18 @@ export default function MessageBlock(props: MessageBlockProps) {
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
</Match> </Match>
<Match when={item.type === "tool"}> <Match when={item().type === "tool"}>
{(() => { {(() => {
const toolItem = item as ToolDisplayItem const toolItem = item() as ToolDisplayItem
return ( return (
<div class="tool-call-message" data-key={toolItem.key}> <div class="tool-call-message" data-key={toolItem.key}>
<ToolCallItem <ToolCallItem
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
store={props.store} store={props.store}
messageId={toolItem.messageId} messageId={toolItem.messageId}
partId={toolItem.partId} partId={toolItem.partId}
showDeleteMessage={index() === 0} showDeleteMessage={index === 0}
deleteHover={props.deleteHover} deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange} onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
@@ -849,13 +861,13 @@ export default function MessageBlock(props: MessageBlockProps) {
) )
})()} })()}
</Match> </Match>
<Match when={item.type === "step-start"}> <Match when={item().type === "step-start"}>
<StepCard <StepCard
kind="start" kind="start"
part={(item as StepDisplayItem).part} part={(item() as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo} messageInfo={(item() as StepDisplayItem).messageInfo}
showAgentMeta showAgentMeta
showDeleteMessage={index() === 0} showDeleteMessage={index === 0}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={props.messageId} messageId={props.messageId}
@@ -865,14 +877,14 @@ export default function MessageBlock(props: MessageBlockProps) {
onToggleSelectedMessage={props.onToggleSelectedMessage} onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
<Match when={item.type === "step-finish"}> <Match when={item().type === "step-finish"}>
<StepCard <StepCard
kind="finish" kind="finish"
part={(item as StepDisplayItem).part} part={(item() as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo} messageInfo={(item() as StepDisplayItem).messageInfo}
showUsage={props.showUsageMetrics()} showUsage={props.showUsageMetrics()}
borderColor={(item as StepDisplayItem).accentColor} borderColor={(item() as StepDisplayItem).accentColor}
showDeleteMessage={index() === 0} showDeleteMessage={index === 0}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={props.messageId} messageId={props.messageId}
@@ -882,31 +894,31 @@ export default function MessageBlock(props: MessageBlockProps) {
onToggleSelectedMessage={props.onToggleSelectedMessage} onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
<Match when={item.type === "compaction"}> <Match when={item().type === "compaction"}>
<CompactionCard <CompactionCard
part={(item as CompactionDisplayItem).part} part={(item() as CompactionDisplayItem).part}
messageInfo={(item as CompactionDisplayItem).messageInfo} messageInfo={(item() as CompactionDisplayItem).messageInfo}
borderColor={(item as CompactionDisplayItem).accentColor} borderColor={(item() as CompactionDisplayItem).accentColor}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={(item as CompactionDisplayItem).messageId} messageId={(item() as CompactionDisplayItem).messageId}
showDeleteMessage={index() === 0} showDeleteMessage={index === 0}
onDeleteHoverChange={props.onDeleteHoverChange} onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds} selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage} onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
<Match when={item.type === "reasoning"}> <Match when={item().type === "reasoning"}>
<ReasoningCard <ReasoningCard
part={(item as ReasoningDisplayItem).part} part={(item() as ReasoningDisplayItem).part}
messageInfo={(item as ReasoningDisplayItem).messageInfo} messageInfo={(item() as ReasoningDisplayItem).messageInfo}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={(item as ReasoningDisplayItem).messageId} messageId={(item() as ReasoningDisplayItem).messageId}
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta} showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded} defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded}
showDeleteMessage={index() === 0} showDeleteMessage={index === 0}
onDeleteHoverChange={props.onDeleteHoverChange} onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds} selectedMessageIds={props.selectedMessageIds}
@@ -916,7 +928,7 @@ export default function MessageBlock(props: MessageBlockProps) {
</Match> </Match>
</Switch> </Switch>
)} )}
</For> </Index>
</div> </div>
)} )}
</Show> </Show>
@@ -1098,17 +1110,23 @@ function StepCard(props: StepCardProps) {
return null return null
} }
const info = props.messageInfo const info = props.messageInfo
if (!info || info.role !== "assistant" || !info.tokens) { const part = props.part as any
// step-finish parts have tokens embedded; also check messageInfo
const partTokens = part?.tokens
const infoTokens = info && info.role === "assistant" ? info.tokens : undefined
const tokens = partTokens ?? infoTokens
if (!tokens) {
return null return null
} }
const tokens = info.tokens
return { return {
input: tokens.input ?? 0, input: tokens.input ?? 0,
output: tokens.output ?? 0, output: tokens.output ?? 0,
reasoning: tokens.reasoning ?? 0, reasoning: tokens.reasoning ?? 0,
cacheRead: tokens.cache?.read ?? 0, cacheRead: tokens.cache?.read ?? 0,
cacheWrite: tokens.cache?.write ?? 0, cacheWrite: tokens.cache?.write ?? 0,
cost: info.cost ?? 0, cost: (part?.cost ?? (info && info.role === "assistant" ? info.cost : 0)) ?? 0,
} }
} }
@@ -1293,14 +1311,23 @@ interface ReasoningCardProps {
onContentRendered?: () => void onContentRendered?: () => void
} }
function ReasoningCard(props: ReasoningCardProps) { function ReasoningStreamOutput(props: {
const { t } = useI18n() text: Accessor<string>
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded)) scrollTopSnapshot: Accessor<number>
const [deletingMessage, setDeletingMessage] = createSignal(false) setScrollTopSnapshot: (next: number) => void
const [deletingUpTo, setDeletingUpTo] = createSignal(false) onContentRendered?: () => void
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) ariaLabel: string
}) {
let preRef: HTMLPreElement | undefined
let pendingRenderNotificationFrame: number | null = null let pendingRenderNotificationFrame: number | null = null
const followScroll = createFollowScroll({
getScrollTopSnapshot: props.scrollTopSnapshot,
setScrollTopSnapshot: props.setScrollTopSnapshot,
sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX,
sentinelClassName: "reasoning-scroll-sentinel",
})
const notifyContentRendered = () => { const notifyContentRendered = () => {
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
if (pendingRenderNotificationFrame !== null) { if (pendingRenderNotificationFrame !== null) {
@@ -1312,6 +1339,15 @@ function ReasoningCard(props: ReasoningCardProps) {
}) })
} }
createEffect(() => {
const nextText = props.text()
if (preRef && preRef.textContent !== nextText) {
preRef.textContent = nextText
}
followScroll.restoreAfterRender()
notifyContentRendered()
})
onCleanup(() => { onCleanup(() => {
if (pendingRenderNotificationFrame !== null) { if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame) cancelAnimationFrame(pendingRenderNotificationFrame)
@@ -1319,6 +1355,37 @@ function ReasoningCard(props: ReasoningCardProps) {
} }
}) })
return (
<div
ref={followScroll.registerContainer}
class="message-reasoning-output"
role="region"
aria-label={props.ariaLabel}
onScroll={followScroll.handleScroll}
>
<pre
ref={(element) => {
preRef = element || undefined
if (preRef) {
preRef.textContent = props.text() || ""
}
}}
class="message-reasoning-text"
dir="auto"
/>
{followScroll.renderSentinel()}
</div>
)
}
function ReasoningCard(props: ReasoningCardProps) {
const { t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const [scrollTopSnapshot, setScrollTopSnapshot] = createSignal(0)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
createEffect(() => { createEffect(() => {
setExpanded(Boolean(props.defaultExpanded)) setExpanded(Boolean(props.defaultExpanded))
}) })
@@ -1393,12 +1460,6 @@ function ReasoningCard(props: ReasoningCardProps) {
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech() const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
createEffect(() => {
if (!expanded()) return
reasoningText()
notifyContentRendered()
})
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage() const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => { const handleDeleteMessage = async (event: MouseEvent) => {
@@ -1553,9 +1614,13 @@ function ReasoningCard(props: ReasoningCardProps) {
<Show when={expanded()}> <Show when={expanded()}>
<div class="message-reasoning-expanded"> <div class="message-reasoning-expanded">
<div class="message-reasoning-body"> <div class="message-reasoning-body">
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}> <ReasoningStreamOutput
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre> text={reasoningText}
</div> scrollTopSnapshot={scrollTopSnapshot}
setScrollTopSnapshot={setScrollTopSnapshot}
onContentRendered={props.onContentRendered}
ariaLabel={t("messageBlock.reasoning.detailsAriaLabel")}
/>
</div> </div>
</div> </div>
</Show> </Show>

View File

@@ -2,7 +2,7 @@ import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
import { Portal } from "solid-js/web" import { Portal } from "solid-js/web"
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid" import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message" import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
import { partHasRenderableText } from "../types/message" import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part" import MessagePart from "./message-part"
import { copyToClipboard } from "../lib/clipboard" import { copyToClipboard } from "../lib/clipboard"
@@ -290,9 +290,9 @@ export default function MessageItem(props: MessageItemProps) {
const getRawContent = () => { const getRawContent = () => {
return props.parts return props.parts
.filter(part => part.type === "text") .filter((part) => part.type === "text" && !isHiddenSyntheticTextPart(part))
.map(part => (part as { text?: string }).text || "") .map((part) => (part as { text?: string }).text || "")
.filter(text => text.trim().length > 0) .filter((text) => text.trim().length > 0)
.join("\n\n") .join("\n\n")
} }
@@ -338,7 +338,7 @@ export default function MessageItem(props: MessageItemProps) {
} }
} }
if (!isUser() && !hasContent() && !isGenerating()) { if (!hasContent() && !isGenerating()) {
return null return null
} }

View File

@@ -33,19 +33,7 @@ export default function MessagePart(props: MessagePartProps) {
const shouldHideTextPart = () => { const shouldHideTextPart = () => {
const part = props.part const part = props.part
if (!part || part.type !== "text") return false if (!part || part.type !== "text") return false
return Boolean((part as any).synthetic)
const isSynthetic = Boolean((part as any).synthetic)
if (!isSynthetic) return false
// Keep optimistic user prompts visible; hide other synthetic user helper parts.
if (props.messageType === "user") {
const primaryId = props.primaryUserTextPartId
if (!primaryId) return false
return part.id !== primaryId
}
// Hide synthetic assistant text.
return true
} }

View File

@@ -1,5 +1,5 @@
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js" import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
import { MoreHorizontal, Trash, X } from "lucide-solid" import { MoreHorizontal, Pause, Trash, X } from "lucide-solid"
import Kbd from "./kbd" import Kbd from "./kbd"
import MessageBlock from "./message-block" import MessageBlock from "./message-block"
import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors" import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
@@ -16,12 +16,14 @@ import { showAlertDialog } from "../stores/alerts"
import { deleteMessage, deleteMessagePart } from "../stores/session-actions" import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { DeleteHoverState } from "../types/delete-hover" import type { DeleteHoverState } from "../types/delete-hover"
import { partHasRenderableText } from "../types/message"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getPartCharCount } from "../lib/token-utils" import { getPartCharCount } from "../lib/token-utils"
const SCROLL_SENTINEL_MARGIN_PX = 8 const SCROLL_SENTINEL_MARGIN_PX = 8
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
const QUOTE_SELECTION_MAX_LENGTH = 2000 const QUOTE_SELECTION_MAX_LENGTH = 2000
const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
export interface MessageSectionProps { export interface MessageSectionProps {
@@ -40,12 +42,40 @@ export interface MessageSectionProps {
} }
export default function MessageSection(props: MessageSectionProps) { export default function MessageSection(props: MessageSectionProps) {
const { preferences } = useConfig() const { preferences, updatePreferences } = useConfig()
const { t } = useI18n() const { t } = useI18n()
const showUsagePreference = () => preferences().showUsageMetrics ?? true const showUsagePreference = () => preferences().showUsageMetrics ?? true
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
const holdLongAssistantRepliesEnabled = () => preferences().holdLongAssistantReplies ?? true
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId)) const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId)) const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
const visibleMessageIds = createMemo(() => {
const resolvedStore = store()
return messageIds().filter((messageId) => {
const record = resolvedStore.getMessage(messageId)
if (!record) return false
if (buildTimelineSegments(props.instanceId, record, t).length > 0) {
return true
}
if (record.role !== "assistant") {
return false
}
const info = resolvedStore.getMessageInfo(messageId)
if (!info || info.role !== "assistant") {
return false
}
if (info.error) {
return true
}
const timeInfo = info.time as { created: number; end?: number } | undefined
return Boolean(timeInfo && (timeInfo.end === undefined || timeInfo.end === 0))
})
})
const scrollCache = useScrollCache({ const scrollCache = useScrollCache({
instanceId: props.instanceId, instanceId: props.instanceId,
@@ -129,6 +159,8 @@ export default function MessageSection(props: MessageSectionProps) {
return map return map
}) })
const lastAssistantMessageId = createMemo(() => store().getLastAssistantMessageId(props.sessionId))
const lastCompactionIndex = createMemo(() => { const lastCompactionIndex = createMemo(() => {
// Depend on a single session revision signal (not every message/part read) // Depend on a single session revision signal (not every message/part read)
// to keep reactive overhead small. // to keep reactive overhead small.
@@ -315,15 +347,9 @@ export default function MessageSection(props: MessageSectionProps) {
} }
const lastAssistantIndex = createMemo(() => { const lastAssistantIndex = createMemo(() => {
const ids = messageIds() const messageId = lastAssistantMessageId()
const resolvedStore = store() if (!messageId) return -1
for (let index = ids.length - 1; index >= 0; index--) { return messageIndexById().get(messageId) ?? -1
const record = resolvedStore.getMessage(ids[index])
if (record?.role === "assistant") {
return index
}
}
return -1
}) })
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([]) const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
@@ -571,7 +597,10 @@ export default function MessageSection(props: MessageSectionProps) {
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>() const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>() const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>()
const followToken = createMemo(() => `${sessionRevision()}|${preferenceSignature()}`) // Only preferences should force a follow-token re-anchor. Message/session
// revision churn at the end of a turn (message.updated, session.idle, etc.)
// should not trigger an immediate scroll-to-bottom.
const followToken = createMemo(() => preferenceSignature())
const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE)) const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true) const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
@@ -601,6 +630,35 @@ export default function MessageSection(props: MessageSectionProps) {
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null) const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
const lastVisibleMessageId = createMemo(() => {
const ids = visibleMessageIds()
return ids[ids.length - 1] ?? null
})
const autoPinHoldTargetKey = createMemo(() => {
if (!holdLongAssistantRepliesEnabled()) return null
const messageId = lastVisibleMessageId()
return isAssistantTextMessage(messageId) ? messageId : null
})
function toggleHoldLongAssistantReplies() {
updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() })
}
function isAssistantTextMessage(messageId: string | null | undefined) {
if (!messageId) return false
const resolvedStore = store()
const record = resolvedStore.getMessage(messageId)
if (!record || record.role !== "assistant") return false
const { orderedParts } = buildRecordDisplayData(props.instanceId, record)
return orderedParts.some((part) => {
if ((part as any)?.type !== "text") return false
if (partHasRenderableText(part)) return true
return typeof (part as { text?: unknown }).text === "string"
})
}
createEffect(() => { createEffect(() => {
const api = listApi() const api = listApi()
if (!api) return if (!api) return
@@ -615,7 +673,7 @@ export default function MessageSection(props: MessageSectionProps) {
const api = listApi() const api = listApi()
if (!element || !api) return if (!element || !api) return
if (props.loading) return if (props.loading) return
if (messageIds().length === 0) return if (visibleMessageIds().length === 0) return
if (didRestoreScroll()) return if (didRestoreScroll()) return
scrollCache.restore(element, { scrollCache.restore(element, {
@@ -734,88 +792,93 @@ export default function MessageSection(props: MessageSectionProps) {
const loading = Boolean(props.loading) const loading = Boolean(props.loading)
const ids = messageIds() const ids = messageIds()
if (loading) { // Wrap all iteration of the store-proxied `ids` array in untrack()
handleClearTimelineSelection() // to prevent O(n) per-element reactive subscriptions. The effect
previousTimelineIds = [] // only needs to re-run when `messageIds` (memo) changes.
setTimelineSegments([]) untrack(() => {
seenTimelineMessageIds.clear() if (loading) {
seenTimelineSegmentKeys.clear() handleClearTimelineSelection()
timelinePartCountsByMessageId.clear() previousTimelineIds = []
pendingTimelineMessagePartUpdates.clear() setTimelineSegments([])
if (pendingTimelinePartUpdateFrame !== null) { seenTimelineMessageIds.clear()
cancelAnimationFrame(pendingTimelinePartUpdateFrame) seenTimelineSegmentKeys.clear()
pendingTimelinePartUpdateFrame = null timelinePartCountsByMessageId.clear()
} pendingTimelineMessagePartUpdates.clear()
return if (pendingTimelinePartUpdateFrame !== null) {
} cancelAnimationFrame(pendingTimelinePartUpdateFrame)
pendingTimelinePartUpdateFrame = null
if (previousTimelineIds.length === 0 && ids.length > 0) {
seedTimeline()
previousTimelineIds = ids.slice()
return
}
if (ids.length < previousTimelineIds.length) {
seedTimeline()
previousTimelineIds = ids.slice()
return
}
if (ids.length === previousTimelineIds.length) {
let changedIndex = -1
let changeCount = 0
for (let index = 0; index < ids.length; index++) {
if (ids[index] !== previousTimelineIds[index]) {
changedIndex = index
changeCount += 1
if (changeCount > 1) break
} }
return
} }
if (changeCount === 1 && changedIndex >= 0) {
const oldId = previousTimelineIds[changedIndex]
const newId = ids[changedIndex]
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
seenTimelineMessageIds.delete(oldId)
seenTimelineMessageIds.add(newId)
setTimelineSegments((prev) => {
const next = prev.map((segment) => {
if (segment.messageId !== oldId) return segment
const updatedId = segment.id.replace(oldId, newId)
return { ...segment, messageId: newId, id: updatedId }
})
seenTimelineSegmentKeys.clear()
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next
})
// Keep part count tracking in sync with id replacement. if (previousTimelineIds.length === 0 && ids.length > 0) {
const existingPartCount = timelinePartCountsByMessageId.get(oldId) seedTimeline()
if (existingPartCount !== undefined) { previousTimelineIds = [...ids]
timelinePartCountsByMessageId.delete(oldId) return
timelinePartCountsByMessageId.set(newId, existingPartCount) }
if (ids.length < previousTimelineIds.length) {
seedTimeline()
previousTimelineIds = [...ids]
return
}
if (ids.length === previousTimelineIds.length) {
let changedIndex = -1
let changeCount = 0
for (let index = 0; index < ids.length; index++) {
if (ids[index] !== previousTimelineIds[index]) {
changedIndex = index
changeCount += 1
if (changeCount > 1) break
} }
}
if (changeCount === 1 && changedIndex >= 0) {
const oldId = previousTimelineIds[changedIndex]
const newId = ids[changedIndex]
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
seenTimelineMessageIds.delete(oldId)
seenTimelineMessageIds.add(newId)
setTimelineSegments((prev) => {
const next = prev.map((segment) => {
if (segment.messageId !== oldId) return segment
const updatedId = segment.id.replace(oldId, newId)
return { ...segment, messageId: newId, id: updatedId }
})
seenTimelineSegmentKeys.clear()
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next
})
previousTimelineIds = ids.slice() // Keep part count tracking in sync with id replacement.
return const existingPartCount = timelinePartCountsByMessageId.get(oldId)
if (existingPartCount !== undefined) {
timelinePartCountsByMessageId.delete(oldId)
timelinePartCountsByMessageId.set(newId, existingPartCount)
}
previousTimelineIds = [...ids]
return
}
} }
} }
}
const newIds: string[] = [] const newIds: string[] = []
ids.forEach((id) => { ids.forEach((id) => {
if (!seenTimelineMessageIds.has(id)) { if (!seenTimelineMessageIds.has(id)) {
newIds.push(id) newIds.push(id)
} }
})
if (newIds.length > 0) {
newIds.forEach((id) => {
seenTimelineMessageIds.add(id)
appendTimelineForMessage(id)
}) })
}
previousTimelineIds = ids.slice() if (newIds.length > 0) {
newIds.forEach((id) => {
seenTimelineMessageIds.add(id)
appendTimelineForMessage(id)
})
}
previousTimelineIds = [...ids]
})
}) })
function clearPendingTimelinePartUpdateFrame() { function clearPendingTimelinePartUpdateFrame() {
@@ -886,36 +949,49 @@ export default function MessageSection(props: MessageSectionProps) {
createEffect(() => { createEffect(() => {
if (props.loading) return if (props.loading) return
const ids = messageIds() const ids = messageIds()
const resolvedStore = store() // Also re-run when sessionRevision bumps (covers part additions within
// existing messages) but read individual records inside untrack() to
// avoid creating O(n) fine-grained subscriptions.
sessionRevision()
let hasChanges = false // Wrap the iteration in untrack() so that accessing individual elements
for (const messageId of ids) { // of the store-proxied `ids` array does not create O(n) per-element
const record = resolvedStore.getMessage(messageId) // reactive subscriptions. We only need to re-run when the memo
const partCount = record?.partIds.length ?? 0 // (messageIds) or sessionRevision changes — not per-element.
const previousCount = timelinePartCountsByMessageId.get(messageId) untrack(() => {
const resolvedStore = store()
const idsSet = new Set(ids)
let hasChanges = false
if (previousCount === undefined) { for (const messageId of ids) {
timelinePartCountsByMessageId.set(messageId, partCount) const record = resolvedStore.getMessage(messageId)
continue const partCount = record?.partIds.length ?? 0
const previousCount = timelinePartCountsByMessageId.get(messageId)
if (previousCount === undefined) {
timelinePartCountsByMessageId.set(messageId, partCount)
continue
}
if (previousCount !== partCount) {
timelinePartCountsByMessageId.set(messageId, partCount)
pendingTimelineMessagePartUpdates.add(messageId)
hasChanges = true
}
} }
if (previousCount !== partCount) { // Drop tracking for ids that are no longer present.
timelinePartCountsByMessageId.set(messageId, partCount) // Use the Set for O(1) lookups instead of ids.includes() which is O(n).
pendingTimelineMessagePartUpdates.add(messageId) for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
hasChanges = true if (!idsSet.has(trackedId)) {
timelinePartCountsByMessageId.delete(trackedId)
}
} }
}
// Drop tracking for ids that are no longer present. if (hasChanges) {
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) { scheduleTimelinePartUpdateFlush()
if (!ids.includes(trackedId)) {
timelinePartCountsByMessageId.delete(trackedId)
} }
} })
if (hasChanges) {
scheduleTimelinePartUpdateFlush()
}
}) })
createEffect(() => { createEffect(() => {
@@ -989,7 +1065,7 @@ export default function MessageSection(props: MessageSectionProps) {
data-scroll-buttons={scrollButtonsCount()} data-scroll-buttons={scrollButtonsCount()}
> >
<VirtualFollowList <VirtualFollowList
items={messageIds} items={visibleMessageIds}
getKey={(messageId) => messageId} getKey={(messageId) => messageId}
getAnchorId={getMessageAnchorId} getAnchorId={getMessageAnchorId}
getKeyFromAnchorId={getMessageIdFromAnchorId} getKeyFromAnchorId={getMessageIdFromAnchorId}
@@ -1003,6 +1079,12 @@ export default function MessageSection(props: MessageSectionProps) {
initialAutoScroll={initialAutoScroll} initialAutoScroll={initialAutoScroll}
resetKey={() => props.sessionId} resetKey={() => props.sessionId}
followToken={followToken} followToken={followToken}
autoPinHoldTargetKey={autoPinHoldTargetKey}
autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX}
resolveAutoPinHoldElement={(itemWrapper, key) => {
const candidates = Array.from(itemWrapper.querySelectorAll<HTMLElement>(`.message-item-base[data-message-id="${key}"][data-message-role="assistant"]`))
return candidates[candidates.length - 1] ?? null
}}
onScroll={() => { onScroll={() => {
clearQuoteSelection() clearQuoteSelection()
scrollCache.persist(streamElement()) scrollCache.persist(streamElement())
@@ -1033,9 +1115,55 @@ export default function MessageSection(props: MessageSectionProps) {
scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")} scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")}
registerApi={(api) => setListApi(api)} registerApi={(api) => setListApi(api)}
registerState={(state) => setListState(state)} registerState={(state) => setListState(state)}
renderControls={(state, api) => (
<div class="message-scroll-button-wrapper">
<button
type="button"
class="message-scroll-button"
data-active={holdLongAssistantRepliesEnabled() ? "true" : "false"}
onClick={toggleHoldLongAssistantReplies}
aria-label={
holdLongAssistantRepliesEnabled()
? t("messageSection.scroll.disableHoldAriaLabel")
: t("messageSection.scroll.enableHoldAriaLabel")
}
title={
holdLongAssistantRepliesEnabled()
? t("messageSection.scroll.disableHoldAriaLabel")
: t("messageSection.scroll.enableHoldAriaLabel")
}
>
<Pause class="message-scroll-icon message-scroll-icon--toggle w-4 h-4" aria-hidden="true" />
</button>
<Show when={state.showScrollTopButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => api.scrollToTop()}
aria-label={t("messageSection.scroll.toFirstAriaLabel")}
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
<Show when={state.showScrollBottomButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => api.scrollToBottom()}
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
</div>
)}
renderBeforeItems={() => ( renderBeforeItems={() => (
<> <>
<Show when={!props.loading && messageIds().length === 0}> <Show when={!props.loading && visibleMessageIds().length === 0}>
<div class="empty-state"> <div class="empty-state">
<div class="empty-state-content"> <div class="empty-state-content">
<div class="flex flex-col items-center gap-3 mb-6"> <div class="flex flex-col items-center gap-3 mb-6">

View File

@@ -1,7 +1,10 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js" import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
import { Portal } from "solid-js/web"
import MessagePreview from "./message-preview" import MessagePreview from "./message-preview"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import type { ClientPart } from "../types/message" import type { ClientPart } from "../types/message"
import { isHiddenSyntheticTextPart } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getPartCharCount } from "../lib/token-utils" import { getPartCharCount } from "../lib/token-utils"
@@ -53,6 +56,7 @@ const MAX_TOOLTIP_LENGTH = 220
const LONG_PRESS_MS = 500 const LONG_PRESS_MS = 500
const JITTER_THRESHOLD = 10 const JITTER_THRESHOLD = 10
const ABSOLUTE_TOKEN_CAP = 10000 const ABSOLUTE_TOKEN_CAP = 10000
const TIMELINE_VIRTUALIZER_BUFFER_PX = 240
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -65,6 +69,13 @@ interface PendingSegment {
hasPrimaryText: boolean hasPrimaryText: boolean
} }
interface TimelineSegmentState {
deleteHovered: boolean
deleteSelected: boolean
hasActivePermission: boolean
hidden: boolean
}
function truncateText(value: string): string { function truncateText(value: string): string {
if (value.length <= MAX_TOOLTIP_LENGTH) { if (value.length <= MAX_TOOLTIP_LENGTH) {
return value return value
@@ -105,6 +116,7 @@ function collectReasoningText(part: ClientPart): string {
function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string { function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string {
if (!part) return "" if (!part) return ""
if (isHiddenSyntheticTextPart(part)) return ""
if (typeof (part as any).text === "string") { if (typeof (part as any).text === "string") {
return (part as any).text as string return (part as any).text as string
} }
@@ -349,6 +361,13 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
} }
const clearHoverPreview = () => {
clearHoverTimer()
clearCloseTimer()
setHoveredSegment(null)
setHoverAnchorRect(null)
}
const scheduleClose = () => { const scheduleClose = () => {
if (typeof window === "undefined") return if (typeof window === "undefined") return
clearHoverTimer() clearHoverTimer()
@@ -356,8 +375,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
// Small delay so the pointer can travel from the segment to the tooltip. // Small delay so the pointer can travel from the segment to the tooltip.
closeTimer = window.setTimeout(() => { closeTimer = window.setTimeout(() => {
closeTimer = null closeTimer = null
setHoveredSegment(null) clearHoverPreview()
setHoverAnchorRect(null)
}, 160) }, 160)
} }
@@ -397,8 +415,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
}) })
onCleanup(() => { onCleanup(() => {
clearHoverTimer() clearHoverPreview()
clearCloseTimer()
}) })
// --- Selection & histogram rib state --- // --- Selection & histogram rib state ---
@@ -416,6 +433,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
// on activation, resize, or expansion — NOT on every scroll frame. // on activation, resize, or expansion — NOT on every scroll frame.
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({}) const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200) const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [virtualizerHandle, setVirtualizerHandle] = createSignal<VirtualizerHandle | undefined>()
let scrollContainerRef: HTMLDivElement | undefined let scrollContainerRef: HTMLDivElement | undefined
let xrayOverlayRef: HTMLDivElement | undefined let xrayOverlayRef: HTMLDivElement | undefined
@@ -447,6 +466,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
const handleScroll = () => { const handleScroll = () => {
if (renderVirtualizedTimeline()) {
if (hoveredSegment()) {
clearHoverPreview()
}
return
}
if (!isSelectionActive()) return if (!isSelectionActive()) return
if (!scrollContainerRef || !xrayOverlayRef) return if (!scrollContainerRef || !xrayOverlayRef) return
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`) xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
@@ -475,6 +500,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
}) })
const renderVirtualizedTimeline = createMemo(() => !isSelectionActive())
createEffect(on(renderVirtualizedTimeline, () => {
clearHoverPreview()
}))
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5)) const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
// Compute fresh char counts from the store. segment.totalChars can be stale for // Compute fresh char counts from the store. segment.totalChars can be stale for
@@ -577,7 +608,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
wasLongPress = true wasLongPress = true
// Scroll anchoring: preserve visual position of the pressed badge. // Scroll anchoring: preserve visual position of the pressed badge.
const btn = buttonRefs.get(segment.id) const btn = renderVirtualizedTimeline() ? null : buttonRefs.get(segment.id)
let anchorOffset: number | null = null let anchorOffset: number | null = null
if (btn && scrollContainerRef) { if (btn && scrollContainerRef) {
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
@@ -629,9 +660,17 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
createEffect(on(() => props.activeSegmentId, (activeId) => { createEffect(on(() => props.activeSegmentId, (activeId) => {
if (!activeId) return if (!activeId) return
const element = buttonRefs.get(activeId)
if (!element) return
const timer = typeof window !== "undefined" ? window.setTimeout(() => { const timer = typeof window !== "undefined" ? window.setTimeout(() => {
if (renderVirtualizedTimeline()) {
const index = segmentIndexById().get(activeId)
if (index !== undefined) {
virtualizerHandle()?.scrollToIndex(index, { align: "nearest", smooth: true })
}
return
}
const element = buttonRefs.get(activeId)
if (!element) return
element.scrollIntoView({ block: "nearest", behavior: "smooth" }) element.scrollIntoView({ block: "nearest", behavior: "smooth" })
}, 120) : null }, 120) : null
onCleanup(() => { onCleanup(() => {
@@ -682,60 +721,239 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
return map return map
}) })
const segmentIndexById = createMemo(() => {
const map = new Map<string, number>()
for (let i = 0; i < props.segments.length; i++) map.set(props.segments[i].id, i)
return map
})
const segmentStates = createMemo(() => {
const hover = deleteHover()
const selectedMessages = props.selectedMessageIds?.()
const expandedMessages = props.expandedMessageIds?.()
const resolvedStore = store()
const indexMap = messageIdToSessionIndex()
const selectionActive = isSelectionActive()
const result = new Map<string, TimelineSegmentState>()
for (const segment of props.segments) {
let deleteHovered = false
if (hover.kind === "message") {
deleteHovered = hover.messageId === segment.messageId
} else if (hover.kind === "deleteUpTo") {
const targetIndex = indexMap.get(hover.messageId)
const segmentIndex = indexMap.get(segment.messageId)
deleteHovered = targetIndex !== undefined && segmentIndex !== undefined && segmentIndex >= targetIndex
}
const deleteSelected = selectedMessages?.has(segment.messageId) ?? false
let hasActivePermission = false
if (segment.type === "tool") {
const partIds = segment.toolPartIds ?? []
for (const partId of partIds) {
const permissionState = resolvedStore.getPermissionState(segment.messageId, partId)
if (permissionState?.active) {
hasActivePermission = true
break
}
}
}
const hidden = segment.type === "tool" && !(
showTools()
|| expandedMessages?.has(segment.messageId)
|| selectionActive
|| props.activeSegmentId === segment.id
|| hasActivePermission
|| deleteHovered
|| deleteSelected
)
result.set(segment.id, {
deleteHovered,
deleteSelected,
hasActivePermission,
hidden,
})
}
return result
})
const segmentStateFor = (segmentId: string): TimelineSegmentState => {
return segmentStates().get(segmentId) ?? {
deleteHovered: false,
deleteSelected: false,
hasActivePermission: false,
hidden: false,
}
}
const segmentSpacerHeights = createMemo(() => {
const states = segmentStates()
const result = new Map<string, string>()
let previousVisible: TimelineSegment | null = null
for (let index = 0; index < props.segments.length; index += 1) {
const segment = props.segments[index]
const state = states.get(segment.id)
if (state?.hidden) {
result.set(segment.id, "0")
continue
}
if (!previousVisible) {
result.set(segment.id, "0")
previousVisible = segment
continue
}
const previousRaw = index > 0 ? props.segments[index - 1] : null
const startsVisibleToolGroup = segment.type === "tool"
&& (previousVisible.type !== "tool" || previousVisible.messageId !== segment.messageId)
const startsCollapsedToolGroup = segment.type === "assistant"
&& previousVisible.messageId !== segment.messageId
&& messagesWithTools().has(segment.messageId)
&& previousRaw?.type === "tool"
&& previousRaw.messageId === segment.messageId
const followsVisibleGroupParent = (segment.type === "user" || segment.type === "compaction")
&& previousVisible.type === "assistant"
&& messagesWithTools().has(previousVisible.messageId)
const gapUnits = 1 + (startsVisibleToolGroup || startsCollapsedToolGroup || followsVisibleGroupParent ? 1 : 0)
result.set(
segment.id,
gapUnits === 1
? "var(--message-timeline-segment-gap)"
: "calc(var(--message-timeline-segment-gap) * 2)",
)
previousVisible = segment
}
return result
})
return ( return (
<div class="message-timeline-container"> <div class="message-timeline-container">
<div <div
ref={scrollContainerRef} ref={(element) => {
scrollContainerRef = element
setScrollElement(element)
}}
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`} class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
role="navigation" role="navigation"
aria-label={t("messageTimeline.ariaLabel")} aria-label={t("messageTimeline.ariaLabel")}
onScroll={handleScroll} onScroll={handleScroll}
> >
<For each={props.segments}> <Show
{(segment, segIndex) => { when={renderVirtualizedTimeline()}
onCleanup(() => buttonRefs.delete(segment.id)) fallback={(
<For each={props.segments}>
{(segment, segIndex) => {
onCleanup(() => buttonRefs.delete(segment.id))
const isActive = () => props.activeSegmentId === segment.id
const isSelected = () => props.selectedIds?.().has(segment.id)
const state = () => segmentStateFor(segment.id)
const isDeleteHovered = () => state().deleteHovered
const isDeleteSelected = () => state().deleteSelected
const hasActivePermission = () => state().hasActivePermission
const isHidden = () => state().hidden
const groupRole = (): "child" | "parent" | "none" => {
if (segment.type === "tool") return "child"
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
return "none"
}
const shortLabelContent = () => {
if (segment.type === "tool") {
if (hasActivePermission()) {
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
}
return segment.shortLabel ?? getToolIcon("tool")
}
if (segment.type === "compaction") {
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
}
if (segment.type === "user") {
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
}
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
}
return (
<div class="message-timeline-item">
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
<button
ref={(el) => registerButtonRef(segment.id, el)}
type="button"
data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
aria-current={isActive() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined}
onClick={(event) => {
if (wasLongPress) {
wasLongPress = false
return
}
const btn = buttonRefs.get(segment.id)
const stableBtn = renderVirtualizedTimeline() ? null : btn
let anchorOffset: number | null = null
if (stableBtn && scrollContainerRef) {
anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
}
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
if (event.shiftKey) {
props.onSelectRange?.(segment.id)
} else if (event.ctrlKey || event.metaKey) {
props.onToggleSelection?.(segment.id)
} else if (isMultiSelectActive) {
props.onSegmentClick?.(segment)
} else {
props.onSegmentClick?.(segment)
}
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
const desired = stableBtn.offsetTop - anchorOffset
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
scrollContainerRef.scrollTop = desired
}
}
}}
onPointerDown={(e) => handlePointerDown(segment, e)}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerMove={handlePointerMove}
onContextMenu={handleContextMenu}
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave}
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
</button>
</div>
)
}}
</For>
)}
>
<Virtualizer ref={setVirtualizerHandle} data={props.segments} scrollRef={scrollElement()} bufferSize={TIMELINE_VIRTUALIZER_BUFFER_PX}>
{(segment, index) => {
const segIndex = () => index()
const isActive = () => props.activeSegmentId === segment.id const isActive = () => props.activeSegmentId === segment.id
const isSelected = () => props.selectedIds?.().has(segment.id) const isSelected = () => props.selectedIds?.().has(segment.id)
const state = () => segmentStateFor(segment.id)
const isDeleteHovered = () => { const isDeleteHovered = () => state().deleteHovered
const hover = deleteHover() as DeleteHoverState const isDeleteSelected = () => state().deleteSelected
if (hover.kind === "message") { const hasActivePermission = () => state().hasActivePermission
return hover.messageId === segment.messageId const isHidden = () => state().hidden
}
if (hover.kind === "deleteUpTo") {
const indexMap = messageIdToSessionIndex()
const targetIndex = indexMap.get(hover.messageId)
if (targetIndex === undefined) return false
const segmentIndex = indexMap.get(segment.messageId)
if (segmentIndex === undefined) return false
return segmentIndex >= targetIndex
}
return false
}
const isDeleteSelected = () => {
const selected = props.selectedMessageIds?.()
if (!selected) return false
return selected.has(segment.messageId)
}
const hasActivePermission = () => {
if (segment.type !== "tool") return false
const partIds = segment.toolPartIds ?? []
if (partIds.length === 0) return false
for (const partId of partIds) {
const permissionState = store().getPermissionState(segment.messageId, partId)
if (permissionState?.active) return true
}
return false
}
const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false
const isHidden = () =>
segment.type === "tool" &&
!(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered() || isDeleteSelected())
// Group visual indicators: tools belong to the same message as their // Group visual indicators: tools belong to the same message as their
// assistant. Uses messageId for correctness (not positional adjacency). // assistant. Uses messageId for correctness (not positional adjacency).
@@ -744,18 +962,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent" if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
return "none" return "none"
} }
const isGroupStart = () => {
if (segment.type !== "tool") return false
const idx = segIndex()
const prev = idx > 0 ? props.segments[idx - 1] : null
// First tool in the message's run: either nothing before, or previous
// segment is from a different message or is not a tool.
return !prev || prev.type !== "tool" || prev.messageId !== segment.messageId
}
const shortLabelContent = () => { const shortLabelContent = () => {
if (segment.type === "tool") { if (segment.type === "tool") {
if (hasActivePermission()) { if (hasActivePermission()) {
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" /> return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
} }
return segment.shortLabel ?? getToolIcon("tool") return segment.shortLabel ?? getToolIcon("tool")
@@ -765,95 +975,92 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
if (segment.type === "user") { if (segment.type === "user") {
return <UserIcon class="message-timeline-icon" aria-hidden="true" /> return <UserIcon class="message-timeline-icon" aria-hidden="true" />
} }
return <BotIcon class="message-timeline-icon" aria-hidden="true" /> return <BotIcon class="message-timeline-icon" aria-hidden="true" />
} }
return ( return (
<button <div class="message-timeline-item">
ref={(el) => registerButtonRef(segment.id, el)} <div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
type="button" <button
data-variant={segment.variant} type="button"
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""} ${isGroupStart() ? "message-timeline-group-start" : ""}`} data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined} data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
aria-current={isActive() ? "true" : undefined}
aria-current={isActive() ? "true" : undefined} aria-hidden={isHidden() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined} onClick={(event) => {
onClick={(event) => { if (wasLongPress) {
if (wasLongPress) { wasLongPress = false
wasLongPress = false return
return
}
// Capture scroll anchor before selection changes may toggle
// tool segment visibility, which shifts timeline layout.
const btn = buttonRefs.get(segment.id)
let anchorOffset: number | null = null
if (btn && scrollContainerRef) {
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
}
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
if (event.shiftKey) {
props.onSelectRange?.(segment.id)
} else if (event.ctrlKey || event.metaKey) {
props.onToggleSelection?.(segment.id)
} else if (isMultiSelectActive) {
// In selection mode, plain click scrolls to the message
// instead of clearing. Selection is cleared by clicking
// anywhere inside the chat container or pressing Esc.
props.onSegmentClick?.(segment)
} else {
props.onSegmentClick?.(segment)
}
// Restore scroll anchor: keep the clicked badge at the same
// visual position after hidden tools appear or disappear.
if (anchorOffset !== null && btn && scrollContainerRef) {
const desired = btn.offsetTop - anchorOffset
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
scrollContainerRef.scrollTop = desired
} }
}
}} const btn = buttonRefs.get(segment.id)
onPointerDown={(e) => handlePointerDown(segment, e)} const stableBtn = renderVirtualizedTimeline() ? null : btn
onPointerUp={handlePointerUp} let anchorOffset: number | null = null
onPointerCancel={handlePointerUp} if (stableBtn && scrollContainerRef) {
onPointerMove={handlePointerMove} anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
onContextMenu={handleContextMenu} }
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave} const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span> if (event.shiftKey) {
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span> props.onSelectRange?.(segment.id)
</button> } else if (event.ctrlKey || event.metaKey) {
) props.onToggleSelection?.(segment.id)
}} } else if (isMultiSelectActive) {
</For> props.onSegmentClick?.(segment)
} else {
props.onSegmentClick?.(segment)
}
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
const desired = stableBtn.offsetTop - anchorOffset
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
scrollContainerRef.scrollTop = desired
}
}
}}
onPointerDown={(e) => handlePointerDown(segment, e)}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerMove={handlePointerMove}
onContextMenu={handleContextMenu}
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave}
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
</button>
</div>
)
}}
</Virtualizer>
</Show>
<Show when={previewData()}> <Show when={previewData()}>
{(data) => { {(data) => {
onCleanup(() => setTooltipElement(null)) onCleanup(() => setTooltipElement(null))
return ( return (
<div <Portal>
ref={(element) => setTooltipElement(element)} <div
class="message-timeline-tooltip" ref={(element) => setTooltipElement(element)}
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }} class="message-timeline-tooltip"
onMouseEnter={() => clearCloseTimer()} style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
onMouseLeave={() => scheduleClose()} onMouseEnter={() => clearCloseTimer()}
> onMouseLeave={() => scheduleClose()}
<MessagePreview >
messageId={data().messageId} <MessagePreview
instanceId={props.instanceId} messageId={data().messageId}
sessionId={props.sessionId} instanceId={props.instanceId}
store={store} sessionId={props.sessionId}
deleteHover={props.deleteHover} store={store}
onDeleteHoverChange={props.onDeleteHoverChange} deleteHover={props.deleteHover}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onDeleteHoverChange={props.onDeleteHoverChange}
selectedMessageIds={props.selectedMessageIds} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
/> selectedMessageIds={props.selectedMessageIds}
</div> />
</div>
</Portal>
) )
}} }}
</Show> </Show>

View File

@@ -540,6 +540,10 @@ export default function PromptInput(props: PromptInputProps) {
mode={pickerMode()} mode={pickerMode()}
onClose={handlePickerClose} onClose={handlePickerClose}
onSelect={handlePickerSelect} onSelect={handlePickerSelect}
onSubmitWithoutSelection={() => {
handlePickerClose()
void handleSend()
}}
agents={instanceAgents()} agents={instanceAgents()}
commands={getCommands(props.instanceId)} commands={getCommands(props.instanceId)}
instanceClient={instance()!.client} instanceClient={instance()!.client}

View File

@@ -324,28 +324,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const pos = atPosition() const pos = atPosition()
if (pickerMode() === "mention" && pos !== null) { if (pickerMode() === "mention" && pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos)) setIgnoredAtPositions((prev) => new Set(prev).add(pos))
// Remove the partial @mention text from the textarea when ESC is pressed
const textarea = options.getTextarea()
if (textarea) {
const currentPrompt = options.prompt()
const cursorPos = textarea.selectionStart
// Remove text from @ position to cursor position
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
options.setPrompt(before + after)
// Restore cursor position to where @ was
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
nextTextarea.setSelectionRange(pos, pos)
}
}, 0)
// Clear ignoredAtPositions so typing @ again will work
setIgnoredAtPositions(new Set<number>())
}
} }
setShowPicker(false) setShowPicker(false)
setAtPosition(null) setAtPosition(null)

View File

@@ -169,18 +169,25 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
const textarea = options.getTextarea() const textarea = options.getTextarea()
const start = textarea ? textarea.selectionStart : current.length const start = textarea ? textarea.selectionStart : current.length
const end = textarea ? textarea.selectionEnd : current.length const end = textarea ? textarea.selectionEnd : current.length
const wasCursorAtEnd = end === current.length
const wasScrolledToBottom = textarea
? textarea.scrollHeight - (textarea.scrollTop + textarea.clientHeight) <= 4
: false
const before = current.slice(0, start) const before = current.slice(0, start)
const after = current.slice(end) const after = current.slice(end)
const prefix = before.length > 0 && !/\s$/.test(before) ? " " : "" const prefix = ""
const suffix = after.length > 0 && !/^\s/.test(after) ? " " : "" const suffix = after.length > 0 ? (/^\s/.test(after) ? "" : " ") : " "
const nextValue = `${before}${prefix}${text}${suffix}${after}` const nextValue = `${before}${prefix}${text}${suffix}${after}`
const cursor = before.length + prefix.length + text.length const cursor = before.length + prefix.length + text.length + suffix.length
options.setPrompt(nextValue) options.setPrompt(nextValue)
if (textarea) { if (textarea) {
setTimeout(() => { setTimeout(() => {
textarea.focus() textarea.focus()
textarea.setSelectionRange(cursor, cursor) textarea.setSelectionRange(cursor, cursor)
if (wasCursorAtEnd || wasScrolledToBottom) {
textarea.scrollTop = textarea.scrollHeight
}
}, 0) }, 0)
} }
} }

View File

@@ -79,11 +79,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
requestAnimationFrame(() => scrollToBottomHandle?.()) requestAnimationFrame(() => scrollToBottomHandle?.())
}) })
} }
createEffect(() => { createEffect(
if (!props.isActive) return on(
if (!shouldScrollToBottomOnActivate()) return () => props.isActive,
scheduleScrollToBottom() (isActive, wasActive) => {
}) if (!isActive) return
if (wasActive === true) return
if (!shouldScrollToBottomOnActivate()) return
scheduleScrollToBottom()
},
),
)
createEffect( createEffect(
on( on(
@@ -332,16 +338,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
loading={messagesLoading()} loading={messagesLoading()}
onRevert={handleRevert} onRevert={handleRevert}
onDeleteMessagesUpTo={handleDeleteMessagesUpTo} onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
onFork={handleFork} onFork={handleFork}
isActive={props.isActive} isActive={props.isActive}
registerScrollToBottom={(fn) => { registerScrollToBottom={(fn) => {
scrollToBottomHandle = fn scrollToBottomHandle = fn
if (props.isActive) { }}
if (shouldScrollToBottomOnActivate()) {
scheduleScrollToBottom()
}
}
}}

View File

@@ -1,5 +1,5 @@
import { Dialog } from "@kobalte/core/dialog" import { Dialog } from "@kobalte/core/dialog"
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, X } from "lucide-solid" import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, Globe, X } from "lucide-solid"
import { createMemo, For, type Component } from "solid-js" import { createMemo, For, type Component } from "solid-js"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { import {
@@ -14,6 +14,7 @@ import { NotificationsSettingsSection } from "./settings/notifications-settings-
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section" import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section" import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
import { SpeechSettingsSection } from "./settings/speech-settings-section" import { SpeechSettingsSection } from "./settings/speech-settings-section"
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
export const SettingsScreen: Component = () => { export const SettingsScreen: Component = () => {
const { t } = useI18n() const { t } = useI18n()
@@ -23,6 +24,7 @@ export const SettingsScreen: Component = () => {
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") }, { id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") }, { id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") }, { id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") }, { id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
]) ])
@@ -34,6 +36,8 @@ export const SettingsScreen: Component = () => {
return <RemoteAccessSettingsSection /> return <RemoteAccessSettingsSection />
case "speech": case "speech":
return <SpeechSettingsSection /> return <SpeechSettingsSection />
case "sidecars":
return <SideCarsSettingsSection />
case "opencode": case "opencode":
return <OpenCodeSettingsSection /> return <OpenCodeSettingsSection />
case "appearance": case "appearance":

View File

@@ -1,14 +1,30 @@
import { createEffect, createSignal, type Component } from "solid-js" import { Select } from "@kobalte/core/select"
import { Terminal } from "lucide-solid" import { createEffect, createMemo, createSignal, type Component } from "solid-js"
import { ChevronDown, Terminal } from "lucide-solid"
import OpenCodeBinarySelector from "../opencode-binary-selector" import OpenCodeBinarySelector from "../opencode-binary-selector"
import EnvironmentVariablesEditor from "../environment-variables-editor" import EnvironmentVariablesEditor from "../environment-variables-editor"
import { useConfig } from "../../stores/preferences" import { useConfig } from "../../stores/preferences"
import type { ServerLogLevel } from "../../stores/preferences"
import { useI18n } from "../../lib/i18n" import { useI18n } from "../../lib/i18n"
type LogLevelOption = {
value: ServerLogLevel
label: string
}
export const OpenCodeSettingsSection: Component = () => { export const OpenCodeSettingsSection: Component = () => {
const { t } = useI18n() const { t } = useI18n()
const { serverSettings, updateLastUsedBinary } = useConfig() const { serverSettings, updateLastUsedBinary, updateLogLevel } = useConfig()
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode") const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
const logLevelOptions = createMemo<LogLevelOption[]>(() => [
{ value: "DEBUG", label: t("settings.opencode.logLevel.option.debug") },
{ value: "INFO", label: t("settings.opencode.logLevel.option.info") },
{ value: "WARN", label: t("settings.opencode.logLevel.option.warn") },
{ value: "ERROR", label: t("settings.opencode.logLevel.option.error") },
])
const selectedLogLevel = createMemo(
() => logLevelOptions().find((option) => option.value === serverSettings().logLevel) ?? logLevelOptions()[0],
)
createEffect(() => { createEffect(() => {
const binary = serverSettings().opencodeBinary || "opencode" const binary = serverSettings().opencodeBinary || "opencode"
@@ -37,6 +53,60 @@ export const OpenCodeSettingsSection: Component = () => {
<OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible /> <OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible />
</div> </div>
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("settings.opencode.logLevel.title")}</h3>
<p class="settings-card-subtitle">{t("settings.opencode.logLevel.subtitle")}</p>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<div class="settings-card-body">
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("settings.opencode.logLevel.selector.title")}</div>
<div class="settings-toggle-caption">{t("settings.opencode.logLevel.selector.subtitle")}</div>
</div>
<Select<LogLevelOption>
value={selectedLogLevel()}
onChange={(option) => {
if (!option) return
updateLogLevel(option.value)
}}
options={logLevelOptions()}
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("settings.opencode.logLevel.title")}>
<div class="flex-1 min-w-0">
<Select.Value<LogLevelOption>>
{(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">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
</div>
</div>
</div>
<div class="settings-card"> <div class="settings-card">
<div class="settings-card-header"> <div class="settings-card-header">
<div> <div>

View File

@@ -0,0 +1,201 @@
import { createMemo, createSignal, For, Show, onMount, type Component } from "solid-js"
import { Globe, Loader2, Plus, Trash2 } from "lucide-solid"
import { useI18n } from "../../lib/i18n"
import { serverApi } from "../../lib/api-client"
import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../../stores/sidecars"
function deriveSidecarId(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-|-$/g, "")
}
export const SideCarsSettingsSection: Component = () => {
const { t } = useI18n()
const [name, setName] = createSignal("")
const [port, setPort] = createSignal("3000")
const [insecure, setInsecure] = createSignal(false)
const [prefixMode, setPrefixMode] = createSignal<"strip" | "preserve">("strip")
const [busyId, setBusyId] = createSignal<string | null>(null)
const [creating, setCreating] = createSignal(false)
const [formError, setFormError] = createSignal<string | null>(null)
const [actionError, setActionError] = createSignal<string | null>(null)
onMount(() => {
void ensureSidecarsLoaded()
})
const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name)))
const derivedId = createMemo(() => deriveSidecarId(name()) || "your-sidecar")
async function handleCreate() {
const trimmedName = name().trim()
const nextPort = Number(port())
if (!trimmedName || !Number.isInteger(nextPort) || nextPort <= 0 || nextPort > 65535) {
setFormError(t("sidecars.form.validation"))
return
}
setCreating(true)
setFormError(null)
try {
await serverApi.createSidecar({
kind: "port",
name: trimmedName,
port: nextPort,
insecure: insecure(),
prefixMode: prefixMode(),
})
setName("")
setPort("3000")
setInsecure(false)
setPrefixMode("strip")
} catch (error) {
setFormError(error instanceof Error ? error.message : String(error))
} finally {
setCreating(false)
}
}
async function handleDelete(id: string) {
setBusyId(id)
setActionError(null)
try {
await serverApi.deleteSidecar(id)
} catch (error) {
setActionError(error instanceof Error ? error.message : String(error))
} finally {
setBusyId(null)
}
}
return (
<div class="settings-section-stack">
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Globe class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("settings.section.sidecars.title")}</h3>
<p class="settings-card-subtitle">{t("settings.section.sidecars.subtitle")}</p>
</div>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<div class="settings-card-content">
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("sidecars.form.name")}</div>
<div class="settings-toggle-caption">{t("sidecars.basePath")}: <code>/sidecars/{derivedId()}</code></div>
</div>
<input
class="selector-input w-full max-w-xs"
value={name()}
onInput={(event) => {
setFormError(null)
setName(event.currentTarget.value)
}}
/>
</div>
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("sidecars.form.port")}</div>
<div class="settings-toggle-caption">127.0.0.1</div>
</div>
<input
class="selector-input w-full max-w-xs"
value={port()}
onInput={(event) => {
setFormError(null)
setPort(event.currentTarget.value)
}}
inputMode="numeric"
/>
</div>
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("sidecars.form.protocol")}</div>
<div class="settings-toggle-caption">{t("sidecars.form.protocol.help")}</div>
</div>
<select class="selector-input w-full max-w-xs" value={insecure() ? "http" : "https"} onChange={(event) => setInsecure(event.currentTarget.value === "http") }>
<option value="https">{t("sidecars.form.protocol.https")}</option>
<option value="http">{t("sidecars.form.protocol.http")}</option>
</select>
</div>
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("sidecars.form.prefixMode")}</div>
<div class="settings-toggle-caption">{t("sidecars.form.prefixMode.help")}</div>
</div>
<select class="selector-input w-full max-w-xs" value={prefixMode()} onChange={(event) => setPrefixMode(event.currentTarget.value as "strip" | "preserve") }>
<option value="strip">{t("sidecars.form.prefixMode.strip")}</option>
<option value="preserve">{t("sidecars.form.prefixMode.preserve")}</option>
</select>
</div>
<Show when={formError()}>
<div class="text-sm text-red-500">{formError()}</div>
</Show>
<div class="flex justify-end">
<button type="button" class="selector-button selector-button-primary" disabled={creating()} onClick={() => void handleCreate()}>
<Show when={creating()} fallback={<Plus class="w-4 h-4" />}>
<Loader2 class="w-4 h-4 animate-spin" />
</Show>
<span>{t("sidecars.form.add")}</span>
</button>
</div>
</div>
</div>
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("sidecars.settings.listTitle")}</h3>
<p class="settings-card-subtitle">{t("sidecars.settings.listSubtitle")}</p>
</div>
</div>
<div class="settings-card-content">
<Show when={actionError()}>
<div class="text-sm text-red-500">{actionError()}</div>
</Show>
<Show when={!sidecarsLoading()} fallback={<div class="settings-card-message">{t("sidecars.picker.loading")}</div>}>
<Show when={orderedSidecars().length > 0} fallback={<div class="settings-card-message">{t("sidecars.settings.empty")}</div>}>
<For each={orderedSidecars()}>
{(sidecar) => (
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{sidecar.name}</div>
<div class="settings-toggle-caption">
{t("sidecars.kind.port")} · {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port}
</div>
<div class="settings-toggle-caption">
{t("sidecars.basePath")}: <code>/sidecars/{sidecar.id}</code> · {t(`sidecars.form.prefixMode.${sidecar.prefixMode}`)}
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-secondary min-w-[4.5rem] text-right">{t(`sidecars.status.${sidecar.status}`)}</span>
<button type="button" class="selector-button selector-button-secondary" disabled={busyId() === sidecar.id} onClick={() => void handleDelete(sidecar.id)}>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
)}
</For>
</Show>
</Show>
</div>
</div>
</div>
)
}

View File

@@ -334,7 +334,7 @@ const Field: Component<{
<div class="settings-toggle-title">{props.label}</div> <div class="settings-toggle-title">{props.label}</div>
<div class="settings-toggle-caption">{props.caption}</div> <div class="settings-toggle-caption">{props.caption}</div>
</div> </div>
<div class="flex items-center gap-2 min-w-[18rem] max-w-[24rem] w-full"> <div class="flex items-center gap-2 w-full min-w-0 sm:min-w-[18rem] sm:max-w-[24rem]">
{props.icon} {props.icon}
<input <input
type={props.type ?? "text"} type={props.type ?? "text"}
@@ -361,7 +361,7 @@ const SelectField: Component<{
<div class="settings-toggle-title">{props.label}</div> <div class="settings-toggle-title">{props.label}</div>
<div class="settings-toggle-caption">{props.caption}</div> <div class="settings-toggle-caption">{props.caption}</div>
</div> </div>
<div class="min-w-[18rem] max-w-[24rem] w-full"> <div class="w-full min-w-0 sm:min-w-[18rem] sm:max-w-[24rem]">
<select value={props.value} onInput={(event) => props.onInput(event.currentTarget.value)} class="selector-input w-full"> <select value={props.value} onInput={(event) => props.onInput(event.currentTarget.value)} class="selector-input w-full">
<For each={props.options}>{(option) => <option value={option.value}>{option.label}</option>}</For> <For each={props.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select> </select>

View File

@@ -0,0 +1,82 @@
import { Dialog } from "@kobalte/core/dialog"
import { For, Show, createEffect, createMemo, type Component } from "solid-js"
import { Globe, Square } from "lucide-solid"
import { useI18n } from "../lib/i18n"
import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../stores/sidecars"
interface SideCarPickerDialogProps {
open: boolean
onClose: () => void
onOpenSidecar: (sidecarId: string) => void | Promise<void>
}
export const SideCarPickerDialog: Component<SideCarPickerDialogProps> = (props) => {
const { t } = useI18n()
const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name)))
createEffect(() => {
if (props.open) {
void ensureSidecarsLoaded()
}
})
return (
<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-2xl p-6 flex flex-col gap-4 max-h-[80vh] overflow-hidden">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">{t("sidecars.picker.title")}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
{t("sidecars.picker.subtitle")}
</Dialog.Description>
</div>
<div class="flex-1 overflow-auto flex flex-col gap-3">
<Show when={!sidecarsLoading()} fallback={<div class="panel panel-empty-state">{t("sidecars.picker.loading")}</div>}>
<Show when={orderedSidecars().length > 0} fallback={<div class="panel panel-empty-state">{t("sidecars.picker.empty")}</div>}>
<For each={orderedSidecars()}>
{(sidecar) => (
<button
type="button"
class="panel-list-item panel-list-item-content text-left disabled:cursor-not-allowed disabled:opacity-60"
disabled={sidecar.status !== "running"}
onClick={() => void props.onOpenSidecar(sidecar.id)}
>
<div class="flex items-center justify-between gap-4 w-full">
<div class="flex items-center gap-3 min-w-0">
<span class="panel-empty-state-icon !w-10 !h-10">
<Globe class="w-5 h-5" />
</span>
<div class="min-w-0">
<div class="text-sm font-medium text-primary truncate">{sidecar.name}</div>
<div class="text-xs text-muted">
{t("sidecars.kind.port")} - {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port}
</div>
<div class="text-xs text-muted mt-1">{t("sidecars.basePath")}: <code>/sidecars/{sidecar.id}</code></div>
</div>
</div>
<div class="text-xs text-secondary flex items-center gap-2">
<Square class="w-4 h-4" />
<span>{t(`sidecars.status.${sidecar.status}`)}</span>
</div>
</div>
</button>
)}
</For>
</Show>
</Show>
</div>
<div class="flex justify-end">
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
{t("sidecars.picker.close")}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}

View File

@@ -0,0 +1,197 @@
import { ArrowLeft, ArrowRight, RefreshCw } from "lucide-solid"
import { createEffect, createMemo, createSignal, type Component } from "solid-js"
import type { SideCarTabRecord } from "../stores/sidecars"
import { useI18n } from "../lib/i18n"
interface SideCarViewProps {
tab: SideCarTabRecord
}
export const SideCarView: Component<SideCarViewProps> = (props) => {
const { t } = useI18n()
const [frameSrc, setFrameSrc] = createSignal(props.tab.shellUrl)
const [pathInput, setPathInput] = createSignal("/")
let iframeRef: HTMLIFrameElement | undefined
const lockedBaseLabel = createMemo(() => {
const hostLabel = props.tab.port ? `${props.tab.name}:${props.tab.port}` : props.tab.name
if (props.tab.prefixMode === "preserve") {
return `${hostLabel}${props.tab.proxyBasePath}`
}
return hostLabel
})
const getEditablePathFromUrl = (url: string): string => {
try {
const parsed = new URL(url, window.location.origin)
const basePath = props.tab.proxyBasePath
let pathname = parsed.pathname
if (basePath && pathname.startsWith(basePath)) {
pathname = pathname.slice(basePath.length) || "/"
}
if (!pathname.startsWith("/")) {
pathname = `/${pathname}`
}
return `${pathname}${parsed.search}${parsed.hash}`
} catch {
return "/"
}
}
const buildNormalizedTargetUrl = (rawInput: string): string => {
const trimmed = rawInput.trim()
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`
const parsed = new URL(withLeadingSlash || "/", window.location.origin)
const safeSegments: string[] = []
for (const segment of parsed.pathname.split("/")) {
if (!segment || segment === ".") {
continue
}
if (segment === "..") {
if (safeSegments.length > 0) {
safeSegments.pop()
}
continue
}
safeSegments.push(segment)
}
const normalizedPath = `/${safeSegments.join("/")}` || "/"
const basePath = props.tab.proxyBasePath
return `${basePath}${normalizedPath}${parsed.search}${parsed.hash}`
}
const syncPathInputFromFrame = () => {
try {
const currentHref = iframeRef?.contentWindow?.location.href
if (!currentHref) {
return
}
setPathInput(getEditablePathFromUrl(currentHref))
} catch {
setPathInput(getEditablePathFromUrl(frameSrc()))
}
}
createEffect(() => {
setFrameSrc(props.tab.shellUrl)
setPathInput(getEditablePathFromUrl(props.tab.shellUrl))
})
const handleBack = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
try {
const frameWindow = iframeRef?.contentWindow
if (!frameWindow) {
return
}
if (frameWindow.history.length <= 1) {
return
}
frameWindow.focus()
frameWindow.history.go(-1)
} catch {
// Ignore navigation errors from pages that do not expose history access.
}
}
const handleRefresh = () => {
try {
iframeRef?.contentWindow?.location.reload()
return
} catch {
// Fall back to resetting the iframe source if the frame cannot be reloaded directly.
}
setFrameSrc("about:blank")
requestAnimationFrame(() => setFrameSrc(props.tab.shellUrl))
}
const handleGo = (event?: Event) => {
event?.preventDefault()
const nextUrl = buildNormalizedTargetUrl(pathInput())
setFrameSrc(nextUrl)
setPathInput(getEditablePathFromUrl(nextUrl))
}
return (
<div class="flex h-full min-h-0 w-full flex-col bg-surface">
<div
class="flex shrink-0 items-center gap-2 px-3 py-2"
style={{ "border-bottom": "1px solid var(--border-base)" }}
>
<button
type="button"
class="new-tab-button"
onClick={handleBack}
title={t("sidecars.back")}
aria-label={t("sidecars.back")}
>
<ArrowLeft class="h-4 w-4" />
</button>
<button
type="button"
class="new-tab-button"
onClick={handleRefresh}
title={t("sidecars.refresh")}
aria-label={t("sidecars.refresh")}
>
<RefreshCw class="h-4 w-4" />
</button>
<div
class="shrink-0 rounded-md px-3 py-1.5 text-sm"
style={{
background: "var(--surface-secondary)",
color: "var(--text-secondary)",
border: "1px solid var(--border-base)",
}}
>
{lockedBaseLabel()}
</div>
<form class="flex min-w-0 flex-1 items-center gap-2" onSubmit={(event) => handleGo(event)}>
<input
type="text"
class="min-w-0 flex-1 rounded-md px-3 py-1.5 text-sm outline-none"
style={{
background: "var(--surface-secondary)",
color: "var(--text-primary)",
border: "1px solid var(--border-base)",
}}
value={pathInput()}
onInput={(event) => setPathInput(event.currentTarget.value)}
spellcheck={false}
autocomplete="off"
autocorrect="off"
autocapitalize="off"
aria-label={t("sidecars.path")}
/>
<button
type="submit"
class="new-tab-button"
title={t("sidecars.go")}
aria-label={t("sidecars.go")}
>
<ArrowRight class="h-4 w-4" />
</button>
</form>
</div>
<iframe
ref={iframeRef}
src={frameSrc()}
title={props.tab.name}
class="min-h-0 flex-1 w-full border-0 bg-surface"
referrerPolicy="same-origin"
onLoad={syncPathInputFromFrame}
/>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js" import { createSignal, Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid" import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid"
import { stringify as stringifyYaml } from "yaml" import { stringify as stringifyYaml } from "yaml"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
@@ -44,6 +44,7 @@ import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { useSpeech } from "../lib/hooks/use-speech" import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button" import SpeechActionButton from "./speech-action-button"
import { createFollowScroll } from "../lib/follow-scroll"
const log = getLogger("session") const log = getLogger("session")
@@ -51,8 +52,6 @@ type ToolState = import("@opencode-ai/sdk/v2").ToolState
const TOOL_CALL_CACHE_SCOPE = "tool-call" const TOOL_CALL_CACHE_SCOPE = "tool-call"
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48 const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
function makeRenderCacheKey( function makeRenderCacheKey(
toolCallId?: string | null, toolCallId?: string | null,
@@ -82,6 +81,27 @@ interface ToolCallProps {
forceCollapsed?: boolean forceCollapsed?: boolean
} }
function ToolStatusIndicator(props: { status: Accessor<string> }) {
const isVisible = (value: string) => props.status() === value
return (
<span class="tool-call-header-status" aria-hidden="true" data-status={props.status() || "pending"}>
<span style={{ display: isVisible("pending") ? "inline-flex" : "none" }}>
<Hourglass class="w-4 h-4" />
</span>
<span style={{ display: isVisible("running") ? "inline-flex" : "none" }}>
<Loader2 class="w-4 h-4 animate-spin" />
</span>
<span style={{ display: isVisible("completed") ? "inline-flex" : "none" }}>
<Check class="w-4 h-4" />
</span>
<span style={{ display: isVisible("error") ? "inline-flex" : "none" }}>
<XCircle class="w-4 h-4" />
</span>
</span>
)
}
function ToolCallDetails(props: { function ToolCallDetails(props: {
toolCallMemo: () => ToolCallPart toolCallMemo: () => ToolCallPart
toolState: () => ToolState | undefined toolState: () => ToolState | undefined
@@ -166,179 +186,25 @@ function ToolCallDetails(props: {
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false) const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
const [permissionError, setPermissionError] = createSignal<string | null>(null) const [permissionError, setPermissionError] = createSignal<string | null>(null)
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>() const followScroll = createFollowScroll({
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null) getScrollTopSnapshot: props.scrollTopSnapshot,
const [autoScroll, setAutoScroll] = createSignal(true) setScrollTopSnapshot: props.setScrollTopSnapshot,
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true) sentinelMarginPx: TOOL_SCROLL_SENTINEL_MARGIN_PX,
sentinelClassName: "tool-call-scroll-sentinel",
let scrollContainerRef: HTMLDivElement | undefined })
let detachScrollIntentListeners: (() => void) | undefined
let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
let userScrollIntentUntil = 0
let lastKnownScrollTop = props.scrollTopSnapshot()
function restoreScrollPosition(forceBottom = false) {
const container = scrollContainerRef
if (!container) return
if (forceBottom) {
container.scrollTop = container.scrollHeight
lastKnownScrollTop = container.scrollTop
props.setScrollTopSnapshot(lastKnownScrollTop)
} else {
container.scrollTop = lastKnownScrollTop
}
}
const persistScrollSnapshot = (element?: HTMLElement | null) => {
if (!element) return
lastKnownScrollTop = element.scrollTop
props.setScrollTopSnapshot(lastKnownScrollTop)
}
function markUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
userScrollIntentUntil = now + TOOL_SCROLL_INTENT_WINDOW_MS
}
function hasUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
return now <= userScrollIntentUntil
}
function attachScrollIntentListeners(element: HTMLDivElement) {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
const handlePointerIntent = () => markUserScrollIntent()
const handleKeyIntent = (event: KeyboardEvent) => {
if (TOOL_SCROLL_INTENT_KEYS.has(event.key)) {
markUserScrollIntent()
}
}
element.addEventListener("wheel", handlePointerIntent, { passive: true })
element.addEventListener("pointerdown", handlePointerIntent)
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
element.addEventListener("keydown", handleKeyIntent)
detachScrollIntentListeners = () => {
element.removeEventListener("wheel", handlePointerIntent)
element.removeEventListener("pointerdown", handlePointerIntent)
element.removeEventListener("touchstart", handlePointerIntent)
element.removeEventListener("keydown", handleKeyIntent)
}
}
function scheduleAnchorScroll(immediate = false) {
if (!autoScroll()) return
const sentinel = bottomSentinel()
const container = scrollContainerRef
if (!sentinel || !container) return
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
pendingAnchorScroll = requestAnimationFrame(() => {
pendingAnchorScroll = null
const containerRect = container.getBoundingClientRect()
const sentinelRect = sentinel.getBoundingClientRect()
const delta = sentinelRect.bottom - containerRect.bottom + TOOL_SCROLL_SENTINEL_MARGIN_PX
if (Math.abs(delta) > 1) {
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
}
lastKnownScrollTop = container.scrollTop
props.setScrollTopSnapshot(lastKnownScrollTop)
})
}
function handleScroll() {
const container = scrollContainer()
if (!container) return
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
}
const isUserScroll = hasUserScrollIntent()
pendingScrollFrame = requestAnimationFrame(() => {
pendingScrollFrame = null
const atBottom = bottomSentinelVisible()
if (isUserScroll) {
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else if (autoScroll()) {
setAutoScroll(false)
}
}
})
}
const handleScrollEvent = (event: Event & { currentTarget: HTMLDivElement }) => {
handleScroll()
persistScrollSnapshot(event.currentTarget)
}
const handleScrollRendered = () => {
requestAnimationFrame(() => {
restoreScrollPosition(autoScroll())
scheduleAnchorScroll(true)
})
}
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
const next = element || undefined
if (next === scrollContainerRef) {
return
}
scrollContainerRef = next
setScrollContainer(scrollContainerRef)
if (scrollContainerRef) {
// Refresh our snapshot on mount (e.g. when remounting after collapse)
lastKnownScrollTop = props.scrollTopSnapshot()
restoreScrollPosition(autoScroll())
}
}
const scrollHelpers: ToolScrollHelpers = { const scrollHelpers: ToolScrollHelpers = {
registerContainer: (element, options) => { registerContainer: (element, options) => {
if (options?.disableTracking) return followScroll.registerContainer(element, options)
initializeScrollContainer(element)
},
handleScroll: handleScrollEvent,
renderSentinel: (options) => {
if (options?.disableTracking) return null
return <div ref={setBottomSentinel} aria-hidden="true" class="tool-call-scroll-sentinel" style={{ height: "1px" }} />
}, },
handleScroll: followScroll.handleScroll,
renderSentinel: followScroll.renderSentinel,
restoreAfterRender: followScroll.restoreAfterRender,
} }
createEffect(() => { const handleScrollRendered = () => {
const container = scrollContainer() scrollHelpers.restoreAfterRender()
if (!container) return }
attachScrollIntentListeners(container)
onCleanup(() => {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
})
createEffect(() => {
const container = scrollContainer()
const sentinel = bottomSentinel()
if (!container || !sentinel) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.target === sentinel) {
setBottomSentinelVisible(entry.isIntersecting)
}
})
},
{ root: container, threshold: 0, rootMargin: `0px 0px ${TOOL_SCROLL_SENTINEL_MARGIN_PX}px 0px` },
)
observer.observe(sentinel)
onCleanup(() => observer.disconnect())
})
createEffect(() => { createEffect(() => {
const permission = permissionDetails() const permission = permissionDetails()
@@ -564,11 +430,13 @@ function ToolCallDetails(props: {
partVersion={options.partVersion} partVersion={options.partVersion}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={options.sessionId} sessionId={options.sessionId}
onContentRendered={props.onContentRendered}
forceCollapsed={options.forceCollapsed} forceCollapsed={options.forceCollapsed}
/> />
) )
}, },
scrollHelpers, scrollHelpers,
onContentRendered: props.onContentRendered,
} }
let previousPartVersion: number | undefined let previousPartVersion: number | undefined
@@ -581,12 +449,12 @@ function ToolCallDetails(props: {
return return
} }
previousPartVersion = version previousPartVersion = version
scheduleAnchorScroll(true) scrollHelpers.restoreAfterRender()
}) })
createEffect(() => { createEffect(() => {
if (autoScroll()) { if (followScroll.autoScroll()) {
scheduleAnchorScroll(true) scrollHelpers.restoreAfterRender()
} }
}) })
@@ -634,21 +502,6 @@ function ToolCallDetails(props: {
/> />
) )
onCleanup(() => {
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
pendingScrollFrame = null
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
return ( return (
<div class="tool-call-details"> <div class="tool-call-details">
<Show <Show
@@ -850,24 +703,6 @@ export default function ToolCall(props: ToolCallProps) {
return !current return !current
}) })
} }
const statusIcon = () => {
const status = toolState()?.status || ""
switch (status) {
case "pending":
return <Hourglass class="w-4 h-4" />
case "running":
return <Loader2 class="w-4 h-4 animate-spin" />
case "completed":
return <Check class="w-4 h-4" />
case "error":
return <XCircle class="w-4 h-4" />
default:
return ""
}
}
const statusClass = () => { const statusClass = () => {
const status = toolState()?.status || "pending" const status = toolState()?.status || "pending"
return `tool-call-status-${status}` return `tool-call-status-${status}`
@@ -1051,9 +886,7 @@ export default function ToolCall(props: ToolCallProps) {
/> />
</Show> </Show>
<span class="tool-call-header-status" aria-hidden="true"> <ToolStatusIndicator status={status} />
{statusIcon()}
</span>
</div> </div>
<Show when={expanded()}> <Show when={expanded()}>

View File

@@ -1,4 +1,4 @@
import type { Accessor, JSXElement } from "solid-js" import { createEffect, onCleanup, type Accessor, type JSXElement } from "solid-js"
import type { RenderCache } from "../../types/message" import type { RenderCache } from "../../types/message"
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi" import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
import { escapeHtml } from "../../lib/text-render-utils" import { escapeHtml } from "../../lib/text-render-utils"
@@ -11,6 +11,97 @@ type CacheHandle = {
set(value: unknown): void set(value: unknown): void
} }
export interface StableAnsiStreamUpdater {
update: (element: HTMLElement, content: string) => void
reset: () => void
}
export function createStableAnsiStreamUpdater(): StableAnsiStreamUpdater {
const renderer = createAnsiStreamRenderer()
let previousContent = ""
let ansiActive = false
return {
update(element: HTMLElement, content: string) {
const resetStreaming = !previousContent || !content.startsWith(previousContent)
if (resetStreaming) {
ansiActive = hasAnsi(content)
renderer.reset()
element.innerHTML = ansiActive ? renderer.render(content) : escapeHtml(content)
previousContent = content
return
}
const delta = content.slice(previousContent.length)
if (delta.length === 0) {
return
}
if (!ansiActive && hasAnsi(delta)) {
ansiActive = true
renderer.reset()
element.innerHTML = renderer.render(content)
previousContent = content
return
}
if (ansiActive) {
const htmlChunk = renderer.render(delta)
if (htmlChunk.length > 0) {
element.insertAdjacentHTML("beforeend", htmlChunk)
}
} else {
const escapedDelta = escapeHtml(delta)
if (escapedDelta.length > 0) {
element.insertAdjacentHTML("beforeend", escapedDelta)
}
}
previousContent = content
},
reset() {
previousContent = ""
ansiActive = false
renderer.reset()
},
}
}
function StreamingAnsiContent(props: {
html: string
htmlChunk?: string
updateMode: "replace" | "append" | "noop"
}) {
let preRef: HTMLPreElement | undefined
createEffect(() => {
const element = preRef
if (!element) return
if (props.updateMode === "noop") return
if (props.updateMode === "append") {
if (element.innerHTML.length === 0) {
element.innerHTML = props.html
return
}
const chunk = props.htmlChunk ?? ""
if (chunk.length > 0) {
element.insertAdjacentHTML("beforeend", chunk)
}
return
}
if (element.innerHTML !== props.html) {
element.innerHTML = props.html
}
})
onCleanup(() => {
preRef = undefined
})
return <pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
}
export function createAnsiContentRenderer(params: { export function createAnsiContentRenderer(params: {
ansiRunningCache: CacheHandle ansiRunningCache: CacheHandle
ansiFinalCache: CacheHandle ansiFinalCache: CacheHandle
@@ -46,6 +137,8 @@ export function createAnsiContentRenderer(params: {
const isRunningVariant = options.variant === "running" const isRunningVariant = options.variant === "running"
const disableScrollTracking = !isRunningVariant const disableScrollTracking = !isRunningVariant
const registerRef = disableScrollTracking ? registerUntracked : registerTracked const registerRef = disableScrollTracking ? registerUntracked : registerTracked
let updateMode: "replace" | "append" | "noop" = "replace"
let htmlChunk = ""
let nextCache: AnsiRenderCache let nextCache: AnsiRenderCache
@@ -54,6 +147,7 @@ export function createAnsiContentRenderer(params: {
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
if (resetStreaming) { if (resetStreaming) {
updateMode = "replace"
const detectedAnsi = hasAnsi(content) const detectedAnsi = hasAnsi(content)
if (detectedAnsi) { if (detectedAnsi) {
runningAnsiRenderer.reset() runningAnsiRenderer.reset()
@@ -66,15 +160,21 @@ export function createAnsiContentRenderer(params: {
} else { } else {
const delta = content.slice(cached.text.length) const delta = content.slice(cached.text.length)
if (delta.length === 0) { if (delta.length === 0) {
updateMode = "noop"
nextCache = { ...cached, mode } nextCache = { ...cached, mode }
} else if (!cached.hasAnsi && hasAnsi(delta)) { } else if (!cached.hasAnsi && hasAnsi(delta)) {
updateMode = "replace"
runningAnsiRenderer.reset() runningAnsiRenderer.reset()
const html = runningAnsiRenderer.render(content) const html = runningAnsiRenderer.render(content)
nextCache = { text: content, html, mode, hasAnsi: true } nextCache = { text: content, html, mode, hasAnsi: true }
} else if (cached.hasAnsi) { } else if (cached.hasAnsi) {
const htmlChunk = runningAnsiRenderer.render(delta) const appendedHtml = runningAnsiRenderer.render(delta)
nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true } updateMode = "append"
htmlChunk = appendedHtml
nextCache = { text: content, html: `${cached.html}${appendedHtml}`, mode, hasAnsi: true }
} else { } else {
updateMode = "append"
htmlChunk = escapeHtml(delta)
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false } nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
} }
} }
@@ -98,7 +198,7 @@ export function createAnsiContentRenderer(params: {
return ( return (
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}> <div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} /> <StreamingAnsiContent html={nextCache.html} htmlChunk={htmlChunk} updateMode={updateMode} />
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })} {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div> </div>
) )

View File

@@ -129,9 +129,7 @@ export function createDiffContentRenderer(params: {
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch") const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
const handleDiffRendered = () => { const handleDiffRendered = () => {
if (!disableScrollTracking) { params.handleScrollRendered()
params.handleScrollRendered()
}
params.onContentRendered?.() params.onContentRendered?.()
} }

View File

@@ -1,6 +1,107 @@
import type { ToolRenderer } from "../types" import { Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ToolRenderer, ToolScrollHelpers } from "../types"
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
import { tGlobal } from "../../../lib/i18n" import { tGlobal } from "../../../lib/i18n"
import { createStableAnsiStreamUpdater } from "../ansi-render"
import { ansiToHtml, hasAnsi } from "../../../lib/ansi"
function RunningBashOutput(props: {
content: Accessor<string>
scrollHelpers?: ToolScrollHelpers
}) {
let preRef: HTMLPreElement | undefined
const updater = createStableAnsiStreamUpdater()
createEffect(() => {
const element = preRef
if (!element) return
updater.update(element, props.content())
})
onCleanup(() => {
preRef = undefined
updater.reset()
})
return (
<div
class="message-text tool-call-markdown"
ref={props.scrollHelpers?.registerContainer}
onScroll={props.scrollHelpers ? (event) => props.scrollHelpers!.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined}
>
<pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
{props.scrollHelpers?.renderSentinel?.()}
</div>
)
}
function BashToolBody(props: {
toolState: Accessor<ToolState | undefined>
renderMarkdown: (options: { content: string }) => ReturnType<ToolRenderer["renderBody"]>
scrollHelpers?: ToolScrollHelpers
}) {
const state = createMemo(() => props.toolState())
const joinedContent = createMemo(() => {
const current = state()
if (!current || current.status === "pending") return ""
const { input, metadata } = readToolStatePayload(current)
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
const outputResult = formatUnknown(
isToolStateCompleted(current)
? current.output
: (isToolStateRunning(current) || isToolStateError(current)) && metadata.output
? metadata.output
: undefined,
)
return [command, outputResult?.text].filter(Boolean).join("\n")
})
const finalMarkdown = createMemo(() => {
const current = state()
const content = joinedContent()
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
return null
}
if (hasAnsi(content)) {
return null
}
return ensureMarkdownContent(content, "bash", true)
})
const finalAnsiHtml = createMemo(() => {
const current = state()
const content = joinedContent()
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
return null
}
if (!hasAnsi(content)) {
return null
}
return ansiToHtml(content)
})
return (
<Show when={state() && joinedContent().length > 0}>
<Show
when={state()?.status === "running"}
fallback={
<Show when={finalAnsiHtml()} fallback={finalMarkdown() ? props.renderMarkdown({ content: finalMarkdown()! as string }) : null}>
{(html) => (
<div class="message-text tool-call-markdown" ref={props.scrollHelpers?.registerContainer}>
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={html()} />
</div>
)}
</Show>
}
>
<RunningBashOutput content={joinedContent} scrollHelpers={props.scrollHelpers} />
</Show>
</Show>
)
}
export const bashRenderer: ToolRenderer = { export const bashRenderer: ToolRenderer = {
tools: ["bash"], tools: ["bash"],
@@ -21,35 +122,7 @@ export const bashRenderer: ToolRenderer = {
const timeoutLabel = `${timeout}ms` const timeoutLabel = `${timeout}ms`
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}` return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
}, },
renderBody({ toolState, renderMarkdown, renderAnsi }) { renderBody({ toolState, renderMarkdown, scrollHelpers }) {
const state = toolState() return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} />
if (!state || state.status === "pending") return null
const { input, metadata } = readToolStatePayload(state)
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
const outputResult = formatUnknown(
isToolStateCompleted(state)
? state.output
: (isToolStateRunning(state) || isToolStateError(state)) && metadata.output
? metadata.output
: undefined,
)
const parts = [command, outputResult?.text].filter(Boolean)
if (parts.length === 0) return null
const joined = parts.join("\n")
if (state.status === "running") {
return renderAnsi({ content: joined, variant: "running" })
}
const ansiBody = renderAnsi({ content: joined, requireAnsi: true, variant: "final" })
if (ansiBody) {
return ansiBody
}
const content = ensureMarkdownContent(joined, "bash", true)
if (!content) return null
return renderMarkdown({ content })
}, },
} }

View File

@@ -1,4 +1,4 @@
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js" import { For, Index, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ToolRenderer } from "../types" import type { ToolRenderer } from "../types"
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils" import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
@@ -145,7 +145,7 @@ export const taskRenderer: ToolRenderer = {
const { input } = readToolStatePayload(state) const { input } = readToolStatePayload(state)
return describeTaskTitle(input) return describeTaskTitle(input)
}, },
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) { renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) {
const store = messageStoreBus.getOrCreate(instanceId) const store = messageStoreBus.getOrCreate(instanceId)
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false) const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
@@ -360,6 +360,14 @@ export const taskRenderer: ToolRenderer = {
}) })
}) })
createEffect(() => {
const childCount = childToolKeys().length
const legacyCount = legacyItems().length
if (childCount === 0 && legacyCount === 0) return
scrollHelpers?.restoreAfterRender()
onContentRendered?.()
})
return ( return (
<div class="tool-call-task-sections"> <div class="tool-call-task-sections">
<Show when={promptContent()}> <Show when={promptContent()}>
@@ -443,12 +451,12 @@ export const taskRenderer: ToolRenderer = {
} }
> >
<div class="tool-call-task-summary"> <div class="tool-call-task-summary">
<For each={childToolKeys()}> <Index each={childToolKeys()}>
{(key) => ( {(key) => (
<Show when={renderToolCall}> <Show when={renderToolCall}>
{(render) => ( {(render) => (
<TaskToolCallRow <TaskToolCallRow
toolKey={key} toolKey={key()}
store={store} store={store}
sessionId={childSessionId()} sessionId={childSessionId()}
renderToolCall={render()} renderToolCall={render()}
@@ -456,7 +464,7 @@ export const taskRenderer: ToolRenderer = {
)} )}
</Show> </Show>
)} )}
</For> </Index>
</div> </div>
{scrollHelpers?.renderSentinel?.()} {scrollHelpers?.renderSentinel?.()}
</div> </div>

View File

@@ -47,6 +47,7 @@ export interface ToolScrollHelpers {
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
restoreAfterRender(): void
} }
export interface ToolRendererContext { export interface ToolRendererContext {
@@ -74,6 +75,7 @@ export interface ToolRendererContext {
forceCollapsed?: boolean forceCollapsed?: boolean
}) => JSXElement | null }) => JSXElement | null
scrollHelpers?: ToolScrollHelpers scrollHelpers?: ToolScrollHelpers
onContentRendered?: () => void
} }
export interface ToolRenderer { export interface ToolRenderer {

View File

@@ -79,6 +79,7 @@ interface UnifiedPickerProps {
mode?: "mention" | "command" mode?: "mention" | "command"
onSelect: (item: PickerItem, action: PickerSelectAction) => void onSelect: (item: PickerItem, action: PickerSelectAction) => void
onClose: () => void onClose: () => void
onSubmitWithoutSelection?: () => void
agents: Agent[] agents: Agent[]
commands?: SDKCommand[] commands?: SDKCommand[]
instanceClient: OpencodeClient | null instanceClient: OpencodeClient | null
@@ -404,6 +405,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
if (selected) { if (selected) {
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter" const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
props.onSelect(selected, action) props.onSelect(selected, action)
} else if (e.key === "Enter" && mode() === "mention") {
props.onSubmitWithoutSelection?.()
} }
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
e.preventDefault() e.preventDefault()

View File

@@ -2,6 +2,8 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor,
import { Virtualizer, type VirtualizerHandle } from "virtua/solid" import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48 const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8
const DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX = 128
const USER_SCROLL_INTENT_WINDOW_MS = 600 const USER_SCROLL_INTENT_WINDOW_MS = 600
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"]) const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
@@ -85,6 +87,28 @@ export interface VirtualFollowListProps<T> {
*/ */
followToken?: Accessor<string | number> followToken?: Accessor<string | number>
/**
* Optional item key whose geometry can temporarily hold auto-follow when the
* rendered item grows taller than the viewport and reaches the top edge.
*/
autoPinHoldTargetKey?: Accessor<string | null>
/**
* Optional resolver for the specific element inside an item wrapper that
* should be measured for hold-target geometry.
*/
resolveAutoPinHoldElement?: (itemWrapper: HTMLDivElement, key: string) => HTMLElement | null | undefined
/**
* Top-edge threshold for the hold target in pixels.
*/
autoPinHoldTopThresholdPx?: number
/**
* Temporarily suppress automatic bottom pinning while keeping follow mode enabled.
*/
suspendAutoPinToBottom?: Accessor<boolean>
/** /**
* Optional hooks to render content inside the scroll container. * Optional hooks to render content inside the scroll container.
* Useful for empty/loading states that should scroll with the list. * Useful for empty/loading states that should scroll with the list.
@@ -130,13 +154,19 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true) const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true) const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true) const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
const externalSuspendAutoPinToBottom = () => (props.suspendAutoPinToBottom ? props.suspendAutoPinToBottom() : false)
const holdTargetKey = () => (props.autoPinHoldTargetKey ? props.autoPinHoldTargetKey() : null)
const holdTargetTopThresholdPx = () => props.autoPinHoldTopThresholdPx ?? DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll())) const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [activeKey, setActiveKey] = createSignal<string | null>(null) const [activeKey, setActiveKey] = createSignal<string | null>(null)
const [heldItemCount, setHeldItemCount] = createSignal<number | null>(null)
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || heldItemCount() !== null
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0)) const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
const itemElements = new Map<string, HTMLDivElement>()
let userScrollIntentUntil = 0 let userScrollIntentUntil = 0
let lastUserScrollIntentDirection: "up" | "down" | null = null let lastUserScrollIntentDirection: "up" | "down" | null = null
@@ -220,6 +250,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// Sync autoScroll state based on scroll position if it was a user scroll // Sync autoScroll state based on scroll position if it was a user scroll
if (hasUserScrollIntent()) { if (hasUserScrollIntent()) {
if (atBottom && heldItemCount() !== null) {
setHeldItemCount(null)
}
if (atBottom && !autoScroll()) { if (atBottom && !autoScroll()) {
setAutoScroll(true) setAutoScroll(true)
} else if (!atBottom && autoScroll()) { } else if (!atBottom && autoScroll()) {
@@ -253,6 +286,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
} }
updateScrollButtons() updateScrollButtons()
updateAutoPinHold()
props.onScroll?.() props.onScroll?.()
// Find active key (roughly the first visible item) // Find active key (roughly the first visible item)
@@ -270,6 +304,68 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
} }
function registerItemElement(key: string, element: HTMLDivElement | null | undefined) {
if (!element) {
itemElements.delete(key)
return
}
itemElements.set(key, element)
}
function getAnchorIdForKey(key: string) {
return props.getAnchorId ? props.getAnchorId(key) : key
}
function updateAutoPinHold() {
const element = scrollElement()
const itemCount = props.items().length
const heldCount = heldItemCount()
if (!element) return
if (heldCount !== null) {
if (itemCount > heldCount) {
setHeldItemCount(null)
if (autoScroll()) {
requestAnimationFrame(() => {
if (!autoScroll()) return
scrollToBottom(false)
})
}
return
}
if (itemCount < heldCount) {
setHeldItemCount(null)
return
}
return
}
if (!autoScroll()) return
if (externalSuspendAutoPinToBottom()) return
const targetKey = holdTargetKey()
if (!targetKey) return
const itemWrapper = itemElements.get(targetKey)
if (!itemWrapper) return
const target = props.resolveAutoPinHoldElement?.(itemWrapper, targetKey) ?? itemWrapper
const containerRect = element.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const relativeTop = targetRect.top - containerRect.top
const exceedsViewport = targetRect.height > element.clientHeight
if (
exceedsViewport &&
relativeTop <= holdTargetTopThresholdPx() &&
relativeTop >= holdTargetTopThresholdPx() - DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX
) {
setHeldItemCount(itemCount)
}
}
const api: VirtualFollowListApi = { const api: VirtualFollowListApi = {
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true), scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }), scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
@@ -281,7 +377,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" }) virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
}, },
notifyContentRendered: () => { notifyContentRendered: () => {
if (autoScroll()) { updateAutoPinHold()
if (heldItemCount() !== null) return
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
scrollToBottom(true) scrollToBottom(true)
} }
}, },
@@ -294,9 +392,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
createEffect(() => props.registerApi?.(api)) createEffect(() => props.registerApi?.(api))
createEffect(() => props.registerState?.(state)) createEffect(() => props.registerState?.(state))
createEffect(on(() => props.resetKey?.(), () => {
itemElements.clear()
setHeldItemCount(null)
}))
// Handle autoScroll (Follow) on items change // Handle autoScroll (Follow) on items change
createEffect(on(() => props.items().length, (len, prevLen) => { createEffect(on(() => props.items().length, (len, prevLen) => {
if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) { updateAutoPinHold()
if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) {
requestAnimationFrame(() => scrollToBottom(true)) requestAnimationFrame(() => scrollToBottom(true))
} }
suppressAutoScrollOnce = false suppressAutoScrollOnce = false
@@ -304,11 +408,16 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// Handle followToken change // Handle followToken change
createEffect(on(() => props.followToken?.(), () => { createEffect(on(() => props.followToken?.(), () => {
if (autoScroll()) { updateAutoPinHold()
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
scrollToBottom(true) scrollToBottom(true)
} }
}, { defer: true })) }, { defer: true }))
createEffect(on(() => holdTargetKey(), () => {
updateAutoPinHold()
}, { defer: true }))
// Reset state on resetKey change // Reset state on resetKey change
createEffect(on(() => props.resetKey?.(), (nextKey) => { createEffect(on(() => props.resetKey?.(), (nextKey) => {
if (nextKey === lastResetKey) return if (nextKey === lastResetKey) return
@@ -331,6 +440,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
}) })
createEffect(() => {
if (typeof window === "undefined") return
const handleResize = () => updateAutoPinHold()
window.addEventListener("resize", handleResize)
onCleanup(() => window.removeEventListener("resize", handleResize))
})
return ( return (
<div class="virtual-follow-list-shell" ref={shellElement => { <div class="virtual-follow-list-shell" ref={shellElement => {
setShellElement(shellElement) setShellElement(shellElement)
@@ -356,7 +472,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
bufferSize={props.overscanPx ?? 400} bufferSize={props.overscanPx ?? 400}
onScroll={handleScroll} onScroll={handleScroll}
> >
{(item, index) => props.renderItem(item, index())} {(item, index) => {
const key = props.getKey(item, index())
const anchorId = getAnchorIdForKey(key)
return (
<div id={anchorId} data-virtual-follow-key={key} ref={(element) => registerItemElement(key, element)}>
{props.renderItem(item, index())}
</div>
)
}}
</Virtualizer> </Virtualizer>
</div> </div>

View File

@@ -10,7 +10,10 @@ import type {
SpeechCapabilitiesResponse, SpeechCapabilitiesResponse,
SpeechSynthesisResponse, SpeechSynthesisResponse,
SpeechTranscriptionResponse, SpeechTranscriptionResponse,
SideCar,
ServerMeta, ServerMeta,
RemoteServerProbeRequest,
RemoteServerProbeResponse,
VoiceModeStateResponse, VoiceModeStateResponse,
WorkspaceCreateRequest, WorkspaceCreateRequest,
WorkspaceDescriptor, WorkspaceDescriptor,
@@ -191,9 +194,42 @@ export const serverApi = {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
}, },
fetchSidecars(): Promise<{ sidecars: SideCar[] }> {
return request<{ sidecars: SideCar[] }>("/api/sidecars")
},
createSidecar(payload: {
kind: "port"
name: string
port: number
insecure: boolean
prefixMode: "strip" | "preserve"
}): Promise<SideCar> {
return request<SideCar>("/api/sidecars", {
method: "POST",
body: JSON.stringify(payload),
})
},
updateSidecar(
id: string,
payload: Partial<{ name: string; port: number; insecure: boolean; prefixMode: "strip" | "preserve" }>,
): Promise<SideCar> {
return request<SideCar>(`/api/sidecars/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(payload),
})
},
deleteSidecar(id: string): Promise<void> {
return request(`/api/sidecars/${encodeURIComponent(id)}`, { method: "DELETE" })
},
fetchServerMeta(): Promise<ServerMeta> { fetchServerMeta(): Promise<ServerMeta> {
return request<ServerMeta>("/api/meta") return request<ServerMeta>("/api/meta")
}, },
probeRemoteServer(payload: RemoteServerProbeRequest): Promise<RemoteServerProbeResponse> {
return request<RemoteServerProbeResponse>("/api/remote-servers/probe", {
method: "POST",
body: JSON.stringify(payload),
})
},
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> { fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status") return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
}, },
@@ -430,4 +466,4 @@ function buildClientEventsUrl(identity: { clientId: string; connectionId: string
return `${url.pathname}${url.search}` return `${url.pathname}${url.search}`
} }
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType } export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType, SideCar }

View File

@@ -2,6 +2,7 @@ const HUNK_PATTERN = /(^|\n)@@/m
const FILE_MARKER_PATTERN = /(^|\n)(diff --git |--- |\+\+\+)/ const FILE_MARKER_PATTERN = /(^|\n)(diff --git |--- |\+\+\+)/
const BEGIN_PATCH_PATTERN = /^\*\*\* (Begin|End) Patch/ const BEGIN_PATCH_PATTERN = /^\*\*\* (Begin|End) Patch/
const UPDATE_FILE_PATTERN = /^\*\*\* Update File: (.+)$/ const UPDATE_FILE_PATTERN = /^\*\*\* Update File: (.+)$/
const HUNK_HEADER_PATTERN = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
function stripCodeFence(value: string): string { function stripCodeFence(value: string): string {
const trimmed = value.trim() const trimmed = value.trim()
@@ -48,3 +49,48 @@ export function isRenderableDiffText(raw?: string | null): raw is string {
if (!normalized) return false if (!normalized) return false
return HUNK_PATTERN.test(normalized) return HUNK_PATTERN.test(normalized)
} }
export function parsePatchToBeforeAfter(patch: string): { before: string; after: string } {
if (!patch || patch.trim().length === 0) {
return { before: "", after: "" }
}
const lines = patch.replace(/\r\n/g, "\n").split("\n")
const beforeLines: string[] = []
const afterLines: string[] = []
for (const line of lines) {
if (line.startsWith("---") || line.startsWith("+++") || line.startsWith("diff --git")) {
continue
}
if (HUNK_HEADER_PATTERN.test(line)) {
continue
}
if (line.startsWith("-") && !line.startsWith("---")) {
beforeLines.push(line.slice(1))
} else if (line.startsWith("+") && !line.startsWith("+++")) {
afterLines.push(line.slice(1))
} else if (line.startsWith(" ")) {
beforeLines.push(line.slice(1))
afterLines.push(line.slice(1))
} else if (line === "") {
beforeLines.push("")
afterLines.push("")
} else {
beforeLines.push(line)
afterLines.push(line)
}
}
while (beforeLines.length > 0 && beforeLines[beforeLines.length - 1] === "") {
beforeLines.pop()
}
while (afterLines.length > 0 && afterLines[afterLines.length - 1] === "") {
afterLines.pop()
}
return {
before: beforeLines.join("\n"),
after: afterLines.join("\n"),
}
}

View File

@@ -0,0 +1,262 @@
import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js"
const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600
const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
interface FollowScrollOptions {
getScrollTopSnapshot: Accessor<number>
setScrollTopSnapshot: (next: number) => void
sentinelMarginPx: number
sentinelClassName: string
intentWindowMs?: number
intentKeys?: ReadonlySet<string>
}
export interface FollowScrollHelpers {
registerContainer: (element: HTMLDivElement | null | undefined, options?: { disableTracking?: boolean }) => void
handleScroll: (event: Event & { currentTarget: HTMLDivElement }) => void
renderSentinel: (options?: { disableTracking?: boolean }) => JSXElement | null
restoreAfterRender: () => void
autoScroll: Accessor<boolean>
}
export function createFollowScroll(options: FollowScrollOptions): FollowScrollHelpers {
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
const [autoScroll, setAutoScroll] = createSignal(true)
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
let scrollContainerRef: HTMLDivElement | undefined
let detachScrollIntentListeners: (() => void) | undefined
let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
let userScrollIntentUntil = 0
let lastKnownScrollTop = options.getScrollTopSnapshot()
let pointerInteractionActive = false
let suppressNextScrollHandling = false
function restoreScrollPosition(forceBottom = false) {
const container = scrollContainerRef
if (!container) return
suppressNextScrollHandling = true
if (forceBottom) {
container.scrollTop = container.scrollHeight
lastKnownScrollTop = container.scrollTop
options.setScrollTopSnapshot(lastKnownScrollTop)
} else {
container.scrollTop = lastKnownScrollTop
}
}
function persistScrollSnapshot(element?: HTMLElement | null) {
if (!element) return
lastKnownScrollTop = element.scrollTop
options.setScrollTopSnapshot(lastKnownScrollTop)
}
function markUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
userScrollIntentUntil = now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS)
}
function hasUserScrollIntent() {
if (pointerInteractionActive) {
return true
}
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
return now <= userScrollIntentUntil
}
function attachScrollIntentListeners(element: HTMLDivElement) {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
const intentKeys = options.intentKeys ?? DEFAULT_SCROLL_INTENT_KEYS
const handlePointerIntent = () => {
pointerInteractionActive = true
markUserScrollIntent()
}
const clearPointerIntent = () => {
pointerInteractionActive = false
}
const handleKeyIntent = (event: KeyboardEvent) => {
if (intentKeys.has(event.key)) {
markUserScrollIntent()
}
}
element.addEventListener("wheel", handlePointerIntent, { passive: true })
element.addEventListener("pointerdown", handlePointerIntent)
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
element.addEventListener("keydown", handleKeyIntent)
if (typeof window !== "undefined") {
window.addEventListener("pointerup", clearPointerIntent)
window.addEventListener("pointercancel", clearPointerIntent)
window.addEventListener("mouseup", clearPointerIntent)
window.addEventListener("touchend", clearPointerIntent)
window.addEventListener("touchcancel", clearPointerIntent)
}
detachScrollIntentListeners = () => {
element.removeEventListener("wheel", handlePointerIntent)
element.removeEventListener("pointerdown", handlePointerIntent)
element.removeEventListener("touchstart", handlePointerIntent)
element.removeEventListener("keydown", handleKeyIntent)
if (typeof window !== "undefined") {
window.removeEventListener("pointerup", clearPointerIntent)
window.removeEventListener("pointercancel", clearPointerIntent)
window.removeEventListener("mouseup", clearPointerIntent)
window.removeEventListener("touchend", clearPointerIntent)
window.removeEventListener("touchcancel", clearPointerIntent)
}
pointerInteractionActive = false
}
}
function scheduleAnchorScroll(immediate = false) {
if (!autoScroll()) return
const sentinel = bottomSentinel()
const container = scrollContainerRef
if (!sentinel || !container) return
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
pendingAnchorScroll = requestAnimationFrame(() => {
pendingAnchorScroll = null
const containerRect = container.getBoundingClientRect()
const sentinelRect = sentinel.getBoundingClientRect()
const delta = sentinelRect.bottom - containerRect.bottom + options.sentinelMarginPx
if (Math.abs(delta) > 1) {
suppressNextScrollHandling = true
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
}
lastKnownScrollTop = container.scrollTop
options.setScrollTopSnapshot(lastKnownScrollTop)
})
}
function isAtBottom(container: HTMLDivElement) {
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= options.sentinelMarginPx
}
function updateFollowModeFromScroll(containerOverride?: HTMLDivElement) {
const container = containerOverride ?? scrollContainer()
if (!container) return
if (suppressNextScrollHandling) {
suppressNextScrollHandling = false
return
}
const isUserScroll = hasUserScrollIntent()
const atBottomFromScroll = isAtBottom(container)
const atBottom = atBottomFromScroll || bottomSentinelVisible()
if (isUserScroll || !atBottom) {
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else if (autoScroll()) {
setAutoScroll(false)
}
}
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
updateFollowModeFromScroll(event.currentTarget)
persistScrollSnapshot(event.currentTarget)
}
const registerContainer = (element: HTMLDivElement | null | undefined, config?: { disableTracking?: boolean }) => {
const next = element || undefined
if (next === scrollContainerRef) {
return
}
scrollContainerRef = next
setScrollContainer(scrollContainerRef)
if (scrollContainerRef) {
lastKnownScrollTop = options.getScrollTopSnapshot()
restoreScrollPosition(autoScroll())
}
}
const renderSentinel = (config?: { disableTracking?: boolean }) => {
if (config?.disableTracking) return null
return <div ref={setBottomSentinel} aria-hidden="true" class={options.sentinelClassName} style={{ height: "1px" }} />
}
const restoreAfterRender = () => {
const container = scrollContainerRef
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
if (autoScroll()) {
setAutoScroll(false)
}
requestAnimationFrame(() => {
restoreScrollPosition(false)
})
return
}
// Never let a render-time caller force follow mode back on after the user
// has already escaped it. Staying pinned should depend on the current
// follow state, not on a caller opting into forceBottom.
const shouldFollow = autoScroll()
requestAnimationFrame(() => {
restoreScrollPosition(shouldFollow)
if (shouldFollow) {
scheduleAnchorScroll(true)
}
})
}
createEffect(() => {
const container = scrollContainer()
if (!container) return
attachScrollIntentListeners(container)
onCleanup(() => {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
})
createEffect(() => {
const container = scrollContainer()
const sentinel = bottomSentinel()
if (!container || !sentinel) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.target === sentinel) {
setBottomSentinelVisible(entry.isIntersecting)
}
})
},
{ root: container, threshold: 0, rootMargin: `0px 0px ${options.sentinelMarginPx}px 0px` },
)
observer.observe(sentinel)
onCleanup(() => observer.disconnect())
})
onCleanup(() => {
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
pendingScrollFrame = null
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
return {
registerContainer,
handleScroll,
renderSentinel,
restoreAfterRender,
autoScroll,
}
}

View File

@@ -16,6 +16,7 @@ const log = getLogger("actions")
interface UseAppLifecycleOptions { interface UseAppLifecycleOptions {
setEscapeInDebounce: (value: boolean) => void setEscapeInDebounce: (value: boolean) => void
handleNewInstanceRequest: () => void handleNewInstanceRequest: () => void
handleCloseActiveTab: () => Promise<void>
handleCloseInstance: (instanceId: string) => Promise<void> handleCloseInstance: (instanceId: string) => Promise<void>
handleNewSession: (instanceId: string) => Promise<void> handleNewSession: (instanceId: string) => Promise<void>
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void> handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
@@ -31,7 +32,7 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
setupTabKeyboardShortcuts( setupTabKeyboardShortcuts(
options.handleNewInstanceRequest, options.handleNewInstanceRequest,
options.handleCloseInstance, options.handleCloseActiveTab,
options.handleNewSession, options.handleNewSession,
options.handleCloseSession, options.handleCloseSession,
() => { () => {

View File

@@ -2,7 +2,8 @@ import { createSignal, onMount } from "solid-js"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences" import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences"
import { createCommandRegistry, type Command } from "../commands" import { createCommandRegistry, type Command } from "../commands"
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances" import { activeInstanceId } from "../../stores/instances"
import { selectNextAppTab, selectPreviousAppTab } from "../../stores/app-tabs"
import type { ClientPart, MessageInfo } from "../../types/message" import type { ClientPart, MessageInfo } from "../../types/message"
import { getSessions, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions" import { getSessions, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
import { showAlertDialog } from "../../stores/alerts" import { showAlertDialog } from "../../stores/alerts"
@@ -41,6 +42,7 @@ export interface UseCommandsOptions {
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
handleNewInstanceRequest: () => void handleNewInstanceRequest: () => void
handleCloseActiveTab: () => Promise<void>
handleCloseInstance: (instanceId: string) => Promise<void> handleCloseInstance: (instanceId: string) => Promise<void>
handleNewSession: (instanceId: string) => Promise<void> handleNewSession: (instanceId: string) => Promise<void>
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void> handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
@@ -90,9 +92,7 @@ export function useCommands(options: UseCommandsOptions) {
keywords: () => splitKeywords("commands.closeInstance.keywords"), keywords: () => splitKeywords("commands.closeInstance.keywords"),
shortcut: { key: "W", meta: true }, shortcut: { key: "W", meta: true },
action: async () => { action: async () => {
const instance = activeInstance() await options.handleCloseActiveTab()
if (!instance) return
await options.handleCloseInstance(instance.id)
}, },
}) })
@@ -103,13 +103,7 @@ export function useCommands(options: UseCommandsOptions) {
category: "Instance", category: "Instance",
keywords: () => splitKeywords("commands.nextInstance.keywords"), keywords: () => splitKeywords("commands.nextInstance.keywords"),
shortcut: { key: "]", meta: true }, shortcut: { key: "]", meta: true },
action: () => { action: () => selectNextAppTab(),
const ids = Array.from(instances().keys())
if (ids.length <= 1) return
const current = ids.indexOf(activeInstanceId() || "")
const next = (current + 1) % ids.length
if (ids[next]) setActiveInstanceId(ids[next])
},
}) })
commandRegistry.register({ commandRegistry.register({
@@ -119,13 +113,7 @@ export function useCommands(options: UseCommandsOptions) {
category: "Instance", category: "Instance",
keywords: () => splitKeywords("commands.previousInstance.keywords"), keywords: () => splitKeywords("commands.previousInstance.keywords"),
shortcut: { key: "[", meta: true }, shortcut: { key: "[", meta: true },
action: () => { action: () => selectPreviousAppTab(),
const ids = Array.from(instances().keys())
if (ids.length <= 1) return
const current = ids.indexOf(activeInstanceId() || "")
const prev = current <= 0 ? ids.length - 1 : current - 1
if (ids[prev]) setActiveInstanceId(ids[prev])
},
}) })
commandRegistry.register({ commandRegistry.register({

View File

@@ -15,17 +15,17 @@ export const commandMessages = {
"commands.newInstance.description": "Open folder picker to create new instance", "commands.newInstance.description": "Open folder picker to create new instance",
"commands.newInstance.keywords": "folder, project, workspace", "commands.newInstance.keywords": "folder, project, workspace",
"commands.closeInstance.label": "Close Instance", "commands.closeInstance.label": "Close Tab",
"commands.closeInstance.description": "Stop current instance's server", "commands.closeInstance.description": "Close the current top-level tab",
"commands.closeInstance.keywords": "stop, quit, close", "commands.closeInstance.keywords": "stop, quit, close, tab",
"commands.nextInstance.label": "Next Instance", "commands.nextInstance.label": "Next Tab",
"commands.nextInstance.description": "Cycle to next instance tab", "commands.nextInstance.description": "Cycle to the next top-level tab",
"commands.nextInstance.keywords": "switch, navigate", "commands.nextInstance.keywords": "switch, navigate, tab",
"commands.previousInstance.label": "Previous Instance", "commands.previousInstance.label": "Previous Tab",
"commands.previousInstance.description": "Cycle to previous instance tab", "commands.previousInstance.description": "Cycle to the previous top-level tab",
"commands.previousInstance.keywords": "switch, navigate", "commands.previousInstance.keywords": "switch, navigate, tab",
"commands.newSession.label": "New Session", "commands.newSession.label": "New Session",
"commands.newSession.description": "Create a new parent session", "commands.newSession.description": "Create a new parent session",

View File

@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
"folderSelection.browse.subtitle": "Select any folder on your computer", "folderSelection.browse.subtitle": "Select any folder on your computer",
"folderSelection.browse.button": "Browse Folders", "folderSelection.browse.button": "Browse Folders",
"folderSelection.browse.buttonOpening": "Opening...", "folderSelection.browse.buttonOpening": "Opening...",
"folderSelection.actions.title": "Open Folder or Connect Server",
"folderSelection.actions.subtitle": "Open local folder or connect to a CodeNomad server",
"folderSelection.actions.connectButton": "Connect CodeNomad Server",
"folderSelection.advancedSettings": "Advanced Settings", "folderSelection.advancedSettings": "Advanced Settings",
"folderSelection.opencode": "OpenCode", "folderSelection.opencode": "OpenCode",
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
"folderSelection.dialog.title": "Select Workspace", "folderSelection.dialog.title": "Select Workspace",
"folderSelection.dialog.description": "Select workspace to start coding.", "folderSelection.dialog.description": "Select workspace to start coding.",
"folderSelection.tabs.local": "Local Folders",
"folderSelection.tabs.servers": "Servers",
"folderSelection.servers.title": "Saved Servers",
"folderSelection.servers.subtitle": "Open a saved remote CodeNomad server in a new window",
"folderSelection.servers.count": "{count} Servers",
"folderSelection.servers.empty.title": "No Saved Servers",
"folderSelection.servers.empty.description": "Add a remote server to reconnect quickly from this device",
"folderSelection.servers.connectTitle": "Connect to Server",
"folderSelection.servers.connectSubtitle": "Save a remote CodeNomad server and open it in a new window",
"folderSelection.servers.connectButton": "Connect to Server",
"folderSelection.servers.remove": "Remove saved server",
"folderSelection.servers.skipTls": "Self-signed TLS",
"folderSelection.servers.errorTitle": "Remote Connection Failed",
"folderSelection.servers.dialog.title": "Connect to Server",
"folderSelection.servers.dialog.description": "Add a remote CodeNomad server and optionally open it right away.",
"folderSelection.servers.dialog.name": "Server name",
"folderSelection.servers.dialog.namePlaceholder": "Production Server",
"folderSelection.servers.dialog.url": "Server URL",
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
"folderSelection.servers.dialog.skipTls": "Skip TLS verification for self-signed certificates.",
"folderSelection.servers.dialog.cancel": "Cancel",
"folderSelection.servers.dialog.save": "Save",
"folderSelection.servers.dialog.connect": "Connect",
"folderSelection.servers.dialog.connecting": "Connecting...",
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
"folderSelection.sidecars.button": "Open SideCar",
} as const } as const

View File

@@ -160,6 +160,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "No background processes.", "instanceShell.backgroundProcesses.empty": "No background processes.",
"instanceShell.backgroundProcesses.status": "Status: {status}", "instanceShell.backgroundProcesses.status": "Status: {status}",
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "Completion notification enabled",
"instanceShell.backgroundProcesses.notify.disabled": "Completion notification disabled",
"instanceShell.backgroundProcesses.actions.output": "Output", "instanceShell.backgroundProcesses.actions.output": "Output",
"instanceShell.backgroundProcesses.actions.stop": "Stop", "instanceShell.backgroundProcesses.actions.stop": "Stop",
"instanceShell.backgroundProcesses.actions.terminate": "Terminate", "instanceShell.backgroundProcesses.actions.terminate": "Terminate",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "Loading messages...", "messageSection.loading.messages": "Loading messages...",
"messageSection.scroll.toFirstAriaLabel": "Scroll to first message", "messageSection.scroll.toFirstAriaLabel": "Scroll to first message",
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message", "messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
"messageSection.scroll.enableHoldAriaLabel": "Enable hold for long assistant replies",
"messageSection.scroll.disableHoldAriaLabel": "Disable hold for long assistant replies",
"messageSection.quote.addAsQuote": "Add as quote", "messageSection.quote.addAsQuote": "Add as quote",
"messageSection.quote.addAsCode": "Add as code", "messageSection.quote.addAsCode": "Add as code",
"messageSection.quote.copy": "Copy", "messageSection.quote.copy": "Copy",

View File

@@ -113,6 +113,15 @@ export const settingsMessages = {
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.opencode.logLevel.title": "OpenCode Log Level",
"settings.opencode.logLevel.subtitle": "Control the log verbosity used when launching new OpenCode instances.",
"settings.opencode.logLevel.selector.title": "Default log level",
"settings.opencode.logLevel.selector.subtitle": "Choose how verbose new OpenCode instances should be.",
"settings.opencode.logLevel.option.debug": "Debug",
"settings.opencode.logLevel.option.info": "Info",
"settings.opencode.logLevel.option.warn": "Warn",
"settings.opencode.logLevel.option.error": "Error",
"settings.appearance.behavior.title": "Interaction", "settings.appearance.behavior.title": "Interaction",
"settings.appearance.behavior.subtitle": "Message, diff, and input defaults.", "settings.appearance.behavior.subtitle": "Message, diff, and input defaults.",
@@ -186,4 +195,40 @@ export const settingsMessages = {
"settings.speech.save.saved": "Saved", "settings.speech.save.saved": "Saved",
"settings.speech.save.unsaved": "Unsaved changes", "settings.speech.save.unsaved": "Unsaved changes",
"settings.speech.save.error": "Save failed", "settings.speech.save.error": "Save failed",
"settings.nav.sidecars": "SideCars",
"settings.section.sidecars.eyebrow": "Server services",
"settings.section.sidecars.title": "SideCars",
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
"sidecars.form.name": "Name",
"sidecars.form.validation": "Enter a valid SideCar name and port.",
"sidecars.form.port": "Port",
"sidecars.form.insecure": "Use HTTP",
"sidecars.form.protocol": "Protocol",
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
"sidecars.form.protocol.https": "HTTPS",
"sidecars.form.protocol.http": "HTTP",
"sidecars.form.prefixMode": "Prefix mode",
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
"sidecars.form.prefixMode.strip": "Strip prefix",
"sidecars.form.prefixMode.preserve": "Preserve prefix",
"sidecars.form.add": "Add SideCar",
"sidecars.kind.port": "Port",
"sidecars.status.running": "Running",
"sidecars.status.stopped": "Stopped",
"sidecars.basePath": "Base path",
"sidecars.settings.listTitle": "Configured SideCars",
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
"sidecars.settings.empty": "No SideCars configured yet.",
"sidecars.picker.title": "Open SideCar",
"sidecars.picker.loading": "Loading SideCars...",
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
"sidecars.picker.empty": "No port-based SideCars are available yet.",
"sidecars.picker.close": "Close",
"sidecars.open.errorTitle": "Unable to open SideCar",
"sidecars.open.notFound": "SideCar not found.",
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
"sidecars.back": "Back",
"sidecars.refresh": "Refresh",
"sidecars.path": "Path",
"sidecars.go": "Go",
} as const } as const

View File

@@ -15,17 +15,17 @@ export const commandMessages = {
"commands.newInstance.description": "Abrir el selector de carpetas para crear una nueva instancia", "commands.newInstance.description": "Abrir el selector de carpetas para crear una nueva instancia",
"commands.newInstance.keywords": "carpeta, proyecto, workspace", "commands.newInstance.keywords": "carpeta, proyecto, workspace",
"commands.closeInstance.label": "Cerrar instancia", "commands.closeInstance.label": "Cerrar pestaña",
"commands.closeInstance.description": "Detener el servidor de la instancia actual", "commands.closeInstance.description": "Cerrar la pestaña superior actual",
"commands.closeInstance.keywords": "detener, salir, cerrar", "commands.closeInstance.keywords": "detener, salir, cerrar, pestaña",
"commands.nextInstance.label": "Siguiente instancia", "commands.nextInstance.label": "Siguiente pestaña",
"commands.nextInstance.description": "Cambiar a la siguiente pestaña de instancia", "commands.nextInstance.description": "Cambiar a la siguiente pestaña superior",
"commands.nextInstance.keywords": "cambiar, navegar", "commands.nextInstance.keywords": "cambiar, navegar, pestaña",
"commands.previousInstance.label": "Instancia anterior", "commands.previousInstance.label": "Pestaña anterior",
"commands.previousInstance.description": "Cambiar a la pestaña de instancia anterior", "commands.previousInstance.description": "Cambiar a la pestaña superior anterior",
"commands.previousInstance.keywords": "cambiar, navegar", "commands.previousInstance.keywords": "cambiar, navegar, pestaña",
"commands.newSession.label": "Nueva sesión", "commands.newSession.label": "Nueva sesión",
"commands.newSession.description": "Crear una nueva sesión principal", "commands.newSession.description": "Crear una nueva sesión principal",

View File

@@ -2,35 +2,38 @@ export const folderSelectionMessages = {
"folderSelection.language.ariaLabel": "Idioma", "folderSelection.language.ariaLabel": "Idioma",
"folderSelection.logoAlt": "Logo de CodeNomad", "folderSelection.logoAlt": "Logo de CodeNomad",
"folderSelection.tagline": "Selecciona una carpeta para empezar a programar con IA", "folderSelection.tagline": "Selecciona una carpeta para empezar a programar con AI",
"folderSelection.links.github": "GitHub de CodeNomad", "folderSelection.links.github": "GitHub de CodeNomad",
"folderSelection.links.githubStars": "Estrellas de CodeNomad en GitHub", "folderSelection.links.githubStars": "Estrellas de GitHub de CodeNomad",
"folderSelection.links.discord": "Discord de CodeNomad", "folderSelection.links.discord": "Discord de CodeNomad",
"folderSelection.empty.title": "No hay carpetas recientes", "folderSelection.empty.title": "No hay carpetas recientes",
"folderSelection.empty.description": "Explora una carpeta para comenzar", "folderSelection.empty.description": "Busca una carpeta para comenzar",
"folderSelection.recent.title": "Carpetas recientes", "folderSelection.recent.title": "Carpetas recientes",
"folderSelection.recent.subtitle.one": "{count} carpeta disponible", "folderSelection.recent.subtitle.one": "{count} carpeta disponible",
"folderSelection.recent.subtitle.other": "{count} carpetas disponibles", "folderSelection.recent.subtitle.other": "{count} carpetas disponibles",
"folderSelection.recent.remove": "Quitar de recientes", "folderSelection.recent.remove": "Eliminar de recientes",
"folderSelection.browse.title": "Explorar carpetas", "folderSelection.browse.title": "Buscar carpeta",
"folderSelection.browse.subtitle": "Selecciona cualquier carpeta en tu ordenador", "folderSelection.browse.subtitle": "Selecciona cualquier carpeta en tu ordenador",
"folderSelection.browse.button": "Explorar carpetas", "folderSelection.browse.button": "Buscar carpetas",
"folderSelection.browse.buttonOpening": "Abriendo...", "folderSelection.browse.buttonOpening": "Abriendo...",
"folderSelection.actions.title": "Abrir carpeta o conectar servidor",
"folderSelection.actions.subtitle": "Abre una carpeta local o conéctate a un servidor de CodeNomad",
"folderSelection.actions.connectButton": "Conectar servidor CodeNomad",
"folderSelection.advancedSettings": "Configuración avanzada", "folderSelection.advancedSettings": "Configuración avanzada",
"folderSelection.opencode": "OpenCode", "folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "Navegar", "folderSelection.hints.navigate": "Navegar",
"folderSelection.hints.select": "Seleccionar", "folderSelection.hints.select": "Seleccionar",
"folderSelection.hints.remove": "Quitar", "folderSelection.hints.remove": "Eliminar",
"folderSelection.hints.browse": "Explorar", "folderSelection.hints.browse": "Buscar",
"folderSelection.loading.title": "Iniciando instancia...", "folderSelection.loading.title": "Iniciando instancia...",
"folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.", "folderSelection.loading.subtitle": "Espera mientras preparamos tu espacio de trabajo.",
"folderSelection.drop.title": "Suelta una carpeta para abrirla", "folderSelection.drop.title": "Suelta una carpeta para abrirla",
"folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.", "folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.",
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
"folderSelection.dialog.title": "Seleccionar workspace", "folderSelection.dialog.title": "Seleccionar workspace",
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.", "folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
"folderSelection.tabs.local": "Carpetas locales",
"folderSelection.tabs.servers": "Servidores",
"folderSelection.servers.title": "Servidores guardados",
"folderSelection.servers.subtitle": "Abre un servidor remoto de CodeNomad guardado en una ventana nueva",
"folderSelection.servers.count": "{count} servidores",
"folderSelection.servers.empty.title": "No hay servidores guardados",
"folderSelection.servers.empty.description": "Añade un servidor remoto para volver a conectarte rápidamente desde este dispositivo",
"folderSelection.servers.connectTitle": "Conectar a un servidor",
"folderSelection.servers.connectSubtitle": "Guarda un servidor remoto de CodeNomad y ábrelo en una ventana nueva",
"folderSelection.servers.connectButton": "Conectar a un servidor",
"folderSelection.servers.remove": "Eliminar servidor guardado",
"folderSelection.servers.skipTls": "TLS autofirmado",
"folderSelection.servers.errorTitle": "Falló la conexión remota",
"folderSelection.servers.dialog.title": "Conectar a un servidor",
"folderSelection.servers.dialog.description": "Añade un servidor remoto de CodeNomad y ábrelo ahora si quieres.",
"folderSelection.servers.dialog.name": "Nombre del servidor",
"folderSelection.servers.dialog.namePlaceholder": "Servidor de producción",
"folderSelection.servers.dialog.url": "URL del servidor",
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
"folderSelection.servers.dialog.skipTls": "Omitir la verificación TLS para certificados autofirmados.",
"folderSelection.servers.dialog.cancel": "Cancelar",
"folderSelection.servers.dialog.save": "Guardar",
"folderSelection.servers.dialog.connect": "Conectar",
"folderSelection.servers.dialog.connecting": "Conectando...",
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
"folderSelection.sidecars.button": "Open SideCar",
} as const } as const

View File

@@ -150,6 +150,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.", "instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.",
"instanceShell.backgroundProcesses.status": "Estado: {status}", "instanceShell.backgroundProcesses.status": "Estado: {status}",
"instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB", "instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB",
"instanceShell.backgroundProcesses.notify.enabled": "Notificacion de finalizacion activada",
"instanceShell.backgroundProcesses.notify.disabled": "Notificacion de finalizacion desactivada",
"instanceShell.backgroundProcesses.actions.output": "Salida", "instanceShell.backgroundProcesses.actions.output": "Salida",
"instanceShell.backgroundProcesses.actions.stop": "Detener", "instanceShell.backgroundProcesses.actions.stop": "Detener",
"instanceShell.backgroundProcesses.actions.terminate": "Terminar", "instanceShell.backgroundProcesses.actions.terminate": "Terminar",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "Cargando mensajes...", "messageSection.loading.messages": "Cargando mensajes...",
"messageSection.scroll.toFirstAriaLabel": "Desplazarse al primer mensaje", "messageSection.scroll.toFirstAriaLabel": "Desplazarse al primer mensaje",
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje", "messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",
"messageSection.scroll.enableHoldAriaLabel": "Activar pausa para respuestas largas del asistente",
"messageSection.scroll.disableHoldAriaLabel": "Desactivar pausa para respuestas largas del asistente",
"messageSection.quote.addAsQuote": "Añadir como cita", "messageSection.quote.addAsQuote": "Añadir como cita",
"messageSection.quote.addAsCode": "Añadir como código", "messageSection.quote.addAsCode": "Añadir como código",
"messageSection.quote.copy": "Copiar", "messageSection.quote.copy": "Copiar",

View File

@@ -113,6 +113,14 @@ export const settingsMessages = {
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.opencode.logLevel.title": "Nivel de logs de OpenCode",
"settings.opencode.logLevel.subtitle": "Define el nivel de logs usado al iniciar nuevas instancias de OpenCode.",
"settings.opencode.logLevel.selector.title": "Verbosidad de logs",
"settings.opencode.logLevel.selector.subtitle": "Elige cuanta informacion deben registrar las nuevas instancias de OpenCode.",
"settings.opencode.logLevel.option.debug": "Depuracion",
"settings.opencode.logLevel.option.info": "Informacion",
"settings.opencode.logLevel.option.warn": "Advertencia",
"settings.opencode.logLevel.option.error": "Error",
"settings.appearance.behavior.title": "Interaccion", "settings.appearance.behavior.title": "Interaccion",
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.", "settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
@@ -186,4 +194,40 @@ export const settingsMessages = {
"settings.speech.save.saved": "Guardado", "settings.speech.save.saved": "Guardado",
"settings.speech.save.unsaved": "Cambios sin guardar", "settings.speech.save.unsaved": "Cambios sin guardar",
"settings.speech.save.error": "Error al guardar", "settings.speech.save.error": "Error al guardar",
"settings.nav.sidecars": "SideCars",
"settings.section.sidecars.eyebrow": "Server services",
"settings.section.sidecars.title": "SideCars",
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
"sidecars.form.name": "Name",
"sidecars.form.validation": "Enter a valid SideCar name and port.",
"sidecars.form.port": "Port",
"sidecars.form.insecure": "Use HTTP",
"sidecars.form.protocol": "Protocol",
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
"sidecars.form.protocol.https": "HTTPS",
"sidecars.form.protocol.http": "HTTP",
"sidecars.form.prefixMode": "Prefix mode",
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
"sidecars.form.prefixMode.strip": "Strip prefix",
"sidecars.form.prefixMode.preserve": "Preserve prefix",
"sidecars.form.add": "Add SideCar",
"sidecars.kind.port": "Port",
"sidecars.status.running": "Running",
"sidecars.status.stopped": "Stopped",
"sidecars.basePath": "Base path",
"sidecars.settings.listTitle": "Configured SideCars",
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
"sidecars.settings.empty": "No SideCars configured yet.",
"sidecars.picker.title": "Open SideCar",
"sidecars.picker.loading": "Loading SideCars...",
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
"sidecars.picker.empty": "No port-based SideCars are available yet.",
"sidecars.picker.close": "Close",
"sidecars.open.errorTitle": "Unable to open SideCar",
"sidecars.open.notFound": "SideCar not found.",
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
"sidecars.back": "Back",
"sidecars.refresh": "Refresh",
"sidecars.path": "Path",
"sidecars.go": "Go",
} as const } as const

View File

@@ -15,17 +15,17 @@ export const commandMessages = {
"commands.newInstance.description": "Ouvrir le sélecteur de dossiers pour créer une nouvelle instance", "commands.newInstance.description": "Ouvrir le sélecteur de dossiers pour créer une nouvelle instance",
"commands.newInstance.keywords": "dossier, projet, espace de travail", "commands.newInstance.keywords": "dossier, projet, espace de travail",
"commands.closeInstance.label": "Fermer l'instance", "commands.closeInstance.label": "Fermer l'onglet",
"commands.closeInstance.description": "Arrêter le serveur de l'instance actuelle", "commands.closeInstance.description": "Fermer l'onglet de premier niveau actuel",
"commands.closeInstance.keywords": "arrêter, quitter, fermer", "commands.closeInstance.keywords": "arrêter, quitter, fermer, onglet",
"commands.nextInstance.label": "Instance suivante", "commands.nextInstance.label": "Onglet suivant",
"commands.nextInstance.description": "Passer à l'onglet d'instance suivant", "commands.nextInstance.description": "Passer à l'onglet de premier niveau suivant",
"commands.nextInstance.keywords": "changer, naviguer, suivant", "commands.nextInstance.keywords": "changer, naviguer, suivant, onglet",
"commands.previousInstance.label": "Instance précédente", "commands.previousInstance.label": "Onglet précédent",
"commands.previousInstance.description": "Passer à l'onglet d'instance précédent", "commands.previousInstance.description": "Passer à l'onglet de premier niveau précédent",
"commands.previousInstance.keywords": "changer, naviguer, précédent", "commands.previousInstance.keywords": "changer, naviguer, précédent, onglet",
"commands.newSession.label": "Nouvelle session", "commands.newSession.label": "Nouvelle session",
"commands.newSession.description": "Créer une nouvelle session parente", "commands.newSession.description": "Créer une nouvelle session parente",

View File

@@ -5,7 +5,7 @@ export const folderSelectionMessages = {
"folderSelection.tagline": "Sélectionnez un dossier pour commencer à coder avec l'IA", "folderSelection.tagline": "Sélectionnez un dossier pour commencer à coder avec l'IA",
"folderSelection.links.github": "GitHub de CodeNomad", "folderSelection.links.github": "GitHub de CodeNomad",
"folderSelection.links.githubStars": "Stars GitHub de CodeNomad", "folderSelection.links.githubStars": "Étoiles GitHub de CodeNomad",
"folderSelection.links.discord": "Discord de CodeNomad", "folderSelection.links.discord": "Discord de CodeNomad",
"folderSelection.empty.title": "Aucun dossier récent", "folderSelection.empty.title": "Aucun dossier récent",
@@ -16,10 +16,13 @@ export const folderSelectionMessages = {
"folderSelection.recent.subtitle.other": "{count} dossiers disponibles", "folderSelection.recent.subtitle.other": "{count} dossiers disponibles",
"folderSelection.recent.remove": "Retirer des récents", "folderSelection.recent.remove": "Retirer des récents",
"folderSelection.browse.title": "Parcourir les dossiers", "folderSelection.browse.title": "Parcourir un dossier",
"folderSelection.browse.subtitle": "Sélectionnez n'importe quel dossier sur votre ordinateur", "folderSelection.browse.subtitle": "Sélectionnez n'importe quel dossier sur votre ordinateur",
"folderSelection.browse.button": "Parcourir les dossiers", "folderSelection.browse.button": "Parcourir les dossiers",
"folderSelection.browse.buttonOpening": "Ouverture...", "folderSelection.browse.buttonOpening": "Ouverture...",
"folderSelection.actions.title": "Ouvrir un dossier ou se connecter à un serveur",
"folderSelection.actions.subtitle": "Ouvrez un dossier local ou connectez-vous à un serveur CodeNomad",
"folderSelection.actions.connectButton": "Se connecter au serveur CodeNomad",
"folderSelection.advancedSettings": "Paramètres avancés", "folderSelection.advancedSettings": "Paramètres avancés",
"folderSelection.opencode": "OpenCode", "folderSelection.opencode": "OpenCode",
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
"folderSelection.dialog.title": "Sélectionner l'espace de travail", "folderSelection.dialog.title": "Sélectionner l'espace de travail",
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.", "folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
"folderSelection.tabs.local": "Dossiers locaux",
"folderSelection.tabs.servers": "Serveurs",
"folderSelection.servers.title": "Serveurs enregistrés",
"folderSelection.servers.subtitle": "Ouvrez un serveur CodeNomad distant enregistré dans une nouvelle fenêtre",
"folderSelection.servers.count": "{count} serveurs",
"folderSelection.servers.empty.title": "Aucun serveur enregistré",
"folderSelection.servers.empty.description": "Ajoutez un serveur distant pour vous reconnecter rapidement depuis cet appareil",
"folderSelection.servers.connectTitle": "Se connecter à un serveur",
"folderSelection.servers.connectSubtitle": "Enregistrez un serveur CodeNomad distant et ouvrez-le dans une nouvelle fenêtre",
"folderSelection.servers.connectButton": "Se connecter à un serveur",
"folderSelection.servers.remove": "Supprimer le serveur enregistré",
"folderSelection.servers.skipTls": "TLS auto-signé",
"folderSelection.servers.errorTitle": "Échec de la connexion distante",
"folderSelection.servers.dialog.title": "Se connecter à un serveur",
"folderSelection.servers.dialog.description": "Ajoutez un serveur CodeNomad distant et ouvrez-le immédiatement si vous le souhaitez.",
"folderSelection.servers.dialog.name": "Nom du serveur",
"folderSelection.servers.dialog.namePlaceholder": "Serveur de production",
"folderSelection.servers.dialog.url": "URL du serveur",
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
"folderSelection.servers.dialog.skipTls": "Ignorer la vérification TLS pour les certificats auto-signés.",
"folderSelection.servers.dialog.cancel": "Annuler",
"folderSelection.servers.dialog.save": "Enregistrer",
"folderSelection.servers.dialog.connect": "Se connecter",
"folderSelection.servers.dialog.connecting": "Connexion...",
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
"folderSelection.sidecars.button": "Open SideCar",
} as const } as const

View File

@@ -150,6 +150,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.", "instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.",
"instanceShell.backgroundProcesses.status": "Statut : {status}", "instanceShell.backgroundProcesses.status": "Statut : {status}",
"instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB", "instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "Notification de fin activee",
"instanceShell.backgroundProcesses.notify.disabled": "Notification de fin desactivee",
"instanceShell.backgroundProcesses.actions.output": "Sortie", "instanceShell.backgroundProcesses.actions.output": "Sortie",
"instanceShell.backgroundProcesses.actions.stop": "Arrêter", "instanceShell.backgroundProcesses.actions.stop": "Arrêter",
"instanceShell.backgroundProcesses.actions.terminate": "Terminer", "instanceShell.backgroundProcesses.actions.terminate": "Terminer",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "Chargement des messages...", "messageSection.loading.messages": "Chargement des messages...",
"messageSection.scroll.toFirstAriaLabel": "Aller au premier message", "messageSection.scroll.toFirstAriaLabel": "Aller au premier message",
"messageSection.scroll.toLatestAriaLabel": "Aller au dernier message", "messageSection.scroll.toLatestAriaLabel": "Aller au dernier message",
"messageSection.scroll.enableHoldAriaLabel": "Activer le maintien pour les longues réponses de l'assistant",
"messageSection.scroll.disableHoldAriaLabel": "Désactiver le maintien pour les longues réponses de l'assistant",
"messageSection.quote.addAsQuote": "Ajouter en citation", "messageSection.quote.addAsQuote": "Ajouter en citation",
"messageSection.quote.addAsCode": "Ajouter en code", "messageSection.quote.addAsCode": "Ajouter en code",
"messageSection.quote.copy": "Copier", "messageSection.quote.copy": "Copier",

View File

@@ -113,6 +113,14 @@ export const settingsMessages = {
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.opencode.logLevel.title": "Niveau de logs OpenCode",
"settings.opencode.logLevel.subtitle": "Definir le niveau de logs utilise au lancement des nouvelles instances OpenCode.",
"settings.opencode.logLevel.selector.title": "Verbosite des logs",
"settings.opencode.logLevel.selector.subtitle": "Choisir la quantite de journaux emise par les nouvelles instances OpenCode.",
"settings.opencode.logLevel.option.debug": "Debogage",
"settings.opencode.logLevel.option.info": "Info",
"settings.opencode.logLevel.option.warn": "Avertissement",
"settings.opencode.logLevel.option.error": "Erreur",
"settings.appearance.behavior.title": "Interaction", "settings.appearance.behavior.title": "Interaction",
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.", "settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
@@ -186,4 +194,40 @@ export const settingsMessages = {
"settings.speech.save.saved": "Enregistré", "settings.speech.save.saved": "Enregistré",
"settings.speech.save.unsaved": "Modifications non enregistrées", "settings.speech.save.unsaved": "Modifications non enregistrées",
"settings.speech.save.error": "Échec de l'enregistrement", "settings.speech.save.error": "Échec de l'enregistrement",
"settings.nav.sidecars": "SideCars",
"settings.section.sidecars.eyebrow": "Server services",
"settings.section.sidecars.title": "SideCars",
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
"sidecars.form.name": "Name",
"sidecars.form.validation": "Enter a valid SideCar name and port.",
"sidecars.form.port": "Port",
"sidecars.form.insecure": "Use HTTP",
"sidecars.form.protocol": "Protocol",
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
"sidecars.form.protocol.https": "HTTPS",
"sidecars.form.protocol.http": "HTTP",
"sidecars.form.prefixMode": "Prefix mode",
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
"sidecars.form.prefixMode.strip": "Strip prefix",
"sidecars.form.prefixMode.preserve": "Preserve prefix",
"sidecars.form.add": "Add SideCar",
"sidecars.kind.port": "Port",
"sidecars.status.running": "Running",
"sidecars.status.stopped": "Stopped",
"sidecars.basePath": "Base path",
"sidecars.settings.listTitle": "Configured SideCars",
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
"sidecars.settings.empty": "No SideCars configured yet.",
"sidecars.picker.title": "Open SideCar",
"sidecars.picker.loading": "Loading SideCars...",
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
"sidecars.picker.empty": "No port-based SideCars are available yet.",
"sidecars.picker.close": "Close",
"sidecars.open.errorTitle": "Unable to open SideCar",
"sidecars.open.notFound": "SideCar not found.",
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
"sidecars.back": "Back",
"sidecars.refresh": "Refresh",
"sidecars.path": "Path",
"sidecars.go": "Go",
} as const } as const

View File

@@ -15,17 +15,17 @@ export const commandMessages = {
"commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש", "commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש",
"commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה", "commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה",
"commands.closeInstance.label": "סגור מופע", "commands.closeInstance.label": "סגור לשונית",
"commands.closeInstance.description": "עצור את השרת של המופע הנוכחי", "commands.closeInstance.description": "סגור את הלשונית העליונה הנוכחית",
"commands.closeInstance.keywords": "עצור, סגור", "commands.closeInstance.keywords": "עצור, סגור, לשונית",
"commands.nextInstance.label": "מופע הבא", "commands.nextInstance.label": "הלשונית הבאה",
"commands.nextInstance.description": "עבור למופע הבא", "commands.nextInstance.description": "עבור ללשונית העליונה הבאה",
"commands.nextInstance.keywords": "החלף, נווט", "commands.nextInstance.keywords": "החלף, נווט, לשונית",
"commands.previousInstance.label": "מופע קודם", "commands.previousInstance.label": "הלשונית הקודמת",
"commands.previousInstance.description": "עבור למופע הקודם", "commands.previousInstance.description": "עבור ללשונית העליונה הקודמת",
"commands.previousInstance.keywords": "החלף, נווט", "commands.previousInstance.keywords": "החלף, נווט, לשונית",
"commands.newSession.label": "סשן חדש", "commands.newSession.label": "סשן חדש",
"commands.newSession.description": "צור סשן הורה חדש", "commands.newSession.description": "צור סשן הורה חדש",

View File

@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
"folderSelection.browse.subtitle": "בחר כל תיקייה במחשב שלך", "folderSelection.browse.subtitle": "בחר כל תיקייה במחשב שלך",
"folderSelection.browse.button": "עיון בתיקיות", "folderSelection.browse.button": "עיון בתיקיות",
"folderSelection.browse.buttonOpening": "פותח...", "folderSelection.browse.buttonOpening": "פותח...",
"folderSelection.actions.title": "פתח תיקייה או התחבר לשרת",
"folderSelection.actions.subtitle": "פתח תיקייה מקומית או התחבר לשרת CodeNomad",
"folderSelection.actions.connectButton": "התחבר לשרת CodeNomad",
"folderSelection.advancedSettings": "הגדרות מתקדמות", "folderSelection.advancedSettings": "הגדרות מתקדמות",
"folderSelection.opencode": "OpenCode", "folderSelection.opencode": "OpenCode",
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
"folderSelection.dialog.title": "בחר סביבת עבודה", "folderSelection.dialog.title": "בחר סביבת עבודה",
"folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.", "folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.",
"folderSelection.tabs.local": "תיקיות מקומיות",
"folderSelection.tabs.servers": "שרתים",
"folderSelection.servers.title": "שרתים שמורים",
"folderSelection.servers.subtitle": "פתח שרת CodeNomad מרוחק שמור בחלון חדש",
"folderSelection.servers.count": "{count} שרתים",
"folderSelection.servers.empty.title": "אין שרתים שמורים",
"folderSelection.servers.empty.description": "הוסף שרת מרוחק כדי להתחבר אליו במהירות מהמכשיר הזה",
"folderSelection.servers.connectTitle": "התחבר לשרת",
"folderSelection.servers.connectSubtitle": "שמור שרת CodeNomad מרוחק ופתח אותו בחלון חדש",
"folderSelection.servers.connectButton": "התחבר לשרת",
"folderSelection.servers.remove": "הסר שרת שמור",
"folderSelection.servers.skipTls": "TLS בחתימה עצמית",
"folderSelection.servers.errorTitle": "החיבור המרוחק נכשל",
"folderSelection.servers.dialog.title": "התחבר לשרת",
"folderSelection.servers.dialog.description": "הוסף שרת CodeNomad מרוחק ופתח אותו מיד אם תרצה.",
"folderSelection.servers.dialog.name": "שם השרת",
"folderSelection.servers.dialog.namePlaceholder": "שרת ייצור",
"folderSelection.servers.dialog.url": "כתובת השרת",
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
"folderSelection.servers.dialog.skipTls": "דלג על אימות TLS עבור תעודות בחתימה עצמית.",
"folderSelection.servers.dialog.cancel": "ביטול",
"folderSelection.servers.dialog.save": "שמור",
"folderSelection.servers.dialog.connect": "התחבר",
"folderSelection.servers.dialog.connecting": "מתחבר...",
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
"folderSelection.sidecars.button": "Open SideCar",
} as const } as const

View File

@@ -158,6 +158,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.", "instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
"instanceShell.backgroundProcesses.status": "סטטוס: {status}", "instanceShell.backgroundProcesses.status": "סטטוס: {status}",
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "התראת סיום פעילה",
"instanceShell.backgroundProcesses.notify.disabled": "התראת סיום כבויה",
"instanceShell.backgroundProcesses.actions.output": "פלט", "instanceShell.backgroundProcesses.actions.output": "פלט",
"instanceShell.backgroundProcesses.actions.stop": "עצור", "instanceShell.backgroundProcesses.actions.stop": "עצור",
"instanceShell.backgroundProcesses.actions.terminate": "סיים", "instanceShell.backgroundProcesses.actions.terminate": "סיים",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "טוען הודעות...", "messageSection.loading.messages": "טוען הודעות...",
"messageSection.scroll.toFirstAriaLabel": "גלול להודעה הראשונה", "messageSection.scroll.toFirstAriaLabel": "גלול להודעה הראשונה",
"messageSection.scroll.toLatestAriaLabel": "גלול להודעה האחרונה", "messageSection.scroll.toLatestAriaLabel": "גלול להודעה האחרונה",
"messageSection.scroll.enableHoldAriaLabel": "הפעל עצירה לתגובות עוזר ארוכות",
"messageSection.scroll.disableHoldAriaLabel": "כבה עצירה לתגובות עוזר ארוכות",
"messageSection.quote.addAsQuote": "הוסף כציטוט", "messageSection.quote.addAsQuote": "הוסף כציטוט",
"messageSection.quote.addAsCode": "הוסף כקוד", "messageSection.quote.addAsCode": "הוסף כקוד",
"messageSection.quote.copy": "העתק", "messageSection.quote.copy": "העתק",

View File

@@ -112,6 +112,14 @@ export const settingsMessages = {
"settings.section.opencode.subtitle": "בחר את הקובץ הבינארי של OpenCode והסביבה לשימוש במופעים חדשים.", "settings.section.opencode.subtitle": "בחר את הקובץ הבינארי של OpenCode והסביבה לשימוש במופעים חדשים.",
"settings.opencode.runtime.title": "סביבת ריצה", "settings.opencode.runtime.title": "סביבת ריצה",
"settings.opencode.runtime.subtitle": "הגדר עם איזה קובץ בינארי של OpenCode מופעים חדשים יופעלו.", "settings.opencode.runtime.subtitle": "הגדר עם איזה קובץ בינארי של OpenCode מופעים חדשים יופעלו.",
"settings.opencode.logLevel.title": "רמת הלוגים של OpenCode",
"settings.opencode.logLevel.subtitle": "הגדר את רמת הלוגים שבה ייעשה שימוש בעת הפעלת מופעי OpenCode חדשים.",
"settings.opencode.logLevel.selector.title": "פירוט לוגים",
"settings.opencode.logLevel.selector.subtitle": "בחר כמה לוגים מופעי OpenCode חדשים צריכים להפיק.",
"settings.opencode.logLevel.option.debug": "ניפוי שגיאות",
"settings.opencode.logLevel.option.info": "מידע",
"settings.opencode.logLevel.option.warn": "אזהרה",
"settings.opencode.logLevel.option.error": "שגיאה",
"settings.appearance.behavior.title": "אינטראקציה", "settings.appearance.behavior.title": "אינטראקציה",
"settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.", "settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.",
@@ -185,4 +193,40 @@ export const settingsMessages = {
"settings.speech.save.saved": "נשמר", "settings.speech.save.saved": "נשמר",
"settings.speech.save.unsaved": "יש שינויים שלא נשמרו", "settings.speech.save.unsaved": "יש שינויים שלא נשמרו",
"settings.speech.save.error": "השמירה נכשלה", "settings.speech.save.error": "השמירה נכשלה",
"settings.nav.sidecars": "SideCars",
"settings.section.sidecars.eyebrow": "Server services",
"settings.section.sidecars.title": "SideCars",
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
"sidecars.form.name": "Name",
"sidecars.form.validation": "Enter a valid SideCar name and port.",
"sidecars.form.port": "Port",
"sidecars.form.insecure": "Use HTTP",
"sidecars.form.protocol": "Protocol",
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
"sidecars.form.protocol.https": "HTTPS",
"sidecars.form.protocol.http": "HTTP",
"sidecars.form.prefixMode": "Prefix mode",
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
"sidecars.form.prefixMode.strip": "Strip prefix",
"sidecars.form.prefixMode.preserve": "Preserve prefix",
"sidecars.form.add": "Add SideCar",
"sidecars.kind.port": "Port",
"sidecars.status.running": "Running",
"sidecars.status.stopped": "Stopped",
"sidecars.basePath": "Base path",
"sidecars.settings.listTitle": "Configured SideCars",
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
"sidecars.settings.empty": "No SideCars configured yet.",
"sidecars.picker.title": "Open SideCar",
"sidecars.picker.loading": "Loading SideCars...",
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
"sidecars.picker.empty": "No port-based SideCars are available yet.",
"sidecars.picker.close": "Close",
"sidecars.open.errorTitle": "Unable to open SideCar",
"sidecars.open.notFound": "SideCar not found.",
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
"sidecars.back": "Back",
"sidecars.refresh": "Refresh",
"sidecars.path": "Path",
"sidecars.go": "Go",
} as const } as const

View File

@@ -15,17 +15,17 @@ export const commandMessages = {
"commands.newInstance.description": "フォルダ選択を開いて新しいインスタンスを作成", "commands.newInstance.description": "フォルダ選択を開いて新しいインスタンスを作成",
"commands.newInstance.keywords": "フォルダ, プロジェクト, ワークスペース, folder, project, workspace", "commands.newInstance.keywords": "フォルダ, プロジェクト, ワークスペース, folder, project, workspace",
"commands.closeInstance.label": "インスタンスを閉じる", "commands.closeInstance.label": "タブを閉じる",
"commands.closeInstance.description": "現在のインスタンスのサーバーを停止", "commands.closeInstance.description": "現在のトップレベルタブを閉じる",
"commands.closeInstance.keywords": "停止, 終了, 閉じる, stop, quit, close", "commands.closeInstance.keywords": "閉じる, タブ, stop, quit, close",
"commands.nextInstance.label": "次のインスタンス", "commands.nextInstance.label": "次のタブ",
"commands.nextInstance.description": "次のインスタンスタブへ切り替え", "commands.nextInstance.description": "次のトップレベルタブへ切り替え",
"commands.nextInstance.keywords": "切り替え, 移動, switch, navigate", "commands.nextInstance.keywords": "切り替え, 移動, タブ, switch, navigate",
"commands.previousInstance.label": "前のインスタンス", "commands.previousInstance.label": "前のタブ",
"commands.previousInstance.description": "前のインスタンスタブへ切り替え", "commands.previousInstance.description": "前のトップレベルタブへ切り替え",
"commands.previousInstance.keywords": "切り替え, 移動, switch, navigate", "commands.previousInstance.keywords": "切り替え, 移動, タブ, switch, navigate",
"commands.newSession.label": "新しいセッション", "commands.newSession.label": "新しいセッション",
"commands.newSession.description": "新しい親セッションを作成", "commands.newSession.description": "新しい親セッションを作成",

View File

@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
"folderSelection.browse.subtitle": "コンピュータ上の任意のフォルダを選択", "folderSelection.browse.subtitle": "コンピュータ上の任意のフォルダを選択",
"folderSelection.browse.button": "フォルダを参照", "folderSelection.browse.button": "フォルダを参照",
"folderSelection.browse.buttonOpening": "開いています...", "folderSelection.browse.buttonOpening": "開いています...",
"folderSelection.actions.title": "フォルダを開くかサーバーに接続",
"folderSelection.actions.subtitle": "ローカルフォルダを開くか CodeNomad サーバーに接続します",
"folderSelection.actions.connectButton": "CodeNomad サーバーに接続",
"folderSelection.advancedSettings": "詳細設定", "folderSelection.advancedSettings": "詳細設定",
"folderSelection.opencode": "OpenCode", "folderSelection.opencode": "OpenCode",
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
"folderSelection.dialog.title": "ワークスペースを選択", "folderSelection.dialog.title": "ワークスペースを選択",
"folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。", "folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。",
"folderSelection.tabs.local": "ローカルフォルダ",
"folderSelection.tabs.servers": "サーバー",
"folderSelection.servers.title": "保存済みサーバー",
"folderSelection.servers.subtitle": "保存したリモート CodeNomad サーバーを新しいウィンドウで開きます",
"folderSelection.servers.count": "{count} サーバー",
"folderSelection.servers.empty.title": "保存済みサーバーはありません",
"folderSelection.servers.empty.description": "この端末からすばやく再接続できるように、リモートサーバーを追加してください",
"folderSelection.servers.connectTitle": "サーバーに接続",
"folderSelection.servers.connectSubtitle": "リモート CodeNomad サーバーを保存して新しいウィンドウで開きます",
"folderSelection.servers.connectButton": "サーバーに接続",
"folderSelection.servers.remove": "保存したサーバーを削除",
"folderSelection.servers.skipTls": "自己署名 TLS",
"folderSelection.servers.errorTitle": "リモート接続に失敗しました",
"folderSelection.servers.dialog.title": "サーバーに接続",
"folderSelection.servers.dialog.description": "リモート CodeNomad サーバーを追加し、必要に応じてすぐに開きます。",
"folderSelection.servers.dialog.name": "サーバー名",
"folderSelection.servers.dialog.namePlaceholder": "本番サーバー",
"folderSelection.servers.dialog.url": "サーバー URL",
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
"folderSelection.servers.dialog.skipTls": "自己署名証明書の TLS 検証をスキップします。",
"folderSelection.servers.dialog.cancel": "キャンセル",
"folderSelection.servers.dialog.save": "保存",
"folderSelection.servers.dialog.connect": "接続",
"folderSelection.servers.dialog.connecting": "接続中...",
"folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。",
"folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。",
"folderSelection.sidecars.button": "Open SideCar",
} as const } as const

View File

@@ -150,6 +150,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "バックグラウンドプロセスはありません。", "instanceShell.backgroundProcesses.empty": "バックグラウンドプロセスはありません。",
"instanceShell.backgroundProcesses.status": "状態: {status}", "instanceShell.backgroundProcesses.status": "状態: {status}",
"instanceShell.backgroundProcesses.output": "出力: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "出力: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "完了通知が有効",
"instanceShell.backgroundProcesses.notify.disabled": "完了通知が無効",
"instanceShell.backgroundProcesses.actions.output": "出力", "instanceShell.backgroundProcesses.actions.output": "出力",
"instanceShell.backgroundProcesses.actions.stop": "停止", "instanceShell.backgroundProcesses.actions.stop": "停止",
"instanceShell.backgroundProcesses.actions.terminate": "終了", "instanceShell.backgroundProcesses.actions.terminate": "終了",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "メッセージを読み込み中...", "messageSection.loading.messages": "メッセージを読み込み中...",
"messageSection.scroll.toFirstAriaLabel": "最初のメッセージへスクロール", "messageSection.scroll.toFirstAriaLabel": "最初のメッセージへスクロール",
"messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール", "messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール",
"messageSection.scroll.enableHoldAriaLabel": "長いアシスタント返信の保持を有効にする",
"messageSection.scroll.disableHoldAriaLabel": "長いアシスタント返信の保持を無効にする",
"messageSection.quote.addAsQuote": "引用として追加", "messageSection.quote.addAsQuote": "引用として追加",
"messageSection.quote.addAsCode": "コードとして追加", "messageSection.quote.addAsCode": "コードとして追加",
"messageSection.quote.copy": "コピー", "messageSection.quote.copy": "コピー",

View File

@@ -113,6 +113,14 @@ export const settingsMessages = {
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.opencode.logLevel.title": "OpenCode のログレベル",
"settings.opencode.logLevel.subtitle": "新しい OpenCode インスタンスの起動時に使うログレベルを設定します。",
"settings.opencode.logLevel.selector.title": "ログ出力の詳細度",
"settings.opencode.logLevel.selector.subtitle": "新しい OpenCode インスタンスがどの程度ログを出力するかを選択します。",
"settings.opencode.logLevel.option.debug": "デバッグ",
"settings.opencode.logLevel.option.info": "情報",
"settings.opencode.logLevel.option.warn": "警告",
"settings.opencode.logLevel.option.error": "エラー",
"settings.appearance.behavior.title": "操作", "settings.appearance.behavior.title": "操作",
"settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。", "settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。",
@@ -186,4 +194,40 @@ export const settingsMessages = {
"settings.speech.save.saved": "保存済み", "settings.speech.save.saved": "保存済み",
"settings.speech.save.unsaved": "未保存の変更", "settings.speech.save.unsaved": "未保存の変更",
"settings.speech.save.error": "保存に失敗しました", "settings.speech.save.error": "保存に失敗しました",
"settings.nav.sidecars": "SideCars",
"settings.section.sidecars.eyebrow": "Server services",
"settings.section.sidecars.title": "SideCars",
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
"sidecars.form.name": "Name",
"sidecars.form.validation": "Enter a valid SideCar name and port.",
"sidecars.form.port": "Port",
"sidecars.form.insecure": "Use HTTP",
"sidecars.form.protocol": "Protocol",
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
"sidecars.form.protocol.https": "HTTPS",
"sidecars.form.protocol.http": "HTTP",
"sidecars.form.prefixMode": "Prefix mode",
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
"sidecars.form.prefixMode.strip": "Strip prefix",
"sidecars.form.prefixMode.preserve": "Preserve prefix",
"sidecars.form.add": "Add SideCar",
"sidecars.kind.port": "Port",
"sidecars.status.running": "Running",
"sidecars.status.stopped": "Stopped",
"sidecars.basePath": "Base path",
"sidecars.settings.listTitle": "Configured SideCars",
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
"sidecars.settings.empty": "No SideCars configured yet.",
"sidecars.picker.title": "Open SideCar",
"sidecars.picker.loading": "Loading SideCars...",
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
"sidecars.picker.empty": "No port-based SideCars are available yet.",
"sidecars.picker.close": "Close",
"sidecars.open.errorTitle": "Unable to open SideCar",
"sidecars.open.notFound": "SideCar not found.",
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
"sidecars.back": "Back",
"sidecars.refresh": "Refresh",
"sidecars.path": "Path",
"sidecars.go": "Go",
} as const } as const

View File

@@ -15,17 +15,17 @@ export const commandMessages = {
"commands.newInstance.description": "Открыть выбор папки для создания нового экземпляра", "commands.newInstance.description": "Открыть выбор папки для создания нового экземпляра",
"commands.newInstance.keywords": "папка, проект, рабочее пространство", "commands.newInstance.keywords": "папка, проект, рабочее пространство",
"commands.closeInstance.label": "Закрыть экземпляр", "commands.closeInstance.label": "Закрыть вкладку",
"commands.closeInstance.description": "Остановить сервер текущего экземпляра", "commands.closeInstance.description": "Закрыть текущую верхнеуровневую вкладку",
"commands.closeInstance.keywords": "остановить, выйти, закрыть", "commands.closeInstance.keywords": "остановить, выйти, закрыть, вкладка",
"commands.nextInstance.label": "Следующий экземпляр", "commands.nextInstance.label": "Следующая вкладка",
"commands.nextInstance.description": "Переключиться на следующую вкладку экземпляра", "commands.nextInstance.description": "Переключиться на следующую верхнеуровневую вкладку",
"commands.nextInstance.keywords": "переключить, навигация", "commands.nextInstance.keywords": "переключить, навигация, вкладка",
"commands.previousInstance.label": "Предыдущий экземпляр", "commands.previousInstance.label": "Предыдущая вкладка",
"commands.previousInstance.description": "Переключиться на предыдущую вкладку экземпляра", "commands.previousInstance.description": "Переключиться на предыдущую верхнеуровневую вкладку",
"commands.previousInstance.keywords": "переключить, навигация", "commands.previousInstance.keywords": "переключить, навигация, вкладка",
"commands.newSession.label": "Новая сессия", "commands.newSession.label": "Новая сессия",
"commands.newSession.description": "Создать новую родительскую сессию", "commands.newSession.description": "Создать новую родительскую сессию",

View File

@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
"folderSelection.browse.subtitle": "Выберите любую папку на компьютере", "folderSelection.browse.subtitle": "Выберите любую папку на компьютере",
"folderSelection.browse.button": "Обзор папок", "folderSelection.browse.button": "Обзор папок",
"folderSelection.browse.buttonOpening": "Открытие…", "folderSelection.browse.buttonOpening": "Открытие…",
"folderSelection.actions.title": "Открыть папку или подключить сервер",
"folderSelection.actions.subtitle": "Откройте локальную папку или подключитесь к серверу CodeNomad",
"folderSelection.actions.connectButton": "Подключить сервер CodeNomad",
"folderSelection.advancedSettings": "Расширенные настройки", "folderSelection.advancedSettings": "Расширенные настройки",
"folderSelection.opencode": "OpenCode", "folderSelection.opencode": "OpenCode",
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
"folderSelection.dialog.title": "Выберите рабочее пространство", "folderSelection.dialog.title": "Выберите рабочее пространство",
"folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.", "folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.",
"folderSelection.tabs.local": "Локальные папки",
"folderSelection.tabs.servers": "Серверы",
"folderSelection.servers.title": "Сохраненные серверы",
"folderSelection.servers.subtitle": "Откройте сохраненный удаленный сервер CodeNomad в новом окне",
"folderSelection.servers.count": "{count} серверов",
"folderSelection.servers.empty.title": "Нет сохраненных серверов",
"folderSelection.servers.empty.description": "Добавьте удаленный сервер, чтобы быстро подключаться к нему с этого устройства",
"folderSelection.servers.connectTitle": "Подключиться к серверу",
"folderSelection.servers.connectSubtitle": "Сохраните удаленный сервер CodeNomad и откройте его в новом окне",
"folderSelection.servers.connectButton": "Подключиться к серверу",
"folderSelection.servers.remove": "Удалить сохраненный сервер",
"folderSelection.servers.skipTls": "Самоподписанный TLS",
"folderSelection.servers.errorTitle": "Ошибка удаленного подключения",
"folderSelection.servers.dialog.title": "Подключиться к серверу",
"folderSelection.servers.dialog.description": "Добавьте удаленный сервер CodeNomad и при желании сразу откройте его.",
"folderSelection.servers.dialog.name": "Имя сервера",
"folderSelection.servers.dialog.namePlaceholder": "Продакшн сервер",
"folderSelection.servers.dialog.url": "URL сервера",
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
"folderSelection.servers.dialog.skipTls": "Пропустить проверку TLS для самоподписанных сертификатов.",
"folderSelection.servers.dialog.cancel": "Отмена",
"folderSelection.servers.dialog.save": "Сохранить",
"folderSelection.servers.dialog.connect": "Подключиться",
"folderSelection.servers.dialog.connecting": "Подключение...",
"folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.",
"folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.",
"folderSelection.sidecars.button": "Open SideCar",
} as const } as const

View File

@@ -150,6 +150,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "Нет фоновых процессов.", "instanceShell.backgroundProcesses.empty": "Нет фоновых процессов.",
"instanceShell.backgroundProcesses.status": "Статус: {status}", "instanceShell.backgroundProcesses.status": "Статус: {status}",
"instanceShell.backgroundProcesses.output": "Вывод: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "Вывод: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "Уведомление о завершении включено",
"instanceShell.backgroundProcesses.notify.disabled": "Уведомление о завершении выключено",
"instanceShell.backgroundProcesses.actions.output": "Вывод", "instanceShell.backgroundProcesses.actions.output": "Вывод",
"instanceShell.backgroundProcesses.actions.stop": "Остановить", "instanceShell.backgroundProcesses.actions.stop": "Остановить",
"instanceShell.backgroundProcesses.actions.terminate": "Завершить", "instanceShell.backgroundProcesses.actions.terminate": "Завершить",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "Загрузка сообщений…", "messageSection.loading.messages": "Загрузка сообщений…",
"messageSection.scroll.toFirstAriaLabel": "Прокрутить к первому сообщению", "messageSection.scroll.toFirstAriaLabel": "Прокрутить к первому сообщению",
"messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению", "messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению",
"messageSection.scroll.enableHoldAriaLabel": "Включить удержание для длинных ответов ассистента",
"messageSection.scroll.disableHoldAriaLabel": "Выключить удержание для длинных ответов ассистента",
"messageSection.quote.addAsQuote": "Добавить как цитату", "messageSection.quote.addAsQuote": "Добавить как цитату",
"messageSection.quote.addAsCode": "Добавить как код", "messageSection.quote.addAsCode": "Добавить как код",
"messageSection.quote.copy": "Копировать", "messageSection.quote.copy": "Копировать",

View File

@@ -113,6 +113,14 @@ export const settingsMessages = {
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.", "settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime", "settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.", "settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.opencode.logLevel.title": "Уровень логирования OpenCode",
"settings.opencode.logLevel.subtitle": "Задайте уровень логирования, используемый при запуске новых экземпляров OpenCode.",
"settings.opencode.logLevel.selector.title": "Подробность логов",
"settings.opencode.logLevel.selector.subtitle": "Выберите, сколько логов должны выводить новые экземпляры OpenCode.",
"settings.opencode.logLevel.option.debug": "Отладка",
"settings.opencode.logLevel.option.info": "Информация",
"settings.opencode.logLevel.option.warn": "Предупреждение",
"settings.opencode.logLevel.option.error": "Ошибка",
"settings.appearance.behavior.title": "Взаимодействие", "settings.appearance.behavior.title": "Взаимодействие",
"settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.", "settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.",
@@ -186,4 +194,40 @@ export const settingsMessages = {
"settings.speech.save.saved": "Сохранено", "settings.speech.save.saved": "Сохранено",
"settings.speech.save.unsaved": "Есть несохранённые изменения", "settings.speech.save.unsaved": "Есть несохранённые изменения",
"settings.speech.save.error": "Не удалось сохранить", "settings.speech.save.error": "Не удалось сохранить",
"settings.nav.sidecars": "SideCars",
"settings.section.sidecars.eyebrow": "Server services",
"settings.section.sidecars.title": "SideCars",
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
"sidecars.form.name": "Name",
"sidecars.form.validation": "Enter a valid SideCar name and port.",
"sidecars.form.port": "Port",
"sidecars.form.insecure": "Use HTTP",
"sidecars.form.protocol": "Protocol",
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
"sidecars.form.protocol.https": "HTTPS",
"sidecars.form.protocol.http": "HTTP",
"sidecars.form.prefixMode": "Prefix mode",
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
"sidecars.form.prefixMode.strip": "Strip prefix",
"sidecars.form.prefixMode.preserve": "Preserve prefix",
"sidecars.form.add": "Add SideCar",
"sidecars.kind.port": "Port",
"sidecars.status.running": "Running",
"sidecars.status.stopped": "Stopped",
"sidecars.basePath": "Base path",
"sidecars.settings.listTitle": "Configured SideCars",
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
"sidecars.settings.empty": "No SideCars configured yet.",
"sidecars.picker.title": "Open SideCar",
"sidecars.picker.loading": "Loading SideCars...",
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
"sidecars.picker.empty": "No port-based SideCars are available yet.",
"sidecars.picker.close": "Close",
"sidecars.open.errorTitle": "Unable to open SideCar",
"sidecars.open.notFound": "SideCar not found.",
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
"sidecars.back": "Back",
"sidecars.refresh": "Refresh",
"sidecars.path": "Path",
"sidecars.go": "Go",
} as const } as const

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