summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2025-12-12 09:44:06 +0000
committerDavid Hill <[email protected]>2025-12-12 09:44:06 +0000
commit99158e736bd983ee5c62bfc43032da1bafc45d71 (patch)
tree59b1afc0fff72a8c47ca76c90a767aafae7a5f45 /packages/desktop
parent4c02d515a1e15a99fc009587e821087e042bd45b (diff)
parentf9d5e1879056dd9507bb1a1645da5b5ede87fcca (diff)
downloadopencode-99158e736bd983ee5c62bfc43032da1bafc45d71.tar.gz
opencode-99158e736bd983ee5c62bfc43032da1bafc45d71.zip
Merge branch 'dev' of https://github.com/sst/opencode into dev
Diffstat (limited to 'packages/desktop')
-rw-r--r--packages/desktop/package.json2
-rw-r--r--packages/desktop/src/components/link.tsx17
-rw-r--r--packages/desktop/src/components/prompt-input.tsx147
-rw-r--r--packages/desktop/src/context/layout.tsx87
-rw-r--r--packages/desktop/src/context/local.tsx28
-rw-r--r--packages/desktop/src/context/session.tsx4
-rw-r--r--packages/desktop/src/hooks/use-providers.ts12
-rw-r--r--packages/desktop/src/pages/layout.tsx533
-rw-r--r--packages/desktop/src/pages/session.tsx1
9 files changed, 548 insertions, 283 deletions
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 926861010..1d12a9cb9 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
- "version": "1.0.149",
+ "version": "1.0.150",
"description": "",
"type": "module",
"exports": {
diff --git a/packages/desktop/src/components/link.tsx b/packages/desktop/src/components/link.tsx
new file mode 100644
index 000000000..e13c31330
--- /dev/null
+++ b/packages/desktop/src/components/link.tsx
@@ -0,0 +1,17 @@
+import { ComponentProps, splitProps } from "solid-js"
+import { usePlatform } from "@/context/platform"
+
+export interface LinkProps extends ComponentProps<"button"> {
+ href: string
+}
+
+export function Link(props: LinkProps) {
+ const platform = usePlatform()
+ const [local, rest] = splitProps(props, ["href", "children"])
+
+ return (
+ <button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}>
+ {local.children}
+ </button>
+ )
+}
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 41af8644b..70ee0a739 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"
@@ -21,7 +33,6 @@ 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"
@@ -470,60 +481,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.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
+ <Tag>Free</Tag>
+ </Show>
+ <Show when={i.latest}>
+ <Tag>Latest</Tag>
+ </Show>
+ </div>
+ )}
+ </SelectDialog>
+ )
+ })}
</Match>
<Match when={true}>
{iife(() => {
@@ -532,6 +556,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (e.key === "Escape") return
listRef?.onKeyDown(e)
}
+
+ onMount(() => {
+ document.addEventListener("keydown", handleKey)
+ onCleanup(() => {
+ document.removeEventListener("keydown", handleKey)
+ })
+ })
+
return (
<Dialog
modal
@@ -549,12 +581,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<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()}
+ items={local.model.list}
current={local.model.current()}
key={(x) => `${x.provider.id}:${x.id}`}
onSelect={(x) => {
@@ -587,7 +618,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..9cafdce96 100644
--- a/packages/desktop/src/context/layout.tsx
+++ b/packages/desktop/src/context/layout.tsx
@@ -6,18 +6,26 @@ 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
-]
+const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
+
+export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
+
+export function isAvatarColorKey(value: string): value is AvatarColorKey {
+ return AVATAR_COLOR_KEYS.includes(value as AvatarColorKey)
+}
+
+export function getAvatarColors(key?: string) {
+ if (key && isAvatarColorKey(key)) {
+ return {
+ background: `var(--avatar-background-${key})`,
+ foreground: `var(--avatar-text-${key})`,
+ }
+ }
+ return {
+ background: "var(--surface-info-base)",
+ foreground: "var(--text-base)",
+ }
+}
type Dialog = "provider" | "model" | "connect"
@@ -45,21 +53,24 @@ 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>()
+ const usedColors = new Set<AvatarColorKey>()
- 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)]
+ function pickAvailableColor(): AvatarColorKey {
+ const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c))
+ if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
return available[Math.floor(Math.random() * available.length)]
}
@@ -177,22 +188,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/context/session.tsx b/packages/desktop/src/context/session.tsx
index db2b3af7c..860c1a14f 100644
--- a/packages/desktop/src/context/session.tsx
+++ b/packages/desktop/src/context/session.tsx
@@ -62,10 +62,10 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
const userMessages = createMemo(() =>
messages()
.filter((m) => m.role === "user")
- .sort((a, b) => b.id.localeCompare(a.id)),
+ .sort((a, b) => a.id.localeCompare(b.id)),
)
const lastUserMessage = createMemo(() => {
- return userMessages()?.at(0)
+ return userMessages()?.at(-1)
})
const activeMessage = createMemo(() => {
if (!store.messageId) return lastUserMessage()
diff --git a/packages/desktop/src/hooks/use-providers.ts b/packages/desktop/src/hooks/use-providers.ts
index 04ef855d4..501ff9d0c 100644
--- a/packages/desktop/src/hooks/use-providers.ts
+++ b/packages/desktop/src/hooks/use-providers.ts
@@ -17,13 +17,15 @@ export function useProviders() {
return globalSync.data.provider
})
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 paid = createMemo(() =>
+ connected().filter((p) => p.id !== "opencode" || 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 257cfc8a3..b997296fa 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -1,7 +1,7 @@
-import { createEffect, createMemo, For, Match, ParentProps, Show, Switch, type JSX } from "solid-js"
+import { createEffect, createMemo, For, Match, onCleanup, onMount, ParentProps, Show, Switch, type JSX } from "solid-js"
import { DateTime } from "luxon"
import { A, useNavigate, useParams } from "@solidjs/router"
-import { useLayout } from "@/context/layout"
+import { useLayout, getAvatarColors } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { Mark } from "@opencode-ai/ui/logo"
@@ -17,9 +17,9 @@ 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, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
+import { Session, Project, ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
-import { createStore } from "solid-js/store"
+import { createStore, produce } from "solid-js/store"
import {
DragDropProvider,
DragDropSensors,
@@ -36,9 +36,12 @@ import { IconName } from "@opencode-ai/ui/icons/provider"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { Dialog } from "@opencode-ai/ui/dialog"
import { iife } from "@opencode-ai/util/iife"
+import { Link } from "@/components/link"
import { List, ListRef } from "@opencode-ai/ui/list"
-import { Input } from "@opencode-ai/ui/input"
+import { TextField } from "@opencode-ai/ui/text-field"
+import { showToast, Toast } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
+import { Spinner } from "@opencode-ai/ui/spinner"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -177,7 +180,7 @@ export default function Layout(props: ParentProps) {
<Avatar
fallback={name()}
src={props.project.icon?.url}
- background={props.project.icon?.color ?? "var(--surface-info-base)"}
+ {...getAvatarColors(props.project.icon?.color)}
class="size-full"
/>
</div>
@@ -197,7 +200,7 @@ export default function Layout(props: ParentProps) {
<Avatar
fallback={name()}
src={props.project.icon?.url}
- background={props.project.icon?.color ?? "var(--surface-info-base)"}
+ {...getAvatarColors(props.project.icon?.color)}
class="size-full"
/>
</div>
@@ -228,7 +231,7 @@ export default function Layout(props: ParentProps) {
<Avatar
fallback={name()}
src={props.project.icon?.url}
- background={props.project.icon?.color ?? "var(--surface-info-base)"}
+ {...getAvatarColors(props.project.icon?.color)}
class="size-full group-hover/session:hidden"
/>
<Icon
@@ -487,7 +490,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>
@@ -533,17 +536,17 @@ export default function Layout(props: ParentProps) {
</Button>
</Tooltip>
</Show>
- <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 px-2"
- variant="ghost"
- size="large"
- icon="settings-gear"
- >
- <Show when={layout.sidebar.opened()}>Settings</Show>
- </Button>
- </Tooltip>
+ {/* <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 px-2" */}
+ {/* variant="ghost" */}
+ {/* size="large" */}
+ {/* icon="settings-gear" */}
+ {/* > */}
+ {/* <Show when={layout.sidebar.opened()}>Settings</Show> */}
+ {/* </Button> */}
+ {/* </Tooltip> */}
<Tooltip placement="right" value="Share feedback" inactive={layout.sidebar.opened()}>
<Button
as={"a"}
@@ -567,7 +570,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) => {
@@ -617,27 +620,102 @@ export default function Layout(props: ParentProps) {
</Show>
<Show when={layout.dialog.opened() === "connect"}>
{iife(() => {
+ const providerID = createMemo(() => layout.connect.provider()!)
+ const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === providerID())!)
+ const methods = createMemo(
+ () =>
+ globalSync.data.provider_auth[providerID()] ?? [
+ {
+ type: "api",
+ label: "API key",
+ },
+ ],
+ )
const [store, setStore] = createStore({
method: undefined as undefined | ProviderAuthMethod,
+ authorization: undefined as undefined | ProviderAuthAuthorization,
+ state: "pending" as undefined | "pending" | "complete" | "error",
+ error: undefined as string | undefined,
})
- const 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 methodIndex = createMemo(() => methods().findIndex((x) => x.label === store.method?.label))
+
+ async function selectMethod(index: number) {
+ const method = methods()[index]
+ setStore(
+ produce((draft) => {
+ draft.method = method
+ draft.authorization = undefined
+ draft.state = undefined
+ draft.error = undefined
+ }),
+ )
+
+ if (method.type === "oauth") {
+ setStore("state", "pending")
+ const start = Date.now()
+ await globalSDK.client.provider.oauth
+ .authorize(
+ {
+ providerID: providerID(),
+ method: index,
+ },
+ { throwOnError: true },
+ )
+ .then((x) => {
+ const elapsed = Date.now() - start
+ const delay = 1000 - elapsed
+
+ if (delay > 0) {
+ setTimeout(() => {
+ setStore("state", "complete")
+ setStore("authorization", x.data!)
+ }, delay)
+ return
+ }
+ setStore("state", "complete")
+ setStore("authorization", x.data!)
+ })
+ .catch((e) => {
+ setStore("state", "error")
+ setStore("error", String(e))
+ })
+ }
}
let listRef: ListRef | undefined
- const handleKey = (e: KeyboardEvent) => {
+ function handleKey(e: KeyboardEvent) {
+ if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
+ return
+ }
if (e.key === "Escape") return
listRef?.onKeyDown(e)
}
+ onMount(() => {
+ if (methods().length === 1) {
+ selectMethod(0)
+ }
+
+ document.addEventListener("keydown", handleKey)
+ onCleanup(() => {
+ document.removeEventListener("keydown", handleKey)
+ })
+ })
+
+ async function complete() {
+ await globalSDK.client.global.dispose()
+ setTimeout(() => {
+ showToast({
+ variant: "success",
+ icon: "circle-check",
+ title: `${provider().name} connected`,
+ description: `${provider().name} models are now available to use.`,
+ })
+ layout.connect.complete()
+ }, 500)
+ }
+
return (
<Dialog
modal
@@ -657,7 +735,16 @@ export default function Layout(props: ParentProps) {
icon="arrow-left"
variant="ghost"
onClick={() => {
- if (store.method && methods.length > 1) {
+ if (methods().length === 1) {
+ layout.dialog.open("provider")
+ return
+ }
+ if (store.authorization) {
+ setStore("authorization", undefined)
+ setStore("method", undefined)
+ return
+ }
+ if (store.method) {
setStore("method", undefined)
return
}
@@ -670,145 +757,256 @@ 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">
+ <Switch>
+ <Match
+ when={providerID() === "anthropic" && store.method?.label?.toLowerCase().includes("max")}
+ >
+ Login with Claude Pro/Max
+ </Match>
+ <Match when={true}>Connect {provider().name}</Match>
+ </Switch>
+ </div>
</div>
- <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 class="px-2.5 pb-10 flex flex-col gap-6">
+ <Switch>
+ <Match when={store.method === undefined}>
+ <div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
+ <div class="">
+ <List
+ ref={(ref) => (listRef = ref)}
+ items={methods}
+ key={(m) => m?.label}
+ onSelect={async (method, index) => {
+ if (!method) return
+ selectMethod(index)
+ }}
+ >
+ {(i) => (
+ <div class="w-full flex items-center gap-x-4">
+ <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
+ <div
+ class="w-2.5 h-0.5 bg-icon-strong-base hidden"
+ data-slot="list-item-extra-icon"
+ />
+ </div>
+ <span>{i.label}</span>
+ </div>
+ )}
+ </List>
+ </div>
+ </Match>
+ <Match when={store.state === "pending"}>
+ <div class="text-14-regular text-text-base">
+ <div class="flex items-center gap-x-4">
+ <Spinner />
+ <span>Authorization in progress...</span>
</div>
- )}
- </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)
- await globalSDK.client.auth.set({
- providerID,
- auth: {
- type: "api",
- key: apiKey,
- },
- })
- await globalSDK.client.global.dispose()
- layout.connect.complete()
- }
+ </div>
+ </Match>
+ <Match when={store.state === "error"}>
+ <div class="text-14-regular text-text-base">
+ <div class="flex items-center gap-x-4">
+ <Icon name="circle-ban-sign" class="text-icon-critical-base" />
+ <span>Authorization failed: {store.error}</span>
+ </div>
+ </div>
+ </Match>
+ <Match when={store.method?.type === "api"}>
+ {iife(() => {
+ const [formStore, setFormStore] = createStore({
+ value: "",
+ error: undefined as string | undefined,
+ })
- 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.
+ async function handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
+
+ const form = e.currentTarget as HTMLFormElement
+ const formData = new FormData(form)
+ const apiKey = formData.get("apiKey") as string
+
+ if (!apiKey?.trim()) {
+ setFormStore("error", "API key is required")
+ return
+ }
+
+ setFormStore("error", undefined)
+ await globalSDK.client.auth.set({
+ providerID: providerID(),
+ auth: {
+ type: "api",
+ key: apiKey,
+ },
+ })
+ await complete()
+ }
+
+ return (
+ <div class="flex flex-col gap-6">
+ <Switch>
+ <Match when={provider().id === "opencode"}>
+ <div class="flex flex-col gap-4">
+ <div class="text-14-regular text-text-base">
+ OpenCode Zen gives you access to a curated set of reliable optimized models for
+ coding agents.
+ </div>
+ <div class="text-14-regular text-text-base">
+ With a single API key you’ll get access to models such as Claude, GPT, Gemini,
+ GLM and more.
+ </div>
+ <div class="text-14-regular text-text-base">
+ Visit{" "}
+ <Link href="https://opencode.ai/zen" tabIndex={-1}>
+ opencode.ai/zen
+ </Link>{" "}
+ to collect your API key.
+ </div>
+ </div>
+ </Match>
+ <Match when={true}>
+ <div class="text-14-regular text-text-base">
+ Enter your {provider().name} API key to connect your account and use{" "}
+ {provider().name} models in OpenCode.
+ </div>
+ </Match>
+ </Switch>
+ <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
+ <TextField
+ autofocus
+ type="text"
+ label={`${provider().name} API key`}
+ placeholder="API key"
+ name="apiKey"
+ value={formStore.value}
+ onChange={setFormStore.bind(null, "value")}
+ validationState={formStore.error ? "invalid" : undefined}
+ error={formStore.error}
+ />
+ <Button class="w-auto" type="submit" size="large" variant="primary">
+ Submit
+ </Button>
+ </form>
+ </div>
+ )
+ })}
+ </Match>
+ <Match when={store.method?.type === "oauth"}>
+ <Switch>
+ <Match when={store.authorization?.method === "code"}>
+ {iife(() => {
+ const [formStore, setFormStore] = createStore({
+ value: "",
+ error: undefined as string | undefined,
+ })
+
+ onMount(() => {
+ if (store.authorization?.method === "code" && store.authorization?.url) {
+ platform.openLink(store.authorization.url)
+ }
+ })
+
+ async function handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
+
+ const form = e.currentTarget as HTMLFormElement
+ const formData = new FormData(form)
+ const code = formData.get("code") as string
+
+ if (!code?.trim()) {
+ setFormStore("error", "Authorization code is required")
+ return
+ }
+
+ setFormStore("error", undefined)
+ const { error } = await globalSDK.client.provider.oauth.callback({
+ providerID: providerID(),
+ method: methodIndex(),
+ code,
+ })
+ if (!error) {
+ await complete()
+ return
+ }
+ setFormStore("error", "Invalid authorization code")
+ }
+
+ return (
+ <div class="flex flex-col gap-6">
+ <div class="text-14-regular text-text-base">
+ Visit <Link href={store.authorization!.url}>this link</Link> to collect your
+ authorization code to connect your account and use {provider().name} models in
+ OpenCode.
+ </div>
+ <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
+ <TextField
+ autofocus
+ type="text"
+ label={`${store.method?.label} authorization code`}
+ placeholder="Authorization code"
+ name="code"
+ value={formStore.value}
+ onChange={setFormStore.bind(null, "value")}
+ validationState={formStore.error ? "invalid" : undefined}
+ error={formStore.error}
+ />
+ <Button class="w-auto" type="submit" size="large" variant="primary">
+ Submit
+ </Button>
+ </form>
</div>
- <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.
+ )
+ })}
+ </Match>
+ <Match when={store.authorization?.method === "auto"}>
+ {iife(() => {
+ const code = createMemo(() => {
+ const instructions = store.authorization?.instructions
+ if (instructions?.includes(":")) {
+ return instructions?.split(":")[1]?.trim()
+ }
+ return instructions
+ })
+
+ onMount(async () => {
+ const result = await globalSDK.client.provider.oauth.callback({
+ providerID: providerID(),
+ method: methodIndex(),
+ })
+ if (result.error) {
+ // TODO: show error
+ layout.dialog.close("connect")
+ return
+ }
+ await complete()
+ })
+
+ return (
+ <div class="flex flex-col gap-6">
+ <div class="text-14-regular text-text-base">
+ Visit <Link href={store.authorization!.url}>this link</Link> and enter the code
+ below to connect your account and use {provider().name} models in OpenCode.
+ </div>
+ <TextField
+ label="Confirmation code"
+ class="font-mono"
+ value={code()}
+ readOnly
+ copyable
+ />
+ <div class="text-14-regular text-text-base flex items-center gap-4">
+ <Spinner />
+ <span>Waiting for authorization...</span>
+ </div>
</div>
- </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>
+ </Match>
+ </Switch>
+ </div>
</div>
</Dialog.Body>
</Dialog>
@@ -816,6 +1014,7 @@ export default function Layout(props: ParentProps) {
})}
</Show>
</div>
+ <Toast.Region />
</div>
)
}
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index 890401723..5dae4ce55 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -415,7 +415,6 @@ export default function Page() {
messages={session.messages.user()}
current={session.messages.active()}
onMessageSelect={session.messages.setActive}
- working={session.working()}
wide={wide()}
/>
<SessionTurn