diff options
Diffstat (limited to 'packages/desktop')
| -rw-r--r-- | packages/desktop/src/app.tsx | 8 | ||||
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 237 | ||||
| -rw-r--r-- | packages/desktop/src/context/global-sync.tsx | 85 | ||||
| -rw-r--r-- | packages/desktop/src/context/layout.tsx | 81 | ||||
| -rw-r--r-- | packages/desktop/src/context/local.tsx | 31 | ||||
| -rw-r--r-- | packages/desktop/src/context/session.tsx | 2 | ||||
| -rw-r--r-- | packages/desktop/src/context/sync.tsx | 6 | ||||
| -rw-r--r-- | packages/desktop/src/hooks/use-providers.ts | 31 | ||||
| -rw-r--r-- | packages/desktop/src/pages/home.tsx | 4 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 108 |
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> ) |
