diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 7e242130f..9fbc93bde 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -36,6 +36,7 @@ import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { sanitizeProject } from "./global-sync/utils" import { usePlatform } from "./platform" +import { formatServerError } from "@/utils/server-errors" type GlobalStore = { ready: boolean @@ -51,11 +52,6 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } -function errorMessage(error: unknown) { - if (error instanceof Error && error.message) return error.message - if (typeof error === "string" && error) return error - return "Unknown error" -} function createGlobalSync() { const globalSDK = useGlobalSDK() @@ -207,8 +203,9 @@ function createGlobalSync() { console.error("Failed to load sessions", err) const project = getFilename(directory) showToast({ + variant: "error", title: language.t("toast.session.listFailed.title", { project }), - description: errorMessage(err), + description: formatServerError(err), }) }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 6e7714828..b35f1cd80 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -16,6 +16,7 @@ import { batch } from "solid-js" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State, VcsCache } from "./types" import { cmp, normalizeProviderList } from "./utils" +import { formatServerError } from "@/utils/server-errors" type GlobalStore = { ready: boolean @@ -133,8 +134,11 @@ export async function bootstrapDirectory(input: { } catch (err) { console.error("Failed to bootstrap instance", err) const project = getFilename(input.directory) - const message = err instanceof Error ? err.message : String(err) - showToast({ title: `Failed to reload ${project}`, description: message }) + showToast({ + variant: "error", + title: `Failed to reload ${project}`, + description: formatServerError(err) + }) input.setStore("status", "partial") return } diff --git a/packages/app/src/utils/server-errors.test.ts b/packages/app/src/utils/server-errors.test.ts new file mode 100644 index 000000000..1969d1afc --- /dev/null +++ b/packages/app/src/utils/server-errors.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import type { ConfigInvalidError } from "./server-errors" +import { formatServerError, parseReabaleConfigInvalidError } from "./server-errors" + +describe("parseReabaleConfigInvalidError", () => { + test("formats issues with file path", () => { + const error = { + name: "ConfigInvalidError", + data: { + path: "opencode.config.ts", + issues: [ + { path: ["settings", "host"], message: "Required" }, + { path: ["mode"], message: "Invalid" }, + ], + }, + } satisfies ConfigInvalidError + + const result = parseReabaleConfigInvalidError(error) + + expect(result).toBe( + ["Invalid configuration", "opencode.config.ts", "settings.host: Required", "mode: Invalid"].join("\n"), + ) + }) + + test("uses trimmed message when issues are missing", () => { + const error = { + name: "ConfigInvalidError", + data: { + path: "config", + message: " Bad value ", + }, + } satisfies ConfigInvalidError + + const result = parseReabaleConfigInvalidError(error) + + expect(result).toBe(["Invalid configuration", "Bad value"].join("\n")) + }) +}) + +describe("formatServerError", () => { + test("formats config invalid errors", () => { + const error = { + name: "ConfigInvalidError", + data: { + message: "Missing host", + }, + } satisfies ConfigInvalidError + + const result = formatServerError(error) + + expect(result).toBe(["Invalid configuration", "Missing host"].join("\n")) + }) + + test("returns error messages", () => { + expect(formatServerError(new Error("Request failed with status 503"))).toBe("Request failed with status 503") + }) + + test("returns provided string errors", () => { + expect(formatServerError("Failed to connect to server")).toBe("Failed to connect to server") + }) + + test("falls back to unknown", () => { + expect(formatServerError(0)).toBe("Unknown error") + }) + + test("falls back for unknown error objects and names", () => { + expect(formatServerError({ name: "ServerTimeoutError", data: { seconds: 30 } })).toBe("Unknown error") + }) +}) diff --git a/packages/app/src/utils/server-errors.ts b/packages/app/src/utils/server-errors.ts new file mode 100644 index 000000000..4b9727e61 --- /dev/null +++ b/packages/app/src/utils/server-errors.ts @@ -0,0 +1,32 @@ +export type ConfigInvalidError = { + name: "ConfigInvalidError" + data: { + path?: string + message?: string + issues?: Array<{ message: string; path: string[] }> + } +} + +export function formatServerError(error: unknown) { + if (isConfigInvalidErrorLike(error)) return parseReabaleConfigInvalidError(error) + if (error instanceof Error && error.message) return error.message + if (typeof error === "string" && error) return error + return "Unknown error" +} + +function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError { + if (typeof error !== "object" || error === null) return false + const o = error as Record + return o.name === "ConfigInvalidError" && typeof o.data === "object" && o.data !== null +} + +export function parseReabaleConfigInvalidError(errorInput: ConfigInvalidError) { + const head = "Invalid configuration" + const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : "" + const detail = errorInput.data.message?.trim() ?? "" + const issues = (errorInput.data.issues ?? []).map((issue) => { + return `${issue.path.join(".")}: ${issue.message}` + }) + if (issues.length) return [head, file, "", ...issues].filter(Boolean).join("\n") + return [head, file, detail].filter(Boolean).join("\n") +}