diff options
| author | David Hill <[email protected]> | 2025-12-12 09:44:06 +0000 |
|---|---|---|
| committer | David Hill <[email protected]> | 2025-12-12 09:44:06 +0000 |
| commit | 99158e736bd983ee5c62bfc43032da1bafc45d71 (patch) | |
| tree | 59b1afc0fff72a8c47ca76c90a767aafae7a5f45 /packages/desktop/src/pages | |
| parent | 4c02d515a1e15a99fc009587e821087e042bd45b (diff) | |
| parent | f9d5e1879056dd9507bb1a1645da5b5ede87fcca (diff) | |
| download | opencode-99158e736bd983ee5c62bfc43032da1bafc45d71.tar.gz opencode-99158e736bd983ee5c62bfc43032da1bafc45d71.zip | |
Merge branch 'dev' of https://github.com/sst/opencode into dev
Diffstat (limited to 'packages/desktop/src/pages')
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 533 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session.tsx | 1 |
2 files changed, 366 insertions, 168 deletions
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 257cfc8a3..b997296fa 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -1,7 +1,7 @@ -import { createEffect, createMemo, For, Match, ParentProps, Show, Switch, type JSX } from "solid-js" +import { createEffect, createMemo, For, Match, onCleanup, 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" +import { useLayout, getAvatarColors } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { base64Decode, base64Encode } from "@opencode-ai/util/encode" import { Mark } from "@opencode-ai/ui/logo" @@ -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, @@ -36,9 +36,12 @@ 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 { Input } from "@opencode-ai/ui/input" +import { TextField } from "@opencode-ai/ui/text-field" +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({ @@ -177,7 +180,7 @@ export default function Layout(props: ParentProps) { <Avatar fallback={name()} src={props.project.icon?.url} - background={props.project.icon?.color ?? "var(--surface-info-base)"} + {...getAvatarColors(props.project.icon?.color)} class="size-full" /> </div> @@ -197,7 +200,7 @@ export default function Layout(props: ParentProps) { <Avatar fallback={name()} src={props.project.icon?.url} - background={props.project.icon?.color ?? "var(--surface-info-base)"} + {...getAvatarColors(props.project.icon?.color)} class="size-full" /> </div> @@ -228,7 +231,7 @@ export default function Layout(props: ParentProps) { <Avatar fallback={name()} src={props.project.icon?.url} - background={props.project.icon?.color ?? "var(--surface-info-base)"} + {...getAvatarColors(props.project.icon?.color)} class="size-full group-hover/session:hidden" /> <Icon @@ -487,7 +490,7 @@ export default function Layout(props: ParentProps) { </div> <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3"> <Switch> - <Match when={!providers().paid().length && layout.sidebar.opened()}> + <Match when={!providers.paid().length && layout.sidebar.opened()}> <div class="rounded-md bg-background-stronger shadow-xs-border-base"> <div class="p-3 flex flex-col gap-2"> <div class="text-12-medium text-text-strong">Getting started</div> @@ -533,17 +536,17 @@ export default function Layout(props: ParentProps) { </Button> </Tooltip> </Show> - <Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}> - <Button - disabled - class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2" - variant="ghost" - size="large" - icon="settings-gear" - > - <Show when={layout.sidebar.opened()}>Settings</Show> - </Button> - </Tooltip> + {/* <Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}> */} + {/* <Button */} + {/* disabled */} + {/* class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2" */} + {/* variant="ghost" */} + {/* size="large" */} + {/* icon="settings-gear" */} + {/* > */} + {/* <Show when={layout.sidebar.opened()}>Settings</Show> */} + {/* </Button> */} + {/* </Tooltip> */} <Tooltip placement="right" value="Share feedback" inactive={layout.sidebar.opened()}> <Button as={"a"} @@ -567,7 +570,7 @@ export default function Layout(props: ParentProps) { placeholder="Search providers" activeIcon="plus-small" key={(x) => x?.id} - items={providers().all} + items={providers.all} filterKeys={["id", "name"]} groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} sortBy={(a, b) => { @@ -617,27 +620,102 @@ export default function Layout(props: ParentProps) { </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 providerID = layout.connect.provider()! - const provider = globalSync.data.provider.all.find((x) => x.id === providerID)! - const methods = globalSync.data.provider_auth[providerID] ?? [ - { - type: "api", - label: "API key", - }, - ] - if (methods.length === 1) { - setStore("method", methods[0]) + + 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 - const handleKey = (e: KeyboardEvent) => { + 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 @@ -657,7 +735,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 } @@ -670,145 +757,256 @@ export default function Layout(props: ParentProps) { <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">Connect {provider.name}</div> + <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> - <Show 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 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> - )} - </List> - </div> - </Show> - <Show 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, - auth: { - type: "api", - key: apiKey, - }, - }) - await globalSDK.client.global.dispose() - layout.connect.complete() - } + </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, + }) - 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. - </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. + 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> - <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. + ) + })} + </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> - </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> - ) - })} - </Show> + ) + })} + </Match> + </Switch> + </Match> + </Switch> + </div> </div> </Dialog.Body> </Dialog> @@ -816,6 +1014,7 @@ export default function Layout(props: ParentProps) { })} </Show> </div> + <Toast.Region /> </div> ) } diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 890401723..5dae4ce55 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -415,7 +415,6 @@ export default function Page() { messages={session.messages.user()} current={session.messages.active()} onMessageSelect={session.messages.setActive} - working={session.working()} wide={wide()} /> <SessionTurn |
