summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-10 21:16:50 -0600
committerAdam <[email protected]>2025-12-11 06:48:58 -0600
commit3bb546c94d6bb295bfeafdafbb9d34b7cc462560 (patch)
tree54ae4fa1b18c68d23ff460caab596e942593a857 /packages/desktop/src
parent8e15bcb68e9a8a37bb12afca3984f3967ccb58eb (diff)
downloadopencode-3bb546c94d6bb295bfeafdafbb9d34b7cc462560.tar.gz
opencode-3bb546c94d6bb295bfeafdafbb9d34b7cc462560.zip
wip(desktop): progress
Diffstat (limited to 'packages/desktop/src')
-rw-r--r--packages/desktop/src/components/prompt-input.tsx97
-rw-r--r--packages/desktop/src/context/global-sync.tsx44
-rw-r--r--packages/desktop/src/context/layout.tsx22
-rw-r--r--packages/desktop/src/context/sync.tsx23
-rw-r--r--packages/desktop/src/pages/layout.tsx213
5 files changed, 331 insertions, 68 deletions
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 22f2c1642..41af8644b 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -579,54 +579,61 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</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-6">
+ <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>
- <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) => {
- layout.dialog.close("model")
- }}
- >
- {(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">
- <div class="flex items-center gap-2">
- <Icon name="plus-small" />
- <div class="text-text-strong">View all providers</div>
- </div>
- </Button>
+ <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>
diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx
index 3a6062fb8..09dfb3a83 100644
--- a/packages/desktop/src/context/global-sync.tsx
+++ b/packages/desktop/src/context/global-sync.tsx
@@ -12,11 +12,13 @@ import type {
Todo,
SessionStatus,
ProviderListResponse,
+ ProviderAuthResponse,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
+import { onMount } from "solid-js"
type State = {
ready: boolean
@@ -54,11 +56,13 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
ready: boolean
project: Project[]
provider: ProviderListResponse
+ provider_auth: ProviderAuthResponse
children: Record<string, State>
}>({
ready: false,
project: [],
provider: { all: [], connected: [], default: {} },
+ provider_auth: {},
children: {},
})
@@ -113,6 +117,10 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
const [store, setStore] = child(directory)
switch (event.type) {
+ // case "server.instance.disposed": {
+ // bootstrap()
+ // break
+ // }
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
@@ -181,19 +189,28 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
}
})
- Promise.all([
- sdk.client.project.list().then(async (x) => {
- setGlobalStore(
- "project",
- x
- .data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs)
- .sort((a, b) => a.id.localeCompare(b.id)),
- )
- }),
- sdk.client.provider.list().then((x) => {
- setGlobalStore("provider", x.data ?? {})
- }),
- ]).then(() => setGlobalStore("ready", true))
+ async function bootstrap() {
+ return Promise.all([
+ sdk.client.project.list().then(async (x) => {
+ setGlobalStore(
+ "project",
+ x
+ .data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs)
+ .sort((a, b) => a.id.localeCompare(b.id)),
+ )
+ }),
+ sdk.client.provider.list().then((x) => {
+ setGlobalStore("provider", x.data ?? {})
+ }),
+ sdk.client.provider.auth().then((x) => {
+ setGlobalStore("provider_auth", x.data ?? {})
+ }),
+ ]).then(() => setGlobalStore("ready", true))
+ }
+
+ onMount(() => {
+ bootstrap()
+ })
return {
data: globalStore,
@@ -201,6 +218,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
return globalStore.ready
},
child,
+ bootstrap,
}
},
})
diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx
index 5530ad28f..d00e101b8 100644
--- a/packages/desktop/src/context/layout.tsx
+++ b/packages/desktop/src/context/layout.tsx
@@ -1,5 +1,5 @@
import { createStore } from "solid-js/store"
-import { createMemo, onMount } from "solid-js"
+import { batch, createMemo, onMount } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makePersisted } from "@solid-primitives/storage"
import { useGlobalSync } from "./global-sync"
@@ -19,6 +19,8 @@ const PASTEL_COLORS = [
"#C1E1C1", // pastel mint
]
+type Dialog = "provider" | "model" | "connect"
+
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
@@ -44,8 +46,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
)
const [ephemeral, setEphemeral] = createStore({
+ connect: {
+ provider: undefined as undefined | string,
+ },
dialog: {
- open: undefined as undefined | "provider" | "model",
+ open: undefined as undefined | Dialog,
},
})
const usedColors = new Set<string>()
@@ -169,14 +174,23 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
dialog: {
opened: createMemo(() => ephemeral.dialog?.open),
- open(dialog: "provider" | "model") {
+ open(dialog: Dialog) {
setEphemeral("dialog", "open", dialog)
},
- close(dialog: "provider" | "model") {
+ close(dialog: Dialog) {
if (ephemeral.dialog?.open === dialog) {
setEphemeral("dialog", "open", undefined)
}
},
+ connect(provider: string) {
+ batch(() => {
+ setEphemeral("dialog", "open", "connect")
+ setEphemeral("connect", "provider", provider)
+ })
+ },
+ },
+ connect: {
+ provider: createMemo(() => ephemeral.connect.provider),
},
}
},
diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx
index 1a11cd599..d64fcce2a 100644
--- a/packages/desktop/src/context/sync.tsx
+++ b/packages/desktop/src/context/sync.tsx
@@ -1,5 +1,5 @@
import { produce } from "solid-js/store"
-import { createMemo } from "solid-js"
+import { createMemo, onMount } from "solid-js"
import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
@@ -31,7 +31,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
node: () => sdk.client.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
}
- Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
+ async function bootstrap() {
+ return Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
+ }
+
+ onMount(() => {
+ bootstrap()
+ })
+
+ sdk.event.listen((e) => {
+ if (e.name !== sdk.directory) return
+ const event = e.details
+ switch (event.type) {
+ case "server.instance.disposed": {
+ bootstrap()
+ break
+ }
+ }
+ })
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
@@ -82,7 +99,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
more: createMemo(() => store.session.length >= store.limit),
},
- load,
+ bootstrap,
absolute,
get directory() {
return store.path.directory
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index 10d4cbfda..0ba6c0a2d 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -17,7 +17,7 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { getFilename } from "@opencode-ai/util/path"
import { Select } from "@opencode-ai/ui/select"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { Session, Project } from "@opencode-ai/sdk/v2/client"
+import { Session, Project, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
import { createStore } from "solid-js/store"
import {
@@ -34,6 +34,11 @@ 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 { List, ListRef } from "@opencode-ai/ui/list"
+import { Input } from "@opencode-ai/ui/input"
+import { useGlobalSDK } from "@/context/global-sdk"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -42,6 +47,7 @@ export default function Layout(props: ParentProps) {
})
const params = useParams()
+ const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const layout = useLayout()
const platform = usePlatform()
@@ -562,7 +568,6 @@ export default function Layout(props: ParentProps) {
activeIcon="plus-small"
key={(x) => x?.id}
items={providers().all}
- // current={local.model.current()}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")}
sortBy={(a, b) => {
@@ -575,7 +580,10 @@ export default function Layout(props: ParentProps) {
if (b.category === "Popular" && a.category !== "Popular") return 1
return 0
}}
- // onSelect={(x) => }
+ onSelect={(x) => {
+ if (!x) return
+ layout.dialog.connect(x.id)
+ }}
onOpenChange={(open) => {
if (open) {
layout.dialog.open("provider")
@@ -607,6 +615,205 @@ export default function Layout(props: ParentProps) {
)}
</SelectDialog>
</Show>
+ <Show when={layout.dialog?.opened() === "connect"}>
+ {iife(() => {
+ 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])
+ }
+
+ let listRef: ListRef | undefined
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") return
+ listRef?.onKeyDown(e)
+ }
+
+ 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 (store.method && methods.length > 1) {
+ 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">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)
+
+ 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,
+ })
+
+ 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)
+ globalSDK.client.auth.set({
+ providerID,
+ auth: {
+ type: "api",
+ key: apiKey,
+ },
+ })
+ await globalSDK.client.instance.dispose()
+ }
+
+ 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">
+ 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>
+ </div>
+ </Dialog.Body>
+ </Dialog>
+ )
+ })}
+ </Show>
</div>
</div>
)