From 2c99b549dbb1152f6ec9a0c963b618b0dd641761 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:34:14 +0800 Subject: [PATCH] fix(app): redesign model selection dialog (#44383) --- packages/app/src/providers/models/manage.tsx | 82 ++++-- .../src/providers/models/select-dialog.tsx | 248 +++++++++++++----- 2 files changed, 239 insertions(+), 91 deletions(-) diff --git a/packages/app/src/providers/models/manage.tsx b/packages/app/src/providers/models/manage.tsx index b75937843ce..b07ce142901 100644 --- a/packages/app/src/providers/models/manage.tsx +++ b/packages/app/src/providers/models/manage.tsx @@ -7,6 +7,7 @@ import { Switch } from "@opencode-ai/ui/switch" import { TextInput } from "@opencode-ai/ui/text-input" import { useFilteredList } from "@opencode-ai/ui/hooks" import { For, Show, type Component } from "solid-js" +import { createStore } from "solid-js/store" import { useLocal } from "@/providers/models/selection" import { popularProviders } from "@/providers/catalog/providers" import { useLanguage } from "@/runtime/i18n/language" @@ -23,6 +24,7 @@ export const DialogManageModels: Component = () => { const local = useLocal() const language = useLanguage() const dialog = useDialog() + const [store, setStore] = createStore({ collapsed: {} as Record }) const directory = () => decode64(local.slug()) const handleConnectProvider = () => { @@ -120,16 +122,38 @@ export const DialogManageModels: Component = () => { } > - {(group) => ( -
-
-
- -

{group.items[0].provider.name}

-
-
+ {(group) => { + const searching = () => list.filter().length > 0 + const expanded = () => searching() || !store.collapsed[group.category] + + return ( +
+
+ setProviderVisibility(group.category, checked)} hideLabel @@ -137,26 +161,28 @@ export const DialogManageModels: Component = () => { {group.items[0].provider.name}
+ + + + {(item) => ( + +
+ setModelVisibility(item, checked)} + hideLabel + > + {item.name} + +
+
+ )} +
+
+
- - - {(item) => ( - -
- setModelVisibility(item, checked)} - hideLabel - > - {item.name} - -
-
- )} -
-
-
- )} + ) + }} diff --git a/packages/app/src/providers/models/select-dialog.tsx b/packages/app/src/providers/models/select-dialog.tsx index e7bc97fc449..35a8ec7934e 100644 --- a/packages/app/src/providers/models/select-dialog.tsx +++ b/packages/app/src/providers/models/select-dialog.tsx @@ -10,9 +10,10 @@ import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/d import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { ScrollView } from "@opencode-ai/ui/scroll-view" -import { List } from "@opencode-ai/ui/list" import { Tooltip } from "@opencode-ai/ui/tooltip" import { Menu } from "@opencode-ai/ui/menu" +import { TextInput } from "@opencode-ai/ui/text-input" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ModelTooltip } from "./tooltip" import { useLanguage } from "@/runtime/i18n/language" import { decode64 } from "@/runtime/persistence/base64" @@ -20,6 +21,8 @@ import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown" import { createMenuDismissController } from "@/shell/commands/menu-dismiss" import { createEventListener } from "@solid-primitives/event-listener" import { matchesModelSearch } from "./search" +import { SettingsList } from "@/settings/list" +import "@/settings/settings.css" const isFree = (provider: string, cost: { input: number } | undefined) => provider === "opencode" && (!cost || cost.input === 0) @@ -44,70 +47,182 @@ const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { categ const ModelList: Component<{ provider?: string - class?: string onSelect: () => void - action?: JSX.Element model?: ModelState }> = (props) => { - const model = props.model ?? useLocal().model const language = useLanguage() + const controller = createModelSelectorController({ + model: props.model, + provider: () => props.provider, + onSelect: props.onSelect, + }) + const [store, setStore] = createStore({ + search: "", + active: "", + collapsed: {} as Record, + }) + const models = createMemo(() => controller.models(store.search)) + const groups = createMemo(() => controller.groups(models())) + const expanded = (provider: string) => store.search.length > 0 || !store.collapsed[provider] + const visibleModels = () => models().filter((item) => expanded(item.provider.id)) + let scrollRef: HTMLDivElement | undefined - const models = createMemo(() => - model - .list() - .filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id })) - .filter((m) => (props.provider ? m.provider.id === props.provider : true)), - ) + const setSearch = (value: string) => { + const first = controller.models(value).find((item) => value.length > 0 || !store.collapsed[item.provider.id]) + setStore({ search: value, active: first ? modelKey(first) : "" }) + } + const moveActive = (delta: number) => { + const keys = visibleModels().map(modelKey) + if (keys.length === 0) return + const index = keys.indexOf(store.active) + const start = index === -1 ? (delta > 0 ? -1 : 0) : index + setStore("active", keys[(start + delta + keys.length) % keys.length]) + queueMicrotask(() => { + scrollRef + ?.querySelector(`[data-option-key="${CSS.escape(store.active)}"]`) + ?.scrollIntoView({ block: "nearest" }) + }) + } + const selectActive = () => { + const item = visibleModels().find((item) => modelKey(item) === store.active) + if (item) controller.select(item) + } return ( - `${x.provider.id}:${x.id}`} - items={models} - current={model.current()} - filterKeys={["provider.name", "name", "id"]} - sortBy={(a, b) => a.name.localeCompare(b.name)} - groupBy={(x) => x.provider.name} - sortGroupsBy={(a, b) => { - 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) - }} - itemWrapper={(item, node) => ( - } - > - {node} - - )} - onSelect={(x) => { - model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - props.onSelect() - }} - > - {(i) => ( -
- {i.name} - - {language.t("model.tag.free")} - - - {language.t("model.tag.latest")} +
+
+
+ setSearch(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.altKey || event.metaKey) return + if (event.key === "ArrowDown") { + event.preventDefault() + moveActive(1) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + moveActive(-1) + return + } + if (event.key === "Enter" && !event.isComposing) { + event.preventDefault() + selectActive() + } + }} + aria-label={language.t("dialog.model.search.placeholder")} + /> + + } + onClick={() => setSearch("")} + aria-label={language.t("common.clear")} + />
- )} - +
+
+
(scrollRef = element)} class="settings-panel settings-models h-full px-4 pt-4 pb-4"> + 0} + fallback={
{language.t("dialog.model.empty")}
} + > + + {(group) => { + const searching = () => store.search.length > 0 + const open = () => expanded(group.category) + + return ( +
+

+ +

+ + + + {(item) => ( + + )} + + + +
+ ) + }} +
+
+
+
+
) } @@ -431,18 +546,25 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat } return ( - - + + {language.t("dialog.model.select.title")} - - + dialog.close()} /> - +
+ +
)