summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-17 15:42:50 -0600
committerAdam <[email protected]>2025-12-17 15:42:55 -0600
commite48d804d84931e5437c5dd62a8cabaa58f989fce (patch)
tree7f4c1d619d16c3b07565e91bd6c531c6bdd7a454
parentb4209582fb7d696e28b499b2b254ef4f853e0a13 (diff)
downloadopencode-e48d804d84931e5437c5dd62a8cabaa58f989fce.tar.gz
opencode-e48d804d84931e5437c5dd62a8cabaa58f989fce.zip
feat(desktop): startup errors shown
-rw-r--r--packages/desktop/src/app.tsx40
-rw-r--r--packages/desktop/src/context/global-sdk.tsx1
-rw-r--r--packages/desktop/src/context/global-sync.tsx467
-rw-r--r--packages/desktop/src/context/sdk.tsx1
-rw-r--r--packages/desktop/src/pages/error.tsx44
-rw-r--r--packages/ui/src/components/text-field.css10
-rw-r--r--packages/ui/src/components/text-field.tsx9
7 files changed, 334 insertions, 238 deletions
diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx
index 13ef6833b..10bde2202 100644
--- a/packages/desktop/src/app.tsx
+++ b/packages/desktop/src/app.tsx
@@ -1,5 +1,5 @@
import "@/index.css"
-import { Show, Suspense } from "solid-js"
+import { Show } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
@@ -38,16 +38,16 @@ const url =
export function App() {
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
root={(props) => (
<CommandProvider>
@@ -72,14 +72,14 @@ export function App() {
/>
</Route>
</Router>
- </MetaProvider>
- </NotificationProvider>
- </LayoutProvider>
- </GlobalSyncProvider>
- </GlobalSDKProvider>
- </CodeComponentProvider>
- </DiffComponentProvider>
- </MarkedProvider>
- </DialogProvider>
+ </NotificationProvider>
+ </LayoutProvider>
+ </GlobalSyncProvider>
+ </GlobalSDKProvider>
+ </CodeComponentProvider>
+ </DiffComponentProvider>
+ </MarkedProvider>
+ </DialogProvider>
+ </MetaProvider>
)
}
diff --git a/packages/desktop/src/context/global-sdk.tsx b/packages/desktop/src/context/global-sdk.tsx
index 34e731ac9..0d301d2f3 100644
--- a/packages/desktop/src/context/global-sdk.tsx
+++ b/packages/desktop/src/context/global-sdk.tsx
@@ -10,6 +10,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const sdk = createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
+ throwOnError: true,
})
const emitter = createGlobalEmitter<{
diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx
index 53b891065..15cdd48cd 100644
--- a/packages/desktop/src/context/global-sync.tsx
+++ b/packages/desktop/src/context/global-sync.tsx
@@ -15,12 +15,13 @@ import {
type ProviderAuthResponse,
type Command,
createOpencodeClient,
+ EventSessionError,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
-import { createSimpleContext } from "@opencode-ai/ui/context"
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 = {
ready: boolean
@@ -51,56 +52,57 @@ type State = {
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 nonArchived = (x.data ?? [])
.slice()
@@ -114,206 +116,239 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
})
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
}
- 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) {
- setStore("session", result.index, reconcile(event.properties.info))
- break
+ setGlobalStore("project", result.index, reconcile(event.properties))
+ return
}
- setStore(
- "session",
+ setGlobalStore(
+ "project",
produce((draft) => {
- draft.splice(result.index, 0, event.properties.info)
+ draft.splice(result.index, 0, event.properties)
}),
)
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
- case "todo.updated":
- setStore("todo", event.properties.sessionID, event.properties.todos)
+ }
+ if (result.found) {
+ setStore("session", result.index, reconcile(event.properties.info))
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
}
- 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(
"message",
- event.properties.info.sessionID,
+ event.properties.sessionID,
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
}
- 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
}
- 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(
"part",
- part.messageID,
+ event.properties.messageID,
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
+}
diff --git a/packages/desktop/src/context/sdk.tsx b/packages/desktop/src/context/sdk.tsx
index 764b01f8a..0e556167b 100644
--- a/packages/desktop/src/context/sdk.tsx
+++ b/packages/desktop/src/context/sdk.tsx
@@ -13,6 +13,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
baseUrl: globalSDK.url,
signal: abort.signal,
directory: props.directory,
+ throwOnError: true,
})
const emitter = createGlobalEmitter<{
diff --git a/packages/desktop/src/pages/error.tsx b/packages/desktop/src/pages/error.tsx
new file mode 100644
index 000000000..7a7c85aad
--- /dev/null
+++ b/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>
+ )
+}
diff --git a/packages/ui/src/components/text-field.css b/packages/ui/src/components/text-field.css
index 897050a63..a739c4eb2 100644
--- a/packages/ui/src/components/text-field.css
+++ b/packages/ui/src/components/text-field.css
@@ -42,7 +42,7 @@
[data-slot="input-wrapper"] {
display: flex;
- align-items: center;
+ align-items: start;
justify-content: space-between;
width: 100%;
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"] {
flex-shrink: 0;
+ margin-top: 4px;
color: var(--icon-base);
&:hover {
diff --git a/packages/ui/src/components/text-field.tsx b/packages/ui/src/components/text-field.tsx
index 77f014b6b..ed3d13fe3 100644
--- a/packages/ui/src/components/text-field.tsx
+++ b/packages/ui/src/components/text-field.tsx
@@ -26,6 +26,7 @@ export interface TextFieldProps
error?: string
variant?: "normal" | "ghost"
copyable?: boolean
+ multiline?: boolean
}
export function TextField(props: TextFieldProps) {
@@ -46,6 +47,7 @@ export function TextField(props: TextFieldProps) {
"error",
"variant",
"copyable",
+ "multiline",
])
const [copied, setCopied] = createSignal(false)
@@ -81,7 +83,12 @@ export function TextField(props: TextFieldProps) {
</Kobalte.Label>
</Show>
<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}>
<Tooltip value={copied() ? "Copied" : "Copy to clipboard"} placement="top" gutter={8}>
<IconButton