summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop
diff options
context:
space:
mode:
Diffstat (limited to 'packages/desktop')
-rw-r--r--packages/desktop/src/app.tsx8
-rw-r--r--packages/desktop/src/components/prompt-input.tsx237
-rw-r--r--packages/desktop/src/context/global-sync.tsx85
-rw-r--r--packages/desktop/src/context/layout.tsx81
-rw-r--r--packages/desktop/src/context/local.tsx31
-rw-r--r--packages/desktop/src/context/session.tsx2
-rw-r--r--packages/desktop/src/context/sync.tsx6
-rw-r--r--packages/desktop/src/hooks/use-providers.ts31
-rw-r--r--packages/desktop/src/pages/home.tsx4
-rw-r--r--packages/desktop/src/pages/layout.tsx108
10 files changed, 449 insertions, 144 deletions
diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx
index 0ca4d5e6b..a1ff90d26 100644
--- a/packages/desktop/src/app.tsx
+++ b/packages/desktop/src/app.tsx
@@ -15,8 +15,14 @@ import { GlobalSDKProvider } from "./context/global-sdk"
import { SessionProvider } from "./context/session"
import { Show } from "solid-js"
+declare global {
+ interface Window {
+ __OPENCODE__?: { updaterEnabled?: boolean; port?: number }
+ }
+}
+
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
-const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
+const port = window.__OPENCODE__?.port ?? import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
const url =
new URLSearchParams(document.location.search).get("url") ||
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 6590b6d18..22f2c1642 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -3,7 +3,6 @@ import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Mat
import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
-import { DateTime } from "luxon"
import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session"
import { useSDK } from "@/context/sdk"
import { useNavigate } from "@solidjs/router"
@@ -14,10 +13,17 @@ 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 { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Select } from "@opencode-ai/ui/select"
+import { Tag } from "@opencode-ai/ui/tag"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { type IconName } from "@opencode-ai/ui/icons/provider"
+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 { Input } from "@opencode-ai/ui/input"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { IconName } from "@opencode-ai/ui/icons/provider"
interface PromptInputProps {
class?: string
@@ -58,6 +64,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const sync = useSync()
const local = useLocal()
const session = useSession()
+ const layout = useLayout()
+ const providers = useProviders()
let editorRef!: HTMLDivElement
const [store, setStore] = createStore<{
@@ -455,55 +463,180 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="capitalize"
variant="ghost"
/>
- <SelectDialog
- 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)}
- sortGroupsBy={(a, b) => {
- const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
- 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 (order.includes(aProvider) && !order.includes(bProvider)) return -1
- if (!order.includes(aProvider) && order.includes(bProvider)) return 1
- return order.indexOf(aProvider) - order.indexOf(bProvider)
- }}
- onSelect={(x) =>
- local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true })
- }
- trigger={
- <Button as="div" variant="ghost">
- {local.model.current()?.name ?? "Select model"}
- <span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
- <Icon name="chevron-down" size="small" />
- </Button>
- }
- >
- {(i) => (
- <div class="w-full flex items-center justify-between gap-x-3">
- <div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0">
- <ProviderIcon name={i.provider.id as IconName} class="size-6 p-0.5 shrink-0" />
- <div class="flex gap-x-3 items-baseline flex-[1_0_0]">
- <span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
- <Show when={false}>
- <span class="text-12-medium text-text-weak overflow-hidden text-ellipsis truncate min-w-0">
- {DateTime.fromFormat("unknown", "yyyy-MM-dd").toFormat("LLL yyyy")}
- </span>
- </Show>
- </div>
- </div>
- <Show when={!i.cost || i.cost?.input === 0}>
- <div class="overflow-hidden text-12-medium text-text-strong">Free</div>
- </Show>
- </div>
- )}
- </SelectDialog>
+ <Button as="div" variant="ghost" onClick={() => layout.dialog.open("model")}>
+ {local.model.current()?.name ?? "Select model"}
+ <span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
+ <Icon name="chevron-down" size="small" />
+ </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")}
+ >
+ 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>
+ </Match>
+ <Match when={true}>
+ {iife(() => {
+ 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("model")
+ } else {
+ layout.dialog.close("model")
+ }
+ }}
+ >
+ <Dialog.Header>
+ <Dialog.Title>Select model</Dialog.Title>
+ <Dialog.CloseButton tabIndex={-1} />
+ </Dialog.Header>
+ <Dialog.Body>
+ <Input hidden type="text" class="opacity-0 size-0" autofocus onKeyDown={handleKey} />
+ <div class="flex flex-col gap-3 px-2.5">
+ <div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div>
+ <List
+ ref={(ref) => (listRef = ref)}
+ items={local.model.list()}
+ 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-6">
+ <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>
+ </div>
+ </div>
+ </Dialog.Body>
+ </Dialog>
+ )
+ })}
+ </Match>
+ </Switch>
+ </Show>
</div>
<Tooltip
placement="top"
diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx
index 890801611..3a6062fb8 100644
--- a/packages/desktop/src/context/global-sync.tsx
+++ b/packages/desktop/src/context/global-sync.tsx
@@ -1,7 +1,6 @@
import type {
Message,
Agent,
- Provider,
Session,
Part,
Config,
@@ -12,49 +11,18 @@ import type {
FileDiff,
Todo,
SessionStatus,
+ ProviderListResponse,
} 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"
-const PASTEL_COLORS = [
- "#FCEAFD", // pastel pink
- "#FFDFBA", // pastel peach
- "#FFFFBA", // pastel yellow
- "#BAFFC9", // pastel green
- "#EAF6FD", // pastel blue
- "#EFEAFD", // pastel lavender
- "#FEC8D8", // pastel rose
- "#D4F0F0", // pastel cyan
- "#FDF0EA", // pastel coral
- "#C1E1C1", // pastel mint
-]
-
-function pickAvailableColor(usedColors: Set<string>) {
- const available = PASTEL_COLORS.filter((c) => !usedColors.has(c))
- if (available.length === 0) return PASTEL_COLORS[Math.floor(Math.random() * PASTEL_COLORS.length)]
- return available[Math.floor(Math.random() * available.length)]
-}
-
-async function ensureProjectColor(
- project: Project,
- sdk: ReturnType<typeof useGlobalSDK>,
- usedColors: Set<string>,
-): Promise<Project> {
- if (project.icon?.color) return project
- const color = pickAvailableColor(usedColors)
- usedColors.add(color)
- const updated = { ...project, icon: { ...project.icon, color } }
- sdk.client.project.update({ projectID: project.id, icon: { color } })
- return updated
-}
-
type State = {
ready: boolean
- provider: Provider[]
agent: Agent[]
project: string
+ provider: ProviderListResponse
config: Config
path: Path
session: Session[]
@@ -81,13 +49,16 @@ type State = {
export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimpleContext({
name: "GlobalSync",
init: () => {
+ const sdk = useGlobalSDK()
const [globalStore, setGlobalStore] = createStore<{
ready: boolean
- projects: Project[]
+ project: Project[]
+ provider: ProviderListResponse
children: Record<string, State>
}>({
ready: false,
- projects: [],
+ project: [],
+ provider: { all: [], connected: [], default: {} },
children: {},
})
@@ -96,11 +67,11 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
if (!children[directory]) {
setGlobalStore("children", directory, {
project: "",
+ provider: { all: [], connected: [], default: {} },
config: {},
- path: { state: "", config: "", worktree: "", directory: "" },
+ path: { state: "", config: "", worktree: "", directory: "", home: "" },
ready: false,
agent: [],
- provider: [],
session: [],
session_status: {},
session_diff: {},
@@ -116,7 +87,6 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
return children[directory]
}
- const sdk = useGlobalSDK()
sdk.event.listen((e) => {
const directory = e.name
const event = e.details
@@ -124,20 +94,17 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
if (directory === "global") {
switch (event.type) {
case "project.updated": {
- const usedColors = new Set(globalStore.projects.map((p) => p.icon?.color).filter(Boolean) as string[])
- ensureProjectColor(event.properties, sdk, usedColors).then((project) => {
- const result = Binary.search(globalStore.projects, project.id, (s) => s.id)
- if (result.found) {
- setGlobalStore("projects", result.index, reconcile(project))
- return
- }
- setGlobalStore(
- "projects",
- produce((draft) => {
- draft.splice(result.index, 0, project)
- }),
- )
- })
+ const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
+ if (result.found) {
+ setGlobalStore("project", result.index, reconcile(event.properties))
+ return
+ }
+ setGlobalStore(
+ "project",
+ produce((draft) => {
+ draft.splice(result.index, 0, event.properties)
+ }),
+ )
break
}
}
@@ -216,14 +183,16 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
Promise.all([
sdk.client.project.list().then(async (x) => {
- const filtered = x.data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs)
- const usedColors = new Set(filtered.map((p) => p.icon?.color).filter(Boolean) as string[])
- const projects = await Promise.all(filtered.map((p) => ensureProjectColor(p, sdk, usedColors)))
setGlobalStore(
- "projects",
- projects.sort((a, b) => a.id.localeCompare(b.id)),
+ "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))
return {
diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx
index b7d1fabb5..5530ad28f 100644
--- a/packages/desktop/src/context/layout.tsx
+++ b/packages/desktop/src/context/layout.tsx
@@ -4,6 +4,20 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { makePersisted } from "@solid-primitives/storage"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
+import { Project } from "@opencode-ai/sdk/v2"
+
+const PASTEL_COLORS = [
+ "#FCEAFD", // pastel pink
+ "#FFDFBA", // pastel peach
+ "#FFFFBA", // pastel yellow
+ "#BAFFC9", // pastel green
+ "#EAF6FD", // pastel blue
+ "#EFEAFD", // pastel lavender
+ "#FEC8D8", // pastel rose
+ "#D4F0F0", // pastel cyan
+ "#FDF0EA", // pastel coral
+ "#C1E1C1", // pastel mint
+]
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
@@ -26,9 +40,44 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
}),
{
- name: "default-layout.v6",
+ name: "default-layout.v7",
},
)
+ const [ephemeral, setEphemeral] = createStore({
+ dialog: {
+ open: undefined as undefined | "provider" | "model",
+ },
+ })
+ const usedColors = new Set<string>()
+
+ function pickAvailableColor() {
+ const available = PASTEL_COLORS.filter((c) => !usedColors.has(c))
+ if (available.length === 0) return PASTEL_COLORS[Math.floor(Math.random() * PASTEL_COLORS.length)]
+ return available[Math.floor(Math.random() * available.length)]
+ }
+
+ function enrich(project: { worktree: string; expanded: boolean }) {
+ const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree)
+ if (!metadata) return []
+ return [
+ {
+ ...project,
+ ...metadata,
+ },
+ ]
+ }
+
+ function colorize(project: Project & { expanded: boolean }) {
+ if (project.icon?.color) return project
+ const color = pickAvailableColor()
+ usedColors.add(color)
+ project.icon = { ...project.icon, color }
+ globalSdk.client.project.update({ projectID: project.id, icon: { color } })
+ return project
+ }
+
+ const enriched = createMemo(() => store.projects.flatMap(enrich))
+ const list = createMemo(() => enriched().flatMap(colorize))
async function loadProjectSessions(directory: string) {
const [, setStore] = globalSync.child(directory)
@@ -43,30 +92,19 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
onMount(() => {
Promise.all(
- store.projects.map(({ worktree }) => {
- return loadProjectSessions(worktree)
+ store.projects.map((project) => {
+ return loadProjectSessions(project.worktree)
}),
)
})
- function enrich(project: { worktree: string; expanded: boolean }) {
- const metadata = globalSync.data.projects.find((x) => x.worktree === project.worktree)
- if (!metadata) return []
- return [
- {
- ...project,
- ...metadata,
- },
- ]
- }
-
return {
projects: {
- list: createMemo(() => store.projects.flatMap(enrich)),
+ list,
open(directory: string) {
if (store.projects.find((x) => x.worktree === directory)) return
loadProjectSessions(directory)
- setStore("projects", (x) => [...x, { worktree: directory, expanded: true }])
+ setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
},
close(directory: string) {
setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
@@ -129,6 +167,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("review", "state", "tab")
},
},
+ dialog: {
+ opened: createMemo(() => ephemeral.dialog?.open),
+ open(dialog: "provider" | "model") {
+ setEphemeral("dialog", "open", dialog)
+ },
+ close(dialog: "provider" | "model") {
+ if (ephemeral.dialog?.open === dialog) {
+ setEphemeral("dialog", "open", undefined)
+ }
+ },
+ },
}
},
})
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx
index 8223a36b9..74d3ac364 100644
--- a/packages/desktop/src/context/local.tsx
+++ b/packages/desktop/src/context/local.tsx
@@ -25,6 +25,7 @@ export type View = LocalFile["view"]
export type LocalModel = Omit<Model, "provider"> & {
provider: Provider
+ latest?: boolean
}
export type ModelKey = { providerID: string; modelID: string }
@@ -38,8 +39,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sync = useSync()
function isModelValid(model: ModelKey) {
- const provider = sync.data.provider.find((x) => x.id === model.providerID)
- return !!provider?.models[model.modelID]
+ const provider = sync.data.provider?.all.find((x) => x.id === model.providerID)
+ return !!provider?.models[model.modelID] && sync.data.provider?.connected.includes(model.providerID)
}
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
@@ -114,7 +115,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const list = createMemo(() =>
- sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
+ sync.data.provider.all
+ .filter((p) => sync.data.provider.connected.includes(p.id))
+ .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)
@@ -134,12 +144,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return item
}
}
- const provider = sync.data.provider[0]
- const model = Object.values(provider.models)[0]
- return {
- providerID: provider.id,
- modelID: model.id,
+
+ for (const p of sync.data.provider.connected) {
+ if (p in sync.data.provider.default) {
+ return {
+ providerID: p,
+ modelID: sync.data.provider.default[p],
+ }
+ }
}
+
+ throw new Error("No default model found")
})
const currentModel = createMemo(() => {
diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx
index 31004811b..db2b3af7c 100644
--- a/packages/desktop/src/context/session.tsx
+++ b/packages/desktop/src/context/session.tsx
@@ -94,7 +94,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
() => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
)
const model = createMemo(() =>
- last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
+ last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
)
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx
index 85986c327..1a11cd599 100644
--- a/packages/desktop/src/context/sync.tsx
+++ b/packages/desktop/src/context/sync.tsx
@@ -14,7 +14,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const load = {
project: () => sdk.client.project.current().then((x) => setStore("project", x.data!.id)),
- provider: () => sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)),
+ provider: () => sdk.client.provider.list().then((x) => setStore("provider", x.data!)),
path: () => sdk.client.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])),
session: () =>
@@ -42,8 +42,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return store.ready
},
get project() {
- const match = Binary.search(globalSync.data.projects, store.project, (p) => p.id)
- if (match.found) return globalSync.data.projects[match.index]
+ const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
+ if (match.found) return globalSync.data.project[match.index]
return undefined
},
session: {
diff --git a/packages/desktop/src/hooks/use-providers.ts b/packages/desktop/src/hooks/use-providers.ts
new file mode 100644
index 000000000..c3fcc7898
--- /dev/null
+++ b/packages/desktop/src/hooks/use-providers.ts
@@ -0,0 +1,31 @@
+import { useGlobalSync } from "@/context/global-sync"
+import { base64Decode } from "@opencode-ai/util/encode"
+import { useParams } from "@solidjs/router"
+import { createMemo } from "solid-js"
+
+export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
+
+export function useProviders() {
+ const params = useParams()
+ const globalSync = useGlobalSync()
+ const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
+ const providers = createMemo(() => {
+ if (currentDirectory()) {
+ const [projectStore] = globalSync.child(currentDirectory())
+ return projectStore.provider
+ }
+ return globalSync.data.provider
+ })
+ const connected = createMemo(() =>
+ providers().all.filter(
+ (p) => providers().connected.includes(p.id) && 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,
+ popular,
+ connected,
+ }))
+}
diff --git a/packages/desktop/src/pages/home.tsx b/packages/desktop/src/pages/home.tsx
index 4aac241e1..205ffd815 100644
--- a/packages/desktop/src/pages/home.tsx
+++ b/packages/desktop/src/pages/home.tsx
@@ -38,7 +38,7 @@ export default function Home() {
<div class="mx-auto mt-55">
<Logo class="w-xl opacity-12" />
<Switch>
- <Match when={sync.data.projects.length > 0}>
+ <Match when={sync.data.project.length > 0}>
<div class="mt-20 w-full flex flex-col gap-4">
<div class="flex gap-2 items-center justify-between pl-3">
<div class="text-14-medium text-text-strong">Recent projects</div>
@@ -50,7 +50,7 @@ export default function Home() {
</div>
<ul class="flex flex-col gap-2">
<For
- each={sync.data.projects
+ each={sync.data.project
.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.slice(0, 5)}
>
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index 3ff3abb0e..10d4cbfda 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -9,6 +9,7 @@ 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"
@@ -29,6 +30,10 @@ 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"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -44,6 +49,7 @@ export default function Layout(props: ParentProps) {
const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? [])
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
+ const providers = useProviders()
function navigateToProject(directory: string | undefined) {
if (!directory) return
@@ -82,12 +88,21 @@ export default function Layout(props: ParentProps) {
}
}
+ async function connectProvider() {
+ layout.dialog.open("provider")
+ }
+
createEffect(() => {
if (!params.dir || !params.id) return
const directory = base64Decode(params.dir)
setStore("lastSession", directory, params.id)
})
+ createEffect(() => {
+ const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
+ document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
+ })
+
function getDraggableId(event: unknown): string | undefined {
if (typeof event !== "object" || event === null) return undefined
if (!("draggable" in event)) return undefined
@@ -465,10 +480,44 @@ export default function Layout(props: ParentProps) {
</DragDropProvider>
</div>
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
+ <Switch>
+ <Match when={!providers().connected().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>
+ <div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
+ <div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
+ </div>
+ <Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
+ <Button
+ class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
+ size="large"
+ icon="plus-small"
+ onClick={connectProvider}
+ >
+ <Show when={layout.sidebar.opened()}>Connect provider</Show>
+ </Button>
+ </Tooltip>
+ </div>
+ </Match>
+ <Match when={true}>
+ <Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
+ <Button
+ class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
+ variant="ghost"
+ size="large"
+ icon="plus-small"
+ onClick={connectProvider}
+ >
+ <Show when={layout.sidebar.opened()}>Connect provider</Show>
+ </Button>
+ </Tooltip>
+ </Match>
+ </Switch>
<Show when={platform.openDirectoryPickerDialog}>
<Tooltip placement="right" value="Open project" inactive={layout.sidebar.opened()}>
<Button
- class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg"
+ class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="folder-add-left"
@@ -481,7 +530,7 @@ export default function Layout(props: ParentProps) {
<Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}>
<Button
disabled
- class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg"
+ class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="settings-gear"
@@ -494,7 +543,7 @@ export default function Layout(props: ParentProps) {
as={"a"}
href="https://opencode.ai/desktop-feedback"
target="_blank"
- class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg"
+ class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="bubble-5"
@@ -505,6 +554,59 @@ export default function Layout(props: ParentProps) {
</div>
</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}
+ // current={local.model.current()}
+ 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) => }
+ 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>
+ </Show>
</div>
</div>
)