diff options
| author | Adam <[email protected]> | 2025-12-17 15:42:50 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-17 15:42:55 -0600 |
| commit | e48d804d84931e5437c5dd62a8cabaa58f989fce (patch) | |
| tree | 7f4c1d619d16c3b07565e91bd6c531c6bdd7a454 | |
| parent | b4209582fb7d696e28b499b2b254ef4f853e0a13 (diff) | |
| download | opencode-e48d804d84931e5437c5dd62a8cabaa58f989fce.tar.gz opencode-e48d804d84931e5437c5dd62a8cabaa58f989fce.zip | |
feat(desktop): startup errors shown
| -rw-r--r-- | packages/desktop/src/app.tsx | 40 | ||||
| -rw-r--r-- | packages/desktop/src/context/global-sdk.tsx | 1 | ||||
| -rw-r--r-- | packages/desktop/src/context/global-sync.tsx | 467 | ||||
| -rw-r--r-- | packages/desktop/src/context/sdk.tsx | 1 | ||||
| -rw-r--r-- | packages/desktop/src/pages/error.tsx | 44 | ||||
| -rw-r--r-- | packages/ui/src/components/text-field.css | 10 | ||||
| -rw-r--r-- | packages/ui/src/components/text-field.tsx | 9 |
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 |
