diff options
Diffstat (limited to 'packages/desktop/src')
| -rw-r--r-- | packages/desktop/src/components/link.tsx | 17 | ||||
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 147 | ||||
| -rw-r--r-- | packages/desktop/src/context/layout.tsx | 87 | ||||
| -rw-r--r-- | packages/desktop/src/context/local.tsx | 28 | ||||
| -rw-r--r-- | packages/desktop/src/context/session.tsx | 4 | ||||
| -rw-r--r-- | packages/desktop/src/hooks/use-providers.ts | 12 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 533 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session.tsx | 1 |
8 files changed, 547 insertions, 282 deletions
diff --git a/packages/desktop/src/components/link.tsx b/packages/desktop/src/components/link.tsx new file mode 100644 index 000000000..e13c31330 --- /dev/null +++ b/packages/desktop/src/components/link.tsx @@ -0,0 +1,17 @@ +import { ComponentProps, splitProps } from "solid-js" +import { usePlatform } from "@/context/platform" + +export interface LinkProps extends ComponentProps<"button"> { + href: string +} + +export function Link(props: LinkProps) { + const platform = usePlatform() + const [local, rest] = splitProps(props, ["href", "children"]) + + return ( + <button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}> + {local.children} + </button> + ) +} diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 41af8644b..70ee0a739 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,5 +1,17 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createSignal } from "solid-js" +import { + createEffect, + on, + Component, + Show, + For, + onMount, + onCleanup, + Switch, + Match, + createSignal, + createMemo, +} from "solid-js" import { createStore } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" @@ -21,7 +33,6 @@ import { popularProviders, useProviders } from "@/hooks/use-providers" import { Dialog } from "@opencode-ai/ui/dialog" import { List, ListRef } from "@opencode-ai/ui/list" import { iife } from "@opencode-ai/util/iife" -import { Input } from "@opencode-ai/ui/input" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconName } from "@opencode-ai/ui/icons/provider" @@ -470,60 +481,73 @@ export const PromptInput: Component<PromptInputProps> = (props) => { </Button> <Show when={layout.dialog.opened() === "model"}> <Switch> - <Match when={providers().connected().length > 0}> - <SelectDialog - defaultOpen - onOpenChange={(open) => { - if (open) { - layout.dialog.open("model") - } else { - layout.dialog.close("model") - } - }} - title="Select model" - placeholder="Search models" - emptyMessage="No model results" - key={(x) => `${x.provider.id}:${x.id}`} - items={local.model.list()} - current={local.model.current()} - filterKeys={["provider.name", "name", "id"]} - // groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} - groupBy={(x) => x.provider.name} - sortGroupsBy={(a, b) => { - if (a.category === "Recent" && b.category !== "Recent") return -1 - if (b.category === "Recent" && a.category !== "Recent") return 1 - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 - if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 - return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) - }} - onSelect={(x) => - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true }) - } - actions={ - <Button - class="h-7 -my-1 text-14-medium" - icon="plus-small" - tabIndex={-1} - onClick={() => layout.dialog.open("provider")} + <Match when={providers.paid().length > 0}> + {iife(() => { + const models = createMemo(() => + local.model + .list() + .filter((m) => + layout.connect.state() === "complete" ? m.provider.id === layout.connect.provider() : true, + ), + ) + return ( + <SelectDialog + defaultOpen + onOpenChange={(open) => { + if (open) { + layout.dialog.open("model") + } else { + layout.dialog.close("model") + } + }} + title="Select model" + placeholder="Search models" + emptyMessage="No model results" + key={(x) => `${x.provider.id}:${x.id}`} + items={models} + current={local.model.current()} + filterKeys={["provider.name", "name", "id"]} + // groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + if (a.category === "Recent" && b.category !== "Recent") return -1 + if (b.category === "Recent" && a.category !== "Recent") return 1 + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 + if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 + return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + }} + onSelect={(x) => + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + } + actions={ + <Button + class="h-7 -my-1 text-14-medium" + icon="plus-small" + tabIndex={-1} + onClick={() => layout.dialog.open("provider")} + > + Connect provider + </Button> + } > - Connect provider - </Button> - } - > - {(i) => ( - <div class="w-full flex items-center gap-x-2.5"> - <span>{i.name}</span> - <Show when={!i.cost || i.cost?.input === 0}> - <Tag>Free</Tag> - </Show> - <Show when={i.latest}> - <Tag>Latest</Tag> - </Show> - </div> - )} - </SelectDialog> + {(i) => ( + <div class="w-full flex items-center gap-x-2.5"> + <span>{i.name}</span> + <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}> + <Tag>Free</Tag> + </Show> + <Show when={i.latest}> + <Tag>Latest</Tag> + </Show> + </div> + )} + </SelectDialog> + ) + })} </Match> <Match when={true}> {iife(() => { @@ -532,6 +556,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (e.key === "Escape") return listRef?.onKeyDown(e) } + + onMount(() => { + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + return ( <Dialog modal @@ -549,12 +581,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <Dialog.CloseButton tabIndex={-1} /> </Dialog.Header> <Dialog.Body> - <Input hidden type="text" class="opacity-0 size-0" autofocus onKeyDown={handleKey} /> <div class="flex flex-col gap-3 px-2.5"> <div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div> <List ref={(ref) => (listRef = ref)} - items={local.model.list()} + items={local.model.list} current={local.model.current()} key={(x) => `${x.provider.id}:${x.id}`} onSelect={(x) => { @@ -587,7 +618,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <List class="w-full" key={(x) => x?.id} - items={providers().popular()} + items={providers.popular} activeIcon="plus-small" sortBy={(a, b) => { if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 24ba55a53..9cafdce96 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -6,18 +6,26 @@ import { useGlobalSync } from "./global-sync" import { useGlobalSDK } from "./global-sdk" import { Project } from "@opencode-ai/sdk/v2" -const PASTEL_COLORS = [ - "#FCEAFD", // pastel pink - "#FFDFBA", // pastel peach - "#FFFFBA", // pastel yellow - "#BAFFC9", // pastel green - "#EAF6FD", // pastel blue - "#EFEAFD", // pastel lavender - "#FEC8D8", // pastel rose - "#D4F0F0", // pastel cyan - "#FDF0EA", // pastel coral - "#C1E1C1", // pastel mint -] +const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const + +export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] + +export function isAvatarColorKey(value: string): value is AvatarColorKey { + return AVATAR_COLOR_KEYS.includes(value as AvatarColorKey) +} + +export function getAvatarColors(key?: string) { + if (key && isAvatarColorKey(key)) { + return { + background: `var(--avatar-background-${key})`, + foreground: `var(--avatar-text-${key})`, + } + } + return { + background: "var(--surface-info-base)", + foreground: "var(--text-base)", + } +} type Dialog = "provider" | "model" | "connect" @@ -45,21 +53,24 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( name: "default-layout.v7", }, ) - const [ephemeral, setEphemeral] = createStore({ + const [ephemeral, setEphemeral] = createStore<{ connect: { - provider: undefined as undefined | string, - state: undefined as undefined | "pending" | "complete" | "error", - error: undefined as undefined | string, - }, + provider?: string + state?: "pending" | "complete" | "error" + error?: string + } dialog: { - open: undefined as undefined | Dialog, - }, + open?: Dialog + } + }>({ + connect: {}, + dialog: {}, }) - const usedColors = new Set<string>() + const usedColors = new Set<AvatarColorKey>() - function pickAvailableColor() { - const available = PASTEL_COLORS.filter((c) => !usedColors.has(c)) - if (available.length === 0) return PASTEL_COLORS[Math.floor(Math.random() * PASTEL_COLORS.length)] + function pickAvailableColor(): AvatarColorKey { + const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c)) + if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)] return available[Math.floor(Math.random() * available.length)] } @@ -177,22 +188,30 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( dialog: { opened: createMemo(() => ephemeral.dialog?.open), open(dialog: Dialog) { - setEphemeral("dialog", "open", dialog) - if (dialog !== "connect") { - setEphemeral("connect", {}) - } + batch(() => { + // if (dialog !== "connect") { + // setEphemeral("connect", {}) + // } + setEphemeral("dialog", "open", dialog) + }) }, close(dialog: Dialog) { - if (ephemeral.dialog?.open === dialog) { - setEphemeral("dialog", "open", undefined) - setEphemeral("connect", {}) + if (ephemeral.dialog.open === dialog) { + setEphemeral( + produce((state) => { + state.dialog.open = undefined + state.connect = {} + }), + ) } }, connect(provider: string) { - batch(() => { - setEphemeral("dialog", "open", "connect") - setEphemeral("connect", { provider, state: "pending" }) - }) + setEphemeral( + produce((state) => { + state.dialog.open = "connect" + state.connect = { provider, state: "pending" } + }), + ) }, }, connect: { diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index d8dfa732a..39fd1f987 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -41,10 +41,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const providers = useProviders() function isModelValid(model: ModelKey) { - const provider = providers().all.find((x) => x.id === model.providerID) + const provider = providers.all().find((x) => x.id === model.providerID) return ( !!provider?.models[model.modelID] && - providers() + providers .connected() .map((p) => p.id) .includes(model.providerID) @@ -123,16 +123,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const list = createMemo(() => - providers() - .connected() - .flatMap((p) => - Object.values(p.models).map((m) => ({ - ...m, - name: m.name.replace("(latest)", "").trim(), - provider: p, - latest: m.name.includes("(latest)"), - })), - ), + providers.connected().flatMap((p) => + Object.values(p.models).map((m) => ({ + ...m, + name: m.name.replace("(latest)", "").trim(), + provider: p, + latest: m.name.includes("(latest)"), + })), + ), ) const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) @@ -153,11 +151,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } - for (const p of providers().connected()) { - if (p.id in providers().default) { + for (const p of providers.connected()) { + if (p.id in providers.default()) { return { providerID: p.id, - modelID: providers().default[p.id], + modelID: providers.default()[p.id], } } } diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx index db2b3af7c..860c1a14f 100644 --- a/packages/desktop/src/context/session.tsx +++ b/packages/desktop/src/context/session.tsx @@ -62,10 +62,10 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex const userMessages = createMemo(() => messages() .filter((m) => m.role === "user") - .sort((a, b) => b.id.localeCompare(a.id)), + .sort((a, b) => a.id.localeCompare(b.id)), ) const lastUserMessage = createMemo(() => { - return userMessages()?.at(0) + return userMessages()?.at(-1) }) const activeMessage = createMemo(() => { if (!store.messageId) return lastUserMessage() diff --git a/packages/desktop/src/hooks/use-providers.ts b/packages/desktop/src/hooks/use-providers.ts index 04ef855d4..501ff9d0c 100644 --- a/packages/desktop/src/hooks/use-providers.ts +++ b/packages/desktop/src/hooks/use-providers.ts @@ -17,13 +17,15 @@ export function useProviders() { return globalSync.data.provider }) const connected = createMemo(() => providers().all.filter((p) => providers().connected.includes(p.id))) - const paid = createMemo(() => connected().filter((p) => Object.values(p.models).find((m) => m.cost?.input))) + const paid = createMemo(() => + connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)), + ) const popular = createMemo(() => providers().all.filter((p) => popularProviders.includes(p.id))) - return createMemo(() => ({ - all: providers().all, - default: providers().default, + return { + all: createMemo(() => providers().all), + default: createMemo(() => providers().default), popular, connected, paid, - })) + } } 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 |
