fix(ui): show workspace launch errors in dialog
This commit is contained in:
@@ -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)
|
||||
|
||||
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 }
|
||||
Reference in New Issue
Block a user