summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-11 11:28:34 -0600
committerAdam <[email protected]>2025-12-11 13:42:47 -0600
commite845eedbc325b05a19679bc439a57cc0fbf23aa3 (patch)
treef2bd6686664870ef11aba12a9946356d9df149c4
parent4ae7e1b19c3915e3e9b1a39195d54c4721836b03 (diff)
downloadopencode-e845eedbc325b05a19679bc439a57cc0fbf23aa3.tar.gz
opencode-e845eedbc325b05a19679bc439a57cc0fbf23aa3.zip
wip(desktop): progress
-rw-r--r--packages/desktop/src/pages/layout.tsx352
-rw-r--r--packages/ui/src/components/list.css6
-rw-r--r--packages/ui/src/components/toast.css28
-rw-r--r--packages/ui/src/components/toast.tsx42
4 files changed, 262 insertions, 166 deletions
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index e9f10e3a2..4a3fa766b 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createMemo, For, Match, ParentProps, Show, Switch, type JSX } from "solid-js"
+import { createEffect, createMemo, For, Match, onMount, ParentProps, Show, Switch, type JSX } from "solid-js"
import { DateTime } from "luxon"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
@@ -17,9 +17,9 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { getFilename } from "@opencode-ai/util/path"
import { Select } from "@opencode-ai/ui/select"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { Session, Project, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
+import { Session, Project, ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
-import { createStore } from "solid-js/store"
+import { createStore, produce } from "solid-js/store"
import {
DragDropProvider,
DragDropSensors,
@@ -40,6 +40,7 @@ import { List, ListRef } from "@opencode-ai/ui/list"
import { Input } from "@opencode-ai/ui/input"
import { showToast, Toast } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
+import { Spinner } from "@opencode-ai/ui/spinner"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -618,9 +619,6 @@ export default function Layout(props: ParentProps) {
</Show>
<Show when={layout.dialog.opened() === "connect"}>
{iife(() => {
- const [store, setStore] = createStore({
- method: undefined as undefined | ProviderAuthMethod,
- })
const providerID = createMemo(() => layout.connect.provider()!)
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === providerID())!)
const methods = createMemo(
@@ -632,12 +630,61 @@ export default function Layout(props: ParentProps) {
},
],
)
- if (methods().length === 1) {
- setStore("method", methods()[0])
+ const [store, setStore] = createStore({
+ method: undefined as undefined | ProviderAuthMethod,
+ authorization: undefined as undefined | ProviderAuthAuthorization,
+ state: "pending" as undefined | "pending" | "complete" | "error",
+ error: undefined as string | undefined,
+ })
+
+ async function selectMethod(index: number) {
+ const method = methods()[index]
+ setStore(
+ produce((draft) => {
+ draft.method = method
+ draft.authorization = undefined
+ draft.state = undefined
+ draft.error = undefined
+ }),
+ )
+
+ if (method.type === "oauth") {
+ setStore("state", "pending")
+ const start = Date.now()
+ await globalSDK.client.provider.oauth
+ .authorize({
+ providerID: providerID(),
+ method: index,
+ })
+ .then((x) => {
+ const elapsed = Date.now() - start
+ const delay = 1000 - elapsed
+
+ if (delay > 0) {
+ setTimeout(() => {
+ setStore("state", "complete")
+ setStore("authorization", x.data!)
+ }, delay)
+ return
+ }
+ setStore("state", "complete")
+ setStore("authorization", x.data!)
+ })
+ .catch((e) => {
+ setStore("state", "error")
+ setStore("error", String(e))
+ })
+ }
}
+ onMount(() => {
+ if (methods().length === 1) {
+ selectMethod(0)
+ }
+ })
+
let listRef: ListRef | undefined
- const handleKey = (e: KeyboardEvent) => {
+ function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") return
listRef?.onKeyDown(e)
}
@@ -661,7 +708,16 @@ export default function Layout(props: ParentProps) {
icon="arrow-left"
variant="ghost"
onClick={() => {
- if (store.method && methods.length > 1) {
+ if (methods().length === 1) {
+ layout.dialog.open("provider")
+ return
+ }
+ if (store.authorization) {
+ setStore("authorization", undefined)
+ setStore("method", undefined)
+ return
+ }
+ if (store.method) {
setStore("method", undefined)
return
}
@@ -677,154 +733,152 @@ export default function Layout(props: ParentProps) {
<ProviderIcon id={providerID() as IconName} class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">Connect {provider().name}</div>
</div>
- <Switch>
- <Match when={store.method === undefined}>
- <div class="px-2.5 text-14-regular text-text-base">
- Select login method for {provider().name}.
- </div>
- <div class="">
- <Input hidden type="text" class="opacity-0 size-0" autofocus onKeyDown={handleKey} />
- <List
- ref={(ref) => (listRef = ref)}
- items={methods}
- key={(m) => m?.label}
- onSelect={(method) => {
- if (!method) return
- setStore("method", method)
-
- if (method.type === "oauth") {
- // const result = await sdk.client.provider.oauth.authorize({
- // providerID: provider.id,
- // method: index,
- // })
- // if (result.data?.method === "code") {
- // dialog.replace(() => (
- // <CodeMethod
- // providerID={provider.id}
- // title={method.label}
- // index={index}
- // authorization={result.data!}
- // />
- // ))
- // }
- // if (result.data?.method === "auto") {
- // dialog.replace(() => (
- // <AutoMethod
- // providerID={provider.id}
- // title={method.label}
- // index={index}
- // authorization={result.data!}
- // />
- // ))
- // }
- }
- if (method.type === "api") {
- // return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
- }
- }}
- >
- {(i) => (
- <div class="w-full flex items-center gap-x-2.5">
- {/* TODO: add checkmark thing */}
- <span>{i.label}</span>
- </div>
- )}
- </List>
- </div>
- </Match>
- <Match when={store.method?.type === "api"}>
- {iife(() => {
- const [formStore, setFormStore] = createStore({
- value: "",
- error: undefined as string | undefined,
- })
+ <div class="px-2.5 pb-10 flex flex-col gap-6">
+ <Switch>
+ <Match when={store.method === undefined}>
+ <div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
+ <div class="">
+ <Input hidden type="text" class="opacity-0 size-0" autofocus onKeyDown={handleKey} />
+ <List
+ ref={(ref) => (listRef = ref)}
+ items={methods}
+ key={(m) => m?.label}
+ onSelect={async (method, index) => {
+ if (!method) return
+ selectMethod(index)
+ }}
+ >
+ {(i) => (
+ <div class="w-full flex items-center gap-x-4">
+ <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
+ <div
+ class="w-2.5 h-0.5 bg-icon-strong-base hidden"
+ data-slot="list-item-extra-icon"
+ />
+ </div>
+ {/* TODO: add checkmark thing */}
+ <span>{i.label}</span>
+ </div>
+ )}
+ </List>
+ </div>
+ </Match>
+ <Match when={store.state === "pending"}>
+ <div class="text-14-regular text-text-base">
+ <div class="flex items-center gap-x-4">
+ <Spinner />
+ <span>Authorization in progress...</span>
+ </div>
+ </div>
+ </Match>
+ <Match when={store.state === "error"}>
+ <div class="text-14-regular text-text-base">
+ <div class="flex items-center gap-x-4">
+ <Icon name="circle-ban-sign" class="text-icon-critical-base" />
+ <span>Authorization failed: {store.error}</span>
+ </div>
+ </div>
+ </Match>
+ <Match when={store.method?.type === "api"}>
+ {iife(() => {
+ const [formStore, setFormStore] = createStore({
+ value: "",
+ error: undefined as string | undefined,
+ })
- async function handleSubmit(e: SubmitEvent) {
- e.preventDefault()
+ async function handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
- const form = e.currentTarget as HTMLFormElement
- const formData = new FormData(form)
- const apiKey = formData.get("apiKey") as string
+ const form = e.currentTarget as HTMLFormElement
+ const formData = new FormData(form)
+ const apiKey = formData.get("apiKey") as string
- if (!apiKey?.trim()) {
- setFormStore("error", "API key is required")
- return
- }
+ if (!apiKey?.trim()) {
+ setFormStore("error", "API key is required")
+ return
+ }
- setFormStore("error", undefined)
- await globalSDK.client.auth.set({
- providerID: providerID(),
- auth: {
- type: "api",
- key: apiKey,
- },
- })
- await globalSDK.client.global.dispose()
- setTimeout(() => {
- showToast({
- variant: "success",
- icon: "circle-check",
- title: `${provider().name} connected`,
- description: `${provider().name} models are now available to use.`,
+ setFormStore("error", undefined)
+ await globalSDK.client.auth.set({
+ providerID: providerID(),
+ auth: {
+ type: "api",
+ key: apiKey,
+ },
})
- layout.connect.complete()
- }, 500)
- }
+ await globalSDK.client.global.dispose()
+ setTimeout(() => {
+ showToast({
+ variant: "success",
+ icon: "circle-check",
+ title: `${provider().name} connected`,
+ description: `${provider().name} models are now available to use.`,
+ })
+ layout.connect.complete()
+ }, 500)
+ }
- return (
- <div class="px-2.5 pb-10 flex flex-col gap-6">
- <Switch>
- <Match when={provider().id === "opencode"}>
- <div class="flex flex-col gap-4">
- <div class="text-14-regular text-text-base">
- OpenCode Zen gives you access to a curated set of reliable optimized models for
- coding agents.
+ return (
+ <div class="flex flex-col gap-6">
+ <Switch>
+ <Match when={provider().id === "opencode"}>
+ <div class="flex flex-col gap-4">
+ <div class="text-14-regular text-text-base">
+ OpenCode Zen gives you access to a curated set of reliable optimized models for
+ coding agents.
+ </div>
+ <div class="text-14-regular text-text-base">
+ With a single API key you’ll get access to models such as Claude, GPT, Gemini,
+ GLM and more.
+ </div>
+ <div class="text-14-regular text-text-base">
+ Visit{" "}
+ <button
+ tabIndex={-1}
+ class="text-text-strong underline"
+ onClick={() => platform.openLink("https://opencode.ai/zen")}
+ >
+ opencode.ai/zen
+ </button>{" "}
+ to collect your API key.
+ </div>
</div>
+ </Match>
+ <Match when={true}>
<div class="text-14-regular text-text-base">
- With a single API key you’ll get access to models such as Claude, GPT, Gemini, GLM
- and more.
+ Enter your {provider().name} API key to connect your account and use{" "}
+ {provider().name} models in OpenCode.
</div>
- <div class="text-14-regular text-text-base">
- Visit{" "}
- <button
- tabIndex={-1}
- class="text-text-strong underline"
- onClick={() => platform.openLink("https://opencode.ai/zen")}
- >
- opencode.ai/zen
- </button>{" "}
- to collect your API key.
- </div>
- </div>
- </Match>
- <Match when={true}>
- <div class="text-14-regular text-text-base">
- Enter your {provider().name} API key to connect your account and use{" "}
- {provider().name} models in OpenCode.
- </div>
- </Match>
- </Switch>
- <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
- <Input
- autofocus
- type="text"
- label={`${provider().name} API key`}
- placeholder="API key"
- name="apiKey"
- value={formStore.value}
- onChange={setFormStore.bind(null, "value")}
- validationState={formStore.error ? "invalid" : undefined}
- error={formStore.error}
- />
- <Button class="w-auto" type="submit" size="large" variant="primary">
- Submit
- </Button>
- </form>
- </div>
- )
- })}
- </Match>
- </Switch>
+ </Match>
+ </Switch>
+ <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
+ <Input
+ autofocus
+ type="text"
+ label={`${provider().name} API key`}
+ placeholder="API key"
+ name="apiKey"
+ value={formStore.value}
+ onChange={setFormStore.bind(null, "value")}
+ validationState={formStore.error ? "invalid" : undefined}
+ error={formStore.error}
+ />
+ <Button class="w-auto" type="submit" size="large" variant="primary">
+ Submit
+ </Button>
+ </form>
+ </div>
+ )
+ })}
+ </Match>
+ <Match when={store.method?.type === "oauth"}>
+ <Switch>
+ <Match when={store.authorization?.method === "code"}>Code {store.authorization?.url}</Match>
+ <Match when={store.authorization?.method === "auto"}>Auto {store.authorization?.url}</Match>
+ </Switch>
+ </Match>
+ </Switch>
+ </div>
</div>
</Dialog.Body>
</Dialog>
diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css
index 38dcb773b..783b0ef4a 100644
--- a/packages/ui/src/components/list.css
+++ b/packages/ui/src/components/list.css
@@ -98,17 +98,13 @@
display: block;
}
[data-slot="list-item-extra-icon"] {
+ display: block !important;
color: var(--icon-strong-base) !important;
}
}
&:active {
background: var(--surface-raised-base-active);
}
- &:hover {
- [data-slot="list-item-extra-icon"] {
- color: var(--icon-strong-base) !important;
- }
- }
}
}
}
diff --git a/packages/ui/src/components/toast.css b/packages/ui/src/components/toast.css
index 2c55a4b06..3389f477a 100644
--- a/packages/ui/src/components/toast.css
+++ b/packages/ui/src/components/toast.css
@@ -120,6 +120,34 @@
margin: 0;
}
+ [data-slot="toast-actions"] {
+ display: flex;
+ gap: 16px;
+ margin-top: 8px;
+ }
+
+ [data-slot="toast-action"] {
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+
+ color: rgba(253, 252, 252, 0.94);
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large);
+ letter-spacing: var(--letter-spacing-normal);
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &:last-child {
+ color: rgba(253, 249, 249, 0.5);
+ }
+ }
+
[data-slot="toast-close-button"] {
flex-shrink: 0;
}
diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx
index b6c9f8b08..5869f8a6b 100644
--- a/packages/ui/src/components/toast.tsx
+++ b/packages/ui/src/components/toast.tsx
@@ -57,6 +57,10 @@ function ToastDescription(props: ToastDescriptionProps & ComponentProps<"div">)
return <Kobalte.Description data-slot="toast-description" {...props} />
}
+function ToastActions(props: ComponentProps<"div">) {
+ return <div data-slot="toast-actions" {...props} />
+}
+
function ToastCloseButton(props: ToastCloseButtonProps & ComponentProps<"button">) {
return <Kobalte.CloseButton data-slot="toast-close-button" as={IconButton} icon="close" variant="ghost" {...props} />
}
@@ -75,6 +79,7 @@ export const Toast = Object.assign(ToastRoot, {
Content: ToastContent,
Title: ToastTitle,
Description: ToastDescription,
+ Actions: ToastActions,
CloseButton: ToastCloseButton,
ProgressTrack: ToastProgressTrack,
ProgressFill: ToastProgressFill,
@@ -84,31 +89,44 @@ export { toaster }
export type ToastVariant = "default" | "success" | "error" | "loading"
+export interface ToastAction {
+ label: string
+ onClick: () => void
+}
+
export interface ToastOptions {
title?: string
description?: string
icon?: IconProps["name"]
variant?: ToastVariant
duration?: number
+ actions?: ToastAction[]
}
export function showToast(options: ToastOptions | string) {
const opts = typeof options === "string" ? { description: options } : options
return toaster.show((props) => (
<Toast toastId={props.toastId} duration={opts.duration} data-variant={opts.variant ?? "default"}>
- <div data-slot="toast-inner">
- <Show when={opts.icon}>
- <Toast.Icon name={opts.icon!} />
+ <Show when={opts.icon}>
+ <Toast.Icon name={opts.icon!} />
+ </Show>
+ <Toast.Content>
+ <Show when={opts.title}>
+ <Toast.Title>{opts.title}</Toast.Title>
+ </Show>
+ <Show when={opts.description}>
+ <Toast.Description>{opts.description}</Toast.Description>
</Show>
- <Toast.Content>
- <Show when={opts.title}>
- <Toast.Title>{opts.title}</Toast.Title>
- </Show>
- <Show when={opts.description}>
- <Toast.Description>{opts.description}</Toast.Description>
- </Show>
- </Toast.Content>
- </div>
+ <Show when={opts.actions?.length}>
+ <Toast.Actions>
+ {opts.actions!.map((action) => (
+ <button data-slot="toast-action" onClick={action.onClick}>
+ {action.label}
+ </button>
+ ))}
+ </Toast.Actions>
+ </Show>
+ </Toast.Content>
<Toast.CloseButton />
</Toast>
))