Skip to content

Commit 86c0989

Browse files
committed
wip(desktop): progress
1 parent 3df8d60 commit 86c0989

6 files changed

Lines changed: 222 additions & 70 deletions

File tree

packages/desktop/src/components/prompt-input.tsx

Lines changed: 168 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ import { Select } from "@opencode-ai/ui/select"
1717
import { Tag } from "@opencode-ai/ui/tag"
1818
import { getDirectory, getFilename } from "@opencode-ai/util/path"
1919
import { useLayout } from "@/context/layout"
20+
import { popularProviders, useProviders } from "@/hooks/use-providers"
21+
import { Dialog } from "@opencode-ai/ui/dialog"
22+
import { List, ListRef } from "@opencode-ai/ui/list"
23+
import { iife } from "@opencode-ai/util/iife"
24+
import { Input } from "@opencode-ai/ui/input"
25+
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
26+
import { IconName } from "@opencode-ai/ui/icons/provider"
2027

2128
interface PromptInputProps {
2229
class?: string
@@ -58,6 +65,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
5865
const local = useLocal()
5966
const session = useSession()
6067
const layout = useLayout()
68+
const providers = useProviders()
6169
let editorRef!: HTMLDivElement
6270

6371
const [store, setStore] = createStore<{
@@ -461,60 +469,167 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
461469
<Icon name="chevron-down" size="small" />
462470
</Button>
463471
<Show when={layout.dialog.opened() === "model"}>
464-
<SelectDialog
465-
defaultOpen
466-
onOpenChange={(open) => {
467-
if (open) {
468-
layout.dialog.open("model")
469-
} else {
470-
layout.dialog.close("model")
471-
}
472-
}}
473-
title="Select model"
474-
placeholder="Search models"
475-
emptyMessage="No model results"
476-
key={(x) => `${x.provider.id}:${x.id}`}
477-
items={local.model.list()}
478-
current={local.model.current()}
479-
filterKeys={["provider.name", "name", "id"]}
480-
// groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)}
481-
groupBy={(x) => x.provider.name}
482-
sortGroupsBy={(a, b) => {
483-
const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
484-
if (a.category === "Recent" && b.category !== "Recent") return -1
485-
if (b.category === "Recent" && a.category !== "Recent") return 1
486-
const aProvider = a.items[0].provider.id
487-
const bProvider = b.items[0].provider.id
488-
if (order.includes(aProvider) && !order.includes(bProvider)) return -1
489-
if (!order.includes(aProvider) && order.includes(bProvider)) return 1
490-
return order.indexOf(aProvider) - order.indexOf(bProvider)
491-
}}
492-
onSelect={(x) =>
493-
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true })
494-
}
495-
actions={
496-
<Button
497-
class="h-7 -my-1 text-14-medium"
498-
icon="plus-small"
499-
tabIndex={-1}
500-
onClick={() => layout.dialog.open("provider")}
472+
<Switch>
473+
<Match when={providers().connected().length > 0}>
474+
<SelectDialog
475+
defaultOpen
476+
onOpenChange={(open) => {
477+
if (open) {
478+
layout.dialog.open("model")
479+
} else {
480+
layout.dialog.close("model")
481+
}
482+
}}
483+
title="Select model"
484+
placeholder="Search models"
485+
emptyMessage="No model results"
486+
key={(x) => `${x.provider.id}:${x.id}`}
487+
items={local.model.list()}
488+
current={local.model.current()}
489+
filterKeys={["provider.name", "name", "id"]}
490+
// groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)}
491+
groupBy={(x) => x.provider.name}
492+
sortGroupsBy={(a, b) => {
493+
if (a.category === "Recent" && b.category !== "Recent") return -1
494+
if (b.category === "Recent" && a.category !== "Recent") return 1
495+
const aProvider = a.items[0].provider.id
496+
const bProvider = b.items[0].provider.id
497+
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
498+
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
499+
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
500+
}}
501+
onSelect={(x) =>
502+
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true })
503+
}
504+
actions={
505+
<Button
506+
class="h-7 -my-1 text-14-medium"
507+
icon="plus-small"
508+
tabIndex={-1}
509+
onClick={() => layout.dialog.open("provider")}
510+
>
511+
Connect provider
512+
</Button>
513+
}
501514
>
502-
Connect provider
503-
</Button>
504-
}
505-
>
506-
{(i) => (
507-
<div class="w-full flex items-center gap-x-2.5">
508-
<span>{i.name}</span>
509-
<Show when={!i.cost || i.cost?.input === 0}>
510-
<Tag>Free</Tag>
511-
</Show>
512-
<Show when={i.latest}>
513-
<Tag>Latest</Tag>
514-
</Show>
515-
</div>
516-
)}
517-
</SelectDialog>
515+
{(i) => (
516+
<div class="w-full flex items-center gap-x-2.5">
517+
<span>{i.name}</span>
518+
<Show when={!i.cost || i.cost?.input === 0}>
519+
<Tag>Free</Tag>
520+
</Show>
521+
<Show when={i.latest}>
522+
<Tag>Latest</Tag>
523+
</Show>
524+
</div>
525+
)}
526+
</SelectDialog>
527+
</Match>
528+
<Match when={true}>
529+
{iife(() => {
530+
let listRef: ListRef | undefined
531+
const handleKey = (e: KeyboardEvent) => {
532+
if (e.key === "Escape") return
533+
listRef?.onKeyDown(e)
534+
}
535+
return (
536+
<Dialog
537+
modal
538+
defaultOpen
539+
onOpenChange={(open) => {
540+
if (open) {
541+
layout.dialog.open("model")
542+
} else {
543+
layout.dialog.close("model")
544+
}
545+
}}
546+
>
547+
<Dialog.Header>
548+
<Dialog.Title>Select model</Dialog.Title>
549+
<Dialog.CloseButton tabIndex={-1} />
550+
</Dialog.Header>
551+
<Dialog.Body>
552+
<Input hidden type="text" class="opacity-0 size-0" autofocus onKeyDown={handleKey} />
553+
<div class="flex flex-col gap-3 px-2.5">
554+
<div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div>
555+
<List
556+
ref={(ref) => (listRef = ref)}
557+
items={local.model.list()}
558+
current={local.model.current()}
559+
key={(x) => `${x.provider.id}:${x.id}`}
560+
onSelect={(x) => {
561+
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
562+
recent: true,
563+
})
564+
layout.dialog.close("model")
565+
}}
566+
>
567+
{(i) => (
568+
<div class="w-full flex items-center gap-x-2.5">
569+
<span>{i.name}</span>
570+
<Tag>Free</Tag>
571+
<Show when={i.latest}>
572+
<Tag>Latest</Tag>
573+
</Show>
574+
</div>
575+
)}
576+
</List>
577+
<div />
578+
<div />
579+
</div>
580+
<div class="px-1.5 pb-1.5">
581+
<div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
582+
<div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-6">
583+
<div class="px-2 text-14-medium text-text-base">
584+
Add more models from popular providers
585+
</div>
586+
<List
587+
class="w-full"
588+
key={(x) => x?.id}
589+
items={providers().popular()}
590+
activeIcon="plus-small"
591+
sortBy={(a, b) => {
592+
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
593+
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
594+
return a.name.localeCompare(b.name)
595+
}}
596+
onSelect={(x) => {
597+
layout.dialog.close("model")
598+
}}
599+
>
600+
{(i) => (
601+
<div class="w-full flex items-center gap-x-4">
602+
<ProviderIcon
603+
data-slot="list-item-extra-icon"
604+
id={i.id as IconName}
605+
// TODO: clean this up after we update icon in models.dev
606+
classList={{
607+
"text-icon-weak-base": true,
608+
"size-4 mx-0.5": i.id === "opencode",
609+
"size-5": i.id !== "opencode",
610+
}}
611+
/>
612+
<span>{i.name}</span>
613+
<Show when={i.id === "opencode"}>
614+
<Tag>Recommended</Tag>
615+
</Show>
616+
<Show when={i.id === "anthropic"}>
617+
<div class="text-14-regular text-text-weak">
618+
Connect with Claude Pro/Max or API key
619+
</div>
620+
</Show>
621+
</div>
622+
)}
623+
</List>
624+
</div>
625+
</div>
626+
</div>
627+
</Dialog.Body>
628+
</Dialog>
629+
)
630+
})}
631+
</Match>
632+
</Switch>
518633
</Show>
519634
</div>
520635
<Tooltip
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useGlobalSync } from "@/context/global-sync"
2+
import { base64Decode } from "@opencode-ai/util/encode"
3+
import { useParams } from "@solidjs/router"
4+
import { createMemo } from "solid-js"
5+
6+
export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
7+
8+
export function useProviders() {
9+
const params = useParams()
10+
const globalSync = useGlobalSync()
11+
const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
12+
const providers = createMemo(() => {
13+
if (currentDirectory()) {
14+
const [projectStore] = globalSync.child(currentDirectory())
15+
return projectStore.provider
16+
}
17+
return globalSync.data.provider
18+
})
19+
const connected = createMemo(() =>
20+
providers().all.filter(
21+
(p) => providers().connected.includes(p.id) && Object.values(p.models).find((m) => m.cost?.input),
22+
),
23+
)
24+
const popular = createMemo(() => providers().all.filter((p) => popularProviders.includes(p.id)))
25+
return createMemo(() => ({
26+
all: providers().all,
27+
default: providers().default,
28+
popular,
29+
connected,
30+
}))
31+
}

