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 ( - - - - - - - - - - + + + + + + + + + + ( @@ -72,14 +72,14 @@ export function App() { /> - - - - - - - - - + + + + + + + + + ) } 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 - }>({ - 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 + }>({ + ready: false, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, + project: [], + provider: { all: [], connected: [], default: {} }, + provider_auth: {}, + children: {}, + }) - const children: Record>> = {} - 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>> = {} + 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 - - 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 - } - - 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 - } - if (result.found) { - setStore("session", result.index, reconcile(event.properties.info)) - break - } - 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 - } - 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.sessionID, - produce((draft) => { - 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 - } - 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", - event.properties.messageID, - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - } - break - } - } + 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)) + } - async function bootstrap() { - return Promise.all([ - globalSDK.client.path.get().then((x) => { - setGlobalStore("path", x.data!) - }), - globalSDK.client.project.list().then(async (x) => { + globalSDK.event.listen((e) => { + const directory = e.name + const event = e.details + + 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", - x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)), + produce((draft) => { + draft.splice(result.index, 0, event.properties) + }), ) - }), - 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)) + break + } + } + return } - onMount(() => { - bootstrap() - }) - - return { - data: globalStore, - get ready() { - return globalStore.ready - }, - child, - bootstrap, - project: { - loadSessions, - }, + 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 + } + if (result.found) { + setStore("session", result.index, reconcile(event.properties.info)) + break + } + 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 + } + 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.sessionID, + produce((draft) => { + 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 + } + 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", + event.properties.messageID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + 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)) + .catch((e) => setGlobalStore("error", e)) + } + + onMount(() => { + bootstrap() + }) + + return { + data: globalStore, + get ready() { + return globalStore.ready + }, + get error() { + return globalStore.error + }, + child, + bootstrap, + project: { + loadSessions, + }, + } +} + +const GlobalSyncContext = createContext>() + +export function GlobalSyncProvider(props: ParentProps) { + const value = createGlobalSync() + return ( + + + + + + {props.children} + + + ) +} + +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 = (props) => { + const platform = usePlatform() + return ( + + + + + Something went wrong + An error occurred while loading the application. + + + + Please report this error to the OpenCode team + platform.openLink("https://opencode.ai/desktop-feedback")} + > + on Discord + + + + + + ) +} 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) { - + } + > + +
An error occurred while loading the application.