-
Notifications
You must be signed in to change notification settings - Fork 6.5k
Redesign model picker with favorites and search #2153
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
juliusmarminge
merged 29 commits into
pingdotgg:main
from
Chrono-byte:feature/model-picker-favorites
Apr 20, 2026
Merged
Changes from 12 commits
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
c6048dd
Redesign model picker with favorites and search
Chrono-byte bbf12b3
Fail test if sidebar provider button missing
Chrono-byte 137723d
Add aria-label to Favorites button
Chrono-byte 687470d
Remove modelOptionsByProvider prop from ModelPicker
Chrono-byte 22da939
Use typed provider/model in settings favorites
Chrono-byte 2a209d7
Exclude favorited models from all models list
Chrono-byte d1057fb
Add accessibility attributes to model picker
Chrono-byte 55c46c1
Show favorites when provider is all or favorites
Chrono-byte e690645
Introduce ClientSettingsPatch and refine settings types
Chrono-byte 13e57b1
Use provider label in ProviderModelPicker test
Chrono-byte 05b83ad
Clear client settings localStorage in test
Chrono-byte 3f21091
remove codex bug file.
Chrono-byte b6b5e51
Consolidate provider icons and options
Chrono-byte 3f33982
Use scoped queries in ProviderModelPicker tests
Chrono-byte 8b5abce
Fix ModelPickerSidebar to disable provider buttons when missing from …
Chrono-byte 3952a47
Mock env runtime and reset local API in tests
Chrono-byte 47032ad
Expand environment runtime mock in tests
Chrono-byte 6e0297d
Add keyboard-driven model picker favorites
juliusmarminge 5224a63
Add favorites-aware model picker combobox
juliusmarminge f35dcaf
Update model picker favorites and shortcut handling
juliusmarminge 15f55e9
Mark Cursor as new in the model picker
juliusmarminge fc65ff8
Simplify model picker provider icon styling
juliusmarminge 28445c5
Stabilize shortcut modifier state updates
juliusmarminge 0a52512
Format shortcut modifier state test helper
juliusmarminge faf636f
Separate opencode model labels from provider names
juliusmarminge cc8004f
Remove obsolete .codex file
juliusmarminge 958b79d
Move model picker open state to its own store
juliusmarminge d3119a9
rm react-scan
juliusmarminge ad3f024
Fall back to the active provider model in the picker
juliusmarminge File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import { type ProviderKind } from "@t3tools/contracts"; | ||
| import { memo } from "react"; | ||
| import { StarIcon } from "lucide-react"; | ||
| import { | ||
| PROVIDER_ICON_BY_PROVIDER, | ||
| providerIconClassName, | ||
| getProviderLabel, | ||
| getDisplayModelName, | ||
| } from "./providerIconUtils"; | ||
| import { cn } from "~/lib/utils"; | ||
|
|
||
| export const ModelListRow = memo(function ModelListRow(props: { | ||
| slug: string; | ||
| name: string; | ||
| provider: ProviderKind; | ||
| isSelected: boolean; | ||
| isFavorite: boolean; | ||
| showProvider: boolean; | ||
| onSelect: () => void; | ||
| onToggleFavorite: () => void; | ||
| }) { | ||
| const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; | ||
|
|
||
| return ( | ||
| <div | ||
| className={cn( | ||
| "w-full px-3 py-2 rounded transition-colors flex items-start gap-2 group", | ||
| !props.isSelected && "hover:bg-muted", | ||
| props.isSelected && "bg-accent", | ||
| )} | ||
| > | ||
| <button | ||
| className="shrink-0 mt-0.5 opacity-40 group-hover:opacity-100 transition-opacity" | ||
| onClick={props.onToggleFavorite} | ||
| type="button" | ||
| aria-label={props.isFavorite ? "Remove from favorites" : "Add to favorites"} | ||
| > | ||
| <StarIcon className={cn("size-4", props.isFavorite && "fill-current text-yellow-500")} /> | ||
| </button> | ||
|
|
||
| <button className="min-w-0 flex-1 text-left" onClick={props.onSelect} type="button"> | ||
| <div className="font-medium text-sm truncate"> | ||
| {getDisplayModelName(props.provider, props.name)} | ||
| </div> | ||
| {props.showProvider && ( | ||
| <div className="flex items-center gap-1 mt-0.5"> | ||
| <ProviderIcon | ||
| className={cn( | ||
| "size-3 shrink-0", | ||
| providerIconClassName(props.provider, "text-muted-foreground/70"), | ||
| )} | ||
| /> | ||
| <span className="text-xs text-muted-foreground/70 truncate"> | ||
| {getProviderLabel(props.provider, props.name)} | ||
| </span> | ||
| </div> | ||
| )} | ||
| </button> | ||
| </div> | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,272 @@ | ||
| import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; | ||
| import { resolveSelectableModel } from "@t3tools/shared/model"; | ||
| import { memo, useMemo, useState, useCallback } from "react"; | ||
| import { SearchIcon } from "lucide-react"; | ||
| import { ModelListRow } from "./ModelListRow"; | ||
| import { ModelPickerSidebar } from "./ModelPickerSidebar"; | ||
| import { | ||
| PROVIDER_ICON_BY_PROVIDER, | ||
| providerIconClassName, | ||
| getProviderLabel, | ||
| } from "./providerIconUtils"; | ||
| import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; | ||
| import { cn } from "~/lib/utils"; | ||
|
|
||
| export const ModelPickerContent = memo(function ModelPickerContent(props: { | ||
| provider: ProviderKind; | ||
| model: string; | ||
| lockedProvider: ProviderKind | null; | ||
| providers?: ReadonlyArray<ServerProvider>; | ||
| modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>; | ||
| onProviderModelChange: (provider: ProviderKind, model: string) => void; | ||
| }) { | ||
| const [searchQuery, setSearchQuery] = useState(""); | ||
| const [selectedProvider, setSelectedProvider] = useState<ProviderKind | "all" | "favorites">( | ||
| "all", | ||
| ); | ||
| const favorites = useSettings((s) => s.favorites ?? []); | ||
| const { updateSettings } = useUpdateSettings(); | ||
|
|
||
| const handleSelectProvider = useCallback((provider: ProviderKind | "all" | "favorites") => { | ||
| setSelectedProvider(provider); | ||
| }, []); | ||
|
|
||
| // Create a Set for efficient lookup | ||
| const favoritesSet = useMemo(() => { | ||
| return new Set(favorites.map((fav) => `${fav.provider}:${fav.model}`)); | ||
| }, [favorites]); | ||
|
|
||
| const readyProviderSet = useMemo(() => { | ||
| if (!props.providers || props.providers.length === 0) { | ||
| return null; | ||
| } | ||
| return new Set( | ||
| props.providers | ||
| .filter((provider) => provider.status === "ready") | ||
| .map((provider) => provider.provider), | ||
| ); | ||
| }, [props.providers]); | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| // Flatten models into a searchable array | ||
| const flatModels = useMemo(() => { | ||
| return Object.entries(props.modelOptionsByProvider).flatMap(([providerKind, models]) => { | ||
| if (readyProviderSet && !readyProviderSet.has(providerKind as ProviderKind)) { | ||
| return []; | ||
| } | ||
| return models.map((m) => ({ | ||
| slug: m.slug, | ||
| name: m.name, | ||
| provider: providerKind as ProviderKind, | ||
| })); | ||
| }); | ||
| }, [props.modelOptionsByProvider, readyProviderSet]); | ||
|
|
||
| // Get favorite models from the flat list | ||
| const favoriteModels = useMemo(() => { | ||
| return flatModels.filter((m) => favoritesSet.has(`${m.provider}:${m.slug}`)); | ||
| }, [flatModels, favoritesSet]); | ||
|
|
||
| // Filter models based on search query and selected provider | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
| const filteredModels = useMemo(() => { | ||
| let result = flatModels; | ||
|
|
||
| // Handle favorites filter | ||
| if (selectedProvider === "favorites") { | ||
| result = result.filter((m) => favoritesSet.has(`${m.provider}:${m.slug}`)); | ||
| } else { | ||
| // Filter by locked provider if applicable | ||
| if (props.lockedProvider !== null) { | ||
| result = result.filter((m) => m.provider === props.lockedProvider); | ||
| } else if (selectedProvider !== "all") { | ||
| // Filter by selected provider (only in unlocked mode) | ||
| result = result.filter((m) => m.provider === selectedProvider); | ||
| } | ||
| } | ||
|
|
||
| // Apply search query (model name + provider name) | ||
| if (searchQuery.trim()) { | ||
| const query = searchQuery.toLowerCase(); | ||
| result = result.filter( | ||
| (m) => m.name.toLowerCase().includes(query) || m.provider.toLowerCase().includes(query), | ||
| ); | ||
| } | ||
|
|
||
| return result; | ||
| }, [flatModels, searchQuery, selectedProvider, props.lockedProvider, favoritesSet]); | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| // Get visible favorite models (respecting search/provider filter) | ||
| const visibleFavoriteModels = useMemo(() => { | ||
| if (!searchQuery.trim() && (selectedProvider === "all" || selectedProvider === "favorites")) { | ||
| // No search and viewing all models or in favorites mode: show favorites in dedicated section | ||
| let result = favoriteModels; | ||
|
|
||
| if (props.lockedProvider !== null) { | ||
| result = result.filter((m) => m.provider === props.lockedProvider); | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
| // With search, when provider is filtered, or in other modes: don't show separate section | ||
| return []; | ||
|
Chrono-byte marked this conversation as resolved.
Outdated
|
||
| }, [favoriteModels, searchQuery, selectedProvider, props.lockedProvider]); | ||
|
|
||
| const visibleFavoriteModelKeys = useMemo(() => { | ||
| return new Set(visibleFavoriteModels.map((model) => `${model.provider}:${model.slug}`)); | ||
| }, [visibleFavoriteModels]); | ||
|
|
||
| const allModelsSectionModels = useMemo(() => { | ||
| if (visibleFavoriteModelKeys.size === 0) { | ||
| return filteredModels; | ||
| } | ||
| return filteredModels.filter( | ||
| (model) => !visibleFavoriteModelKeys.has(`${model.provider}:${model.slug}`), | ||
| ); | ||
| }, [filteredModels, visibleFavoriteModelKeys]); | ||
|
|
||
| const handleModelSelect = (modelSlug: string, provider: ProviderKind) => { | ||
| const resolvedModel = resolveSelectableModel( | ||
| provider, | ||
| modelSlug, | ||
| props.modelOptionsByProvider[provider], | ||
| ); | ||
| if (resolvedModel) { | ||
| props.onProviderModelChange(provider, resolvedModel); | ||
| } | ||
| }; | ||
|
|
||
| const toggleFavorite = useCallback( | ||
| (provider: ProviderKind, model: string) => { | ||
| const newFavorites = [...favorites]; | ||
| const index = newFavorites.findIndex((f) => f.provider === provider && f.model === model); | ||
| if (index >= 0) { | ||
| newFavorites.splice(index, 1); | ||
| } else { | ||
| newFavorites.push({ provider, model }); | ||
| } | ||
| updateSettings({ favorites: newFavorites }); | ||
| }, | ||
| [favorites, updateSettings], | ||
| ); | ||
|
|
||
| const isLocked = props.lockedProvider !== null; | ||
| const LockedProviderIcon = | ||
| isLocked && props.lockedProvider ? PROVIDER_ICON_BY_PROVIDER[props.lockedProvider] : null; | ||
|
|
||
| // Get a model name from the locked provider to extract sub-provider info (for OpenCode) | ||
| const lockedProviderModelName = | ||
| isLocked && props.lockedProvider | ||
| ? (props.modelOptionsByProvider[props.lockedProvider]?.[0]?.name ?? "") | ||
| : ""; | ||
|
|
||
| return ( | ||
| <div | ||
| className={cn( | ||
| "flex h-screen max-h-96 w-screen max-w-100 bg-popover", | ||
|
Chrono-byte marked this conversation as resolved.
Outdated
|
||
| isLocked ? "flex-col" : "flex-row", | ||
| )} | ||
| > | ||
| {/* Locked provider header (only shown in locked mode) */} | ||
| {isLocked && LockedProviderIcon && props.lockedProvider && ( | ||
| <div className="flex items-center gap-2 px-4 py-3 border-b"> | ||
| <LockedProviderIcon | ||
| className={cn( | ||
| "size-5 shrink-0", | ||
| providerIconClassName(props.lockedProvider, "text-muted-foreground/85"), | ||
| )} | ||
| /> | ||
| <span className="font-medium text-sm"> | ||
| {getProviderLabel(props.lockedProvider, lockedProviderModelName)} | ||
| </span> | ||
| </div> | ||
| )} | ||
|
|
||
| {/* Sidebar (only in unlocked mode) */} | ||
| {!isLocked && ( | ||
| <ModelPickerSidebar | ||
| selectedProvider={selectedProvider} | ||
| onSelectProvider={handleSelectProvider} | ||
| {...(props.providers && { providers: props.providers })} | ||
| /> | ||
| )} | ||
|
|
||
| {/* Main content area */} | ||
| <div className={cn("flex-1 flex flex-col", isLocked ? "min-w-0" : "border-l")}> | ||
| {/* Search bar */} | ||
| <div className="px-3 py-2 border-b flex items-center gap-2 relative z-20"> | ||
| <SearchIcon className="size-4 shrink-0 text-muted-foreground/50" /> | ||
| <input | ||
| type="text" | ||
| placeholder="Search models..." | ||
| value={searchQuery} | ||
| onChange={(e) => setSearchQuery(e.target.value)} | ||
| onKeyDown={(e) => e.stopPropagation()} | ||
| onMouseDown={(e) => e.stopPropagation()} | ||
| onTouchStart={(e) => e.stopPropagation()} | ||
| autoFocus | ||
| className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/50 relative z-20" | ||
| /> | ||
| </div> | ||
|
|
||
| {/* Model list */} | ||
| <div className="flex-1 overflow-y-auto model-picker-list"> | ||
| {visibleFavoriteModels.length > 0 || allModelsSectionModels.length > 0 ? ( | ||
| <div> | ||
| {/* Favorites section with sticky header */} | ||
| {visibleFavoriteModels.length > 0 && ( | ||
| <div> | ||
| <div className="px-3 py-2 text-xs font-semibold text-muted-foreground bg-popover sticky top-0 z-20"> | ||
| FAVORITES | ||
| </div> | ||
| <div className="divide-y"> | ||
| {visibleFavoriteModels.map((model) => ( | ||
| <ModelListRow | ||
| key={`${model.provider}:${model.slug}`} | ||
| slug={model.slug} | ||
| name={model.name} | ||
| provider={model.provider} | ||
| isSelected={props.provider === model.provider && props.model === model.slug} | ||
| isFavorite={true} | ||
| showProvider={!isLocked} | ||
| onSelect={() => handleModelSelect(model.slug, model.provider)} | ||
| onToggleFavorite={() => toggleFavorite(model.provider, model.slug)} | ||
| /> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| )} | ||
|
|
||
| {/* All models section - always shows with sticky header */} | ||
| {allModelsSectionModels.length > 0 && ( | ||
| <div> | ||
| <div className="px-3 py-2 text-xs font-semibold text-muted-foreground bg-popover sticky top-0 z-20"> | ||
| ALL MODELS | ||
| </div> | ||
| <div className="divide-y"> | ||
| {allModelsSectionModels.map((model) => ( | ||
| <ModelListRow | ||
| key={`${model.provider}:${model.slug}`} | ||
| slug={model.slug} | ||
| name={model.name} | ||
| provider={model.provider} | ||
| isSelected={props.provider === model.provider && props.model === model.slug} | ||
| isFavorite={favoritesSet.has(`${model.provider}:${model.slug}`)} | ||
| showProvider={!isLocked} | ||
| onSelect={() => handleModelSelect(model.slug, model.provider)} | ||
| onToggleFavorite={() => toggleFavorite(model.provider, model.slug)} | ||
| /> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| )} | ||
| </div> | ||
| ) : ( | ||
| <div className="flex items-center justify-center h-full text-muted-foreground text-sm"> | ||
| No models found | ||
| </div> | ||
| )} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unrelated refactoring of command palette thread items
Low Severity
The return statement in
buildCommandPaletteThreadItemswas refactored from spread syntax toObject.assignwithout any functional reason related to the model picker redesign. This increases the diff size, changes string quoting from""to backtick templates, and introduces a different (arguably less readable) pattern while the original spread syntax was clear and idiomatic. This change makes the PR harder to review without any benefit.Reviewed by Cursor Bugbot for commit fc65ff8. Configure here.