From 0ca758e13516d96180cc11631e02e5f7929ba4b0 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:32:53 -0600 Subject: wip(desktop): progress --- packages/ui/src/hooks/use-filtered-list.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'packages/ui/src') diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index f9745918a..e3b373d4d 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -5,7 +5,7 @@ import { createStore } from "solid-js/store" import { createList } from "solid-list" export interface FilteredListProps { - items: T[] | ((filter: string) => Promise) + items: (filter: string) => T[] | Promise key: (item: T) => string filterKeys?: string[] current?: T @@ -22,7 +22,7 @@ export function useFilteredList(props: FilteredListProps) { () => store.filter, async (filter) => { const needle = filter?.toLowerCase() - const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || [] + const all = (await props.items(needle)) || [] const result = pipe( all, (x) => { -- cgit v1.2.3 From 4ae7e1b19c3915e3e9b1a39195d54c4721836b03 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:19:24 -0600 Subject: wip(desktop): progress --- packages/desktop/src/pages/layout.tsx | 14 ++- packages/ui/src/components/icon.tsx | 1 + packages/ui/src/components/toast.css | 175 ++++++++++++++++++++++++++++++++++ packages/ui/src/components/toast.tsx | 142 +++++++++++++++++++++++++++ packages/ui/src/styles/index.css | 1 + 5 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/components/toast.css create mode 100644 packages/ui/src/components/toast.tsx (limited to 'packages/ui/src') diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 65a106708..e9f10e3a2 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -38,6 +38,7 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { iife } from "@opencode-ai/util/iife" import { List, ListRef } from "@opencode-ai/ui/list" import { Input } from "@opencode-ai/ui/input" +import { showToast, Toast } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" export default function Layout(props: ParentProps) { @@ -760,6 +761,12 @@ export default function Layout(props: ParentProps) { }) 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) } @@ -792,8 +799,8 @@ export default function Layout(props: ParentProps) {
- Enter your {provider.name} API key to connect your account and use {provider.name}{" "} - models in OpenCode. + Enter your {provider().name} API key to connect your account and use{" "} + {provider().name} models in OpenCode.
@@ -801,7 +808,7 @@ export default function Layout(props: ParentProps) { + ) } diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 080a6274d..56cef2d8c 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -46,6 +46,7 @@ const icons = { "layout-bottom-partial": ``, "layout-bottom-full": ``, "dot-grid": ``, + "circle-check": ``, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/toast.css b/packages/ui/src/components/toast.css new file mode 100644 index 000000000..2c55a4b06 --- /dev/null +++ b/packages/ui/src/components/toast.css @@ -0,0 +1,175 @@ +[data-component="toast-region"] { + position: fixed; + bottom: 32px; + right: 32px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 8px; + max-width: 400px; + width: 100%; + pointer-events: none; + + [data-slot="toast-list"] { + display: flex; + flex-direction: column; + gap: 8px; + list-style: none; + margin: 0; + padding: 0; + } +} + +[data-component="toast"] { + display: flex; + align-items: flex-start; + gap: 20px; + padding: 16px 20px; + pointer-events: auto; + transition: all 150ms ease-out; + + border-radius: var(--radius-lg); + border: 1px solid var(--border-weak-base); + background: var(--surface-float-base); + color: rgba(253, 252, 252, 0.94); + box-shadow: var(--shadow-md); + + [data-slot="toast-inner"] { + display: flex; + align-items: flex-start; + gap: 10px; + } + + &[data-opened] { + animation: toastPopIn 150ms ease-out; + } + + &[data-closed] { + animation: toastPopOut 100ms ease-in forwards; + } + + &[data-swipe="move"] { + transform: translateX(var(--kb-toast-swipe-move-x)); + } + + &[data-swipe="cancel"] { + transform: translateX(0); + transition: transform 200ms ease-out; + } + + &[data-swipe="end"] { + animation: toastSwipeOut 100ms ease-out forwards; + } + + &[data-variant="success"] { + border-color: var(--color-semantic-positive); + } + + &[data-variant="error"] { + border-color: var(--color-semantic-danger); + } + + &[data-variant="loading"] { + border-color: var(--color-semantic-info); + } + + [data-slot="toast-icon"] { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + [data-component="icon"] { + color: rgba(253, 252, 252, 0.94); + } + } + + [data-slot="toast-content"] { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + [data-slot="toast-title"] { + color: rgba(253, 252, 252, 0.94); + + /* text-14-medium */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + + margin: 0; + } + + [data-slot="toast-description"] { + color: rgba(253, 249, 249, 0.7); + + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-x-large); /* 171.429% */ + letter-spacing: var(--letter-spacing-normal); + + margin: 0; + } + + [data-slot="toast-close-button"] { + flex-shrink: 0; + } + + [data-slot="toast-progress-track"] { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background-color: var(--surface-base); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); + overflow: hidden; + } + + [data-slot="toast-progress-fill"] { + height: 100%; + width: var(--kb-toast-progress-fill-width); + background-color: var(--color-primary); + transition: width 250ms linear; + } +} + +@keyframes toastPopIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes toastPopOut { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(20px); + } +} + +@keyframes toastSwipeOut { + from { + transform: translateX(var(--kb-toast-swipe-end-x)); + } + to { + transform: translateX(100%); + } +} diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx new file mode 100644 index 000000000..b6c9f8b08 --- /dev/null +++ b/packages/ui/src/components/toast.tsx @@ -0,0 +1,142 @@ +import { Toast as Kobalte, toaster } from "@kobalte/core/toast" +import type { ToastRootProps, ToastCloseButtonProps, ToastTitleProps, ToastDescriptionProps } from "@kobalte/core/toast" +import type { ComponentProps, JSX } from "solid-js" +import { Show } from "solid-js" +import { Portal } from "solid-js/web" +import { Icon, type IconProps } from "./icon" +import { IconButton } from "./icon-button" + +export interface ToastRegionProps extends ComponentProps {} + +function ToastRegion(props: ToastRegionProps) { + return ( + + + + + + ) +} + +export interface ToastRootComponentProps extends ToastRootProps { + class?: string + classList?: ComponentProps<"li">["classList"] + children?: JSX.Element +} + +function ToastRoot(props: ToastRootComponentProps) { + return ( + + ) +} + +function ToastIcon(props: { name: IconProps["name"] }) { + return ( +
+ +
+ ) +} + +function ToastContent(props: ComponentProps<"div">) { + return
+} + +function ToastTitle(props: ToastTitleProps & ComponentProps<"div">) { + return +} + +function ToastDescription(props: ToastDescriptionProps & ComponentProps<"div">) { + return +} + +function ToastCloseButton(props: ToastCloseButtonProps & ComponentProps<"button">) { + return +} + +function ToastProgressTrack(props: ComponentProps) { + return +} + +function ToastProgressFill(props: ComponentProps) { + return +} + +export const Toast = Object.assign(ToastRoot, { + Region: ToastRegion, + Icon: ToastIcon, + Content: ToastContent, + Title: ToastTitle, + Description: ToastDescription, + CloseButton: ToastCloseButton, + ProgressTrack: ToastProgressTrack, + ProgressFill: ToastProgressFill, +}) + +export { toaster } + +export type ToastVariant = "default" | "success" | "error" | "loading" + +export interface ToastOptions { + title?: string + description?: string + icon?: IconProps["name"] + variant?: ToastVariant + duration?: number +} + +export function showToast(options: ToastOptions | string) { + const opts = typeof options === "string" ? { description: options } : options + return toaster.show((props) => ( + +
+ + + + + + {opts.title} + + + {opts.description} + + +
+ +
+ )) +} + +export interface ToastPromiseOptions { + loading?: JSX.Element + success?: (data: T) => JSX.Element + error?: (error: U) => JSX.Element +} + +export function showPromiseToast( + promise: Promise | (() => Promise), + options: ToastPromiseOptions, +) { + return toaster.promise(promise, (props) => ( + + + + {props.state === "pending" && options.loading} + {props.state === "fulfilled" && options.success?.(props.data!)} + {props.state === "rejected" && options.error?.(props.error)} + + + + + )) +} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 4c7f6e80b..918ba9e44 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -38,6 +38,7 @@ @import "../components/sticky-accordion-header.css" layer(components); @import "../components/tabs.css" layer(components); @import "../components/tag.css" layer(components); +@import "../components/toast.css" layer(components); @import "../components/tooltip.css" layer(components); @import "../components/typewriter.css" layer(components); -- cgit v1.2.3 From e845eedbc325b05a19679bc439a57cc0fbf23aa3 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:28:34 -0600 Subject: wip(desktop): progress --- packages/desktop/src/pages/layout.tsx | 352 ++++++++++++++++++++-------------- packages/ui/src/components/list.css | 6 +- packages/ui/src/components/toast.css | 28 +++ packages/ui/src/components/toast.tsx | 42 ++-- 4 files changed, 262 insertions(+), 166 deletions(-) (limited to 'packages/ui/src') diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index e9f10e3a2..4a3fa766b 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, For, Match, ParentProps, Show, Switch, type JSX } from "solid-js" +import { createEffect, createMemo, For, Match, 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" @@ -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, @@ -40,6 +40,7 @@ import { List, ListRef } from "@opencode-ai/ui/list" import { Input } from "@opencode-ai/ui/input" 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({ @@ -618,9 +619,6 @@ export default function Layout(props: ParentProps) { {iife(() => { - const [store, setStore] = createStore({ - method: undefined as undefined | ProviderAuthMethod, - }) const providerID = createMemo(() => layout.connect.provider()!) const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === providerID())!) const methods = createMemo( @@ -632,12 +630,61 @@ export default function Layout(props: ParentProps) { }, ], ) - if (methods().length === 1) { - setStore("method", methods()[0]) + 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, + }) + + 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, + }) + .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)) + }) + } } + onMount(() => { + if (methods().length === 1) { + selectMethod(0) + } + }) + let listRef: ListRef | undefined - const handleKey = (e: KeyboardEvent) => { + function handleKey(e: KeyboardEvent) { if (e.key === "Escape") return listRef?.onKeyDown(e) } @@ -661,7 +708,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 } @@ -677,154 +733,152 @@ export default function Layout(props: ParentProps) {
Connect {provider().name}
- - -
- Select login method for {provider().name}. -
-
- - (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(() => ( - // - // )) - // } - // if (result.data?.method === "auto") { - // dialog.replace(() => ( - // - // )) - // } - } - if (method.type === "api") { - // return dialog.replace(() => ) - } - }} - > - {(i) => ( -
- {/* TODO: add checkmark thing */} - {i.label} -
- )} -
-
-
- - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) +
+ + +
Select login method for {provider().name}.
+
+ + (listRef = ref)} + items={methods} + key={(m) => m?.label} + onSelect={async (method, index) => { + if (!method) return + selectMethod(index) + }} + > + {(i) => ( +
+
+ + {/* TODO: add checkmark thing */} + {i.label} +
+ )} + +
+ + +
+
+ + Authorization in progress... +
+
+
+ +
+
+ + Authorization failed: {store.error} +
+
+
+ + {iife(() => { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const apiKey = formData.get("apiKey") as string + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const apiKey = formData.get("apiKey") as string - if (!apiKey?.trim()) { - setFormStore("error", "API key is required") - return - } + if (!apiKey?.trim()) { + setFormStore("error", "API key is required") + return + } - setFormStore("error", undefined) - await globalSDK.client.auth.set({ - providerID: providerID(), - auth: { - type: "api", - key: apiKey, - }, - }) - 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.`, + setFormStore("error", undefined) + await globalSDK.client.auth.set({ + providerID: providerID(), + auth: { + type: "api", + key: apiKey, + }, }) - layout.connect.complete() - }, 500) - } + 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 ( -
- - -
-
- OpenCode Zen gives you access to a curated set of reliable optimized models for - coding agents. + return ( +
+ + +
+
+ OpenCode Zen gives you access to a curated set of reliable optimized models for + coding agents. +
+
+ With a single API key you’ll get access to models such as Claude, GPT, Gemini, + GLM and more. +
+
+ Visit{" "} + {" "} + to collect your API key. +
+
+
- With a single API key you’ll get access to models such as Claude, GPT, Gemini, GLM - and more. + Enter your {provider().name} API key to connect your account and use{" "} + {provider().name} models in OpenCode.
-
- Visit{" "} - {" "} - to collect your API key. -
-
- - -
- Enter your {provider().name} API key to connect your account and use{" "} - {provider().name} models in OpenCode. -
-
- -
- - -
-
- ) - })} - - + + +
+ + +
+
+ ) + })} +
+ + + Code {store.authorization?.url} + Auto {store.authorization?.url} + + +
+
diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 38dcb773b..783b0ef4a 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -98,17 +98,13 @@ display: block; } [data-slot="list-item-extra-icon"] { + display: block !important; color: var(--icon-strong-base) !important; } } &:active { background: var(--surface-raised-base-active); } - &:hover { - [data-slot="list-item-extra-icon"] { - color: var(--icon-strong-base) !important; - } - } } } } diff --git a/packages/ui/src/components/toast.css b/packages/ui/src/components/toast.css index 2c55a4b06..3389f477a 100644 --- a/packages/ui/src/components/toast.css +++ b/packages/ui/src/components/toast.css @@ -120,6 +120,34 @@ margin: 0; } + [data-slot="toast-actions"] { + display: flex; + gap: 16px; + margin-top: 8px; + } + + [data-slot="toast-action"] { + background: none; + border: none; + padding: 0; + cursor: pointer; + + color: rgba(253, 252, 252, 0.94); + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + + &:hover { + text-decoration: underline; + } + + &:last-child { + color: rgba(253, 249, 249, 0.5); + } + } + [data-slot="toast-close-button"] { flex-shrink: 0; } diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx index b6c9f8b08..5869f8a6b 100644 --- a/packages/ui/src/components/toast.tsx +++ b/packages/ui/src/components/toast.tsx @@ -57,6 +57,10 @@ function ToastDescription(props: ToastDescriptionProps & ComponentProps<"div">) return } +function ToastActions(props: ComponentProps<"div">) { + return
+} + function ToastCloseButton(props: ToastCloseButtonProps & ComponentProps<"button">) { return } @@ -75,6 +79,7 @@ export const Toast = Object.assign(ToastRoot, { Content: ToastContent, Title: ToastTitle, Description: ToastDescription, + Actions: ToastActions, CloseButton: ToastCloseButton, ProgressTrack: ToastProgressTrack, ProgressFill: ToastProgressFill, @@ -84,31 +89,44 @@ export { toaster } export type ToastVariant = "default" | "success" | "error" | "loading" +export interface ToastAction { + label: string + onClick: () => void +} + export interface ToastOptions { title?: string description?: string icon?: IconProps["name"] variant?: ToastVariant duration?: number + actions?: ToastAction[] } export function showToast(options: ToastOptions | string) { const opts = typeof options === "string" ? { description: options } : options return toaster.show((props) => ( -
- - + + + + + + {opts.title} + + + {opts.description} - - - {opts.title} - - - {opts.description} - - -
+ + + {opts.actions!.map((action) => ( + + ))} + + +
)) -- cgit v1.2.3 From 16b7370d8cac304eb2af800b9c6a584784a0c600 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:02:57 -0600 Subject: wip(desktop): progress --- packages/desktop/src/components/prompt-input.tsx | 10 +- packages/desktop/src/pages/layout.tsx | 108 +++++++++++++------- packages/ui/src/components/dialog.css | 11 ++ packages/ui/src/components/icon.tsx | 2 + packages/ui/src/components/input.css | 99 ------------------ packages/ui/src/components/input.tsx | 75 -------------- packages/ui/src/components/list.css | 3 + packages/ui/src/components/select-dialog.tsx | 4 +- packages/ui/src/components/text-field.css | 125 +++++++++++++++++++++++ packages/ui/src/components/text-field.tsx | 103 +++++++++++++++++++ packages/ui/src/styles/index.css | 2 +- 11 files changed, 328 insertions(+), 214 deletions(-) delete mode 100644 packages/ui/src/components/input.css delete mode 100644 packages/ui/src/components/input.tsx create mode 100644 packages/ui/src/components/text-field.css create mode 100644 packages/ui/src/components/text-field.tsx (limited to 'packages/ui/src') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 7f8568291..2c153ecc3 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -33,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" @@ -557,6 +556,14 @@ export const PromptInput: Component = (props) => { if (e.key === "Escape") return listRef?.onKeyDown(e) } + + onMount(() => { + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + return ( = (props) => { -
Free models provided by OpenCode
{ if (methods().length === 1) { selectMethod(0) } + + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) }) - let listRef: ListRef | undefined - function handleKey(e: KeyboardEvent) { - if (e.key === "Escape") return - listRef?.onKeyDown(e) + 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 ( @@ -753,7 +774,6 @@ export default function Layout(props: ParentProps) {
Select login method for {provider().name}.
- (listRef = ref)} items={methods} @@ -820,16 +840,7 @@ export default function Layout(props: ParentProps) { key: apiKey, }, }) - 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) + await complete() } return ( @@ -862,7 +873,7 @@ export default function Layout(props: ParentProps) {
- { - showToast({ - variant: "success", - icon: "circle-check", - title: `${provider().name} connected`, - description: `${provider().name} models are now available to use.`, - }) - layout.connect.complete() - }, 500) + await complete() return } setFormStore("error", "Invalid authorization code") @@ -938,7 +940,7 @@ export default function Layout(props: ParentProps) { OpenCode.
- -
-
- Visit this link and enter the code below - to connect your account and use {provider().name} models in OpenCode. -
-
+ {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 ( +
+
+ Visit this link and enter the code + below to connect your account and use {provider().name} models in OpenCode. +
+ +
+ + Waiting for authorization... +
+
+ ) + })}
diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index 1c7cd4f41..979906e26 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -88,8 +88,19 @@ flex-direction: column; flex: 1; overflow-y: auto; + + &:focus-visible { + outline: none; + } + } + &:focus-visible { + outline: none; } } + + &:focus-visible { + outline: none; + } } } diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 56cef2d8c..ce4bf7556 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -47,6 +47,8 @@ const icons = { "layout-bottom-full": ``, "dot-grid": ``, "circle-check": ``, + copy: ``, + check: ``, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/input.css b/packages/ui/src/components/input.css deleted file mode 100644 index 276e8069b..000000000 --- a/packages/ui/src/components/input.css +++ /dev/null @@ -1,99 +0,0 @@ -[data-component="input"] { - width: 100%; - - [data-slot="input-input"] { - width: 100%; - color: var(--text-strong); - - /* text-14-regular */ - font-family: var(--font-family-sans); - font-size: 14px; - font-style: normal; - font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); /* 142.857% */ - letter-spacing: var(--letter-spacing-normal); - - &:focus { - outline: none; - } - - &::placeholder { - color: var(--text-weak); - } - } - - &[data-variant="normal"] { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 8px; - - [data-slot="input-label"] { - color: var(--text-weak); - - /* text-12-medium */ - font-family: var(--font-family-sans); - font-size: var(--font-size-small); - font-style: normal; - font-weight: var(--font-weight-medium); - line-height: 18px; /* 150% */ - letter-spacing: var(--letter-spacing-normal); - } - - [data-slot="input-input"] { - color: var(--text-strong); - - display: flex; - height: 32px; - padding: 2px 12px; - align-items: center; - gap: 8px; - align-self: stretch; - - border-radius: var(--radius-md); - border: 1px solid var(--border-weak-base); - background: var(--input-base); - - /* text-14-regular */ - font-family: var(--font-family-sans); - font-size: 14px; - font-style: normal; - font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); /* 142.857% */ - letter-spacing: var(--letter-spacing-normal); - - &:focus { - outline: none; - - /* border/shadow-xs/select */ - box-shadow: - 0 0 0 3px var(--border-weak-selected), - 0 0 0 1px var(--border-selected), - 0 1px 2px -1px rgba(19, 16, 16, 0.25), - 0 1px 2px 0 rgba(19, 16, 16, 0.08), - 0 1px 3px 0 rgba(19, 16, 16, 0.12); - } - - &[data-invalid] { - background: var(--surface-critical-weak); - border: 1px solid var(--border-critical-selected); - } - - &::placeholder { - color: var(--text-weak); - } - } - - [data-slot="input-error"] { - color: var(--text-on-critical-base); - - /* text-12-medium */ - font-family: var(--font-family-sans); - font-size: var(--font-size-small); - font-style: normal; - font-weight: var(--font-weight-medium); - line-height: 18px; /* 150% */ - letter-spacing: var(--letter-spacing-normal); - } - } -} diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/input.tsx deleted file mode 100644 index 8e2a115c6..000000000 --- a/packages/ui/src/components/input.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { TextField as Kobalte } from "@kobalte/core/text-field" -import { Show, splitProps } from "solid-js" -import type { ComponentProps } from "solid-js" - -export interface InputProps - extends ComponentProps, - Partial< - Pick< - ComponentProps, - | "name" - | "defaultValue" - | "value" - | "onChange" - | "onKeyDown" - | "validationState" - | "required" - | "disabled" - | "readOnly" - > - > { - label?: string - hideLabel?: boolean - hidden?: boolean - description?: string - error?: string - variant?: "normal" | "ghost" -} - -export function Input(props: InputProps) { - const [local, others] = splitProps(props, [ - "name", - "defaultValue", - "value", - "onChange", - "onKeyDown", - "validationState", - "required", - "disabled", - "readOnly", - "class", - "label", - "hidden", - "hideLabel", - "description", - "error", - "variant", - ]) - return ( - - - - {local.label} - - - - - {local.description} - - {local.error} - - ) -} diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 783b0ef4a..132824164 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -105,6 +105,9 @@ &:active { background: var(--surface-raised-base-active); } + &:focus-visible { + outline: none; + } } } } diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx index efa6c405b..06953168c 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -3,7 +3,7 @@ import { Dialog, DialogProps } from "./dialog" import { Icon } from "./icon" import { IconButton } from "./icon-button" import { List, ListRef, ListProps } from "./list" -import { Input } from "./input" +import { TextField } from "./text-field" interface SelectDialogProps extends Omit, "filter">, @@ -55,7 +55,7 @@ export function SelectDialog(props: SelectDialogProps) {
- , + Partial< + Pick< + ComponentProps, + | "name" + | "defaultValue" + | "value" + | "onChange" + | "onKeyDown" + | "validationState" + | "required" + | "disabled" + | "readOnly" + > + > { + label?: string + hideLabel?: boolean + description?: string + error?: string + variant?: "normal" | "ghost" + copyable?: boolean +} + +export function TextField(props: TextFieldProps) { + const [local, others] = splitProps(props, [ + "name", + "defaultValue", + "value", + "onChange", + "onKeyDown", + "validationState", + "required", + "disabled", + "readOnly", + "class", + "label", + "hideLabel", + "description", + "error", + "variant", + "copyable", + ]) + const [copied, setCopied] = createSignal(false) + + async function handleCopy() { + const value = local.value ?? local.defaultValue ?? "" + await navigator.clipboard.writeText(value) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + + + {local.label} + + +
+ + + + + + +
+ + {local.description} + + {local.error} +
+ ) +} + +/** @deprecated Use TextField instead */ +export const Input = TextField +/** @deprecated Use TextFieldProps instead */ +export type InputProps = TextFieldProps diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 918ba9e44..d60082d93 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -21,7 +21,7 @@ @import "../components/provider-icon.css" layer(components); @import "../components/icon.css" layer(components); @import "../components/icon-button.css" layer(components); -@import "../components/input.css" layer(components); +@import "../components/text-field.css" layer(components); @import "../components/list.css" layer(components); @import "../components/logo.css" layer(components); @import "../components/markdown.css" layer(components); -- cgit v1.2.3 From b34f434332f2ce7eee60d9dfbef2142e167ac0ec Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:46:18 -0600 Subject: fix: message order ascending --- packages/desktop/src/context/session.tsx | 4 ++-- packages/enterprise/src/routes/share/[shareID].tsx | 2 +- packages/ui/src/components/message-nav.tsx | 2 +- packages/ui/src/components/session-turn.tsx | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) (limited to 'packages/ui/src') 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/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index 1dae09c22..7cce15906 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -209,7 +209,7 @@ export default function () { const messages = createMemo(() => data().sessionID ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort( - (a, b) => b.time.created - a.time.created, + (a, b) => a.time.created - b.time.created, ) : [], ) diff --git a/packages/ui/src/components/message-nav.tsx b/packages/ui/src/components/message-nav.tsx index 29b465c8c..a2db11348 100644 --- a/packages/ui/src/components/message-nav.tsx +++ b/packages/ui/src/components/message-nav.tsx @@ -15,7 +15,7 @@ export function MessageNav( ) { const [local, others] = splitProps(props, ["messages", "current", "size", "working", "onMessageSelect"]) const lastUserMessage = createMemo(() => { - return local.messages?.at(0) + return local.messages?.at(-1) }) const content = () => ( diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 5e73c6772..f97a3224c 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -42,10 +42,10 @@ export function SessionTurn( 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 message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID)) -- cgit v1.2.3 From 7d55aeee0aa70c105e4c7d7beb226f932556ce54 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:46:29 -0600 Subject: fix: no loading state in message nav --- packages/desktop/src/pages/session.tsx | 1 - packages/ui/src/components/message-nav.tsx | 19 +++---------------- packages/ui/src/components/session-message-rail.tsx | 13 +------------ 3 files changed, 4 insertions(+), 29 deletions(-) (limited to 'packages/ui/src') 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()} /> void }, ) { - const [local, others] = splitProps(props, ["messages", "current", "size", "working", "onMessageSelect"]) - const lastUserMessage = createMemo(() => { - return local.messages?.at(-1) - }) + const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect"]) const content = () => (
    {(message) => { - const messageWorking = createMemo(() => message.id === lastUserMessage()?.id && local.working) const handleClick = () => local.onMessageSelect(message) return ( @@ -35,14 +29,7 @@ export function MessageNav(
-- cgit v1.2.3 From dea5111a5affb1dc25a2ace5252f5d35e47cca76 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:52:01 -0600 Subject: fix: message nav popover placement --- packages/ui/src/components/message-nav.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/ui/src') diff --git a/packages/ui/src/components/message-nav.tsx b/packages/ui/src/components/message-nav.tsx index ee73010b9..7416cfd93 100644 --- a/packages/ui/src/components/message-nav.tsx +++ b/packages/ui/src/components/message-nav.tsx @@ -51,7 +51,7 @@ export function MessageNav( return ( - + {content()} -- cgit v1.2.3 From bfdb2365810ef4ba8f7084d2d714fd2fe1449fce Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:16:04 -0600 Subject: fix: toast colors --- packages/ui/src/components/toast.css | 32 ++++++++++++++++---------------- packages/ui/src/components/tooltip.css | 1 + 2 files changed, 17 insertions(+), 16 deletions(-) (limited to 'packages/ui/src') diff --git a/packages/ui/src/components/toast.css b/packages/ui/src/components/toast.css index 3389f477a..fbc84f13c 100644 --- a/packages/ui/src/components/toast.css +++ b/packages/ui/src/components/toast.css @@ -31,7 +31,7 @@ border-radius: var(--radius-lg); border: 1px solid var(--border-weak-base); background: var(--surface-float-base); - color: rgba(253, 252, 252, 0.94); + color: var(--text-inverted-base); box-shadow: var(--shadow-md); [data-slot="toast-inner"] { @@ -61,17 +61,17 @@ animation: toastSwipeOut 100ms ease-out forwards; } - &[data-variant="success"] { - border-color: var(--color-semantic-positive); - } - - &[data-variant="error"] { - border-color: var(--color-semantic-danger); - } - - &[data-variant="loading"] { - border-color: var(--color-semantic-info); - } + /* &[data-variant="success"] { */ + /* border-color: var(--color-semantic-positive); */ + /* } */ + /**/ + /* &[data-variant="error"] { */ + /* border-color: var(--color-semantic-danger); */ + /* } */ + /**/ + /* &[data-variant="loading"] { */ + /* border-color: var(--color-semantic-info); */ + /* } */ [data-slot="toast-icon"] { flex-shrink: 0; @@ -93,7 +93,7 @@ } [data-slot="toast-title"] { - color: rgba(253, 252, 252, 0.94); + color: var(--text-inverted-strong); /* text-14-medium */ font-family: var(--font-family-sans); @@ -107,7 +107,7 @@ } [data-slot="toast-description"] { - color: rgba(253, 249, 249, 0.7); + color: var(--text-inverted-base); /* text-14-regular */ font-family: var(--font-family-sans); @@ -132,7 +132,7 @@ padding: 0; cursor: pointer; - color: rgba(253, 252, 252, 0.94); + color: var(--text-inverted-strong); font-family: var(--font-family-sans); font-size: var(--font-size-base); font-weight: var(--font-weight-medium); @@ -144,7 +144,7 @@ } &:last-child { - color: rgba(253, 249, 249, 0.5); + color: var(--text-inverted-weak); } } diff --git a/packages/ui/src/components/tooltip.css b/packages/ui/src/components/tooltip.css index 72ee269b2..637986249 100644 --- a/packages/ui/src/components/tooltip.css +++ b/packages/ui/src/components/tooltip.css @@ -7,6 +7,7 @@ max-width: 320px; border-radius: var(--radius-md); background-color: var(--surface-float-base); + color: var(--text-inverted-base); color: rgba(253, 252, 252, 0.94); padding: 2px 8px; border: 0.5px solid rgba(253, 252, 252, 0.2); -- cgit v1.2.3 From e149b7c1e276281b37a47997bf98d8954a4674ba Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:39:38 -0600 Subject: fix: avatar colors --- packages/desktop/src/context/layout.tsx | 40 ++++++++++++++++++++------------- packages/desktop/src/pages/layout.tsx | 8 +++---- packages/ui/src/components/avatar.css | 3 ++- packages/ui/src/components/avatar.tsx | 13 ++++++++++- 4 files changed, 42 insertions(+), 22 deletions(-) (limited to 'packages/ui/src') diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index ea5962b40..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" @@ -58,11 +66,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( connect: {}, dialog: {}, }) - const usedColors = new Set() + const usedColors = new Set() - 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)] } diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 05386c1fb..70764292f 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -1,7 +1,7 @@ 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" @@ -180,7 +180,7 @@ export default function Layout(props: ParentProps) {
@@ -200,7 +200,7 @@ export default function Layout(props: ParentProps) {
@@ -231,7 +231,7 @@ export default function Layout(props: ParentProps) { { fallback: string src?: string background?: string + foreground?: string size?: "small" | "normal" | "large" } export function Avatar(props: AvatarProps) { - const [split, rest] = splitProps(props, ["fallback", "src", "background", "size", "class", "classList", "style"]) + const [split, rest] = splitProps(props, [ + "fallback", + "src", + "background", + "foreground", + "size", + "class", + "classList", + "style", + ]) const src = split.src // did this so i can zero it out to test fallback return (
-- cgit v1.2.3