|
@@ -9,6 +9,9 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
|
import { Tag } from "@opencode-ai/ui/tag"
|
|
import { Tag } from "@opencode-ai/ui/tag"
|
|
|
import { TextField } from "@opencode-ai/ui/text-field"
|
|
import { TextField } from "@opencode-ai/ui/text-field"
|
|
|
|
|
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
|
|
|
|
+import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
|
|
|
|
+import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
|
|
import { showToast } from "@/utils/toast"
|
|
import { showToast } from "@/utils/toast"
|
|
|
import {
|
|
import {
|
|
|
type Accessor,
|
|
type Accessor,
|
|
@@ -16,6 +19,8 @@ import {
|
|
|
createEffect,
|
|
createEffect,
|
|
|
createMemo,
|
|
createMemo,
|
|
|
createResource,
|
|
createResource,
|
|
|
|
|
+ createUniqueId,
|
|
|
|
|
+ For,
|
|
|
Match,
|
|
Match,
|
|
|
onCleanup,
|
|
onCleanup,
|
|
|
onMount,
|
|
onMount,
|
|
@@ -27,6 +32,7 @@ import { Link } from "@/components/link"
|
|
|
import { useServerSDK } from "@/context/server-sdk"
|
|
import { useServerSDK } from "@/context/server-sdk"
|
|
|
import { useServerSync } from "@/context/server-sync"
|
|
import { useServerSync } from "@/context/server-sync"
|
|
|
import { useLanguage } from "@/context/language"
|
|
import { useLanguage } from "@/context/language"
|
|
|
|
|
+import { useSettings } from "@/context/settings"
|
|
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
|
|
import { CustomProviderForm } from "./dialog-custom-provider"
|
|
import { CustomProviderForm } from "./dialog-custom-provider"
|
|
|
|
|
|
|
@@ -50,32 +56,22 @@ export const DialogConnectProvider: Component<{
|
|
|
const fallback = useProviderConnectController()
|
|
const fallback = useProviderConnectController()
|
|
|
const controller = props.controller ?? fallback
|
|
const controller = props.controller ?? fallback
|
|
|
const language = useLanguage()
|
|
const language = useLanguage()
|
|
|
|
|
+ const settings = useSettings()
|
|
|
|
|
+ const newLayout = settings.general.newLayoutDesigns
|
|
|
const reset = controller.back
|
|
const reset = controller.back
|
|
|
const back = { current: reset }
|
|
const back = { current: reset }
|
|
|
|
|
+ let focusHost: HTMLDivElement | undefined
|
|
|
|
|
+ const holdFocus = () => focusHost?.focus({ preventScroll: true })
|
|
|
const select = (provider?: string) => {
|
|
const select = (provider?: string) => {
|
|
|
back.current = reset
|
|
back.current = reset
|
|
|
controller.select(provider)
|
|
controller.select(provider)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- return (
|
|
|
|
|
- <Dialog
|
|
|
|
|
- class="h-full"
|
|
|
|
|
- transition
|
|
|
|
|
- title={
|
|
|
|
|
- <Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
|
|
|
|
|
- <IconButton
|
|
|
|
|
- tabIndex={-1}
|
|
|
|
|
- icon="arrow-left"
|
|
|
|
|
- variant="ghost"
|
|
|
|
|
- onClick={() => back.current()}
|
|
|
|
|
- aria-label={language.t("common.goBack")}
|
|
|
|
|
- />
|
|
|
|
|
- </Show>
|
|
|
|
|
- }
|
|
|
|
|
- >
|
|
|
|
|
|
|
+ function Content() {
|
|
|
|
|
+ return (
|
|
|
<Switch>
|
|
<Switch>
|
|
|
<Match when={controller.selected() === CUSTOM_ID}>
|
|
<Match when={controller.selected() === CUSTOM_ID}>
|
|
|
- <CustomProviderForm />
|
|
|
|
|
|
|
+ <CustomProviderForm autofocus={!newLayout()} />
|
|
|
</Match>
|
|
</Match>
|
|
|
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
|
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
|
|
{(provider) => (
|
|
{(provider) => (
|
|
@@ -88,14 +84,76 @@ export const DialogConnectProvider: Component<{
|
|
|
)}
|
|
)}
|
|
|
</Match>
|
|
</Match>
|
|
|
<Match when={true}>
|
|
<Match when={true}>
|
|
|
- <ProviderPicker directory={props.directory} onSelect={select} />
|
|
|
|
|
|
|
+ <ProviderPicker
|
|
|
|
|
+ directory={props.directory}
|
|
|
|
|
+ onSelect={select}
|
|
|
|
|
+ onPrepare={newLayout() ? holdFocus : undefined}
|
|
|
|
|
+ />
|
|
|
</Match>
|
|
</Match>
|
|
|
</Switch>
|
|
</Switch>
|
|
|
- </Dialog>
|
|
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Show
|
|
|
|
|
+ when={newLayout()}
|
|
|
|
|
+ fallback={
|
|
|
|
|
+ <Dialog
|
|
|
|
|
+ class="h-full"
|
|
|
|
|
+ transition
|
|
|
|
|
+ title={
|
|
|
|
|
+ <Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
|
|
|
|
|
+ <IconButton
|
|
|
|
|
+ tabIndex={-1}
|
|
|
|
|
+ icon="arrow-left"
|
|
|
|
|
+ variant="ghost"
|
|
|
|
|
+ onClick={() => back.current()}
|
|
|
|
|
+ aria-label={language.t("common.goBack")}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Show>
|
|
|
|
|
+ }
|
|
|
|
|
+ >
|
|
|
|
|
+ <Content />
|
|
|
|
|
+ </Dialog>
|
|
|
|
|
+ }
|
|
|
|
|
+ >
|
|
|
|
|
+ <DialogV2
|
|
|
|
|
+ containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
|
|
|
|
|
+ class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
|
|
|
|
+ >
|
|
|
|
|
+ <DialogHeader closeLabel={language.t("common.close")}>
|
|
|
|
|
+ <Show
|
|
|
|
|
+ when={controller.selected()}
|
|
|
|
|
+ fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
|
|
|
|
|
+ >
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
|
|
|
|
+ onClick={() => back.current()}
|
|
|
|
|
+ aria-label={language.t("common.goBack")}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Icon name="arrow-left" size="small" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </Show>
|
|
|
|
|
+ </DialogHeader>
|
|
|
|
|
+ <DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
|
|
|
|
|
+ <div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
|
|
|
|
|
+ <Content />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </DialogBody>
|
|
|
|
|
+ </DialogV2>
|
|
|
|
|
+ </Show>
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
|
|
|
|
|
|
|
+function ProviderPicker(props: {
|
|
|
|
|
+ directory?: Accessor<string | undefined>
|
|
|
|
|
+ onSelect: (provider: string) => void
|
|
|
|
|
+ onPrepare?: () => void
|
|
|
|
|
+}) {
|
|
|
|
|
+ const settings = useSettings()
|
|
|
|
|
+ if (settings.general.newLayoutDesigns())
|
|
|
|
|
+ return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
|
|
const providers = useProviders(props.directory)
|
|
const providers = useProviders(props.directory)
|
|
|
const language = useLanguage()
|
|
const language = useLanguage()
|
|
|
const popularGroup = () => language.t("dialog.provider.group.popular")
|
|
const popularGroup = () => language.t("dialog.provider.group.popular")
|
|
@@ -163,6 +221,171 @@ function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSel
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function ProviderPickerV2(props: {
|
|
|
|
|
+ directory?: Accessor<string | undefined>
|
|
|
|
|
+ onSelect: (provider: string) => void
|
|
|
|
|
+ onPrepare?: () => void
|
|
|
|
|
+}) {
|
|
|
|
|
+ const providers = useProviders(props.directory)
|
|
|
|
|
+ const language = useLanguage()
|
|
|
|
|
+ const serverSync = useServerSync()
|
|
|
|
|
+ const serverSDK = useServerSDK()
|
|
|
|
|
+ const [store, setStore] = createStore({
|
|
|
|
|
+ filter: "",
|
|
|
|
|
+ active: undefined as string | undefined,
|
|
|
|
|
+ connecting: undefined as string | undefined,
|
|
|
|
|
+ })
|
|
|
|
|
+ const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
|
|
|
|
|
+ const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
|
|
|
|
|
+ const all = createMemo(() => {
|
|
|
|
|
+ language.locale()
|
|
|
|
|
+ const query = store.filter.trim().toLowerCase()
|
|
|
|
|
+ const values = [custom(), ...providers.all().values()]
|
|
|
|
|
+ if (!query) return values
|
|
|
|
|
+ return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
|
|
|
|
|
+ })
|
|
|
|
|
+ const popular = createMemo(() =>
|
|
|
|
|
+ all()
|
|
|
|
|
+ .filter((provider) => featured.includes(provider.id))
|
|
|
|
|
+ .sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)),
|
|
|
|
|
+ )
|
|
|
|
|
+ const other = createMemo(() =>
|
|
|
|
|
+ all()
|
|
|
|
|
+ .filter((provider) => !featured.includes(provider.id))
|
|
|
|
|
+ .sort((a, b) => {
|
|
|
|
|
+ if (a.id === CUSTOM_ID) return -1
|
|
|
|
|
+ if (b.id === CUSTOM_ID) return 1
|
|
|
|
|
+ return a.name.localeCompare(b.name)
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+ const rows = createMemo(() => [...popular(), ...other()])
|
|
|
|
|
+ let picker: HTMLDivElement | undefined
|
|
|
|
|
+ let search: HTMLInputElement | undefined
|
|
|
|
|
+
|
|
|
|
|
+ onMount(() => search?.focus({ preventScroll: true }))
|
|
|
|
|
+
|
|
|
|
|
+ const connect = (provider: string) => {
|
|
|
|
|
+ props.onPrepare?.()
|
|
|
|
|
+ if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
|
|
|
|
|
+ props.onSelect(provider)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (store.connecting) return
|
|
|
|
|
+ setStore("connecting", provider)
|
|
|
|
|
+ void serverSDK()
|
|
|
|
|
+ .client.provider.auth()
|
|
|
|
|
+ .then((response) => {
|
|
|
|
|
+ serverSync().set("provider_auth", response.data ?? {})
|
|
|
|
|
+ props.onSelect(provider)
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(() => props.onSelect(provider))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const move = (event: KeyboardEvent, direction: number) => {
|
|
|
|
|
+ const items = rows()
|
|
|
|
|
+ if (items.length === 0) return
|
|
|
|
|
+ const index = items.findIndex((provider) => provider.id === store.active)
|
|
|
|
|
+ const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
|
|
|
|
|
+ setStore("active", items[next].id)
|
|
|
|
|
+ picker
|
|
|
|
|
+ ?.querySelector<HTMLElement>(`[data-provider-id="${CSS.escape(items[next].id)}"]`)
|
|
|
|
|
+ ?.focus({ preventScroll: true })
|
|
|
|
|
+ event.preventDefault()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const handleKeyDown = (event: KeyboardEvent) => {
|
|
|
|
|
+ if (event.key === "ArrowDown") return move(event, 1)
|
|
|
|
|
+ if (event.key === "ArrowUp") return move(event, -1)
|
|
|
|
|
+ if (event.key !== "Enter" || !store.active) return
|
|
|
|
|
+ connect(store.active)
|
|
|
|
|
+ event.preventDefault()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}>
|
|
|
|
|
+ <div class="shrink-0 px-1 pt-px">
|
|
|
|
|
+ <TextInputV2
|
|
|
|
|
+ ref={search}
|
|
|
|
|
+ type="search"
|
|
|
|
|
+ class="!w-full [font-family:var(--v2-font-family-sans)]"
|
|
|
|
|
+ leadingIcon={<Icon name="magnifying-glass" size="small" />}
|
|
|
|
|
+ placeholder={language.t("dialog.provider.search.placeholder")}
|
|
|
|
|
+ value={store.filter}
|
|
|
|
|
+ onInput={(event) => {
|
|
|
|
|
+ setStore({ filter: event.currentTarget.value, active: undefined })
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="relative min-h-0 flex-1">
|
|
|
|
|
+ <div class="flex size-full min-h-0 flex-col gap-4 overflow-y-auto pb-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
|
|
|
+ <For
|
|
|
|
|
+ each={[
|
|
|
|
|
+ { title: language.t("dialog.provider.group.popular"), items: popular },
|
|
|
|
|
+ { title: language.t("dialog.provider.group.other"), items: other },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ >
|
|
|
|
|
+ {(group) => (
|
|
|
|
|
+ <Show when={group.items().length > 0}>
|
|
|
|
|
+ <section class="flex flex-col">
|
|
|
|
|
+ <div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
|
|
|
|
|
+ {group.title}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <For each={group.items()}>
|
|
|
|
|
+ {(provider) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ data-provider-id={provider.id}
|
|
|
|
|
+ class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
|
|
|
|
+ classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
|
|
|
|
|
+ onMouseEnter={() => setStore("active", provider.id)}
|
|
|
|
|
+ disabled={store.connecting !== undefined}
|
|
|
|
|
+ aria-busy={store.connecting === provider.id}
|
|
|
|
|
+ onClick={() => connect(provider.id)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />
|
|
|
|
|
+ <span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
|
|
|
|
|
+ <Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
|
|
|
|
|
+ <span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
|
|
|
|
|
+ {language.t(
|
|
|
|
|
+ provider.id === "opencode"
|
|
|
|
|
+ ? "dialog.provider.opencode.tagline"
|
|
|
|
|
+ : "dialog.provider.opencodeGo.tagline",
|
|
|
|
|
+ )}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
|
|
|
|
+ {language.t("dialog.provider.tag.recommended")}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </Show>
|
|
|
|
|
+ <Show when={provider.id === CUSTOM_ID}>
|
|
|
|
|
+ <span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
|
|
|
|
+ {language.t("settings.providers.tag.custom")}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </Show>
|
|
|
|
|
+ <Show when={store.connecting === provider.id}>
|
|
|
|
|
+ <Spinner class="ml-auto size-4 shrink-0 text-v2-icon-icon-muted" />
|
|
|
|
|
+ </Show>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </For>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ </Show>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </For>
|
|
|
|
|
+ <Show when={rows().length === 0}>
|
|
|
|
|
+ <div class="flex h-24 items-center justify-center text-[13px] font-[440] text-v2-text-text-muted">
|
|
|
|
|
+ {language.t("dialog.provider.empty")}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Show>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div
|
|
|
|
|
+ class="pointer-events-none absolute inset-x-0 bottom-0 h-10"
|
|
|
|
|
+ style={{ background: "linear-gradient(to bottom, transparent, var(--v2-background-bg-layer-01))" }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function ProviderConnection(props: {
|
|
function ProviderConnection(props: {
|
|
|
provider: string
|
|
provider: string
|
|
|
directory?: Accessor<string | undefined>
|
|
directory?: Accessor<string | undefined>
|
|
@@ -173,6 +396,8 @@ function ProviderConnection(props: {
|
|
|
const serverSync = useServerSync()
|
|
const serverSync = useServerSync()
|
|
|
const serverSDK = useServerSDK()
|
|
const serverSDK = useServerSDK()
|
|
|
const language = useLanguage()
|
|
const language = useLanguage()
|
|
|
|
|
+ const settings = useSettings()
|
|
|
|
|
+ const newLayout = settings.general.newLayoutDesigns
|
|
|
const providers = useProviders(props.directory)
|
|
const providers = useProviders(props.directory)
|
|
|
|
|
|
|
|
const alive = { value: true }
|
|
const alive = { value: true }
|
|
@@ -207,11 +432,19 @@ function ProviderConnection(props: {
|
|
|
)
|
|
)
|
|
|
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
|
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
|
|
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
|
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
|
|
|
|
+ const cachedMethods = serverSync().data.provider_auth[props.provider]
|
|
|
|
|
+ const directMethod =
|
|
|
|
|
+ cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
|
|
|
const [store, setStore] = createStore({
|
|
const [store, setStore] = createStore({
|
|
|
- methodIndex: undefined as undefined | number,
|
|
|
|
|
|
|
+ methodIndex: directMethod as undefined | number,
|
|
|
authorization: undefined as undefined | ProviderAuthAuthorization,
|
|
authorization: undefined as undefined | ProviderAuthAuthorization,
|
|
|
promptInputs: undefined as undefined | Record<string, string>,
|
|
promptInputs: undefined as undefined | Record<string, string>,
|
|
|
- state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
|
|
|
|
|
|
+ state: (directMethod === undefined ? "pending" : undefined) as
|
|
|
|
|
+ | undefined
|
|
|
|
|
+ | "pending"
|
|
|
|
|
+ | "complete"
|
|
|
|
|
+ | "error"
|
|
|
|
|
+ | "prompt",
|
|
|
error: undefined as string | undefined,
|
|
error: undefined as string | undefined,
|
|
|
})
|
|
})
|
|
|
|
|
|
|
@@ -279,6 +512,16 @@ function ProviderConnection(props: {
|
|
|
return value.label ?? ""
|
|
return value.label ?? ""
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ const methodDetails = (value?: { type?: string; label?: string }) => {
|
|
|
|
|
+ const label = methodLabel(value)
|
|
|
|
|
+ const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i)
|
|
|
|
|
+ const hint = suffix?.[1]
|
|
|
|
|
+ return {
|
|
|
|
|
+ label: suffix ? label.slice(0, -suffix[0].length) : label,
|
|
|
|
|
+ hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined,
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
function formatError(value: unknown, fallback: string): string {
|
|
function formatError(value: unknown, fallback: string): string {
|
|
|
if (value && typeof value === "object" && "data" in value) {
|
|
if (value && typeof value === "object" && "data" in value) {
|
|
|
const data = (value as { data?: { message?: unknown } }).data
|
|
const data = (value as { data?: { message?: unknown } }).data
|
|
@@ -519,6 +762,37 @@ function ProviderConnection(props: {
|
|
|
props.setBack(goBack)
|
|
props.setBack(goBack)
|
|
|
|
|
|
|
|
function MethodSelection() {
|
|
function MethodSelection() {
|
|
|
|
|
+ if (newLayout())
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div class="flex flex-col gap-2">
|
|
|
|
|
+ <div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
|
|
|
|
+ {language.t("provider.connect.selectMethod", { provider: provider().name })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="flex flex-col">
|
|
|
|
|
+ <For each={methods()}>
|
|
|
|
|
+ {(item, index) => {
|
|
|
|
|
+ const details = () => methodDetails(item)
|
|
|
|
|
+ return (
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
|
|
|
|
+ onClick={() => void selectMethod(index())}
|
|
|
|
|
+ >
|
|
|
|
|
+ <span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
|
|
|
|
|
+ <span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span class="font-[530] text-v2-text-text-base">{details().label}</span>
|
|
|
|
|
+ <Show when={details().hint}>
|
|
|
|
|
+ {(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
|
|
|
|
|
+ </Show>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )
|
|
|
|
|
+ }}
|
|
|
|
|
+ </For>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
<>
|
|
<>
|
|
|
<div class="text-14-regular text-text-base">
|
|
<div class="text-14-regular text-text-base">
|
|
@@ -552,11 +826,18 @@ function ProviderConnection(props: {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function ApiAuthView() {
|
|
function ApiAuthView() {
|
|
|
|
|
+ let apiKey: HTMLInputElement | undefined
|
|
|
|
|
+ const errorID = createUniqueId()
|
|
|
const [formStore, setFormStore] = createStore({
|
|
const [formStore, setFormStore] = createStore({
|
|
|
value: "",
|
|
value: "",
|
|
|
error: undefined as string | undefined,
|
|
error: undefined as string | undefined,
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+ onMount(() => {
|
|
|
|
|
+ if (!newLayout()) return
|
|
|
|
|
+ apiKey?.focus({ preventScroll: true })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
async function handleSubmit(e: SubmitEvent) {
|
|
async function handleSubmit(e: SubmitEvent) {
|
|
|
e.preventDefault()
|
|
e.preventDefault()
|
|
|
|
|
|
|
@@ -581,6 +862,58 @@ function ProviderConnection(props: {
|
|
|
await complete()
|
|
await complete()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ if (newLayout())
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
|
|
|
|
+ <Show
|
|
|
|
|
+ when={provider().id === "opencode"}
|
|
|
|
|
+ fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div class="flex flex-col gap-5">
|
|
|
|
|
+ <div>{language.t("provider.connect.opencodeZen.line1")}</div>
|
|
|
|
|
+ <div>{language.t("provider.connect.opencodeZen.line2")}</div>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ {language.t("provider.connect.opencodeZen.visit.prefix")}
|
|
|
|
|
+ <Link
|
|
|
|
|
+ href="https://opencode.ai/zen"
|
|
|
|
|
+ class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
|
|
|
|
+ >
|
|
|
|
|
+ {language.t("provider.connect.opencodeZen.visit.link")}
|
|
|
|
|
+ </Link>
|
|
|
|
|
+ {language.t("provider.connect.opencodeZen.visit.suffix")}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Show>
|
|
|
|
|
+ <form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
|
|
|
|
+ <label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
|
|
|
|
+ {language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
|
|
|
|
+ <TextInputV2
|
|
|
|
|
+ ref={apiKey}
|
|
|
|
|
+ class="!w-full"
|
|
|
|
|
+ name="apiKey"
|
|
|
|
|
+ placeholder={language.t("provider.connect.apiKey.placeholder")}
|
|
|
|
|
+ value={formStore.value}
|
|
|
|
|
+ invalid={formStore.error !== undefined}
|
|
|
|
|
+ aria-describedby={formStore.error ? errorID : undefined}
|
|
|
|
|
+ autocomplete="off"
|
|
|
|
|
+ spellcheck={false}
|
|
|
|
|
+ onInput={(event) => setFormStore("value", event.currentTarget.value)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <Show when={formStore.error}>
|
|
|
|
|
+ {(error) => (
|
|
|
|
|
+ <div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
|
|
|
|
|
+ {error()}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Show>
|
|
|
|
|
+ <ButtonV2 type="submit" variant="contrast">
|
|
|
|
|
+ {language.t("common.continue")}
|
|
|
|
|
+ </ButtonV2>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
<div class="flex flex-col gap-6">
|
|
<div class="flex flex-col gap-6">
|
|
|
<Switch>
|
|
<Switch>
|
|
@@ -605,7 +938,8 @@ function ProviderConnection(props: {
|
|
|
</Switch>
|
|
</Switch>
|
|
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
|
|
<TextField
|
|
<TextField
|
|
|
- autofocus
|
|
|
|
|
|
|
+ autofocus={!newLayout()}
|
|
|
|
|
+ ref={apiKey}
|
|
|
type="text"
|
|
type="text"
|
|
|
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
|
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
|
|
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
|
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
|
@@ -624,11 +958,18 @@ function ProviderConnection(props: {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function OAuthCodeView() {
|
|
function OAuthCodeView() {
|
|
|
|
|
+ let codeInput: HTMLInputElement | undefined
|
|
|
|
|
+ const errorID = createUniqueId()
|
|
|
const [formStore, setFormStore] = createStore({
|
|
const [formStore, setFormStore] = createStore({
|
|
|
value: "",
|
|
value: "",
|
|
|
error: undefined as string | undefined,
|
|
error: undefined as string | undefined,
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+ onMount(() => {
|
|
|
|
|
+ if (!newLayout()) return
|
|
|
|
|
+ codeInput?.focus({ preventScroll: true })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
async function handleSubmit(e: SubmitEvent) {
|
|
async function handleSubmit(e: SubmitEvent) {
|
|
|
e.preventDefault()
|
|
e.preventDefault()
|
|
|
|
|
|
|
@@ -657,6 +998,46 @@ function ProviderConnection(props: {
|
|
|
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
|
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ if (newLayout())
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ {language.t("provider.connect.oauth.code.visit.prefix")}
|
|
|
|
|
+ <Link href={store.authorization!.url} class="text-v2-text-text-base">
|
|
|
|
|
+ {language.t("provider.connect.oauth.code.visit.link")}
|
|
|
|
|
+ </Link>
|
|
|
|
|
+ {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
|
|
|
|
+ <label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
|
|
|
|
+ {language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
|
|
|
|
+ <TextInputV2
|
|
|
|
|
+ ref={codeInput}
|
|
|
|
|
+ class="!w-full"
|
|
|
|
|
+ name="code"
|
|
|
|
|
+ placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
|
|
|
|
+ value={formStore.value}
|
|
|
|
|
+ invalid={formStore.error !== undefined}
|
|
|
|
|
+ aria-describedby={formStore.error ? errorID : undefined}
|
|
|
|
|
+ autocomplete="off"
|
|
|
|
|
+ spellcheck={false}
|
|
|
|
|
+ onInput={(event) => setFormStore("value", event.currentTarget.value)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <Show when={formStore.error}>
|
|
|
|
|
+ {(error) => (
|
|
|
|
|
+ <div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
|
|
|
|
|
+ {error()}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Show>
|
|
|
|
|
+ <ButtonV2 type="submit" variant="contrast">
|
|
|
|
|
+ {language.t("common.continue")}
|
|
|
|
|
+ </ButtonV2>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
<div class="flex flex-col gap-6">
|
|
<div class="flex flex-col gap-6">
|
|
|
<div class="text-14-regular text-text-base">
|
|
<div class="text-14-regular text-text-base">
|
|
@@ -666,7 +1047,8 @@ function ProviderConnection(props: {
|
|
|
</div>
|
|
</div>
|
|
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
|
|
<TextField
|
|
<TextField
|
|
|
- autofocus
|
|
|
|
|
|
|
+ autofocus={!newLayout()}
|
|
|
|
|
+ ref={codeInput}
|
|
|
type="text"
|
|
type="text"
|
|
|
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
|
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
|
|
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
|
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
|
@@ -738,10 +1120,27 @@ function ProviderConnection(props: {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
- <div class="flex flex-col gap-6 px-2.5 pb-3">
|
|
|
|
|
- <div class="px-2.5 flex gap-4 items-center">
|
|
|
|
|
- <ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
|
|
|
|
|
- <div class="text-16-medium text-text-strong">
|
|
|
|
|
|
|
+ <div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-3"}>
|
|
|
|
|
+ <div
|
|
|
|
|
+ class={
|
|
|
|
|
+ newLayout() ? "flex h-10 shrink-0 items-start gap-2 px-3" : "flex items-center gap-4 px-2.5"
|
|
|
|
|
+ }
|
|
|
|
|
+ >
|
|
|
|
|
+ <ProviderIcon
|
|
|
|
|
+ id={props.provider}
|
|
|
|
|
+ class={
|
|
|
|
|
+ newLayout()
|
|
|
|
|
+ ? "mt-0.5 size-4 shrink-0 text-v2-icon-icon-base"
|
|
|
|
|
+ : "size-5 shrink-0 icon-strong-base"
|
|
|
|
|
+ }
|
|
|
|
|
+ />
|
|
|
|
|
+ <div
|
|
|
|
|
+ class={
|
|
|
|
|
+ newLayout()
|
|
|
|
|
+ ? "text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base"
|
|
|
|
|
+ : "text-16-medium text-text-strong"
|
|
|
|
|
+ }
|
|
|
|
|
+ >
|
|
|
<Switch>
|
|
<Switch>
|
|
|
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
|
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
|
|
{language.t("provider.connect.title.anthropicProMax")}
|
|
{language.t("provider.connect.title.anthropicProMax")}
|
|
@@ -750,8 +1149,16 @@ function ProviderConnection(props: {
|
|
|
</Switch>
|
|
</Switch>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
- <div class="px-2.5 pb-10 flex flex-col gap-6">
|
|
|
|
|
- <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
|
|
|
|
|
|
+ <div
|
|
|
|
|
+ class={
|
|
|
|
|
+ newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-10"
|
|
|
|
|
+ }
|
|
|
|
|
+ >
|
|
|
|
|
+ <div
|
|
|
|
|
+ onKeyDown={handleKey}
|
|
|
|
|
+ tabIndex={newLayout() ? undefined : 0}
|
|
|
|
|
+ autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
|
|
|
|
|
+ >
|
|
|
<Switch>
|
|
<Switch>
|
|
|
<Match when={loading()}>
|
|
<Match when={loading()}>
|
|
|
<div class="text-14-regular text-text-base">
|
|
<div class="text-14-regular text-text-base">
|