Compare commits
16 Commits
v0.11.3-de
...
codenomad/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e5695a903 | ||
|
|
77103b7292 | ||
|
|
b14a144ddc | ||
|
|
8ac67311d8 | ||
|
|
0c97db393c | ||
|
|
614c300d2f | ||
|
|
e6ca4bd43d | ||
|
|
84f81cf829 | ||
|
|
3760ba2d7f | ||
|
|
09e7a3f8da | ||
|
|
c55d56c94b | ||
|
|
cc53123bcd | ||
|
|
d64027d43d | ||
|
|
6b7162f50f | ||
|
|
5fd985f0c2 | ||
|
|
2a438b2bb3 |
@@ -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 } from "./runtime"
|
||||
import { WorkspaceRuntime, ProcessExitInfo, probeBinaryVersion } from "./runtime"
|
||||
import { Logger } from "../logger"
|
||||
import { getOpencodeConfigDir } from "../opencode-config.js"
|
||||
import {
|
||||
@@ -109,6 +109,10 @@ export class WorkspaceManager {
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (!descriptor.binaryVersion) {
|
||||
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
||||
}
|
||||
|
||||
this.workspaces.set(id, descriptor)
|
||||
|
||||
|
||||
@@ -145,10 +149,7 @@ export class WorkspaceManager {
|
||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||
})
|
||||
|
||||
const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||
if (runtimeVersion) {
|
||||
descriptor.binaryVersion = runtimeVersion
|
||||
}
|
||||
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||
|
||||
descriptor.pid = pid
|
||||
descriptor.port = port
|
||||
@@ -277,12 +278,36 @@ 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),
|
||||
@@ -296,7 +321,7 @@ export class WorkspaceManager {
|
||||
}),
|
||||
])
|
||||
|
||||
const version = await this.waitForInstanceHealth(params)
|
||||
await this.waitForInstanceHealth(params)
|
||||
|
||||
await Promise.race([
|
||||
this.delay(STARTUP_STABILITY_DELAY_MS),
|
||||
@@ -309,8 +334,6 @@ export class WorkspaceManager {
|
||||
)
|
||||
}),
|
||||
])
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
private async waitForInstanceHealth(params: {
|
||||
@@ -318,7 +341,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) => {
|
||||
@@ -332,7 +355,7 @@ export class WorkspaceManager {
|
||||
])
|
||||
|
||||
if (probeResult.ok) {
|
||||
return probeResult.version
|
||||
return
|
||||
}
|
||||
|
||||
const latestOutput = params.getLastOutput().trim()
|
||||
@@ -343,11 +366,8 @@ 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; version?: string }> {
|
||||
const url = `http://127.0.0.1:${port}/global/health`
|
||||
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
|
||||
const url = `http://127.0.0.1:${port}/project/current`
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {}
|
||||
@@ -358,22 +378,11 @@ export class WorkspaceManager {
|
||||
|
||||
const response = await fetch(url, { headers })
|
||||
if (!response.ok) {
|
||||
const reason = `/global/health returned HTTP ${response.status}`
|
||||
const reason = `health probe returned HTTP ${response.status}`
|
||||
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
||||
return { ok: false, reason }
|
||||
}
|
||||
|
||||
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 }
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
|
||||
|
||||
@@ -18,8 +18,6 @@ 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"
|
||||
@@ -77,6 +75,12 @@ 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)
|
||||
@@ -241,6 +245,35 @@ 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
|
||||
@@ -259,9 +292,13 @@ const App: Component = () => {
|
||||
port: instances().get(instanceId)?.port,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage"))
|
||||
const message = formatLaunchErrorMessage(error)
|
||||
const missingBinary = isMissingBinaryMessage(message)
|
||||
showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary })
|
||||
setLaunchError({
|
||||
message,
|
||||
binaryPath: selectedBinary,
|
||||
missingBinary,
|
||||
})
|
||||
log.error("Failed to create instance", error)
|
||||
} finally {
|
||||
setIsSelectingFolder(false)
|
||||
|
||||
@@ -116,8 +116,11 @@ 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-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
|
||||
<div class="flex items-start gap-3">
|
||||
<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">
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||
style={{
|
||||
@@ -129,11 +132,16 @@ const AlertDialog: Component = () => {
|
||||
>
|
||||
{accent.symbol}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex-1 min-w-0 min-h-0">
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
|
||||
<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 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>
|
||||
</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 px-2 py-0.5 text-xs"
|
||||
class="connection-status-button command-palette-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 px-2 py-0.5 text-xs"
|
||||
class="connection-status-button command-palette-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"
|
||||
class="connection-status-button command-palette-button"
|
||||
onClick={props.onCommandPalette}
|
||||
aria-label={t("messageListHeader.commandPalette.ariaLabel")}
|
||||
>
|
||||
|
||||
@@ -351,9 +351,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const blockquote = lines.map((line) => `> ${line}`).join("\n")
|
||||
if (!blockquote) return
|
||||
|
||||
// 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`)
|
||||
insertBlockContent(`${blockquote}\n`)
|
||||
}
|
||||
|
||||
function insertCodeSelection(rawText: string) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { ArrowRightSquare, Copy } from "lucide-solid"
|
||||
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } 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 "⏳"
|
||||
return <Hourglass class="w-4 h-4" />
|
||||
case "running":
|
||||
return "🔄"
|
||||
return <Loader2 class="w-4 h-4 animate-spin" />
|
||||
case "completed":
|
||||
return "✅"
|
||||
return <Check class="w-4 h-4" />
|
||||
case "error":
|
||||
return "⚠️"
|
||||
return <XCircle class="w-4 h-4" />
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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,7 +35,6 @@ 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")
|
||||
|
||||
@@ -373,7 +372,6 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) {
|
||||
break
|
||||
case "workspace.error":
|
||||
upsertWorkspace(event.workspace)
|
||||
showWorkspaceLaunchError(event.workspace)
|
||||
break
|
||||
case "workspace.stopped":
|
||||
releaseInstanceResources(event.workspaceId)
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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,6 +130,17 @@
|
||||
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