mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
wip(desktop): progress
This commit is contained in:
parent
4ae7e1b19c
commit
e845eedbc3
4 changed files with 263 additions and 167 deletions
|
|
@ -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 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue