wip(desktop): progress

This commit is contained in:
Adam 2025-12-11 11:28:34 -06:00
parent 4ae7e1b19c
commit e845eedbc3
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
4 changed files with 263 additions and 167 deletions

View file

@ -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)
<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,
})
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!}
// />
// ))
// }
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
if (!apiKey?.trim()) {
setFormStore("error", "API key is required")
return
}
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,
})
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
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.`,
})
layout.connect.complete()
}, 500)
}
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.`,
})
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 youll 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 youll 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>

View file

@ -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;
}
}
}
}
}

View file

@ -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;
}

View file

@ -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>
<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.description}>
<Toast.Description>{opts.description}</Toast.Description>
</Show>
<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>
))