feat(app): add manage models icon to selector (per Figma request) (#9722)

This commit is contained in:
Ronan Kearns
2026-01-21 05:44:17 -05:00
committed by GitHub
parent 2e5fe6d5c8
commit 996eeb1f68
3 changed files with 86 additions and 26 deletions

View File

@@ -4,6 +4,7 @@ import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tag } from "@opencode-ai/ui/tag" import { Tag } from "@opencode-ai/ui/tag"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
@@ -15,6 +16,7 @@ const ModelList: Component<{
provider?: string provider?: string
class?: string class?: string
onSelect: () => void onSelect: () => void
action?: JSX.Element
}> = (props) => { }> = (props) => {
const local = useLocal() const local = useLocal()
const language = useLanguage() const language = useLanguage()
@@ -29,7 +31,7 @@ const ModelList: Component<{
return ( return (
<List <List
class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`} class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }} search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }}
emptyMessage={language.t("dialog.model.empty")} emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x.provider.id}:${x.id}`} key={(x) => `${x.provider.id}:${x.id}`}
items={models} items={models}
@@ -71,6 +73,12 @@ export const ModelSelectorPopover: Component<{
children: JSX.Element children: JSX.Element
}> = (props) => { }> = (props) => {
const [open, setOpen] = createSignal(false) const [open, setOpen] = createSignal(false)
const dialog = useDialog()
const handleManage = () => {
setOpen(false)
dialog.show(() => <DialogManageModels />)
}
const language = useLanguage() const language = useLanguage()
return ( return (
@@ -79,7 +87,22 @@ export const ModelSelectorPopover: Component<{
<Kobalte.Portal> <Kobalte.Portal>
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"> <Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden">
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title> <Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" /> <ModelList
provider={props.provider}
onSelect={() => setOpen(false)}
class="p-1"
action={
<IconButton
icon="sliders"
variant="ghost"
iconSize="normal"
class="size-6"
aria-label="Manage models"
title="Manage models"
onClick={handleManage}
/>
}
/>
</Kobalte.Content> </Kobalte.Content>
</Kobalte.Portal> </Kobalte.Portal>
</Kobalte> </Kobalte>

View File

@@ -23,14 +23,46 @@
overflow: hidden; overflow: hidden;
padding: 0 12px; padding: 0 12px;
[data-slot="list-search"] { [data-slot="list-search-wrapper"] {
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
align-items: center;
gap: 8px;
align-self: stretch;
margin-bottom: 4px;
padding-right: 4px;
> [data-component="icon-button"] {
width: 24px;
height: 24px;
flex-shrink: 0;
background-color: transparent;
opacity: 0.5;
transition: opacity 0.15s ease;
&:hover:not(:disabled),
&:focus:not(:disabled),
&:active:not(:disabled) {
background-color: transparent;
opacity: 0.7;
}
&:hover:not(:disabled) [data-slot="icon-svg"] {
color: var(--icon-hover);
}
&:active:not(:disabled) [data-slot="icon-svg"] {
color: var(--icon-active);
}
}
}
[data-slot="list-search"] {
display: flex;
flex: 1;
padding: 8px; padding: 8px;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
align-self: stretch;
margin-bottom: 4px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--surface-base); background: var(--surface-base);

View File

@@ -11,6 +11,7 @@ export interface ListSearchProps {
autofocus?: boolean autofocus?: boolean
hideIcon?: boolean hideIcon?: boolean
class?: string class?: string
action?: JSX.Element
} }
export interface ListProps<T> extends FilteredListProps<T> { export interface ListProps<T> extends FilteredListProps<T> {
@@ -60,6 +61,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList<T>(props) const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList<T>(props)
const searchProps = () => (typeof props.search === "object" ? props.search : {}) const searchProps = () => (typeof props.search === "object" ? props.search : {})
const searchAction = () => searchProps().action
const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0 const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0
@@ -198,29 +200,32 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
return ( return (
<div data-component="list" classList={{ [props.class ?? ""]: !!props.class }}> <div data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
<Show when={!!props.search}> <Show when={!!props.search}>
<div data-slot="list-search" classList={{ [searchProps().class ?? ""]: !!searchProps().class }}> <div data-slot="list-search-wrapper">
<div data-slot="list-search-container"> <div data-slot="list-search" classList={{ [searchProps().class ?? ""]: !!searchProps().class }}>
<Show when={!searchProps().hideIcon}> <div data-slot="list-search-container">
<Icon name="magnifying-glass" /> <Show when={!searchProps().hideIcon}>
<Icon name="magnifying-glass" />
</Show>
<TextField
autofocus={searchProps().autofocus}
variant="ghost"
data-slot="list-search-input"
type="text"
value={internalFilter()}
onChange={setInternalFilter}
onKeyDown={handleKey}
placeholder={searchProps().placeholder}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
/>
</div>
<Show when={internalFilter()}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setInternalFilter("")} />
</Show> </Show>
<TextField
autofocus={searchProps().autofocus}
variant="ghost"
data-slot="list-search-input"
type="text"
value={internalFilter()}
onChange={setInternalFilter}
onKeyDown={handleKey}
placeholder={searchProps().placeholder}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
/>
</div> </div>
<Show when={internalFilter()}> {searchAction()}
<IconButton icon="circle-x" variant="ghost" onClick={() => setInternalFilter("")} />
</Show>
</div> </div>
</Show> </Show>
<div ref={setScrollRef} data-slot="list-scroll"> <div ref={setScrollRef} data-slot="list-scroll">