Browse Source

wip(desktop): progress

Adam 2 months ago
parent
commit
4ae7e1b19c

+ 11 - 3
packages/desktop/src/pages/layout.tsx

@@ -38,6 +38,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
 import { iife } from "@opencode-ai/util/iife"
 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"
 
 export default function Layout(props: ParentProps) {
@@ -760,6 +761,12 @@ export default function Layout(props: ParentProps) {
                             })
                             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)
                           }
@@ -792,8 +799,8 @@ export default function Layout(props: ParentProps) {
                                 </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.
+                                    Enter your {provider().name} API key to connect your account and use{" "}
+                                    {provider().name} models in OpenCode.
                                   </div>
                                 </Match>
                               </Switch>
@@ -801,7 +808,7 @@ export default function Layout(props: ParentProps) {
                                 <Input
                                   autofocus
                                   type="text"
-                                  label={`${provider.name} API key`}
+                                  label={`${provider().name} API key`}
                                   placeholder="API key"
                                   name="apiKey"
                                   value={formStore.value}
@@ -825,6 +832,7 @@ export default function Layout(props: ParentProps) {
           })}
         </Show>
       </div>
+      <Toast.Region />
     </div>
   )
 }

+ 1 - 0
packages/ui/src/components/icon.tsx

@@ -46,6 +46,7 @@ const icons = {
   "layout-bottom-partial": `<path d="M2.5 17.5L2.5 12.2059L17.5 12.2059L17.5 17.5L2.5 17.5Z" fill="currentColor" fill-opacity="40%" /><path d="M2.5 17.5L2.5 2.5M2.5 17.5L17.5 17.5M2.5 17.5L2.5 12.2059M2.5 2.5L17.5 2.5M2.5 2.5L2.5 12.2059M17.5 2.5L17.5 17.5M17.5 2.5L17.5 12.2059M17.5 17.5L17.5 12.2059M17.5 12.2059L2.5 12.2059" stroke="currentColor" stroke-linecap="square"/>`,
   "layout-bottom-full": `<path d="M2.5 17.5L2.5 12.2059L17.5 12.2059L17.5 17.5L2.5 17.5Z" fill="currentColor"/><path d="M2.5 17.5L2.5 2.5M2.5 17.5L17.5 17.5M2.5 17.5L2.5 12.2059M2.5 2.5L17.5 2.5M2.5 2.5L2.5 12.2059M17.5 2.5L17.5 17.5M17.5 2.5L17.5 12.2059M17.5 17.5L17.5 12.2059M17.5 12.2059L2.5 12.2059" stroke="currentColor" stroke-linecap="square"/>`,
   "dot-grid": `<path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" fill="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" fill="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" fill="currentColor"/><path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" stroke="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" stroke="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" stroke="currentColor"/>`,
+  "circle-check": `<path d="M12.4987 7.91732L8.7487 12.5007L7.08203 10.834M17.9154 10.0007C17.9154 14.3729 14.371 17.9173 9.9987 17.9173C5.62644 17.9173 2.08203 14.3729 2.08203 10.0007C2.08203 5.6284 5.62644 2.08398 9.9987 2.08398C14.371 2.08398 17.9154 5.6284 17.9154 10.0007Z" stroke="currentColor" stroke-linecap="square"/>`,
 }
 
 export interface IconProps extends ComponentProps<"svg"> {

+ 175 - 0
packages/ui/src/components/toast.css

@@ -0,0 +1,175 @@
+[data-component="toast-region"] {
+  position: fixed;
+  bottom: 32px;
+  right: 32px;
+  z-index: 1000;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  max-width: 400px;
+  width: 100%;
+  pointer-events: none;
+
+  [data-slot="toast-list"] {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    list-style: none;
+    margin: 0;
+    padding: 0;
+  }
+}
+
+[data-component="toast"] {
+  display: flex;
+  align-items: flex-start;
+  gap: 20px;
+  padding: 16px 20px;
+  pointer-events: auto;
+  transition: all 150ms ease-out;
+
+  border-radius: var(--radius-lg);
+  border: 1px solid var(--border-weak-base);
+  background: var(--surface-float-base);
+  color: rgba(253, 252, 252, 0.94);
+  box-shadow: var(--shadow-md);
+
+  [data-slot="toast-inner"] {
+    display: flex;
+    align-items: flex-start;
+    gap: 10px;
+  }
+
+  &[data-opened] {
+    animation: toastPopIn 150ms ease-out;
+  }
+
+  &[data-closed] {
+    animation: toastPopOut 100ms ease-in forwards;
+  }
+
+  &[data-swipe="move"] {
+    transform: translateX(var(--kb-toast-swipe-move-x));
+  }
+
+  &[data-swipe="cancel"] {
+    transform: translateX(0);
+    transition: transform 200ms ease-out;
+  }
+
+  &[data-swipe="end"] {
+    animation: toastSwipeOut 100ms ease-out forwards;
+  }
+
+  &[data-variant="success"] {
+    border-color: var(--color-semantic-positive);
+  }
+
+  &[data-variant="error"] {
+    border-color: var(--color-semantic-danger);
+  }
+
+  &[data-variant="loading"] {
+    border-color: var(--color-semantic-info);
+  }
+
+  [data-slot="toast-icon"] {
+    flex-shrink: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    [data-component="icon"] {
+      color: rgba(253, 252, 252, 0.94);
+    }
+  }
+
+  [data-slot="toast-content"] {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    min-width: 0;
+  }
+
+  [data-slot="toast-title"] {
+    color: rgba(253, 252, 252, 0.94);
+
+    /* text-14-medium */
+    font-family: var(--font-family-sans);
+    font-size: 14px;
+    font-style: normal;
+    font-weight: var(--font-weight-medium);
+    line-height: var(--line-height-large); /* 142.857% */
+    letter-spacing: var(--letter-spacing-normal);
+
+    margin: 0;
+  }
+
+  [data-slot="toast-description"] {
+    color: rgba(253, 249, 249, 0.7);
+
+    /* text-14-regular */
+    font-family: var(--font-family-sans);
+    font-size: var(--font-size-base);
+    font-style: normal;
+    font-weight: var(--font-weight-regular);
+    line-height: var(--line-height-x-large); /* 171.429% */
+    letter-spacing: var(--letter-spacing-normal);
+
+    margin: 0;
+  }
+
+  [data-slot="toast-close-button"] {
+    flex-shrink: 0;
+  }
+
+  [data-slot="toast-progress-track"] {
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    height: 3px;
+    background-color: var(--surface-base);
+    border-radius: 0 0 var(--radius-lg) var(--radius-lg);
+    overflow: hidden;
+  }
+
+  [data-slot="toast-progress-fill"] {
+    height: 100%;
+    width: var(--kb-toast-progress-fill-width);
+    background-color: var(--color-primary);
+    transition: width 250ms linear;
+  }
+}
+
+@keyframes toastPopIn {
+  from {
+    opacity: 0;
+    transform: translateY(20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes toastPopOut {
+  from {
+    opacity: 1;
+    transform: translateY(0);
+  }
+  to {
+    opacity: 0;
+    transform: translateY(20px);
+  }
+}
+
+@keyframes toastSwipeOut {
+  from {
+    transform: translateX(var(--kb-toast-swipe-end-x));
+  }
+  to {
+    transform: translateX(100%);
+  }
+}

+ 142 - 0
packages/ui/src/components/toast.tsx

@@ -0,0 +1,142 @@
+import { Toast as Kobalte, toaster } from "@kobalte/core/toast"
+import type { ToastRootProps, ToastCloseButtonProps, ToastTitleProps, ToastDescriptionProps } from "@kobalte/core/toast"
+import type { ComponentProps, JSX } from "solid-js"
+import { Show } from "solid-js"
+import { Portal } from "solid-js/web"
+import { Icon, type IconProps } from "./icon"
+import { IconButton } from "./icon-button"
+
+export interface ToastRegionProps extends ComponentProps<typeof Kobalte.Region> {}
+
+function ToastRegion(props: ToastRegionProps) {
+  return (
+    <Portal>
+      <Kobalte.Region data-component="toast-region" {...props}>
+        <Kobalte.List data-slot="toast-list" />
+      </Kobalte.Region>
+    </Portal>
+  )
+}
+
+export interface ToastRootComponentProps extends ToastRootProps {
+  class?: string
+  classList?: ComponentProps<"li">["classList"]
+  children?: JSX.Element
+}
+
+function ToastRoot(props: ToastRootComponentProps) {
+  return (
+    <Kobalte
+      data-component="toast"
+      classList={{
+        ...(props.classList ?? {}),
+        [props.class ?? ""]: !!props.class,
+      }}
+      {...props}
+    />
+  )
+}
+
+function ToastIcon(props: { name: IconProps["name"] }) {
+  return (
+    <div data-slot="toast-icon">
+      <Icon name={props.name} />
+    </div>
+  )
+}
+
+function ToastContent(props: ComponentProps<"div">) {
+  return <div data-slot="toast-content" {...props} />
+}
+
+function ToastTitle(props: ToastTitleProps & ComponentProps<"div">) {
+  return <Kobalte.Title data-slot="toast-title" {...props} />
+}
+
+function ToastDescription(props: ToastDescriptionProps & ComponentProps<"div">) {
+  return <Kobalte.Description data-slot="toast-description" {...props} />
+}
+
+function ToastCloseButton(props: ToastCloseButtonProps & ComponentProps<"button">) {
+  return <Kobalte.CloseButton data-slot="toast-close-button" as={IconButton} icon="close" variant="ghost" {...props} />
+}
+
+function ToastProgressTrack(props: ComponentProps<typeof Kobalte.ProgressTrack>) {
+  return <Kobalte.ProgressTrack data-slot="toast-progress-track" {...props} />
+}
+
+function ToastProgressFill(props: ComponentProps<typeof Kobalte.ProgressFill>) {
+  return <Kobalte.ProgressFill data-slot="toast-progress-fill" {...props} />
+}
+
+export const Toast = Object.assign(ToastRoot, {
+  Region: ToastRegion,
+  Icon: ToastIcon,
+  Content: ToastContent,
+  Title: ToastTitle,
+  Description: ToastDescription,
+  CloseButton: ToastCloseButton,
+  ProgressTrack: ToastProgressTrack,
+  ProgressFill: ToastProgressFill,
+})
+
+export { toaster }
+
+export type ToastVariant = "default" | "success" | "error" | "loading"
+
+export interface ToastOptions {
+  title?: string
+  description?: string
+  icon?: IconProps["name"]
+  variant?: ToastVariant
+  duration?: number
+}
+
+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>
+        <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>
+      <Toast.CloseButton />
+    </Toast>
+  ))
+}
+
+export interface ToastPromiseOptions<T, U = unknown> {
+  loading?: JSX.Element
+  success?: (data: T) => JSX.Element
+  error?: (error: U) => JSX.Element
+}
+
+export function showPromiseToast<T, U = unknown>(
+  promise: Promise<T> | (() => Promise<T>),
+  options: ToastPromiseOptions<T, U>,
+) {
+  return toaster.promise(promise, (props) => (
+    <Toast
+      toastId={props.toastId}
+      data-variant={props.state === "pending" ? "loading" : props.state === "fulfilled" ? "success" : "error"}
+    >
+      <Toast.Content>
+        <Toast.Description>
+          {props.state === "pending" && options.loading}
+          {props.state === "fulfilled" && options.success?.(props.data!)}
+          {props.state === "rejected" && options.error?.(props.error)}
+        </Toast.Description>
+      </Toast.Content>
+      <Toast.CloseButton />
+    </Toast>
+  ))
+}

+ 1 - 0
packages/ui/src/styles/index.css

@@ -38,6 +38,7 @@
 @import "../components/sticky-accordion-header.css" layer(components);
 @import "../components/tabs.css" layer(components);
 @import "../components/tag.css" layer(components);
+@import "../components/toast.css" layer(components);
 @import "../components/tooltip.css" layer(components);
 @import "../components/typewriter.css" layer(components);