diff options
| author | Adam <[email protected]> | 2025-12-14 05:40:43 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-14 21:38:58 -0600 |
| commit | 4a8e8f537ca688cca52674a619065f577cbd3f9b (patch) | |
| tree | 2255aa8ab0a9bb9d90519a1ac6d35e60b1e3e6f0 | |
| parent | a68bee7878d78ac7d480d6fbbd3225759e695c61 (diff) | |
| download | opencode-4a8e8f537ca688cca52674a619065f577cbd3f9b.tar.gz opencode-4a8e8f537ca688cca52674a619065f577cbd3f9b.zip | |
wip(desktop): progress
| -rw-r--r-- | packages/desktop/src/components/dialog-connect.tsx | 406 | ||||
| -rw-r--r-- | packages/desktop/src/components/dialog-model.tsx | 212 | ||||
| -rw-r--r-- | packages/desktop/src/components/dialog-provider.tsx | 68 | ||||
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 208 | ||||
| -rw-r--r-- | packages/desktop/src/context/layout.tsx | 9 | ||||
| -rw-r--r-- | packages/desktop/src/context/local.tsx | 93 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 480 | ||||
| -rw-r--r-- | packages/opencode/src/provider/provider.ts | 4 | ||||
| -rw-r--r-- | packages/ui/src/components/select-dialog.tsx | 11 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 23 |
10 files changed, 784 insertions, 730 deletions
diff --git a/packages/desktop/src/components/dialog-connect.tsx b/packages/desktop/src/components/dialog-connect.tsx new file mode 100644 index 000000000..a44365069 --- /dev/null +++ b/packages/desktop/src/components/dialog-connect.tsx @@ -0,0 +1,406 @@ +import { Component, createMemo, Match, onCleanup, onMount, Switch } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { useLayout } from "@/context/layout" +import { useGlobalSync } from "@/context/global-sync" +import { useGlobalSDK } from "@/context/global-sdk" +import { usePlatform } from "@/context/platform" +import { ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List, ListRef } from "@opencode-ai/ui/list" +import { Button } from "@opencode-ai/ui/button" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TextField } from "@opencode-ai/ui/text-field" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Icon } from "@opencode-ai/ui/icon" +import { showToast } from "@opencode-ai/ui/toast" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" +import { iife } from "@opencode-ai/util/iife" +import { Link } from "@/components/link" + +export const DialogConnect: Component = () => { + const layout = useLayout() + const globalSync = useGlobalSync() + const globalSDK = useGlobalSDK() + const platform = usePlatform() + + 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> + ) +} diff --git a/packages/desktop/src/components/dialog-model.tsx b/packages/desktop/src/components/dialog-model.tsx new file mode 100644 index 000000000..9d36e0797 --- /dev/null +++ b/packages/desktop/src/components/dialog-model.tsx @@ -0,0 +1,212 @@ +import { Component, createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js" +import { useLocal } from "@/context/local" +import { useLayout } from "@/context/layout" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { SelectDialog } from "@opencode-ai/ui/select-dialog" +import { Button } from "@opencode-ai/ui/button" +import { Tag } from "@opencode-ai/ui/tag" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List, ListRef } from "@opencode-ai/ui/list" +import { iife } from "@opencode-ai/util/iife" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" + +export const DialogModel: Component = () => { + const local = useLocal() + const layout = useLayout() + const providers = useProviders() + + return ( + <Switch> + <Match when={providers.paid().length > 0}> + {iife(() => { + const models = createMemo(() => + local.model + .list() + .filter((m) => m.visible) + .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"]} + sortBy={(a, b) => a.name.localeCompare(b.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> + } + > + {(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(() => { + let listRef: ListRef | undefined + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + onMount(() => { + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + + return ( + <Dialog + modal + defaultOpen + onOpenChange={(open) => { + if (open) { + layout.dialog.open("model") + } else { + layout.dialog.close("model") + } + }} + > + <Dialog.Header> + <Dialog.Title>Select model</Dialog.Title> + <Dialog.CloseButton tabIndex={-1} /> + </Dialog.Header> + <Dialog.Body> + <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} + current={local.model.current()} + key={(x) => `${x.provider.id}:${x.id}`} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + layout.dialog.close("model") + }} + > + {(i) => ( + <div class="w-full flex items-center gap-x-2.5"> + <span>{i.name}</span> + <Tag>Free</Tag> + <Show when={i.latest}> + <Tag>Latest</Tag> + </Show> + </div> + )} + </List> + <div /> + <div /> + </div> + <div class="px-1.5 pb-1.5"> + <div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base"> + <div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4"> + <div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div> + <div class="w-full"> + <List + class="w-full" + key={(x) => x?.id} + items={providers.popular} + activeIcon="plus-small" + 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) + }} + onSelect={(x) => { + if (!x) return + layout.dialog.connect(x.id) + }} + > + {(i) => ( + <div class="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> + )} + </List> + <Button + variant="ghost" + class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium" + icon="dot-grid" + onClick={() => { + layout.dialog.open("provider") + }} + > + View all providers + </Button> + </div> + </div> + </div> + </div> + </Dialog.Body> + </Dialog> + ) + })} + </Match> + </Switch> + ) +} diff --git a/packages/desktop/src/components/dialog-provider.tsx b/packages/desktop/src/components/dialog-provider.tsx new file mode 100644 index 000000000..56c791479 --- /dev/null +++ b/packages/desktop/src/components/dialog-provider.tsx @@ -0,0 +1,68 @@ +import { Component, Show } from "solid-js" +import { useLayout } from "@/context/layout" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { SelectDialog } from "@opencode-ai/ui/select-dialog" +import { Tag } from "@opencode-ai/ui/tag" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" + +export const DialogProvider: Component = () => { + const layout = useLayout() + const providers = useProviders() + + return ( + <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> + ) +} diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 114e6d49d..7c60a6d01 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,5 +1,5 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" +import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" @@ -9,21 +9,14 @@ import { useSDK } from "@/context/sdk" import { useNavigate } from "@solidjs/router" import { useSync } from "@/context/sync" import { FileIcon } from "@opencode-ai/ui/file-icon" -import { SelectDialog } from "@opencode-ai/ui/select-dialog" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" -import { Tag } from "@opencode-ai/ui/tag" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { useLayout } from "@/context/layout" -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 { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { IconName } from "@opencode-ai/ui/icons/provider" +import { DialogModel } from "@/components/dialog-model" interface PromptInputProps { class?: string @@ -65,7 +58,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const local = useLocal() const session = useSession() const layout = useLayout() - const providers = useProviders() let editorRef!: HTMLDivElement const [store, setStore] = createStore<{ @@ -624,201 +616,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <Icon name="chevron-down" size="small" /> </Button> <Show when={layout.dialog.opened() === "model"}> - <Switch> - <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"]} - sortBy={(a, b) => a.name.localeCompare(b.name)} - // 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> - } - > - {(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(() => { - let listRef: ListRef | undefined - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - onMount(() => { - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) - }) - }) - - return ( - <Dialog - modal - defaultOpen - onOpenChange={(open) => { - if (open) { - layout.dialog.open("model") - } else { - layout.dialog.close("model") - } - }} - > - <Dialog.Header> - <Dialog.Title>Select model</Dialog.Title> - <Dialog.CloseButton tabIndex={-1} /> - </Dialog.Header> - <Dialog.Body> - <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} - current={local.model.current()} - key={(x) => `${x.provider.id}:${x.id}`} - onSelect={(x) => { - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - layout.dialog.close("model") - }} - > - {(i) => ( - <div class="w-full flex items-center gap-x-2.5"> - <span>{i.name}</span> - <Tag>Free</Tag> - <Show when={i.latest}> - <Tag>Latest</Tag> - </Show> - </div> - )} - </List> - <div /> - <div /> - </div> - <div class="px-1.5 pb-1.5"> - <div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base"> - <div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4"> - <div class="px-2 text-14-medium text-text-base"> - Add more models from popular providers - </div> - <div class="w-full"> - <List - class="w-full" - key={(x) => x?.id} - items={providers.popular} - activeIcon="plus-small" - 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) - }} - onSelect={(x) => { - if (!x) return - layout.dialog.connect(x.id) - }} - > - {(i) => ( - <div class="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> - )} - </List> - <Button - variant="ghost" - class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium" - icon="dot-grid" - onClick={() => { - layout.dialog.open("provider") - }} - > - View all providers - </Button> - </div> - </div> - </div> - </div> - </Dialog.Body> - </Dialog> - ) - })} - </Match> - </Switch> + <DialogModel /> </Show> </div> <Tooltip diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 3d5cad761..587276c53 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -22,7 +22,7 @@ export function getAvatarColors(key?: string) { } } -type Dialog = "provider" | "model" | "connect" +type Dialog = "provider" | "model" | "connect" | "manage-models" export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", @@ -172,12 +172,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( dialog: { opened: createMemo(() => ephemeral.dialog?.open), open(dialog: Dialog) { - batch(() => { - // if (dialog !== "connect") { - // setEphemeral("connect", {}) - // } - setEphemeral("dialog", "open", dialog) - }) + setEphemeral("dialog", "open", dialog) }, close(dialog: Dialog) { if (ephemeral.dialog.open === dialog) { diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 181a4d247..f841da1cc 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -1,12 +1,14 @@ import { createStore, produce, reconcile } from "solid-js/store" import { batch, createEffect, createMemo } from "solid-js" -import { uniqueBy } from "remeda" +import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda" import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "@opencode-ai/ui/context" import { useSDK } from "./sdk" import { useSync } from "./sync" import { base64Encode } from "@opencode-ai/util/encode" import { useProviders } from "@/hooks/use-providers" +import { makePersisted } from "@solid-primitives/storage" +import { DateTime } from "luxon" export type LocalFile = FileNode & Partial<{ @@ -108,30 +110,66 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ })() const model = (() => { - const [store, setStore] = createStore<{ + const [store, setStore] = makePersisted( + createStore<{ + user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[] + recent: ModelKey[] + }>({ + user: [], + recent: [], + }), + { name: "model.v1" }, + ) + + const [ephemeral, setEphemeral] = createStore<{ model: Record<string, ModelKey> - recent: ModelKey[] }>({ model: {}, - recent: [], - }) - - const value = localStorage.getItem("model") - setStore("recent", JSON.parse(value ?? "[]")) - createEffect(() => { - localStorage.setItem("model", JSON.stringify(store.recent)) }) - const list = createMemo(() => + const available = 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)"), + user: store.user.find((x) => x.modelID === m.id && x.providerID === p.id), })), ), ) + const latest = createMemo(() => + pipe( + available(), + filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6), + groupBy((x) => x.provider.id), + mapValues((models) => + pipe( + models, + groupBy((x) => x.family), + values(), + (groups) => + groups.flatMap((g) => { + const first = firstBy(g, [(x) => x.release_date, "desc"]) + return first ? [{ modelID: first.id, providerID: first.provider.id }] : [] + }), + ), + ), + values(), + flat(), + ), + ) + + const list = createMemo(() => + available().map((m) => ({ + ...m, + name: m.name.replace("(latest)", "").trim(), + latest: m.name.includes("(latest)"), + visible: + m.user?.visibility !== "hide" && + (latest().find((x) => x.modelID === m.id && x.providerID === m.provider.id) || + store.user.find((x) => x.modelID === m.id && x.providerID === m.provider.id)?.visibility === "show"), + })), + ) + const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) const fallbackModel = createMemo(() => { @@ -163,10 +201,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ throw new Error("No default model found") }) - const currentModel = createMemo(() => { + const current = createMemo(() => { const a = agent.current() const key = getFirstValidModel( - () => store.model[a.name], + () => ephemeral.model[a.name], () => a.model, fallbackModel, )! @@ -177,10 +215,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const cycle = (direction: 1 | -1) => { const recentList = recent() - const current = currentModel() - if (!current) return + const currentModel = current() + if (!currentModel) return - const index = recentList.findIndex((x) => x?.provider.id === current.provider.id && x?.id === current.id) + const index = recentList.findIndex( + (x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id, + ) if (index === -1) return let next = index + direction @@ -196,14 +236,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) } + function updateVisibility(model: ModelKey, visibility: "show" | "hide") { + const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID) + if (index >= 0) { + setStore("user", index, { visibility: visibility }) + } + } + return { - current: currentModel, + current, recent, list, cycle, set(model: ModelKey | undefined, options?: { recent?: boolean }) { batch(() => { - setStore("model", agent.current().name, model ?? fallbackModel()) + setEphemeral("model", agent.current().name, model ?? fallbackModel()) if (options?.recent && model) { const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) if (uniq.length > 5) uniq.pop() @@ -211,6 +258,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } }) }, + show(model: ModelKey) { + updateVisibility(model, "show") + }, + hide(model: ModelKey) { + updateVisibility(model, "hide") + }, } })() 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 /> diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d4755af17..b8d4dadbd 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -392,6 +392,7 @@ export namespace Provider { status: z.enum(["alpha", "beta", "deprecated", "active"]), options: z.record(z.string(), z.any()), headers: z.record(z.string(), z.string()), + release_date: z.string(), }) .meta({ ref: "Model", @@ -470,6 +471,7 @@ export namespace Provider { }, interleaved: model.interleaved ?? false, }, + release_date: model.release_date, } } @@ -602,6 +604,8 @@ export namespace Provider { output: model.limit?.output ?? existingModel?.limit?.output ?? 0, }, headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}), + family: model.family ?? existingModel?.family ?? "", + release_date: model.release_date ?? existingModel?.release_date ?? "", } parsed.models[modelID] = parsedModel } diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx index 06953168c..68707536a 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -1,4 +1,4 @@ -import { createEffect, Show, type JSX, splitProps, createSignal } from "solid-js" +import { Show, type JSX, splitProps, createSignal } from "solid-js" import { Dialog, DialogProps } from "./dialog" import { Icon } from "./icon" import { IconButton } from "./icon-button" @@ -20,15 +20,6 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) { const [filter, setFilter] = createSignal("") let listRef: ListRef | undefined - createEffect(() => { - if (!props.current) return - const key = props.key(props.current) - requestAnimationFrame(() => { - const element = document.querySelector(`[data-key="${key}"]`) - element?.scrollIntoView({ block: "center" }) - }) - }) - const handleSelect = (item: T | undefined, index: number) => { others.onSelect?.(item, index) closeButton.click() diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index b50e4c8a0..a4a762fc6 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -50,19 +50,16 @@ export function SessionTurn( let scrollRef: HTMLDivElement | undefined const [state, setState] = createStore({ - contentRef: undefined as HTMLDivElement | undefined, stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined, userScrolled: false, stickyHeaderHeight: 0, scrollY: 0, - autoScrolling: false, }) function handleScroll() { if (!scrollRef) return setState("scrollY", scrollRef.scrollTop) - if (state.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { @@ -77,13 +74,9 @@ export function SessionTurn( } function scrollToBottom() { - if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return - setState("autoScrolling", true) + if (!scrollRef || state.userScrolled || !working()) return requestAnimationFrame(() => { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "auto" }) - requestAnimationFrame(() => { - setState("autoScrolling", false) - }) }) } @@ -94,13 +87,6 @@ export function SessionTurn( }) createResizeObserver( - () => state.contentRef, - () => { - scrollToBottom() - }, - ) - - createResizeObserver( () => state.stickyTitleRef, ({ height }) => { const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0 @@ -119,7 +105,7 @@ export function SessionTurn( return ( <div data-component="session-turn" class={props.classes?.root} style={{ "--scroll-y": `${state.scrollY}px` }}> <div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}> - <div ref={(el) => setState("contentRef", el)} onClick={handleInteraction}> + <div onClick={handleInteraction}> <Show when={message()}> {(message) => { const assistantMessages = createMemo(() => { @@ -221,6 +207,11 @@ export function SessionTurn( }) } + createEffect(() => { + lastPart() + scrollToBottom() + }) + const [store, setStore] = createStore({ status: rawStatus(), stepsExpanded: true, |
