feat(ui): add favorite models to selector

This commit is contained in:
Shantur Rathore
2026-01-26 20:24:05 +00:00
parent 562c4b2637
commit 158f6e25cf
11 changed files with 368 additions and 33 deletions

View File

@@ -1,10 +1,11 @@
import { Combobox } from "@kobalte/core/combobox"
import { createEffect, createMemo, createSignal } from "solid-js"
import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import { ChevronDown, Star } from "lucide-solid"
import type { Model } from "../types/session"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
import Kbd from "./kbd"
const log = getLogger("session")
@@ -26,8 +27,19 @@ export default function ModelSelector(props: ModelSelectorProps) {
const { t } = useI18n()
const instanceProviders = () => providers().get(props.instanceId) || []
const [isOpen, setIsOpen] = createSignal(false)
const [manualAll, setManualAll] = createSignal(false)
const [explicitFavorites, setExplicitFavorites] = createSignal(false)
const [autoFavoritesEligibleAtOpen, setAutoFavoritesEligibleAtOpen] = createSignal(false)
const [searchDirty, setSearchDirty] = createSignal(false)
const [initialQuery, setInitialQuery] = createSignal("")
const [initialQueryReady, setInitialQueryReady] = createSignal(false)
const [inputValue, setInputValue] = createSignal("")
let triggerRef!: HTMLButtonElement
let searchInputRef!: HTMLInputElement
let listboxRef!: HTMLUListElement
let suppressNextClose = false
let wasFavoritesOnlyEnabled = false
let wasCurrentModelFavorite = false
createEffect(() => {
if (instanceProviders().length === 0) {
@@ -46,61 +58,232 @@ export default function ModelSelector(props: ModelSelectorProps) {
),
)
const favoriteKeySet = createMemo(() => {
const result = new Set<string>()
for (const item of preferences().modelFavorites ?? []) {
if (item.providerId && item.modelId) {
result.add(`${item.providerId}/${item.modelId}`)
}
}
return result
})
const favoriteModels = createMemo<FlatModel[]>(() => {
const keys = favoriteKeySet()
if (keys.size === 0) return []
return allModels().filter((m) => keys.has(m.key))
})
const hasFavorites = createMemo(() => favoriteModels().length > 0)
const currentModelValue = createMemo(() =>
allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId),
)
const currentModelIsFavorite = createMemo(() => {
const current = props.currentModel
return favoriteKeySet().has(`${current.providerId}/${current.modelId}`)
})
const currentModelKey = createMemo(() => {
const current = props.currentModel
return `${current.providerId}/${current.modelId}`
})
const searchActive = createMemo(() => {
if (!searchDirty()) return false
const next = inputValue().trim()
return next.length > 0
})
const favoritesOnlyEnabled = createMemo(() => {
if (searchActive()) return false
if (manualAll()) return false
if (!hasFavorites()) return false
return explicitFavorites() || autoFavoritesEligibleAtOpen()
})
const visibleOptions = createMemo<FlatModel[]>(() => {
if (!favoritesOnlyEnabled()) {
return allModels()
}
return favoriteModels()
})
const handleChange = async (value: FlatModel | null) => {
if (!value) return
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
}
const customFilter = (option: FlatModel, inputValue: string) => {
return option.searchText.toLowerCase().includes(inputValue.toLowerCase())
const customFilter = (option: FlatModel, rawInput: string) => {
if (!searchDirty()) return true
return option.searchText.toLowerCase().includes(rawInput.toLowerCase())
}
createEffect(() => {
if (isOpen()) {
setManualAll(false)
setExplicitFavorites(false)
setAutoFavoritesEligibleAtOpen(hasFavorites() && currentModelIsFavorite())
setSearchDirty(false)
setInitialQuery("")
setInputValue("")
setInitialQueryReady(false)
setTimeout(() => {
const seeded = searchInputRef?.value ?? ""
setInitialQuery(seeded)
setInputValue(seeded)
setInitialQueryReady(true)
searchInputRef?.focus()
searchInputRef?.select()
}, 100)
} else {
setInitialQueryReady(false)
setSearchDirty(false)
setAutoFavoritesEligibleAtOpen(false)
}
})
createEffect(() => {
if (!isOpen()) {
wasFavoritesOnlyEnabled = favoritesOnlyEnabled()
wasCurrentModelFavorite = currentModelIsFavorite()
return
}
const nowFavoritesOnlyEnabled = favoritesOnlyEnabled()
const nowCurrentModelFavorite = currentModelIsFavorite()
if (wasFavoritesOnlyEnabled && !nowFavoritesOnlyEnabled && wasCurrentModelFavorite && !nowCurrentModelFavorite) {
setTimeout(() => {
const key = currentModelKey()
const target = listboxRef?.querySelector(`[data-key="${key}"]`) as HTMLElement | null
target?.scrollIntoView({ block: "nearest" })
}, 0)
}
wasFavoritesOnlyEnabled = nowFavoritesOnlyEnabled
wasCurrentModelFavorite = nowCurrentModelFavorite
})
const handleSearchInput = (event: InputEvent & { currentTarget: HTMLInputElement }) => {
const next = event.currentTarget.value
setInputValue(next)
if (!initialQueryReady()) return
if (searchDirty()) return
if (next !== initialQuery()) {
setSearchDirty(true)
}
}
const preventListboxPress = (event: PointerEvent | MouseEvent) => {
event.preventDefault()
event.stopImmediatePropagation?.()
event.stopPropagation()
suppressNextClose = true
setTimeout(() => {
suppressNextClose = false
}, 0)
}
const toggleFavoritesOnly = () => {
if (!hasFavorites()) return
if (searchActive()) return
if (favoritesOnlyEnabled()) {
setManualAll(true)
setExplicitFavorites(false)
setAutoFavoritesEligibleAtOpen(false)
return
}
setExplicitFavorites(true)
setManualAll(false)
}
const showAllModels = () => {
setManualAll(true)
setExplicitFavorites(false)
setAutoFavoritesEligibleAtOpen(false)
setTimeout(() => searchInputRef?.focus(), 0)
}
return (
<div class="sidebar-selector">
<Combobox<FlatModel>
open={isOpen()}
value={currentModelValue()}
onChange={handleChange}
onOpenChange={setIsOpen}
options={allModels()}
onOpenChange={(next) => {
if (!next && suppressNextClose) return
setIsOpen(next)
}}
options={visibleOptions()}
optionValue="key"
optionTextValue="searchText"
optionLabel="name"
placeholder={t("modelSelector.placeholder.search")}
defaultFilter={customFilter}
allowsEmptyCollection
itemComponent={(itemProps) => (
<Combobox.Item
item={itemProps.item}
class="selector-option"
>
<div class="selector-option-content">
<Combobox.ItemLabel class="selector-option-label">
{itemProps.item.rawValue.name}
</Combobox.ItemLabel>
<Combobox.ItemDescription class="selector-option-description">
{itemProps.item.rawValue.providerName} {itemProps.item.rawValue.providerId}/
{itemProps.item.rawValue.id}
</Combobox.ItemDescription>
</div>
<Combobox.ItemIndicator class="selector-option-indicator">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</Combobox.ItemIndicator>
</Combobox.Item>
)}
itemComponent={(itemProps) => {
const isFavorite = () => favoriteKeySet().has(itemProps.item.rawValue.key)
return (
<Combobox.Item
item={itemProps.item}
class="selector-option"
>
<>
<div class="selector-option-content">
<Combobox.ItemLabel class="selector-option-label">{itemProps.item.rawValue.name}</Combobox.ItemLabel>
<Combobox.ItemDescription class="selector-option-description">
{itemProps.item.rawValue.providerName} {itemProps.item.rawValue.providerId}/{itemProps.item.rawValue.id}
</Combobox.ItemDescription>
</div>
<Combobox.ItemIndicator class="selector-option-indicator">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</Combobox.ItemIndicator>
<button
type="button"
class="selector-option-star"
data-active={isFavorite()}
aria-label={
isFavorite()
? t("modelSelector.favorite.remove")
: t("modelSelector.favorite.add")
}
onPointerDown={preventListboxPress}
onPointerUp={preventListboxPress}
onMouseDown={preventListboxPress}
onMouseUp={preventListboxPress}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
event.stopPropagation()
suppressNextClose = true
setTimeout(() => {
suppressNextClose = false
}, 0)
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleFavoriteModelPreference({
providerId: itemProps.item.rawValue.providerId,
modelId: itemProps.item.rawValue.id,
})
}}
>
<Star
class="w-4 h-4"
fill={isFavorite() ? "currentColor" : "none"}
/>
</button>
</>
</Combobox.Item>
)
}}
>
<Combobox.Control class="relative w-full" data-model-selector-control>
<Combobox.Input class="sr-only" data-model-selector />
@@ -130,13 +313,53 @@ export default function ModelSelector(props: ModelSelectorProps) {
<Combobox.Portal>
<Combobox.Content class="selector-popover">
<div class="selector-search-container">
<Combobox.Input
ref={searchInputRef}
class="selector-search-input"
placeholder={t("modelSelector.placeholder.search")}
/>
<div class="selector-input-group">
<Combobox.Input
ref={searchInputRef}
class="selector-search-input flex-1 min-w-0"
placeholder={t("modelSelector.placeholder.search")}
onInput={handleSearchInput}
/>
<button
type="button"
class="selector-favorites-toggle"
aria-label={t("modelSelector.favoritesOnly.toggle.ariaLabel")}
aria-pressed={favoritesOnlyEnabled()}
disabled={!hasFavorites() || searchActive()}
data-active={favoritesOnlyEnabled()}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleFavoritesOnly()
}}
>
<Star class="w-4 h-4" fill={favoritesOnlyEnabled() ? "currentColor" : "none"} />
</button>
</div>
</div>
<Combobox.Listbox ref={listboxRef} class="selector-listbox" />
<div class="selector-footer">
<button
type="button"
class="selector-option selector-option-action w-full"
style={{ display: favoritesOnlyEnabled() && !searchActive() ? "flex" : "none" }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
showAllModels()
}}
>
<span class="selector-option-label">{t("modelSelector.favoritesOnly.showAll")}</span>
</button>
</div>
<Combobox.Listbox class="selector-listbox" />
</Combobox.Content>
</Combobox.Portal>
</Combobox>

