| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290 |
- import { TextField } from "@opencode-ai/ui/text-field"
- import { Logo } from "@opencode-ai/ui/logo"
- import { Button } from "@opencode-ai/ui/button"
- import { Component, Show } from "solid-js"
- import { createStore } from "solid-js/store"
- import { usePlatform } from "@/context/platform"
- import { useLanguage } from "@/context/language"
- import { Icon } from "@opencode-ai/ui/icon"
- export type InitError = {
- name: string
- data: Record<string, unknown>
- }
- type Translator = ReturnType<typeof useLanguage>["t"]
- function isInitError(error: unknown): error is InitError {
- return (
- typeof error === "object" &&
- error !== null &&
- "name" in error &&
- "data" in error &&
- typeof (error as InitError).data === "object"
- )
- }
- function safeJson(value: unknown): string {
- const seen = new WeakSet<object>()
- const json = JSON.stringify(
- value,
- (_key, val) => {
- if (typeof val === "bigint") return val.toString()
- if (typeof val === "object" && val) {
- if (seen.has(val)) return "[Circular]"
- seen.add(val)
- }
- return val
- },
- 2,
- )
- return json ?? String(value)
- }
- function formatInitError(error: InitError, t: Translator): string {
- const data = error.data
- switch (error.name) {
- case "MCPFailed": {
- const name = typeof data.name === "string" ? data.name : ""
- return t("error.chain.mcpFailed", { name })
- }
- case "ProviderAuthError": {
- const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
- const message = typeof data.message === "string" ? data.message : safeJson(data.message)
- return t("error.chain.providerAuthFailed", { provider: providerID, message })
- }
- case "APIError": {
- const message = typeof data.message === "string" ? data.message : t("error.chain.apiError")
- const lines: string[] = [message]
- if (typeof data.statusCode === "number") {
- lines.push(t("error.chain.status", { status: data.statusCode }))
- }
- if (typeof data.isRetryable === "boolean") {
- lines.push(t("error.chain.retryable", { retryable: data.isRetryable }))
- }
- if (typeof data.responseBody === "string" && data.responseBody) {
- lines.push(t("error.chain.responseBody", { body: data.responseBody }))
- }
- return lines.join("\n")
- }
- case "ProviderModelNotFoundError": {
- const { providerID, modelID, suggestions } = data as {
- providerID: string
- modelID: string
- suggestions?: string[]
- }
- const suggestionsLine =
- Array.isArray(suggestions) && suggestions.length
- ? [t("error.chain.didYouMean", { suggestions: suggestions.join(", ") })]
- : []
- return [
- t("error.chain.modelNotFound", { provider: providerID, model: modelID }),
- ...suggestionsLine,
- t("error.chain.checkConfig"),
- ].join("\n")
- }
- case "ProviderInitError": {
- const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
- return t("error.chain.providerInitFailed", { provider: providerID })
- }
- case "ConfigJsonError": {
- const path = typeof data.path === "string" ? data.path : safeJson(data.path)
- const message = typeof data.message === "string" ? data.message : ""
- if (message) return t("error.chain.configJsonInvalidWithMessage", { path, message })
- return t("error.chain.configJsonInvalid", { path })
- }
- case "ConfigDirectoryTypoError": {
- const path = typeof data.path === "string" ? data.path : safeJson(data.path)
- const dir = typeof data.dir === "string" ? data.dir : safeJson(data.dir)
- const suggestion = typeof data.suggestion === "string" ? data.suggestion : safeJson(data.suggestion)
- return t("error.chain.configDirectoryTypo", { dir, path, suggestion })
- }
- case "ConfigFrontmatterError": {
- const path = typeof data.path === "string" ? data.path : safeJson(data.path)
- const message = typeof data.message === "string" ? data.message : safeJson(data.message)
- return t("error.chain.configFrontmatterError", { path, message })
- }
- case "ConfigInvalidError": {
- const issues = Array.isArray(data.issues)
- ? data.issues.map(
- (issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
- )
- : []
- const message = typeof data.message === "string" ? data.message : ""
- const path = typeof data.path === "string" ? data.path : safeJson(data.path)
- const line = message
- ? t("error.chain.configInvalidWithMessage", { path, message })
- : t("error.chain.configInvalid", { path })
- return [line, ...issues].join("\n")
- }
- case "UnknownError":
- return typeof data.message === "string" ? data.message : safeJson(data)
- default:
- if (typeof data.message === "string") return data.message
- return safeJson(data)
- }
- }
- function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessage?: string): string {
- if (!error) return t("error.chain.unknown")
- if (isInitError(error)) {
- const message = formatInitError(error, t)
- if (depth > 0 && parentMessage === message) return ""
- const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
- return indent + `${error.name}\n${message}`
- }
- if (error instanceof Error) {
- const isDuplicate = depth > 0 && parentMessage === error.message
- const parts: string[] = []
- const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
- const header = `${error.name}${error.message ? `: ${error.message}` : ""}`
- const stack = error.stack?.trim()
- if (stack) {
- const startsWithHeader = stack.startsWith(header)
- if (isDuplicate && startsWithHeader) {
- const trace = stack.split("\n").slice(1).join("\n").trim()
- if (trace) {
- parts.push(indent + trace)
- }
- }
- if (isDuplicate && !startsWithHeader) {
- parts.push(indent + stack)
- }
- if (!isDuplicate && startsWithHeader) {
- parts.push(indent + stack)
- }
- if (!isDuplicate && !startsWithHeader) {
- parts.push(indent + `${header}\n${stack}`)
- }
- }
- if (!stack && !isDuplicate) {
- parts.push(indent + header)
- }
- if (error.cause) {
- const causeResult = formatErrorChain(error.cause, t, depth + 1, error.message)
- if (causeResult) {
- parts.push(causeResult)
- }
- }
- return parts.join("\n\n")
- }
- if (typeof error === "string") {
- if (depth > 0 && parentMessage === error) return ""
- const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
- return indent + error
- }
- const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
- return indent + safeJson(error)
- }
- function formatError(error: unknown, t: Translator): string {
- return formatErrorChain(error, t, 0)
- }
- interface ErrorPageProps {
- error: unknown
- }
- export const ErrorPage: Component<ErrorPageProps> = (props) => {
- const platform = usePlatform()
- const language = useLanguage()
- const [store, setStore] = createStore({
- checking: false,
- version: undefined as string | undefined,
- })
- async function checkForUpdates() {
- if (!platform.checkUpdate) return
- setStore("checking", true)
- const result = await platform.checkUpdate()
- setStore("checking", false)
- if (result.updateAvailable && result.version) setStore("version", result.version)
- }
- async function installUpdate() {
- if (!platform.update || !platform.restart) return
- await platform.update()
- await platform.restart()
- }
- return (
- <div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
- <div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
- <Logo class="w-58.5 opacity-12 shrink-0" />
- <div class="flex flex-col items-center gap-2 text-center">
- <h1 class="text-lg font-medium text-text-strong">{language.t("error.page.title")}</h1>
- <p class="text-sm text-text-weak">{language.t("error.page.description")}</p>
- </div>
- <TextField
- value={formatError(props.error, language.t)}
- readOnly
- copyable
- multiline
- class="max-h-96 w-full font-mono text-xs no-scrollbar"
- label={language.t("error.page.details.label")}
- hideLabel
- />
- <div class="flex items-center gap-3">
- <Button size="large" onClick={platform.restart}>
- {language.t("error.page.action.restart")}
- </Button>
- <Show when={platform.checkUpdate}>
- <Show
- when={store.version}
- fallback={
- <Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
- {store.checking
- ? language.t("error.page.action.checking")
- : language.t("error.page.action.checkUpdates")}
- </Button>
- }
- >
- <Button size="large" onClick={installUpdate}>
- {language.t("error.page.action.updateTo", { version: store.version ?? "" })}
- </Button>
- </Show>
- </Show>
- </div>
- <div class="flex flex-col items-center gap-2">
- <div class="flex items-center justify-center gap-1">
- {language.t("error.page.report.prefix")}
- <button
- type="button"
- class="flex items-center text-text-interactive-base gap-1"
- onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
- >
- <div>{language.t("error.page.report.discord")}</div>
- <Icon name="discord" class="text-text-interactive-base" />
- </button>
- </div>
- <Show when={platform.version}>
- {(version) => (
- <p class="text-xs text-text-weak">{language.t("error.page.version", { version: version() })}</p>
- )}
- </Show>
- </div>
- </div>
- </div>
- )
- }
|