diff options
| author | Adam <[email protected]> | 2025-12-11 09:32:53 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-11 13:42:45 -0600 |
| commit | 0ca758e13516d96180cc11631e02e5f7929ba4b0 (patch) | |
| tree | caeb8c09a2027991cc007339eb0e68f56b6db628 | |
| parent | ea8508ee44848e9ff225ab9764ab1779b641b7c0 (diff) | |
| download | opencode-0ca758e13516d96180cc11631e02e5f7929ba4b0.tar.gz opencode-0ca758e13516d96180cc11631e02e5f7929ba4b0.zip | |
wip(desktop): progress
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 137 | ||||
| -rw-r--r-- | packages/desktop/src/context/layout.tsx | 47 | ||||
| -rw-r--r-- | packages/desktop/src/context/local.tsx | 28 | ||||
| -rw-r--r-- | packages/desktop/src/hooks/use-providers.ts | 8 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 291 | ||||
| -rw-r--r-- | packages/ui/src/hooks/use-filtered-list.tsx | 4 |
6 files changed, 279 insertions, 236 deletions
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 41af8644b..7f8568291 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" @@ -470,60 +482,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.cost || i.cost?.input === 0}> + <Tag>Free</Tag> + </Show> + <Show when={i.latest}> + <Tag>Latest</Tag> + </Show> + </div> + )} + </SelectDialog> + ) + })} </Match> <Match when={true}> {iife(() => { @@ -554,7 +579,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <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 +612,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..ea5962b40 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -45,15 +45,18 @@ 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>() @@ -177,22 +180,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/hooks/use-providers.ts b/packages/desktop/src/hooks/use-providers.ts index 04ef855d4..cad810b7b 100644 --- a/packages/desktop/src/hooks/use-providers.ts +++ b/packages/desktop/src/hooks/use-providers.ts @@ -19,11 +19,11 @@ export function useProviders() { 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 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 39917c420..65a106708 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -487,7 +487,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> @@ -567,7 +567,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) => { @@ -620,16 +620,19 @@ export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ method: undefined as undefined | ProviderAuthMethod, }) - 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 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", + }, + ], + ) + if (methods().length === 1) { + setStore("method", methods()[0]) } let listRef: ListRef | undefined @@ -670,145 +673,151 @@ 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">Connect {provider().name}</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) + <Switch> + <Match 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> - )} - </List> - </div> - </Show> - <Show when={store.method?.type === "api"}> - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) + 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> + )} + </List> + </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() + 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 + 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 - } + 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() - } + setFormStore("error", undefined) + await globalSDK.client.auth.set({ + providerID: providerID(), + auth: { + type: "api", + key: apiKey, + }, + }) + await globalSDK.client.global.dispose() + setTimeout(() => { + layout.connect.complete() + }, 500) + } - 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. + 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. + </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. + </div> </div> + </Match> + <Match when={true}> <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. + Enter your {provider.name} API key to connect your account and use {provider.name}{" "} + models in OpenCode. </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> + <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> + ) + })} + </Match> + </Switch> </div> </Dialog.Body> </Dialog> diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index f9745918a..e3b373d4d 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -5,7 +5,7 @@ import { createStore } from "solid-js/store" import { createList } from "solid-list" export interface FilteredListProps<T> { - items: T[] | ((filter: string) => Promise<T[]>) + items: (filter: string) => T[] | Promise<T[]> key: (item: T) => string filterKeys?: string[] current?: T @@ -22,7 +22,7 @@ export function useFilteredList<T>(props: FilteredListProps<T>) { () => store.filter, async (filter) => { const needle = filter?.toLowerCase() - const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || [] + const all = (await props.items(needle)) || [] const result = pipe( all, (x) => { |
