fix(ui): show workspace launch errors in dialog

This commit is contained in:
Shantur Rathore
2026-02-19 15:40:58 +00:00
parent f7ac30afe3
commit 3b73d9d5b9
4 changed files with 88 additions and 41 deletions

View File

@@ -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"
@@ -74,12 +76,6 @@ const App: Component = () => {
setThinkingBlocksExpansion,
} = 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)
@@ -244,35 +240,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
@@ -291,13 +258,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)

View 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")
)
}

View File

@@ -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)

View 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 }