error.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. import { TextField } from "@opencode-ai/ui/text-field"
  2. import * as Sentry from "@sentry/solid"
  3. import { Logo } from "@opencode-ai/ui/logo"
  4. import { Button } from "@opencode-ai/ui/button"
  5. import { Component, createSignal, onMount, Show } from "solid-js"
  6. import { createStore } from "solid-js/store"
  7. import { usePlatform } from "@/context/platform"
  8. import { useLanguage } from "@/context/language"
  9. import { Icon } from "@opencode-ai/ui/icon"
  10. import { errorDescriptionKey } from "./error-description"
  11. export type InitError = {
  12. name: string
  13. data: Record<string, unknown>
  14. }
  15. type Translator = ReturnType<typeof useLanguage>["t"]
  16. const CHAIN_SEPARATOR = "\n" + "─".repeat(40) + "\n"
  17. function isIssue(value: unknown): value is { message: string; path: string[] } {
  18. if (!value || typeof value !== "object") return false
  19. if (!("message" in value) || !("path" in value)) return false
  20. const message = (value as { message: unknown }).message
  21. const path = (value as { path: unknown }).path
  22. if (typeof message !== "string") return false
  23. if (!Array.isArray(path)) return false
  24. return path.every((part) => typeof part === "string")
  25. }
  26. function isInitError(error: unknown): error is InitError {
  27. return (
  28. typeof error === "object" &&
  29. error !== null &&
  30. "name" in error &&
  31. "data" in error &&
  32. typeof (error as InitError).data === "object"
  33. )
  34. }
  35. function safeJson(value: unknown, circular: string): string {
  36. const seen = new WeakSet<object>()
  37. const json = JSON.stringify(
  38. value,
  39. (_key, val) => {
  40. if (typeof val === "bigint") return val.toString()
  41. if (typeof val === "object" && val) {
  42. if (seen.has(val)) return circular
  43. seen.add(val)
  44. }
  45. return val
  46. },
  47. 2,
  48. )
  49. return json ?? String(value)
  50. }
  51. function formatInitError(error: InitError, t: Translator): string {
  52. const data = error.data
  53. const json = (value: unknown) => safeJson(value, t("error.page.circular"))
  54. switch (error.name) {
  55. case "MCPFailed": {
  56. const name = typeof data.name === "string" ? data.name : ""
  57. return t("error.chain.mcpFailed", { name })
  58. }
  59. case "ProviderAuthError": {
  60. const providerID = typeof data.providerID === "string" ? data.providerID : t("common.unknown")
  61. const message = typeof data.message === "string" ? data.message : json(data.message)
  62. return t("error.chain.providerAuthFailed", { provider: providerID, message })
  63. }
  64. case "APIError": {
  65. const message = typeof data.message === "string" ? data.message : t("error.chain.apiError")
  66. const lines: string[] = [message]
  67. if (typeof data.statusCode === "number") {
  68. lines.push(t("error.chain.status", { status: data.statusCode }))
  69. }
  70. if (typeof data.isRetryable === "boolean") {
  71. lines.push(t("error.chain.retryable", { retryable: data.isRetryable }))
  72. }
  73. if (typeof data.responseBody === "string" && data.responseBody) {
  74. lines.push(t("error.chain.responseBody", { body: data.responseBody }))
  75. }
  76. return lines.join("\n")
  77. }
  78. case "ProviderModelNotFoundError": {
  79. const { providerID, modelID, suggestions } = data as {
  80. providerID: string
  81. modelID: string
  82. suggestions?: string[]
  83. }
  84. const suggestionsLine =
  85. Array.isArray(suggestions) && suggestions.length
  86. ? [t("error.chain.didYouMean", { suggestions: suggestions.join(", ") })]
  87. : []
  88. return [
  89. t("error.chain.modelNotFound", { provider: providerID, model: modelID }),
  90. ...suggestionsLine,
  91. t("error.chain.checkConfig"),
  92. ].join("\n")
  93. }
  94. case "ProviderInitError": {
  95. const providerID = typeof data.providerID === "string" ? data.providerID : t("common.unknown")
  96. return t("error.chain.providerInitFailed", { provider: providerID })
  97. }
  98. case "ConfigJsonError": {
  99. const path = typeof data.path === "string" ? data.path : json(data.path)
  100. const message = typeof data.message === "string" ? data.message : ""
  101. if (message) return t("error.chain.configJsonInvalidWithMessage", { path, message })
  102. return t("error.chain.configJsonInvalid", { path })
  103. }
  104. case "ConfigDirectoryTypoError": {
  105. const path = typeof data.path === "string" ? data.path : json(data.path)
  106. const dir = typeof data.dir === "string" ? data.dir : json(data.dir)
  107. const suggestion = typeof data.suggestion === "string" ? data.suggestion : json(data.suggestion)
  108. return t("error.chain.configDirectoryTypo", { dir, path, suggestion })
  109. }
  110. case "ConfigFrontmatterError": {
  111. const path = typeof data.path === "string" ? data.path : json(data.path)
  112. const message = typeof data.message === "string" ? data.message : json(data.message)
  113. return t("error.chain.configFrontmatterError", { path, message })
  114. }
  115. case "ConfigInvalidError": {
  116. const issues = Array.isArray(data.issues)
  117. ? data.issues.filter(isIssue).map((issue) => "↳ " + issue.message + " " + issue.path.join("."))
  118. : []
  119. const message = typeof data.message === "string" ? data.message : ""
  120. const path = typeof data.path === "string" ? data.path : json(data.path)
  121. const line = message
  122. ? t("error.chain.configInvalidWithMessage", { path, message })
  123. : t("error.chain.configInvalid", { path })
  124. return [line, ...issues].join("\n")
  125. }
  126. case "UnknownError":
  127. return typeof data.message === "string" ? data.message : json(data)
  128. default:
  129. if (typeof data.message === "string") return data.message
  130. return json(data)
  131. }
  132. }
  133. function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessage?: string): string {
  134. const json = (value: unknown) => safeJson(value, t("error.page.circular"))
  135. if (!error) return t("error.chain.unknown")
  136. if (isInitError(error)) {
  137. const message = formatInitError(error, t)
  138. if (depth > 0 && parentMessage === message) return ""
  139. const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
  140. return indent + `${error.name}\n${message}`
  141. }
  142. if (error instanceof Error) {
  143. const isDuplicate = depth > 0 && parentMessage === error.message
  144. const parts: string[] = []
  145. const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
  146. const header = `${error.name}${error.message ? `: ${error.message}` : ""}`
  147. const stack = error.stack?.trim()
  148. if (stack) {
  149. const startsWithHeader = stack.startsWith(header)
  150. if (isDuplicate && startsWithHeader) {
  151. const trace = stack.split("\n").slice(1).join("\n").trim()
  152. if (trace) {
  153. parts.push(indent + trace)
  154. }
  155. }
  156. if (isDuplicate && !startsWithHeader) {
  157. parts.push(indent + stack)
  158. }
  159. if (!isDuplicate && startsWithHeader) {
  160. parts.push(indent + stack)
  161. }
  162. if (!isDuplicate && !startsWithHeader) {
  163. parts.push(indent + `${header}\n${stack}`)
  164. }
  165. }
  166. if (!stack && !isDuplicate) {
  167. parts.push(indent + header)
  168. }
  169. if (error.cause) {
  170. const causeResult = formatErrorChain(error.cause, t, depth + 1, error.message)
  171. if (causeResult) {
  172. parts.push(causeResult)
  173. }
  174. }
  175. return parts.join("\n\n")
  176. }
  177. if (typeof error === "string") {
  178. if (depth > 0 && parentMessage === error) return ""
  179. const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
  180. return indent + error
  181. }
  182. const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
  183. return indent + json(error)
  184. }
  185. function formatError(error: unknown, t: Translator): string {
  186. return formatErrorChain(error, t, 0)
  187. }
  188. interface ErrorPageProps {
  189. error: unknown
  190. }
  191. export const ErrorPage: Component<ErrorPageProps> = (props) => {
  192. const platform = usePlatform()
  193. const language = useLanguage()
  194. const formattedError = () => formatError(props.error, language.t)
  195. let recordedFatalError: Promise<void> | undefined
  196. const [store, setStore] = createStore({
  197. actionError: undefined as string | undefined,
  198. })
  199. function ensureFatalErrorRecorded() {
  200. recordedFatalError ??=
  201. platform.recordFatalRendererError?.({
  202. error: formattedError(),
  203. url: location.href,
  204. version: platform.version,
  205. platform: platform.platform,
  206. os: platform.os,
  207. }) ?? Promise.resolve()
  208. return recordedFatalError
  209. }
  210. onMount(() => {
  211. void ensureFatalErrorRecorded().catch(() => undefined)
  212. })
  213. async function checkForUpdates() {
  214. const state = await platform.updater?.check()
  215. setStore("actionError", state?.status === "error" ? state.message : undefined)
  216. }
  217. async function installUpdate() {
  218. await platform.updater
  219. ?.install()
  220. .then(() => setStore("actionError", undefined))
  221. .catch((err) => {
  222. setStore("actionError", formatError(err, language.t))
  223. })
  224. }
  225. const updateVersion = () => {
  226. const state = platform.updater?.state()
  227. if (state?.status !== "ready") return
  228. return state.version
  229. }
  230. async function exportDebugLogs() {
  231. const exportLogs = platform.exportDebugLogs
  232. if (!exportLogs) return
  233. await ensureFatalErrorRecorded()
  234. .then(() => exportLogs())
  235. .then(() => setStore("actionError", undefined))
  236. .catch((err) => {
  237. setStore("actionError", formatError(err, language.t))
  238. })
  239. }
  240. return (
  241. <div
  242. class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center font-sans"
  243. data-tauri-drag-region
  244. >
  245. <div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
  246. <Logo class="w-58.5 opacity-12 shrink-0" />
  247. <div class="flex flex-col items-center gap-2 text-center">
  248. <h1 class="text-lg font-medium text-text-strong">{language.t("error.page.title")}</h1>
  249. <p class="text-sm text-text-weak">{language.t(errorDescriptionKey(props.error))}</p>
  250. </div>
  251. <TextField
  252. value={formattedError()}
  253. readOnly
  254. copyable
  255. multiline
  256. class="max-h-96 w-full font-mono text-xs no-scrollbar"
  257. label={language.t("error.page.details.label")}
  258. hideLabel
  259. />
  260. <div class="flex flex-row items-center justify-center gap-3 flex-wrap max-w-64">
  261. <Button size="large" onClick={platform.restart}>
  262. {language.t("error.page.action.restart")}
  263. </Button>
  264. <Show when={platform.platform === "desktop" && platform.exportDebugLogs}>
  265. <Button size="large" variant="ghost" onClick={exportDebugLogs}>
  266. {language.t("error.page.action.exportLogs")}
  267. </Button>
  268. </Show>
  269. <Show when={Sentry.isEnabled}>
  270. {(_) => {
  271. const [reported, setReported] = createSignal(false)
  272. return (
  273. <Button
  274. size="large"
  275. disabled={reported()}
  276. onClick={() => {
  277. Sentry.captureException(props.error)
  278. setReported(true)
  279. }}
  280. >
  281. {language.t(reported() ? "error.page.action.reported" : "error.page.action.report")}
  282. </Button>
  283. )
  284. }}
  285. </Show>
  286. <Show when={platform.updater}>
  287. <Show
  288. when={updateVersion()}
  289. fallback={
  290. <Button
  291. size="large"
  292. variant="ghost"
  293. onClick={checkForUpdates}
  294. disabled={["checking", "downloading", "installing"].includes(platform.updater?.state().status ?? "")}
  295. >
  296. {platform.updater?.state().status === "checking"
  297. ? language.t("error.page.action.checking")
  298. : language.t("error.page.action.checkUpdates")}
  299. </Button>
  300. }
  301. >
  302. {(version) => (
  303. <Button size="large" onClick={installUpdate}>
  304. {language.t("error.page.action.updateTo", { version: version() })}
  305. </Button>
  306. )}
  307. </Show>
  308. </Show>
  309. </div>
  310. <Show when={store.actionError}>
  311. {(message) => <p class="text-xs text-text-danger-base text-center max-w-2xl">{message()}</p>}
  312. </Show>
  313. <div class="flex flex-col items-center gap-2">
  314. <div class="flex items-center justify-center gap-1">
  315. {language.t("error.page.report.prefix")}
  316. <button
  317. type="button"
  318. class="flex items-center text-text-interactive-base gap-1"
  319. onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
  320. >
  321. <div>{language.t("error.page.report.discord")}</div>
  322. <Icon name="discord" class="text-text-interactive-base" />
  323. </button>
  324. </div>
  325. <Show when={platform.version}>
  326. {(version) => (
  327. <p class="text-xs text-text-weak">{language.t("error.page.version", { version: version() })}</p>
  328. )}
  329. </Show>
  330. </div>
  331. </div>
  332. </div>
  333. )
  334. }