Skip to content
Merged
Show file tree
Hide file tree
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 Apr 18, 2026
bbf12b3
Fail test if sidebar provider button missing
Chrono-byte Apr 18, 2026
137723d
Add aria-label to Favorites button
Chrono-byte Apr 18, 2026
687470d
Remove modelOptionsByProvider prop from ModelPicker
Chrono-byte Apr 18, 2026
22da939
Use typed provider/model in settings favorites
Chrono-byte Apr 18, 2026
2a209d7
Exclude favorited models from all models list
Chrono-byte Apr 18, 2026
d1057fb
Add accessibility attributes to model picker
Chrono-byte Apr 18, 2026
55c46c1
Show favorites when provider is all or favorites
Chrono-byte Apr 18, 2026
e690645
Introduce ClientSettingsPatch and refine settings types
Chrono-byte Apr 18, 2026
13e57b1
Use provider label in ProviderModelPicker test
Chrono-byte Apr 18, 2026
05b83ad
Clear client settings localStorage in test
Chrono-byte Apr 18, 2026
3f21091
remove codex bug file.
Chrono-byte Apr 18, 2026
b6b5e51
Consolidate provider icons and options
Chrono-byte Apr 18, 2026
3f33982
Use scoped queries in ProviderModelPicker tests
Chrono-byte Apr 18, 2026
8b5abce
Fix ModelPickerSidebar to disable provider buttons when missing from …
Chrono-byte Apr 18, 2026
3952a47
Mock env runtime and reset local API in tests
Chrono-byte Apr 18, 2026
47032ad
Expand environment runtime mock in tests
Chrono-byte Apr 18, 2026
6e0297d
Add keyboard-driven model picker favorites
juliusmarminge Apr 19, 2026
5224a63
Add favorites-aware model picker combobox
juliusmarminge Apr 19, 2026
f35dcaf
Update model picker favorites and shortcut handling
juliusmarminge Apr 19, 2026
15f55e9
Mark Cursor as new in the model picker
juliusmarminge Apr 19, 2026
fc65ff8
Simplify model picker provider icon styling
juliusmarminge Apr 19, 2026
28445c5
Stabilize shortcut modifier state updates
juliusmarminge Apr 19, 2026
0a52512
Format shortcut modifier state test helper
juliusmarminge Apr 19, 2026
faf636f
Separate opencode model labels from provider names
juliusmarminge Apr 19, 2026
cc8004f
Remove obsolete .codex file
juliusmarminge Apr 19, 2026
958b79d
Move model picker open state to its own store
juliusmarminge Apr 20, 2026
d3119a9
rm react-scan
juliusmarminge Apr 20, 2026
ad3f024
Fall back to the active provider model in the picker
juliusmarminge Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/clientPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const clientSettings: ClientSettings = {
confirmThreadArchive: true,
confirmThreadDelete: false,
diffWordWrap: true,
favorites: [],
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
"environment-1:/tmp/project-a": "separate",
Expand Down
34 changes: 19 additions & 15 deletions apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,22 +151,26 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
const leadingContent = input.renderLeadingContent?.(thread);
const trailingContent = input.renderTrailingContent?.(thread);

return {
kind: "action",
value: `thread:${thread.id}`,
searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""],
title: thread.title,
description: descriptionParts.join(" · "),
timestamp: formatRelativeTimeLabel(
thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
),
icon: input.icon,
...(leadingContent ? { titleLeadingContent: leadingContent } : {}),
...(trailingContent ? { titleTrailingContent: trailingContent } : {}),
run: async () => {
await input.runThread(thread);
return Object.assign(
{
kind: "action" as const,
value: `thread:${thread.id}`,
searchTerms: [thread.title, projectTitle ?? ``, thread.branch ?? ``],
title: thread.title,
description: descriptionParts.join(` · `),
timestamp: formatRelativeTimeLabel(
thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
),
icon: input.icon,
},
leadingContent ? { titleLeadingContent: leadingContent } : {},
trailingContent ? { titleTrailingContent: trailingContent } : {},
{
run: async () => {
await input.runThread(thread);
},
},
};
);

Copy link
Copy Markdown
Contributor

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 buildCommandPaletteThreadItems was refactored from spread syntax to Object.assign without 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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit fc65ff8. Configure here.

});
}

Expand Down
61 changes: 61 additions & 0 deletions apps/web/src/components/chat/ModelListRow.tsx
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>
);
});
272 changes: 272 additions & 0 deletions apps/web/src/components/chat/ModelPickerContent.tsx
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]);
Comment thread
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
Comment thread
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]);
Comment thread
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 [];
Comment thread
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",
Comment thread
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>
);
});
Loading
Loading