diff options
| author | Adam <[email protected]> | 2025-12-11 11:28:34 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-11 13:42:47 -0600 |
| commit | e845eedbc325b05a19679bc439a57cc0fbf23aa3 (patch) | |
| tree | f2bd6686664870ef11aba12a9946356d9df149c4 | |
| parent | 4ae7e1b19c3915e3e9b1a39195d54c4721836b03 (diff) | |
| download | opencode-e845eedbc325b05a19679bc439a57cc0fbf23aa3.tar.gz opencode-e845eedbc325b05a19679bc439a57cc0fbf23aa3.zip | |
wip(desktop): progress
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 352 | ||||
| -rw-r--r-- | packages/ui/src/components/list.css | 6 | ||||
| -rw-r--r-- | packages/ui/src/components/toast.css | 28 | ||||
| -rw-r--r-- | packages/ui/src/components/toast.tsx | 42 |
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> )) |
