Browse Source

wip(desktop): progress

Adam 2 months ago
parent
commit
e845eedbc3

+ 203 - 149
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>

+ 1 - 5
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;
-          }
-        }
       }
     }
   }

+ 28 - 0
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;
   }

+ 30 - 12
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>
   ))