summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src/pages
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-14 05:40:43 -0600
committerAdam <[email protected]>2025-12-14 21:38:58 -0600
commit4a8e8f537ca688cca52674a619065f577cbd3f9b (patch)
tree2255aa8ab0a9bb9d90519a1ac6d35e60b1e3e6f0 /packages/desktop/src/pages
parenta68bee7878d78ac7d480d6fbbd3225759e695c61 (diff)
downloadopencode-4a8e8f537ca688cca52674a619065f577cbd3f9b.tar.gz
opencode-4a8e8f537ca688cca52674a619065f577cbd3f9b.zip
wip(desktop): progress
Diffstat (limited to 'packages/desktop/src/pages')
-rw-r--r--packages/desktop/src/pages/layout.tsx480
1 files changed, 8 insertions, 472 deletions
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index ef51361ba..63ee5b2aa 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -1,16 +1,4 @@
-import {
- createEffect,
- createMemo,
- createSignal,
- For,
- Match,
- onCleanup,
- onMount,
- ParentProps,
- Show,
- Switch,
- type JSX,
-} from "solid-js"
+import { createEffect, createMemo, createSignal, For, Match, ParentProps, Show, Switch, type JSX } from "solid-js"
import { DateTime } from "luxon"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout, getAvatarColors } from "@/context/layout"
@@ -20,14 +8,13 @@ import { Avatar } from "@opencode-ai/ui/avatar"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
-import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { getFilename } from "@opencode-ai/util/path"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { Session, Project, ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
+import { Session, Project } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
import { createStore, produce } from "solid-js/store"
import {
@@ -40,21 +27,14 @@ import {
useDragDropContext,
} from "@thisbeyond/solid-dnd"
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
-import { SelectDialog } from "@opencode-ai/ui/select-dialog"
-import { Tag } from "@opencode-ai/ui/tag"
-import { IconName } from "@opencode-ai/ui/icons/provider"
-import { popularProviders, useProviders } from "@/hooks/use-providers"
-import { Dialog } from "@opencode-ai/ui/dialog"
-import { iife } from "@opencode-ai/util/iife"
-import { Link } from "@/components/link"
-import { List, ListRef } from "@opencode-ai/ui/list"
-import { TextField } from "@opencode-ai/ui/text-field"
-import { showToast, Toast } from "@opencode-ai/ui/toast"
+import { useProviders } from "@/hooks/use-providers"
+import { Toast } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
-import { Spinner } from "@opencode-ai/ui/spinner"
import { useNotification } from "@/context/notification"
import { Binary } from "@opencode-ai/util/binary"
import { Header } from "@/components/header"
+import { DialogProvider } from "@/components/dialog-provider"
+import { DialogConnect } from "@/components/dialog-connect"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -576,454 +556,10 @@ export default function Layout(props: ParentProps) {
</div>
<main class="size-full overflow-x-hidden flex flex-col items-start">{props.children}</main>
<Show when={layout.dialog.opened() === "provider"}>
- <SelectDialog
- defaultOpen
- title="Connect provider"
- placeholder="Search providers"
- activeIcon="plus-small"
- key={(x) => x?.id}
- items={providers.all}
- filterKeys={["id", "name"]}
- groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")}
- sortBy={(a, b) => {
- if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
- return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
- return a.name.localeCompare(b.name)
- }}
- sortGroupsBy={(a, b) => {
- if (a.category === "Popular" && b.category !== "Popular") return -1
- if (b.category === "Popular" && a.category !== "Popular") return 1
- return 0
- }}
- onSelect={(x) => {
- if (!x) return
- layout.dialog.connect(x.id)
- }}
- onOpenChange={(open) => {
- if (open) {
- layout.dialog.open("provider")
- } else {
- layout.dialog.close("provider")
- }
- }}
- >
- {(i) => (
- <div class="px-1.25 w-full flex items-center gap-x-4">
- <ProviderIcon
- data-slot="list-item-extra-icon"
- id={i.id as IconName}
- // TODO: clean this up after we update icon in models.dev
- classList={{
- "text-icon-weak-base": true,
- "size-4 mx-0.5": i.id === "opencode",
- "size-5": i.id !== "opencode",
- }}
- />
- <span>{i.name}</span>
- <Show when={i.id === "opencode"}>
- <Tag>Recommended</Tag>
- </Show>
- <Show when={i.id === "anthropic"}>
- <div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
- </Show>
- </div>
- )}
- </SelectDialog>
+ <DialogProvider />
</Show>
<Show when={layout.dialog.opened() === "connect"}>
- {iife(() => {
- const providerID = createMemo(() => layout.connect.provider()!)
- const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === providerID())!)
- const methods = createMemo(
- () =>
- globalSync.data.provider_auth[providerID()] ?? [
- {
- type: "api",
- label: "API key",
- },
- ],
- )
- 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,
- })
-
- const methodIndex = createMemo(() => methods().findIndex((x) => x.label === store.method?.label))
-
- 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,
- },
- { throwOnError: true },
- )
- .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))
- })
- }
- }
-
- let listRef: ListRef | undefined
- function handleKey(e: KeyboardEvent) {
- if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
- return
- }
- if (e.key === "Escape") return
- listRef?.onKeyDown(e)
- }
-
- onMount(() => {
- if (methods().length === 1) {
- selectMethod(0)
- }
-
- document.addEventListener("keydown", handleKey)
- onCleanup(() => {
- document.removeEventListener("keydown", handleKey)
- })
- })
-
- async function complete() {
- 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 (
- <Dialog
- modal
- defaultOpen
- onOpenChange={(open) => {
- if (open) {
- layout.dialog.open("connect")
- } else {
- layout.dialog.close("connect")
- }
- }}
- >
- <Dialog.Header class="px-4.5">
- <Dialog.Title class="flex items-center">
- <IconButton
- tabIndex={-1}
- icon="arrow-left"
- variant="ghost"
- onClick={() => {
- 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
- }
- layout.dialog.open("provider")
- }}
- />
- </Dialog.Title>
- <Dialog.CloseButton tabIndex={-1} />
- </Dialog.Header>
- <Dialog.Body>
- <div class="flex flex-col gap-6 px-2.5 pb-3">
- <div class="px-2.5 flex gap-4 items-center">
- <ProviderIcon id={providerID() as IconName} class="size-5 shrink-0 icon-strong-base" />
- <div class="text-16-medium text-text-strong">
- <Switch>
- <Match
- when={providerID() === "anthropic" && store.method?.label?.toLowerCase().includes("max")}
- >
- Login with Claude Pro/Max
- </Match>
- <Match when={true}>Connect {provider().name}</Match>
- </Switch>
- </div>
- </div>
- <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="">
- <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>
- <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()
-
- 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 complete()
- }
-
- 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{" "}
- <Link href="https://opencode.ai/zen" tabIndex={-1}>
- opencode.ai/zen
- </Link>{" "}
- 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">
- <TextField
- 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"}>
- {iife(() => {
- const [formStore, setFormStore] = createStore({
- value: "",
- error: undefined as string | undefined,
- })
-
- onMount(() => {
- if (store.authorization?.method === "code" && store.authorization?.url) {
- platform.openLink(store.authorization.url)
- }
- })
-
- async function handleSubmit(e: SubmitEvent) {
- e.preventDefault()
-
- const form = e.currentTarget as HTMLFormElement
- const formData = new FormData(form)
- const code = formData.get("code") as string
-
- if (!code?.trim()) {
- setFormStore("error", "Authorization code is required")
- return
- }
-
- setFormStore("error", undefined)
- const { error } = await globalSDK.client.provider.oauth.callback({
- providerID: providerID(),
- method: methodIndex(),
- code,
- })
- if (!error) {
- await complete()
- return
- }
- setFormStore("error", "Invalid authorization code")
- }
-
- return (
- <div class="flex flex-col gap-6">
- <div class="text-14-regular text-text-base">
- Visit <Link href={store.authorization!.url}>this link</Link> to collect your
- authorization code to connect your account and use {provider().name} models in
- OpenCode.
- </div>
- <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
- <TextField
- autofocus
- type="text"
- label={`${store.method?.label} authorization code`}
- placeholder="Authorization code"
- name="code"
- 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.authorization?.method === "auto"}>
- {iife(() => {
- const code = createMemo(() => {
- const instructions = store.authorization?.instructions
- if (instructions?.includes(":")) {
- return instructions?.split(":")[1]?.trim()
- }
- return instructions
- })
-
- onMount(async () => {
- const result = await globalSDK.client.provider.oauth.callback({
- providerID: providerID(),
- method: methodIndex(),
- })
- if (result.error) {
- // TODO: show error
- layout.dialog.close("connect")
- return
- }
- await complete()
- })
-
- return (
- <div class="flex flex-col gap-6">
- <div class="text-14-regular text-text-base">
- Visit <Link href={store.authorization!.url}>this link</Link> and enter the code
- below to connect your account and use {provider().name} models in OpenCode.
- </div>
- <TextField
- label="Confirmation code"
- class="font-mono"
- value={code()}
- readOnly
- copyable
- />
- <div class="text-14-regular text-text-base flex items-center gap-4">
- <Spinner />
- <span>Waiting for authorization...</span>
- </div>
- </div>
- )
- })}
- </Match>
- </Switch>
- </Match>
- </Switch>
- </div>
- </div>
- </Dialog.Body>
- </Dialog>
- )
- })}
+ <DialogConnect />
</Show>
</div>
<Toast.Region />