Browse Source

feat(desktop): startup errors shown

Adam 2 months ago
parent
commit
e48d804d84

+ 20 - 20
packages/desktop/src/app.tsx

@@ -1,5 +1,5 @@
 import "@/index.css"
 import "@/index.css"
-import { Show, Suspense } from "solid-js"
+import { Show } from "solid-js"
 import { Router, Route, Navigate } from "@solidjs/router"
 import { Router, Route, Navigate } from "@solidjs/router"
 import { MetaProvider } from "@solidjs/meta"
 import { MetaProvider } from "@solidjs/meta"
 import { Font } from "@opencode-ai/ui/font"
 import { Font } from "@opencode-ai/ui/font"
@@ -38,16 +38,16 @@ const url =
 
 
 export function App() {
 export function App() {
   return (
   return (
-    <DialogProvider>
-      <MarkedProvider>
-        <DiffComponentProvider component={Diff}>
-          <CodeComponentProvider component={Code}>
-            <GlobalSDKProvider url={url}>
-              <GlobalSyncProvider>
-                <LayoutProvider>
-                  <NotificationProvider>
-                    <MetaProvider>
-                      <Font />
+    <MetaProvider>
+      <Font />
+      <DialogProvider>
+        <MarkedProvider>
+          <DiffComponentProvider component={Diff}>
+            <CodeComponentProvider component={Code}>
+              <GlobalSDKProvider url={url}>
+                <GlobalSyncProvider>
+                  <LayoutProvider>
+                    <NotificationProvider>
                       <Router
                       <Router
                         root={(props) => (
                         root={(props) => (
                           <CommandProvider>
                           <CommandProvider>
@@ -72,14 +72,14 @@ export function App() {
                           />
                           />
                         </Route>
                         </Route>
                       </Router>
                       </Router>
-                    </MetaProvider>
-                  </NotificationProvider>
-                </LayoutProvider>
-              </GlobalSyncProvider>
-            </GlobalSDKProvider>
-          </CodeComponentProvider>
-        </DiffComponentProvider>
-      </MarkedProvider>
-    </DialogProvider>
+                    </NotificationProvider>
+                  </LayoutProvider>
+                </GlobalSyncProvider>
+              </GlobalSDKProvider>
+            </CodeComponentProvider>
+          </DiffComponentProvider>
+        </MarkedProvider>
+      </DialogProvider>
+    </MetaProvider>
   )
   )
 }
 }

+ 1 - 0
packages/desktop/src/context/global-sdk.tsx

@@ -10,6 +10,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
     const sdk = createOpencodeClient({
     const sdk = createOpencodeClient({
       baseUrl: props.url,
       baseUrl: props.url,
       signal: abort.signal,
       signal: abort.signal,
+      throwOnError: true,
     })
     })
 
 
     const emitter = createGlobalEmitter<{
     const emitter = createGlobalEmitter<{

+ 251 - 216
packages/desktop/src/context/global-sync.tsx

@@ -15,12 +15,13 @@ import {
   type ProviderAuthResponse,
   type ProviderAuthResponse,
   type Command,
   type Command,
   createOpencodeClient,
   createOpencodeClient,
+  EventSessionError,
 } from "@opencode-ai/sdk/v2/client"
 } from "@opencode-ai/sdk/v2/client"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { Binary } from "@opencode-ai/util/binary"
 import { Binary } from "@opencode-ai/util/binary"
-import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useGlobalSDK } from "./global-sdk"
 import { useGlobalSDK } from "./global-sdk"
-import { onMount } from "solid-js"
+import { ErrorPage } from "../pages/error"
+import { createContext, useContext, onMount, type ParentProps, Switch, Match, createEffect } from "solid-js"
 
 
 type State = {
 type State = {
   ready: boolean
   ready: boolean
@@ -51,56 +52,57 @@ type State = {
   changes: File[]
   changes: File[]
 }
 }
 
 
-export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimpleContext({
-  name: "GlobalSync",
-  init: () => {
-    const globalSDK = useGlobalSDK()
-    const [globalStore, setGlobalStore] = createStore<{
-      ready: boolean
-      path: Path
-      project: Project[]
-      provider: ProviderListResponse
-      provider_auth: ProviderAuthResponse
-      children: Record<string, State>
-    }>({
-      ready: false,
-      path: { state: "", config: "", worktree: "", directory: "", home: "" },
-      project: [],
-      provider: { all: [], connected: [], default: {} },
-      provider_auth: {},
-      children: {},
-    })
+function createGlobalSync() {
+  const globalSDK = useGlobalSDK()
+  const [globalStore, setGlobalStore] = createStore<{
+    ready: boolean
+    error?: EventSessionError["properties"]["error"]
+    path: Path
+    project: Project[]
+    provider: ProviderListResponse
+    provider_auth: ProviderAuthResponse
+    children: Record<string, State>
+  }>({
+    ready: false,
+    path: { state: "", config: "", worktree: "", directory: "", home: "" },
+    project: [],
+    provider: { all: [], connected: [], default: {} },
+    provider_auth: {},
+    children: {},
+  })
 
 
-    const children: Record<string, ReturnType<typeof createStore<State>>> = {}
-    function child(directory: string) {
-      if (!children[directory]) {
-        setGlobalStore("children", directory, {
-          project: "",
-          provider: { all: [], connected: [], default: {} },
-          config: {},
-          path: { state: "", config: "", worktree: "", directory: "", home: "" },
-          ready: false,
-          agent: [],
-          command: [],
-          session: [],
-          session_status: {},
-          session_diff: {},
-          todo: {},
-          limit: 5,
-          message: {},
-          part: {},
-          node: [],
-          changes: [],
-        })
-        children[directory] = createStore(globalStore.children[directory])
-        bootstrapInstance(directory)
-      }
-      return children[directory]
+  const children: Record<string, ReturnType<typeof createStore<State>>> = {}
+  function child(directory: string) {
+    if (!children[directory]) {
+      setGlobalStore("children", directory, {
+        project: "",
+        provider: { all: [], connected: [], default: {} },
+        config: {},
+        path: { state: "", config: "", worktree: "", directory: "", home: "" },
+        ready: false,
+        agent: [],
+        command: [],
+        session: [],
+        session_status: {},
+        session_diff: {},
+        todo: {},
+        limit: 5,
+        message: {},
+        part: {},
+        node: [],
+        changes: [],
+      })
+      children[directory] = createStore(globalStore.children[directory])
+      bootstrapInstance(directory)
     }
     }
+    return children[directory]
+  }
 
 
-    async function loadSessions(directory: string) {
-      const [store, setStore] = child(directory)
-      globalSDK.client.session.list({ directory }).then((x) => {
+  async function loadSessions(directory: string) {
+    const [store, setStore] = child(directory)
+    globalSDK.client.session
+      .list({ directory })
+      .then((x) => {
         const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
         const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
         const nonArchived = (x.data ?? [])
         const nonArchived = (x.data ?? [])
           .slice()
           .slice()
@@ -114,206 +116,239 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
         })
         })
         setStore("session", sessions)
         setStore("session", sessions)
       })
       })
-    }
-
-    async function bootstrapInstance(directory: string) {
-      const [, setStore] = child(directory)
-      const sdk = createOpencodeClient({
-        baseUrl: globalSDK.url,
-        directory,
+      .catch((err) => {
+        console.error("Failed to load sessions", err)
+        setGlobalStore("error", err)
       })
       })
-      const load = {
-        project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
-        provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
-        path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
-        agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
-        command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
-        session: () => loadSessions(directory),
-        status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
-        config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
-        changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
-        node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
-      }
-      await Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
-    }
+  }
 
 
-    globalSDK.event.listen((e) => {
-      const directory = e.name
-      const event = e.details
+  async function bootstrapInstance(directory: string) {
+    const [, setStore] = child(directory)
+    const sdk = createOpencodeClient({
+      baseUrl: globalSDK.url,
+      directory,
+      throwOnError: true,
+    })
+    const load = {
+      project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
+      provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
+      path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
+      agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
+      command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
+      session: () => loadSessions(directory),
+      status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
+      config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
+      changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
+      node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
+    }
+    await Promise.all(Object.values(load).map((p) => p().catch((e) => setGlobalStore("error", e))))
+      .then(() => setStore("ready", true))
+      .catch((e) => setGlobalStore("error", e))
+  }
 
 
-      if (directory === "global") {
-        switch (event?.type) {
-          case "global.disposed": {
-            bootstrap()
-            break
-          }
-          case "project.updated": {
-            const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
-            if (result.found) {
-              setGlobalStore("project", result.index, reconcile(event.properties))
-              return
-            }
-            setGlobalStore(
-              "project",
-              produce((draft) => {
-                draft.splice(result.index, 0, event.properties)
-              }),
-            )
-            break
-          }
-        }
-        return
-      }
+  globalSDK.event.listen((e) => {
+    const directory = e.name
+    const event = e.details
 
 
-      const [store, setStore] = child(directory)
-      switch (event.type) {
-        case "server.instance.disposed": {
-          bootstrapInstance(directory)
+    if (directory === "global") {
+      switch (event?.type) {
+        case "global.disposed": {
+          bootstrap()
           break
           break
         }
         }
-        case "session.updated": {
-          const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
-          if (event.properties.info.time.archived) {
-            if (result.found) {
-              setStore(
-                "session",
-                produce((draft) => {
-                  draft.splice(result.index, 1)
-                }),
-              )
-            }
-            break
-          }
+        case "project.updated": {
+          const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
           if (result.found) {
           if (result.found) {
-            setStore("session", result.index, reconcile(event.properties.info))
-            break
+            setGlobalStore("project", result.index, reconcile(event.properties))
+            return
           }
           }
-          setStore(
-            "session",
+          setGlobalStore(
+            "project",
             produce((draft) => {
             produce((draft) => {
-              draft.splice(result.index, 0, event.properties.info)
+              draft.splice(result.index, 0, event.properties)
             }),
             }),
           )
           )
           break
           break
         }
         }
-        case "session.diff":
-          setStore("session_diff", event.properties.sessionID, event.properties.diff)
+      }
+      return
+    }
+
+    const [store, setStore] = child(directory)
+    switch (event.type) {
+      case "server.instance.disposed": {
+        bootstrapInstance(directory)
+        break
+      }
+      case "session.updated": {
+        const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
+        if (event.properties.info.time.archived) {
+          if (result.found) {
+            setStore(
+              "session",
+              produce((draft) => {
+                draft.splice(result.index, 1)
+              }),
+            )
+          }
           break
           break
-        case "todo.updated":
-          setStore("todo", event.properties.sessionID, event.properties.todos)
+        }
+        if (result.found) {
+          setStore("session", result.index, reconcile(event.properties.info))
           break
           break
-        case "session.status": {
-          setStore("session_status", event.properties.sessionID, event.properties.status)
+        }
+        setStore(
+          "session",
+          produce((draft) => {
+            draft.splice(result.index, 0, event.properties.info)
+          }),
+        )
+        break
+      }
+      case "session.diff":
+        setStore("session_diff", event.properties.sessionID, event.properties.diff)
+        break
+      case "todo.updated":
+        setStore("todo", event.properties.sessionID, event.properties.todos)
+        break
+      case "session.status": {
+        setStore("session_status", event.properties.sessionID, event.properties.status)
+        break
+      }
+      case "message.updated": {
+        const messages = store.message[event.properties.info.sessionID]
+        if (!messages) {
+          setStore("message", event.properties.info.sessionID, [event.properties.info])
           break
           break
         }
         }
-        case "message.updated": {
-          const messages = store.message[event.properties.info.sessionID]
-          if (!messages) {
-            setStore("message", event.properties.info.sessionID, [event.properties.info])
-            break
-          }
-          const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
-          if (result.found) {
-            setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
-            break
-          }
+        const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
+        if (result.found) {
+          setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
+          break
+        }
+        setStore(
+          "message",
+          event.properties.info.sessionID,
+          produce((draft) => {
+            draft.splice(result.index, 0, event.properties.info)
+          }),
+        )
+        break
+      }
+      case "message.removed": {
+        const messages = store.message[event.properties.sessionID]
+        if (!messages) break
+        const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
+        if (result.found) {
           setStore(
           setStore(
             "message",
             "message",
-            event.properties.info.sessionID,
+            event.properties.sessionID,
             produce((draft) => {
             produce((draft) => {
-              draft.splice(result.index, 0, event.properties.info)
+              draft.splice(result.index, 1)
             }),
             }),
           )
           )
+        }
+        break
+      }
+      case "message.part.updated": {
+        const part = event.properties.part
+        const parts = store.part[part.messageID]
+        if (!parts) {
+          setStore("part", part.messageID, [part])
           break
           break
         }
         }
-        case "message.removed": {
-          const messages = store.message[event.properties.sessionID]
-          if (!messages) break
-          const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
-          if (result.found) {
-            setStore(
-              "message",
-              event.properties.sessionID,
-              produce((draft) => {
-                draft.splice(result.index, 1)
-              }),
-            )
-          }
+        const result = Binary.search(parts, part.id, (p) => p.id)
+        if (result.found) {
+          setStore("part", part.messageID, result.index, reconcile(part))
           break
           break
         }
         }
-        case "message.part.updated": {
-          const part = event.properties.part
-          const parts = store.part[part.messageID]
-          if (!parts) {
-            setStore("part", part.messageID, [part])
-            break
-          }
-          const result = Binary.search(parts, part.id, (p) => p.id)
-          if (result.found) {
-            setStore("part", part.messageID, result.index, reconcile(part))
-            break
-          }
+        setStore(
+          "part",
+          part.messageID,
+          produce((draft) => {
+            draft.splice(result.index, 0, part)
+          }),
+        )
+        break
+      }
+      case "message.part.removed": {
+        const parts = store.part[event.properties.messageID]
+        if (!parts) break
+        const result = Binary.search(parts, event.properties.partID, (p) => p.id)
+        if (result.found) {
           setStore(
           setStore(
             "part",
             "part",
-            part.messageID,
+            event.properties.messageID,
             produce((draft) => {
             produce((draft) => {
-              draft.splice(result.index, 0, part)
+              draft.splice(result.index, 1)
             }),
             }),
           )
           )
-          break
-        }
-        case "message.part.removed": {
-          const parts = store.part[event.properties.messageID]
-          if (!parts) break
-          const result = Binary.search(parts, event.properties.partID, (p) => p.id)
-          if (result.found) {
-            setStore(
-              "part",
-              event.properties.messageID,
-              produce((draft) => {
-                draft.splice(result.index, 1)
-              }),
-            )
-          }
-          break
         }
         }
+        break
       }
       }
-    })
-
-    async function bootstrap() {
-      return Promise.all([
-        globalSDK.client.path.get().then((x) => {
-          setGlobalStore("path", x.data!)
-        }),
-        globalSDK.client.project.list().then(async (x) => {
-          setGlobalStore(
-            "project",
-            x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)),
-          )
-        }),
-        globalSDK.client.provider.list().then((x) => {
-          setGlobalStore("provider", x.data ?? {})
-        }),
-        globalSDK.client.provider.auth().then((x) => {
-          setGlobalStore("provider_auth", x.data ?? {})
-        }),
-      ]).then(() => setGlobalStore("ready", true))
     }
     }
+  })
 
 
-    onMount(() => {
-      bootstrap()
-    })
+  async function bootstrap() {
+    return Promise.all([
+      globalSDK.client.path.get().then((x) => {
+        setGlobalStore("path", x.data!)
+      }),
+      globalSDK.client.project.list().then(async (x) => {
+        setGlobalStore(
+          "project",
+          x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)),
+        )
+      }),
+      globalSDK.client.provider.list().then((x) => {
+        setGlobalStore("provider", x.data ?? {})
+      }),
+      globalSDK.client.provider.auth().then((x) => {
+        setGlobalStore("provider_auth", x.data ?? {})
+      }),
+    ])
+      .then(() => setGlobalStore("ready", true))
+      .catch((e) => setGlobalStore("error", e))
+  }
 
 
-    return {
-      data: globalStore,
-      get ready() {
-        return globalStore.ready
-      },
-      child,
-      bootstrap,
-      project: {
-        loadSessions,
-      },
-    }
-  },
-})
+  onMount(() => {
+    bootstrap()
+  })
+
+  return {
+    data: globalStore,
+    get ready() {
+      return globalStore.ready
+    },
+    get error() {
+      return globalStore.error
+    },
+    child,
+    bootstrap,
+    project: {
+      loadSessions,
+    },
+  }
+}
+
+const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
+
+export function GlobalSyncProvider(props: ParentProps) {
+  const value = createGlobalSync()
+  return (
+    <Switch>
+      <Match when={value.error}>
+        <ErrorPage error={value.error} />
+      </Match>
+      <Match when={value.ready}>
+        <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
+      </Match>
+    </Switch>
+  )
+}
+
+export function useGlobalSync() {
+  const context = useContext(GlobalSyncContext)
+  if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
+  return context
+}

+ 1 - 0
packages/desktop/src/context/sdk.tsx

@@ -13,6 +13,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
       baseUrl: globalSDK.url,
       baseUrl: globalSDK.url,
       signal: abort.signal,
       signal: abort.signal,
       directory: props.directory,
       directory: props.directory,
+      throwOnError: true,
     })
     })
 
 
     const emitter = createGlobalEmitter<{
     const emitter = createGlobalEmitter<{

+ 44 - 0
packages/desktop/src/pages/error.tsx

@@ -0,0 +1,44 @@
+import { TextField } from "@opencode-ai/ui/text-field"
+import { Logo } from "@opencode-ai/ui/logo"
+import { Component } from "solid-js"
+import { usePlatform } from "@/context/platform"
+import { Icon } from "@opencode-ai/ui/icon"
+
+interface ErrorPageProps {
+  error: any
+}
+
+export const ErrorPage: Component<ErrorPageProps> = (props) => {
+  const platform = usePlatform()
+  return (
+    <div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center">
+      <div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
+        <Logo class="h-8 w-auto text-text-strong" />
+        <div class="flex flex-col items-center gap-2 text-center">
+          <h1 class="text-lg font-medium text-text-strong">Something went wrong</h1>
+          <p class="text-sm text-text-weak">An error occurred while loading the application.</p>
+        </div>
+        <TextField
+          value={String(props.error?.data?.message || props.error?.message || props.error)}
+          readOnly
+          copyable
+          multiline
+          class="max-h-96 w-full font-mono text-xs no-scrollbar whitespace-pre"
+          label="Error Details"
+          hideLabel
+        />
+        <div class="flex items-center justify-center gap-1">
+          Please report this error to the OpenCode team
+          <button
+            type="button"
+            class="flex items-center text-text-interactive-base gap-1"
+            onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
+          >
+            <div>on Discord</div>
+            <Icon name="discord" class="text-text-interactive-base" />
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 9 - 1
packages/ui/src/components/text-field.css

@@ -42,7 +42,7 @@
 
 
     [data-slot="input-wrapper"] {
     [data-slot="input-wrapper"] {
       display: flex;
       display: flex;
-      align-items: center;
+      align-items: start;
       justify-content: space-between;
       justify-content: space-between;
       width: 100%;
       width: 100%;
       padding-right: 4px;
       padding-right: 4px;
@@ -101,8 +101,16 @@
       }
       }
     }
     }
 
 
+    textarea[data-slot="input-input"] {
+      height: auto;
+      min-height: 32px;
+      padding: 6px 12px;
+      resize: none;
+    }
+
     [data-slot="input-copy-button"] {
     [data-slot="input-copy-button"] {
       flex-shrink: 0;
       flex-shrink: 0;
+      margin-top: 4px;
       color: var(--icon-base);
       color: var(--icon-base);
 
 
       &:hover {
       &:hover {

+ 8 - 1
packages/ui/src/components/text-field.tsx

@@ -26,6 +26,7 @@ export interface TextFieldProps
   error?: string
   error?: string
   variant?: "normal" | "ghost"
   variant?: "normal" | "ghost"
   copyable?: boolean
   copyable?: boolean
+  multiline?: boolean
 }
 }
 
 
 export function TextField(props: TextFieldProps) {
 export function TextField(props: TextFieldProps) {
@@ -46,6 +47,7 @@ export function TextField(props: TextFieldProps) {
     "error",
     "error",
     "variant",
     "variant",
     "copyable",
     "copyable",
+    "multiline",
   ])
   ])
   const [copied, setCopied] = createSignal(false)
   const [copied, setCopied] = createSignal(false)
 
 
@@ -81,7 +83,12 @@ export function TextField(props: TextFieldProps) {
         </Kobalte.Label>
         </Kobalte.Label>
       </Show>
       </Show>
       <div data-slot="input-wrapper">
       <div data-slot="input-wrapper">
-        <Kobalte.Input {...others} data-slot="input-input" class={local.class} />
+        <Show
+          when={local.multiline}
+          fallback={<Kobalte.Input {...others} data-slot="input-input" class={local.class} />}
+        >
+          <Kobalte.TextArea {...others} autoResize data-slot="input-input" class={local.class} />
+        </Show>
         <Show when={local.copyable}>
         <Show when={local.copyable}>
           <Tooltip value={copied() ? "Copied" : "Copy to clipboard"} placement="top" gutter={8}>
           <Tooltip value={copied() ? "Copied" : "Copy to clipboard"} placement="top" gutter={8}>
             <IconButton
             <IconButton