wip(desktop): progress

This commit is contained in:
Adam 2025-12-10 17:17:34 -06:00
parent cbb591eb7d
commit 85cfa226c3
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
6 changed files with 222 additions and 70 deletions

View file

@ -17,6 +17,13 @@ import { Select } from "@opencode-ai/ui/select"
import { Tag } from "@opencode-ai/ui/tag"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
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 +65,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const local = useLocal()
const session = useSession()
const layout = useLayout()
const providers = useProviders()
let editorRef!: HTMLDivElement
const [store, setStore] = createStore<{
@ -461,60 +469,167 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Icon name="chevron-down" size="small" />
</Button>
<Show when={layout.dialog.opened() === "model"}>
<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) => {
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 })
}
actions={
<Button
class="h-7 -my-1 text-14-medium"
icon="plus-small"
tabIndex={-1}
onClick={() => layout.dialog.open("provider")}
<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>
}
>
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.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>
</div>
</div>
</div>
</Dialog.Body>
</Dialog>
)
})}
</Match>
</Switch>
</Show>
</div>
<Tooltip

View file

@ -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,
}))
}

View file

@ -33,8 +33,7 @@ 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"
const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
import { popularProviders, useProviders } from "@/hooks/use-providers"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@ -50,18 +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 = createMemo(() => {
if (currentDirectory()) {
const [projectStore] = globalSync.child(currentDirectory())
return projectStore.provider
}
return globalSync.data.provider
})
const connectedProviders = createMemo(() =>
providers().all.filter(
(p) => providers().connected.includes(p.id) && Object.values(p.models).find((m) => m.cost?.input),
),
)
const providers = useProviders()
function navigateToProject(directory: string | undefined) {
if (!directory) return
@ -493,7 +481,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={!connectedProviders().length && layout.sidebar.opened()}>
<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>
@ -599,6 +587,7 @@ export default function Layout(props: ParentProps) {
{(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={{

View file

@ -7,6 +7,7 @@ export interface InputProps
Partial<Pick<ComponentProps<typeof Kobalte>, "value" | "onChange" | "onKeyDown">> {
label?: string
hideLabel?: boolean
hidden?: boolean
description?: string
}
@ -14,6 +15,7 @@ export function Input(props: InputProps) {
const [local, others] = splitProps(props, [
"class",
"label",
"hidden",
"hideLabel",
"description",
"value",
@ -21,7 +23,13 @@ export function Input(props: InputProps) {
"onKeyDown",
])
return (
<Kobalte data-component="input" value={local.value} onChange={local.onChange} onKeyDown={local.onKeyDown}>
<Kobalte
data-component="input"
style={{ height: local.hidden ? 0 : undefined }}
value={local.value}
onChange={local.onChange}
onKeyDown={local.onKeyDown}
>
<Show when={local.label}>
<Kobalte.Label data-slot="input-label" classList={{ "sr-only": local.hideLabel }}>
{local.label}

View file

@ -97,10 +97,18 @@
[data-slot="list-item-active-icon"] {
display: block;
}
[data-slot="list-item-extra-icon"] {
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;
}
}
}
}
}

View file

@ -4,6 +4,7 @@ import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
import { Icon, IconProps } from "./icon"
export interface ListProps<T> extends FilteredListProps<T> {
class?: string
children: (item: T) => JSX.Element
emptyMessage?: string
onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
@ -90,7 +91,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
})
return (
<div ref={setScrollRef} data-component="list">
<div ref={setScrollRef} data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
<Show
when={flat().length > 0}
fallback={