View File

@@ -28,6 +28,10 @@ export const settingsMessages = {
"modelSelector.placeholder.search": "Search models...",
"modelSelector.none": "None",
"modelSelector.trigger.primary": "Model: {model}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "Toggle favorites only",
"modelSelector.favoritesOnly.showAll": "Show all models",
"modelSelector.favorite.add": "Add to favorites",
"modelSelector.favorite.remove": "Remove from favorites",
"thinkingSelector.variant.default": "Default",
"thinkingSelector.label": "Thinking: {variant}",

View File

@@ -28,6 +28,10 @@ export const settingsMessages = {
"modelSelector.placeholder.search": "Buscar modelos...",
"modelSelector.none": "Ninguno",
"modelSelector.trigger.primary": "Modelo: {model}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "Alternar solo favoritos",
"modelSelector.favoritesOnly.showAll": "Mostrar todos los modelos",
"modelSelector.favorite.add": "Agregar a favoritos",
"modelSelector.favorite.remove": "Quitar de favoritos",
"thinkingSelector.variant.default": "Por defecto",
"thinkingSelector.label": "Pensamiento: {variant}",

View File

@@ -28,6 +28,10 @@ export const settingsMessages = {
"modelSelector.placeholder.search": "Rechercher des modèles...",
"modelSelector.none": "Aucun",
"modelSelector.trigger.primary": "Modèle : {model}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "Basculer en favoris uniquement",
"modelSelector.favoritesOnly.showAll": "Afficher tous les modèles",
"modelSelector.favorite.add": "Ajouter aux favoris",
"modelSelector.favorite.remove": "Retirer des favoris",
"thinkingSelector.variant.default": "Par défaut",
"thinkingSelector.label": "Réflexion : {variant}",

View File

@@ -28,6 +28,10 @@ export const settingsMessages = {
"modelSelector.placeholder.search": "モデルを検索...",
"modelSelector.none": "なし",
"modelSelector.trigger.primary": "モデル: {model}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "お気に入りのみ",
"modelSelector.favoritesOnly.showAll": "すべてのモデルを表示",
"modelSelector.favorite.add": "お気に入りに追加",
"modelSelector.favorite.remove": "お気に入りから削除",
"thinkingSelector.variant.default": "デフォルト",
"thinkingSelector.label": "思考: {variant}",

View File

@@ -28,6 +28,10 @@ export const settingsMessages = {
"modelSelector.placeholder.search": "Поиск моделей…",
"modelSelector.none": "Нет",
"modelSelector.trigger.primary": "Модель: {model}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "Только избранное",
"modelSelector.favoritesOnly.showAll": "Показать все модели",
"modelSelector.favorite.add": "Добавить в избранное",
"modelSelector.favorite.remove": "Удалить из избранного",
"thinkingSelector.variant.default": "По умолчанию",
"thinkingSelector.label": "Размышления: {variant}",

View File

@@ -28,6 +28,10 @@ export const settingsMessages = {
"modelSelector.placeholder.search": "搜索模型...",
"modelSelector.none": "无",
"modelSelector.trigger.primary": "模型:{model}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "仅显示收藏",
"modelSelector.favoritesOnly.showAll": "显示所有模型",
"modelSelector.favorite.add": "添加到收藏",
"modelSelector.favorite.remove": "从收藏移除",
"thinkingSelector.variant.default": "默认",
"thinkingSelector.label": "思考:{variant}",

View File

@@ -40,6 +40,7 @@ export interface Preferences {
locale?: string
environmentVariables: Record<string, string>
modelRecents: ModelPreference[]
modelFavorites: ModelPreference[]
modelThinkingSelections: Record<string, string>
diffViewMode: DiffViewMode
toolOutputExpansion: ExpansionPreference
@@ -66,6 +67,7 @@ export type ThemePreference = NonNullable<ConfigData["theme"]>
const MAX_RECENT_FOLDERS = 20
const MAX_RECENT_MODELS = 5
const MAX_FAVORITE_MODELS = 50
const defaultPreferences: Preferences = {
showThinkingBlocks: false,
@@ -73,6 +75,7 @@ const defaultPreferences: Preferences = {
showTimelineTools: true,
environmentVariables: {},
modelRecents: [],
modelFavorites: [],
modelThinkingSelections: {},
diffViewMode: "split",
toolOutputExpansion: "expanded",
@@ -105,6 +108,9 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
const sourceModelRecents = sanitized.modelRecents ?? defaultPreferences.modelRecents
const modelRecents = sourceModelRecents.map((item) => ({ ...item }))
const sourceModelFavorites = sanitized.modelFavorites ?? defaultPreferences.modelFavorites
const modelFavorites = sourceModelFavorites.map((item) => ({ ...item }))
const modelThinkingSelections = {
...defaultPreferences.modelThinkingSelections,
...(sanitized.modelThinkingSelections ?? {}),
@@ -118,6 +124,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
locale: sanitized.locale ?? defaultPreferences.locale,
environmentVariables,
modelRecents,
modelFavorites,
modelThinkingSelections,
diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode,
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
@@ -132,6 +139,29 @@ function getModelKey(model: { providerId: string; modelId: string }): string {
return `${model.providerId}/${model.modelId}`
}
function isFavoriteModelPreference(model: ModelPreference): boolean {
if (!model.providerId || !model.modelId) return false
return (preferences().modelFavorites ?? []).some(
(item) => item.providerId === model.providerId && item.modelId === model.modelId,
)
}
function toggleFavoriteModelPreference(model: ModelPreference): void {
if (!model.providerId || !model.modelId) return
const favorites = preferences().modelFavorites ?? []
const exists = favorites.some((item) => item.providerId === model.providerId && item.modelId === model.modelId)
if (exists) {
const updated = favorites.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId)
updatePreferences({ modelFavorites: updated })
return
}
const filtered = favorites.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId)
const updated = [model, ...filtered].slice(0, MAX_FAVORITE_MODELS)
updatePreferences({ modelFavorites: updated })
}
function getModelThinkingSelection(model: { providerId: string; modelId: string }): string | undefined {
if (!model.providerId || !model.modelId) return undefined
return preferences().modelThinkingSelections?.[getModelKey(model)]
@@ -566,6 +596,8 @@ export {
addEnvironmentVariable,
removeEnvironmentVariable,
addRecentModelPreference,
isFavoriteModelPreference,
toggleFavoriteModelPreference,
getModelThinkingSelection,
setModelThinkingSelection,
setAgentModelPreference,

View File

@@ -148,6 +148,61 @@
color: var(--accent-primary);
}
.selector-option-action {
@apply flex items-center justify-center py-1;
color: var(--text-muted);
}
.selector-option-star {
@apply p-1 rounded transition-colors flex-shrink-0 mt-0.5;
color: var(--text-muted);
}
.selector-option-star[data-active="true"] {
color: var(--accent-primary);
}
.selector-option-star:hover {
background-color: var(--surface-hover);
}
.selector-option-star:focus-visible {
@apply ring-2;
ring-color: var(--accent-primary);
}
.selector-favorites-toggle {
@apply p-2 rounded border transition-colors flex items-center justify-center;
background-color: var(--surface-base);
border-color: var(--border-base);
color: var(--text-muted);
}
.selector-favorites-toggle[data-active="true"] {
color: var(--accent-primary);
}
.selector-favorites-toggle:hover {
background-color: var(--surface-hover);
}
.selector-favorites-toggle:focus-visible {
@apply ring-2;
ring-color: var(--accent-primary);
}
.selector-favorites-toggle:disabled {
@apply opacity-50 cursor-not-allowed;
}
.selector-footer {
@apply border-t;
border-color: var(--border-base);
background-color: var(--surface-base);
position: relative;
z-index: 1;
}
.selector-section {
@apply px-3 py-2 border-b;
border-color: var(--border-base);