diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index b02861c1..c36ceb1b 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -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(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) diff --git a/packages/ui/src/lib/launch-errors.ts b/packages/ui/src/lib/launch-errors.ts new file mode 100644 index 00000000..0d495f97 --- /dev/null +++ b/packages/ui/src/lib/launch-errors.ts @@ -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") + ) +} diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 20c63443..2d4bb6ec 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -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) diff --git a/packages/ui/src/stores/launch-errors.ts b/packages/ui/src/stores/launch-errors.ts new file mode 100644 index 00000000..814790f8 --- /dev/null +++ b/packages/ui/src/stores/launch-errors.ts @@ -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(null) + +// Avoid spamming the user with the same modal on repeated events. +const lastWorkspaceErrorByInstanceId = new Map() + +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 }