Compare commits
9 Commits
codenomad/
...
v0.11.3-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34d3f803d5 | ||
|
|
f824a063a5 | ||
|
|
2124e540aa | ||
|
|
b5790998b7 | ||
|
|
9800afb785 | ||
|
|
3b73d9d5b9 | ||
|
|
f7ac30afe3 | ||
|
|
ce370d5100 | ||
|
|
c639e535b5 |
@@ -5,18 +5,21 @@
|
||||
## Features & Capabilities
|
||||
|
||||
### 🌍 Deployment Freedom
|
||||
|
||||
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
|
||||
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
|
||||
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
|
||||
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
|
||||
|
||||
### ⚡️ Workspace Power
|
||||
|
||||
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
||||
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
|
||||
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **OpenCode**: `opencode` must be installed and configured on your system.
|
||||
- Node.js 18+ and npm (for running or building from source).
|
||||
- A workspace folder on disk you want to serve.
|
||||
@@ -25,6 +28,7 @@
|
||||
## Usage
|
||||
|
||||
### Run via npx (Recommended)
|
||||
|
||||
You can run CodeNomad directly without installing it:
|
||||
|
||||
```sh
|
||||
@@ -43,6 +47,7 @@ On startup, CodeNomad prints two URLs:
|
||||
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
|
||||
|
||||
### Install Globally
|
||||
|
||||
Or install it globally to use the `codenomad` command:
|
||||
|
||||
```sh
|
||||
@@ -51,6 +56,7 @@ codenomad --launch
|
||||
```
|
||||
|
||||
### Install Locally (per-project)
|
||||
|
||||
If you prefer to install CodeNomad into a project and run the local binary:
|
||||
|
||||
```sh
|
||||
@@ -61,6 +67,7 @@ npx codenomad --launch
|
||||
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
|
||||
|
||||
### Common Flags
|
||||
|
||||
You can configure the server using flags or environment variables:
|
||||
|
||||
| Flag | Env Variable | Description |
|
||||
@@ -74,7 +81,7 @@ You can configure the server using flags or environment variables:
|
||||
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
|
||||
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
|
||||
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
||||
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
|
||||
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Restricts the root path where new workspaces can be opened. Git worktrees are created in `.codenomad/worktrees` inside the project folder. |
|
||||
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||
@@ -87,10 +94,11 @@ You can configure the server using flags or environment variables:
|
||||
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
|
||||
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
|
||||
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
|
||||
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) |
|
||||
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true` |
|
||||
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
|
||||
|
||||
### Dev Releases (Advanced)
|
||||
|
||||
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
|
||||
|
||||
```sh
|
||||
@@ -141,12 +149,14 @@ codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
|
||||
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
|
||||
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
|
||||
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
|
||||
|
||||
### Progressive Web App (PWA)
|
||||
|
||||
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
|
||||
|
||||
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
|
||||
@@ -158,5 +168,6 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
|
||||
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
||||
|
||||
### Data Storage
|
||||
|
||||
- **Config**: `~/.config/codenomad/config.json`
|
||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
||||
|
||||
@@ -78,7 +78,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
|
||||
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
|
||||
.addOption(
|
||||
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
||||
new Option("--workspace-root <path>", "Restricts root path where workspaces can be opened").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
||||
)
|
||||
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
|
||||
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
|
||||
|
||||
@@ -8,7 +8,7 @@ import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
||||
import { WorkspaceRuntime, ProcessExitInfo, probeBinaryVersion } from "./runtime"
|
||||
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
|
||||
import { Logger } from "../logger"
|
||||
import { getOpencodeConfigDir } from "../opencode-config.js"
|
||||
import {
|
||||
@@ -109,10 +109,6 @@ export class WorkspaceManager {
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (!descriptor.binaryVersion) {
|
||||
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
||||
}
|
||||
|
||||
this.workspaces.set(id, descriptor)
|
||||
|
||||
|
||||
@@ -149,7 +145,10 @@ export class WorkspaceManager {
|
||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||
})
|
||||
|
||||
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||
const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||
if (runtimeVersion) {
|
||||
descriptor.binaryVersion = runtimeVersion
|
||||
}
|
||||
|
||||
descriptor.pid = pid
|
||||
descriptor.port = port
|
||||
@@ -278,36 +277,12 @@ export class WorkspaceManager {
|
||||
return candidates[0] ?? ""
|
||||
}
|
||||
|
||||
private detectBinaryVersion(resolvedPath: string): string | undefined {
|
||||
if (!resolvedPath) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const result = probeBinaryVersion(resolvedPath)
|
||||
if (result.valid) {
|
||||
if (result.version) {
|
||||
this.options.logger.debug({ binary: resolvedPath, version: result.version }, "Detected binary version")
|
||||
return result.version
|
||||
}
|
||||
if (result.reported) {
|
||||
this.options.logger.debug({ binary: resolvedPath, reported: result.reported }, "Binary reported version string")
|
||||
return result.reported
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to detect binary version")
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async waitForWorkspaceReadiness(params: {
|
||||
workspaceId: string
|
||||
port: number
|
||||
exitPromise: Promise<ProcessExitInfo>
|
||||
getLastOutput: () => string
|
||||
}) {
|
||||
}): Promise<string | undefined> {
|
||||
|
||||
await Promise.race([
|
||||
this.waitForPortAvailability(params.port),
|
||||
@@ -321,7 +296,7 @@ export class WorkspaceManager {
|
||||
}),
|
||||
])
|
||||
|
||||
await this.waitForInstanceHealth(params)
|
||||
const version = await this.waitForInstanceHealth(params)
|
||||
|
||||
await Promise.race([
|
||||
this.delay(STARTUP_STABILITY_DELAY_MS),
|
||||
@@ -334,6 +309,8 @@ export class WorkspaceManager {
|
||||
)
|
||||
}),
|
||||
])
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
private async waitForInstanceHealth(params: {
|
||||
@@ -341,7 +318,7 @@ export class WorkspaceManager {
|
||||
port: number
|
||||
exitPromise: Promise<ProcessExitInfo>
|
||||
getLastOutput: () => string
|
||||
}) {
|
||||
}): Promise<string | undefined> {
|
||||
const probeResult = await Promise.race([
|
||||
this.probeInstance(params.workspaceId, params.port),
|
||||
params.exitPromise.then((info) => {
|
||||
@@ -355,7 +332,7 @@ export class WorkspaceManager {
|
||||
])
|
||||
|
||||
if (probeResult.ok) {
|
||||
return
|
||||
return probeResult.version
|
||||
}
|
||||
|
||||
const latestOutput = params.getLastOutput().trim()
|
||||
@@ -366,8 +343,11 @@ export class WorkspaceManager {
|
||||
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
|
||||
}
|
||||
|
||||
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
|
||||
const url = `http://127.0.0.1:${port}/project/current`
|
||||
private async probeInstance(
|
||||
workspaceId: string,
|
||||
port: number,
|
||||
): Promise<{ ok: boolean; reason?: string; version?: string }> {
|
||||
const url = `http://127.0.0.1:${port}/global/health`
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {}
|
||||
@@ -378,11 +358,22 @@ export class WorkspaceManager {
|
||||
|
||||
const response = await fetch(url, { headers })
|
||||
if (!response.ok) {
|
||||
const reason = `health probe returned HTTP ${response.status}`
|
||||
const reason = `/global/health returned HTTP ${response.status}`
|
||||
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
||||
return { ok: false, reason }
|
||||
}
|
||||
return { ok: true }
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as null | { healthy?: unknown; version?: unknown }
|
||||
const healthy = payload?.healthy === true
|
||||
const version = typeof payload?.version === "string" ? payload.version.trim() : undefined
|
||||
|
||||
if (!healthy) {
|
||||
const reason = "Instance reported unhealthy"
|
||||
this.options.logger.debug({ workspaceId, payload }, "Health probe returned unhealthy response")
|
||||
return { ok: false, reason }
|
||||
}
|
||||
|
||||
return { ok: true, version: version || undefined }
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
|
||||
|
||||
@@ -18,6 +18,8 @@ import { useTheme } from "./lib/theme"
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||
import { getLogger } from "./lib/logger"
|
||||
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
|
||||
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
|
||||
import { initReleaseNotifications } from "./stores/releases"
|
||||
import { runtimeEnv } from "./lib/runtime-env"
|
||||
import { useI18n } from "./lib/i18n"
|
||||
@@ -75,12 +77,6 @@ const App: Component = () => {
|
||||
setToolInputsVisibility,
|
||||
} = useConfig()
|
||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||
interface LaunchErrorState {
|
||||
message: string
|
||||
binaryPath: string
|
||||
missingBinary: boolean
|
||||
}
|
||||
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
|
||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||
@@ -245,35 +241,6 @@ const App: Component = () => {
|
||||
|
||||
const launchErrorMessage = () => launchError()?.message ?? ""
|
||||
|
||||
const formatLaunchErrorMessage = (error: unknown): string => {
|
||||
if (!error) {
|
||||
return t("app.launchError.fallbackMessage")
|
||||
}
|
||||
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed.error === "string") {
|
||||
return parsed.error
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
const isMissingBinaryMessage = (message: string): boolean => {
|
||||
const normalized = message.toLowerCase()
|
||||
return (
|
||||
normalized.includes("opencode binary not found") ||
|
||||
normalized.includes("binary not found") ||
|
||||
normalized.includes("no such file or directory") ||
|
||||
normalized.includes("binary is not executable") ||
|
||||
normalized.includes("enoent")
|
||||
)
|
||||
}
|
||||
|
||||
const clearLaunchError = () => setLaunchError(null)
|
||||
|
||||
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
||||
if (!folderPath) {
|
||||
return
|
||||
@@ -292,13 +259,9 @@ const App: Component = () => {
|
||||
port: instances().get(instanceId)?.port,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = formatLaunchErrorMessage(error)
|
||||
const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage"))
|
||||
const missingBinary = isMissingBinaryMessage(message)
|
||||
setLaunchError({
|
||||
message,
|
||||
binaryPath: selectedBinary,
|
||||
missingBinary,
|
||||
})
|
||||
showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary })
|
||||
log.error("Failed to create instance", error)
|
||||
} finally {
|
||||
setIsSelectingFolder(false)
|
||||
|
||||
@@ -116,11 +116,8 @@ const AlertDialog: Component = () => {
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content
|
||||
class="modal-surface w-full max-w-xl md:max-w-2xl p-6 border border-base shadow-2xl max-h-[85vh] overflow-hidden flex flex-col"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div class="flex items-start gap-3 min-h-0">
|
||||
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||
style={{
|
||||
@@ -132,16 +129,11 @@ const AlertDialog: Component = () => {
|
||||
>
|
||||
{accent.symbol}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 min-h-0">
|
||||
<div class="flex-1 min-w-0">
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-1">
|
||||
<div
|
||||
class="max-h-[60vh] overflow-auto pr-2 whitespace-pre-wrap break-words"
|
||||
style={{ "overflow-wrap": "anywhere" }}
|
||||
>
|
||||
{payload.message}
|
||||
{payload.detail && <div class="mt-3">{payload.detail}</div>}
|
||||
</div>
|
||||
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
|
||||
{payload.message}
|
||||
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -625,7 +625,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
<div class="flex flex-wrap items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-button command-palette-button px-2 py-0.5 text-xs"
|
||||
class="connection-status-button px-2 py-0.5 text-xs"
|
||||
onClick={handleCommandPaletteClick}
|
||||
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||
style={{ flex: "0 0 auto", width: "auto" }}
|
||||
@@ -721,7 +721,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
<div class="session-toolbar-center flex items-center justify-center gap-2 min-w-[160px]">
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-button command-palette-button px-2 py-0.5 text-xs"
|
||||
class="connection-status-button px-2 py-0.5 text-xs"
|
||||
onClick={handleCommandPaletteClick}
|
||||
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||
style={{ flex: "0 0 auto", width: "auto" }}
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
<div class="connection-status-shortcut-action">
|
||||
<button
|
||||
type="button"
|
||||
class="connection-status-button command-palette-button"
|
||||
class="connection-status-button"
|
||||
onClick={props.onCommandPalette}
|
||||
aria-label={t("messageListHeader.commandPalette.ariaLabel")}
|
||||
>
|
||||
|
||||
@@ -351,7 +351,9 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const blockquote = lines.map((line) => `> ${line}`).join("\n")
|
||||
if (!blockquote) return
|
||||
|
||||
insertBlockContent(`${blockquote}\n`)
|
||||
// End the blockquote with a blank line so the user's next line
|
||||
// doesn't get parsed as a lazy continuation of the quote.
|
||||
insertBlockContent(`${blockquote}\n\n`)
|
||||
}
|
||||
|
||||
function insertCodeSelection(rawText: string) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid"
|
||||
import { ArrowRightSquare, Copy } from "lucide-solid"
|
||||
import { stringify as stringifyYaml } from "yaml"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { useTheme } from "../lib/theme"
|
||||
@@ -576,13 +576,13 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const status = toolState()?.status || ""
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return <Hourglass class="w-4 h-4" />
|
||||
return "⏳"
|
||||
case "running":
|
||||
return <Loader2 class="w-4 h-4 animate-spin" />
|
||||
return "🔄"
|
||||
case "completed":
|
||||
return <Check class="w-4 h-4" />
|
||||
return "✅"
|
||||
case "error":
|
||||
return <XCircle class="w-4 h-4" />
|
||||
return "⚠️"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
29
packages/ui/src/lib/launch-errors.ts
Normal file
29
packages/ui/src/lib/launch-errors.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export function formatLaunchErrorMessage(error: unknown, fallbackMessage: string): string {
|
||||
if (!error) {
|
||||
return fallbackMessage
|
||||
}
|
||||
|
||||
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (parsed && typeof parsed === "object" && "error" in parsed && typeof (parsed as any).error === "string") {
|
||||
return (parsed as any).error
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
export function isMissingBinaryMessage(message: string): boolean {
|
||||
const normalized = message.toLowerCase()
|
||||
return (
|
||||
normalized.includes("opencode binary not found") ||
|
||||
normalized.includes("binary not found") ||
|
||||
normalized.includes("no such file or directory") ||
|
||||
normalized.includes("binary is not executable") ||
|
||||
normalized.includes("enoent")
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestio
|
||||
import { clearCacheForInstance } from "../lib/global-cache"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
|
||||
import { showWorkspaceLaunchError } from "./launch-errors"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
@@ -372,6 +373,7 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) {
|
||||
break
|
||||
case "workspace.error":
|
||||
upsertWorkspace(event.workspace)
|
||||
showWorkspaceLaunchError(event.workspace)
|
||||
break
|
||||
case "workspace.stopped":
|
||||
releaseInstanceResources(event.workspaceId)
|
||||
|
||||
53
packages/ui/src/stores/launch-errors.ts
Normal file
53
packages/ui/src/stores/launch-errors.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { WorkspaceDescriptor } from "../../../server/src/api-types"
|
||||
import { tGlobal } from "../lib/i18n"
|
||||
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "../lib/launch-errors"
|
||||
|
||||
type LaunchErrorSource = "create" | "workspace"
|
||||
|
||||
export interface LaunchErrorState {
|
||||
source: LaunchErrorSource
|
||||
message: string
|
||||
binaryPath: string
|
||||
missingBinary: boolean
|
||||
instanceId?: string
|
||||
}
|
||||
|
||||
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
|
||||
|
||||
// Avoid spamming the user with the same modal on repeated events.
|
||||
const lastWorkspaceErrorByInstanceId = new Map<string, string>()
|
||||
|
||||
export function showLaunchError(next: LaunchErrorState) {
|
||||
setLaunchError(next)
|
||||
}
|
||||
|
||||
export function clearLaunchError() {
|
||||
setLaunchError(null)
|
||||
}
|
||||
|
||||
export function showWorkspaceLaunchError(workspace: WorkspaceDescriptor) {
|
||||
const instanceId = workspace.id
|
||||
const rawMessage = workspace.error
|
||||
const message = formatLaunchErrorMessage(rawMessage, tGlobal("app.launchError.fallbackMessage"))
|
||||
|
||||
const previous = lastWorkspaceErrorByInstanceId.get(instanceId)
|
||||
if (previous && previous === message) {
|
||||
return
|
||||
}
|
||||
|
||||
lastWorkspaceErrorByInstanceId.set(instanceId, message)
|
||||
|
||||
const binaryPath = (workspace.binaryLabel || workspace.binaryId || "opencode").trim() || "opencode"
|
||||
const missingBinary = isMissingBinaryMessage(message)
|
||||
|
||||
showLaunchError({
|
||||
source: "workspace",
|
||||
instanceId,
|
||||
message,
|
||||
binaryPath,
|
||||
missingBinary,
|
||||
})
|
||||
}
|
||||
|
||||
export { launchError }
|
||||
@@ -130,17 +130,6 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Make the command palette trigger stand out in the header. */
|
||||
.connection-status-button.command-palette-button {
|
||||
border-color: var(--accent-primary);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.connection-status-button.command-palette-button:hover {
|
||||
border-color: var(--accent-primary);
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.connection-status-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user