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 { useCommands } from "./lib/hooks/use-commands"
|
||||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||||
import { getLogger } from "./lib/logger"
|
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 { initReleaseNotifications } from "./stores/releases"
|
||||||
import { runtimeEnv } from "./lib/runtime-env"
|
import { runtimeEnv } from "./lib/runtime-env"
|
||||||
import { useI18n } from "./lib/i18n"
|
import { useI18n } from "./lib/i18n"
|
||||||
@@ -74,12 +76,6 @@ const App: Component = () => {
|
|||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
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 [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
||||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||||
@@ -244,35 +240,6 @@ const App: Component = () => {
|
|||||||
|
|
||||||
const launchErrorMessage = () => launchError()?.message ?? ""
|
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) {
|
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
||||||
if (!folderPath) {
|
if (!folderPath) {
|
||||||
return
|
return
|
||||||
@@ -291,13 +258,9 @@ const App: Component = () => {
|
|||||||
port: instances().get(instanceId)?.port,
|
port: instances().get(instanceId)?.port,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = formatLaunchErrorMessage(error)
|
const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage"))
|
||||||
const missingBinary = isMissingBinaryMessage(message)
|
const missingBinary = isMissingBinaryMessage(message)
|
||||||
setLaunchError({
|
showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary })
|
||||||
message,
|
|
||||||
binaryPath: selectedBinary,
|
|
||||||
missingBinary,
|
|
||||||
})
|
|
||||||
log.error("Failed to create instance", error)
|
log.error("Failed to create instance", error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSelectingFolder(false)
|
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 { clearCacheForInstance } from "../lib/global-cache"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
|
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
|
||||||
|
import { showWorkspaceLaunchError } from "./launch-errors"
|
||||||
|
|
||||||
const log = getLogger("api")
|
const log = getLogger("api")
|
||||||
|
|
||||||
@@ -372,6 +373,7 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) {
|
|||||||
break
|
break
|
||||||
case "workspace.error":
|
case "workspace.error":
|
||||||
upsertWorkspace(event.workspace)
|
upsertWorkspace(event.workspace)
|
||||||
|
showWorkspaceLaunchError(event.workspace)
|
||||||
break
|
break
|
||||||
case "workspace.stopped":
|
case "workspace.stopped":
|
||||||
releaseInstanceResources(event.workspaceId)
|
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