packages/desktop/src/pages/layout.tsx

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@ import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
3333
import { SelectDialog } from "@opencode-ai/ui/select-dialog"
3434
import { Tag } from "@opencode-ai/ui/tag"
3535
import { IconName } from "@opencode-ai/ui/icons/provider"
36-
37-
const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
36+
import { popularProviders, useProviders } from "@/hooks/use-providers"
3837

3938
export default function Layout(props: ParentProps) {
4039
const [store, setStore] = createStore({
@@ -50,18 +49,7 @@ export default function Layout(props: ParentProps) {
5049
const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
5150
const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? [])
5251
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
53-
const providers = createMemo(() => {
54-
if (currentDirectory()) {
55-
const [projectStore] = globalSync.child(currentDirectory())
56-
return projectStore.provider
57-
}
58-
return globalSync.data.provider
59-
})
60-
const connectedProviders = createMemo(() =>
61-
providers().all.filter(
62-
(p) => providers().connected.includes(p.id) && Object.values(p.models).find((m) => m.cost?.input),
63-
),
64-
)
52+
const providers = useProviders()
6553

6654
function navigateToProject(directory: string | undefined) {
6755
if (!directory) return
@@ -493,7 +481,7 @@ export default function Layout(props: ParentProps) {
493481
</div>
494482
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
495483
<Switch>
496-
<Match when={!connectedProviders().length && layout.sidebar.opened()}>
484+
<Match when={!providers().connected().length && layout.sidebar.opened()}>
497485
<div class="rounded-md bg-background-stronger shadow-xs-border-base">
498486
<div class="p-3 flex flex-col gap-2">
499487
<div class="text-12-medium text-text-strong">Getting started</div>
@@ -599,6 +587,7 @@ export default function Layout(props: ParentProps) {
599587
{(i) => (
600588
<div class="px-1.25 w-full flex items-center gap-x-4">
601589
<ProviderIcon
590+
data-slot="list-item-extra-icon"
602591
id={i.id as IconName}
603592
// TODO: clean this up after we update icon in models.dev
604593
classList={{

packages/ui/src/components/input.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,29 @@ export interface InputProps
77
Partial<Pick<ComponentProps<typeof Kobalte>, "value" | "onChange" | "onKeyDown">> {
88
label?: string
99
hideLabel?: boolean
10+
hidden?: boolean
1011
description?: string
1112
}
1213

1314
export function Input(props: InputProps) {
1415
const [local, others] = splitProps(props, [
1516
"class",
1617
"label",
18+
"hidden",
1719
"hideLabel",
1820
"description",
1921
"value",
2022
"onChange",
2123
"onKeyDown",
2224
])
2325
return (
24-
<Kobalte data-component="input" value={local.value} onChange={local.onChange} onKeyDown={local.onKeyDown}>
26+
<Kobalte
27+
data-component="input"
28+
style={{ height: local.hidden ? 0 : undefined }}
29+
value={local.value}
30+
onChange={local.onChange}
31+
onKeyDown={local.onKeyDown}
32+
>
2533
<Show when={local.label}>
2634
<Kobalte.Label data-slot="input-label" classList={{ "sr-only": local.hideLabel }}>
2735
{local.label}

packages/ui/src/components/list.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,18 @@
9797
[data-slot="list-item-active-icon"] {
9898
display: block;
9999
}
100+
[data-slot="list-item-extra-icon"] {
101+
color: var(--icon-strong-base) !important;
102+
}
100103
}
101104
&:active {
102105
background: var(--surface-raised-base-active);
103106
}
107+
&:hover {
108+
[data-slot="list-item-extra-icon"] {
109+
color: var(--icon-strong-base) !important;
110+
}
111+
}
104112
}
105113
}
106114
}

packages/ui/src/components/list.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
44
import { Icon, IconProps } from "./icon"
55

66
export interface ListProps<T> extends FilteredListProps<T> {
7+
class?: string
78
children: (item: T) => JSX.Element
89
emptyMessage?: string
910
onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
@@ -90,7 +91,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
9091
})
9192

9293
return (
93-
<div ref={setScrollRef} data-component="list">
94+
<div ref={setScrollRef} data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
9495
<Show
9596
when={flat().length > 0}
9697
fallback={

0 commit comments

Comments
 (0)