From 92b8987a748bee520b5e8cc419e28a3e047695c1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Apr 2026 00:37:55 +0000 Subject: [PATCH 1/3] Add model favorite ordering and visibility settings Co-authored-by: Julius Marminge --- apps/web/src/components/chat/ModelListRow.tsx | 112 ++++++++++++++---- .../components/chat/ModelPickerContent.tsx | 64 ++++++++-- .../chat/ProviderModelPicker.browser.tsx | 66 +++++++++++ .../settings/SettingsPanels.browser.tsx | 89 ++++++++++++++ .../components/settings/SettingsPanels.tsx | 60 +++++++++- packages/contracts/src/settings.ts | 27 ++--- 6 files changed, 365 insertions(+), 53 deletions(-) diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 6cd097ad1f..6fd5060e15 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -1,6 +1,6 @@ import { type ProviderKind } from "@t3tools/contracts"; import { memo } from "react"; -import { StarIcon } from "lucide-react"; +import { ArrowDownIcon, ArrowUpIcon, StarIcon } from "lucide-react"; import { getDisplayModelName, getProviderLabel, @@ -23,9 +23,16 @@ export const ModelListRow = memo(function ModelListRow(props: { useTriggerLabel?: boolean; showNewBadge?: boolean; jumpLabel?: string | null; + reorderControls?: { + canMoveUp: boolean; + canMoveDown: boolean; + onMoveUp: () => void; + onMoveDown: () => void; + } | null; onToggleFavorite: () => void; }) { const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; + const reorderControls = props.isFavorite ? props.reorderControls : null; return ( - - { - event.stopPropagation(); - props.onToggleFavorite(); - }} - onKeyDown={(event) => { - event.stopPropagation(); - }} - type="button" - aria-label={props.isFavorite ? "Remove from favorites" : "Add to favorites"} - > - + + { + event.stopPropagation(); + props.onToggleFavorite(); + }} + onKeyDown={(event) => { + event.stopPropagation(); + }} + type="button" + aria-label={props.isFavorite ? "Remove from favorites" : "Add to favorites"} + > + + + } + /> + + {props.isFavorite ? "Remove from favorites" : "Add to favorites"} + + + + {reorderControls ? ( +
+ + { + event.stopPropagation(); + reorderControls.onMoveUp(); + }} + onKeyDown={(event) => { + event.stopPropagation(); + }} + type="button" + aria-label={`Move ${props.model.name} up in favorites`} + > + + + } + /> + + Move favorite up + + + + { + event.stopPropagation(); + reorderControls.onMoveDown(); + }} + onKeyDown={(event) => { + event.stopPropagation(); + }} + type="button" + aria-label={`Move ${props.model.name} down in favorites`} + > + + + } /> - - } - /> - - {props.isFavorite ? "Remove from favorites" : "Add to favorites"} - - + + Move favorite down + + +
+ ) : null} +
diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 82720425ef..546e173fb7 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -50,6 +50,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const listRegionRef = useRef(null); const highlightedModelKeyRef = useRef(null); const favorites = useSettings((s) => s.favorites ?? []); + const hiddenModels = useSettings((s) => s.hiddenModels ?? []); const [selectedProvider, setSelectedProvider] = useState(() => { if (props.lockedProvider !== null) { return props.lockedProvider; @@ -99,6 +100,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { favorites.map((favorite, index) => [`${favorite.provider}:${favorite.model}`, index]), ); }, [favorites]); + const hiddenModelsSet = useMemo(() => { + return new Set( + hiddenModels.map((hiddenModel) => `${hiddenModel.provider}:${hiddenModel.model}`), + ); + }, [hiddenModels]); const readyProviderSet = useMemo(() => { if (!props.providers || props.providers.length === 0) { @@ -117,15 +123,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (readyProviderSet && !readyProviderSet.has(providerKind as ProviderKind)) { return []; } - return models.map((m) => ({ - slug: m.slug, - name: m.name, - ...(m.shortName ? { shortName: m.shortName } : {}), - ...(m.subProvider ? { subProvider: m.subProvider } : {}), - provider: providerKind as ProviderKind, - })) satisfies Array; + return models.flatMap((m) => { + const provider = providerKind as ProviderKind; + if (hiddenModelsSet.has(`${provider}:${m.slug}`)) { + return []; + } + return [ + { + slug: m.slug, + name: m.name, + ...(m.shortName ? { shortName: m.shortName } : {}), + ...(m.subProvider ? { subProvider: m.subProvider } : {}), + provider, + }, + ]; + }) satisfies Array; }); - }, [props.modelOptionsByProvider, readyProviderSet]); + }, [hiddenModelsSet, props.modelOptionsByProvider, readyProviderSet]); // Filter models based on search query and selected provider const filteredModels = useMemo(() => { @@ -249,8 +263,30 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { [favorites, updateSettings], ); + const moveFavorite = useCallback( + (provider: ProviderKind, model: string, direction: -1 | 1) => { + const index = favorites.findIndex( + (favorite) => favorite.provider === provider && favorite.model === model, + ); + const nextIndex = index + direction; + if (index < 0 || nextIndex < 0 || nextIndex >= favorites.length) { + return; + } + + const newFavorites = [...favorites]; + const [movedFavorite] = newFavorites.splice(index, 1); + if (!movedFavorite) { + return; + } + newFavorites.splice(nextIndex, 0, movedFavorite); + updateSettings({ favorites: newFavorites }); + }, + [favorites, updateSettings], + ); + const isLocked = props.lockedProvider !== null; const isSearching = searchQuery.trim().length > 0; + const canReorderFavorites = selectedProvider === "favorites" && !isSearching; const showSidebar = !isLocked && !isSearching; const LockedProviderIcon = isLocked && props.lockedProvider ? PROVIDER_ICON_BY_PROVIDER[props.lockedProvider] : null; @@ -503,6 +539,18 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { showNewBadge={isModelPickerNewModel(model.provider, model.slug)} jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} onToggleFavorite={() => toggleFavorite(model.provider, model.slug)} + reorderControls={ + canReorderFavorites && favoritesSet.has(modelKey) + ? { + canMoveUp: (favoriteOrder.get(modelKey) ?? -1) > 0, + canMoveDown: + (favoriteOrder.get(modelKey) ?? favorites.length) < + favorites.length - 1, + onMoveUp: () => moveFavorite(model.provider, model.slug, -1), + onMoveDown: () => moveFavorite(model.provider, model.slug, 1), + } + : null + } /> ); })} diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index a5f539ae5a..5534d02d08 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -908,6 +908,72 @@ describe("ProviderModelPicker", () => { } }); + it("reorders favorited models from the favorites list", async () => { + localStorage.setItem( + "t3code:client-settings:v1", + JSON.stringify({ + ...DEFAULT_CLIENT_SETTINGS, + favorites: [ + { provider: "codex", model: "gpt-5-codex" }, + { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + ], + }), + ); + + const mounted = await mountPicker({ + provider: "codex", + model: "gpt-5-codex", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(getVisibleModelNames().slice(0, 2)).toEqual(["GPT-5 Codex", "Claude Sonnet 4.6"]); + }); + + await page + .getByRole("button", { name: "Move Claude Sonnet 4.6 up in favorites", exact: true }) + .click(); + + await vi.waitFor(() => { + expect(getVisibleModelNames().slice(0, 2)).toEqual(["Claude Sonnet 4.6", "GPT-5 Codex"]); + }); + } finally { + await mounted.cleanup(); + localStorage.removeItem("t3code:client-settings:v1"); + } + }); + + it("does not show hidden models in the picker", async () => { + localStorage.setItem( + "t3code:client-settings:v1", + JSON.stringify({ + ...DEFAULT_CLIENT_SETTINGS, + hiddenModels: [{ provider: "codex", model: "gpt-5.3-codex" }], + }), + ); + + const mounted = await mountPicker({ + provider: "codex", + model: "gpt-5-codex", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("button", { name: "Codex", exact: true }).click(); + + await vi.waitFor(() => { + expect(getVisibleModelNames()).toEqual(["GPT-5 Codex"]); + }); + } finally { + await mounted.cleanup(); + localStorage.removeItem("t3code:client-settings:v1"); + } + }); + it("dispatches callback with correct provider and model when selected", async () => { const mounted = await mountPicker({ provider: "claudeAgent", diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index b508b29b77..f73dfc8deb 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -10,6 +10,7 @@ import { type DesktopUpdateChannel, type DesktopUpdateState, type LocalApi, + type ServerProvider, type ServerConfig, } from "@t3tools/contracts"; import { DateTime } from "effect"; @@ -201,6 +202,43 @@ function createBaseServerConfig(): ServerConfig { }; } +function createProviderSettingsServerConfig(): ServerConfig { + const config = createBaseServerConfig(); + const providers: ServerProvider[] = [ + { + provider: "codex", + displayName: "Codex", + enabled: true, + installed: true, + version: "0.116.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: new Date().toISOString(), + slashCommands: [], + skills: [], + models: [ + { + slug: "gpt-5-codex", + name: "GPT-5 Codex", + isCustom: false, + capabilities: null, + }, + { + slug: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + isCustom: false, + capabilities: null, + }, + ], + }, + ]; + + return { + ...config, + providers, + }; +} + function makeUtc(value: string) { return DateTime.makeUnsafe(Date.parse(value)); } @@ -718,4 +756,55 @@ describe("GeneralSettingsPanel observability", () => { await expect.element(page.getByText("OpenCode server password")).toBeInTheDocument(); await expect.element(page.getByPlaceholder("Server password")).toBeInTheDocument(); }); + + it("toggles model visibility from provider settings", async () => { + const updateSettings = vi + .fn() + .mockResolvedValue(undefined); + const config = { + ...createBaseServerConfig(), + providers: [ + { + provider: "codex" as const, + displayName: "Codex", + enabled: true, + installed: true, + version: "0.116.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: new Date().toISOString(), + slashCommands: [], + skills: [], + models: [ + { + slug: "gpt-5-codex", + name: "GPT-5 Codex", + isCustom: false, + capabilities: null, + }, + ], + }, + ] satisfies ReadonlyArray, + }; + setServerConfigSnapshot(config); + window.nativeApi = { + server: { + updateSettings, + }, + } as unknown as LocalApi; + + mounted = await render( + + + , + ); + + await page.getByLabelText("Toggle Codex details").click(); + await page.getByRole("button", { name: "Hide GPT-5 Codex in model picker" }).click(); + + await expect.element(page.getByText("hidden")).toBeInTheDocument(); + const persisted = JSON.parse(localStorage.getItem("t3code:client-settings:v1") ?? "{}"); + expect(persisted.hiddenModels).toEqual([{ provider: "codex", model: "gpt-5-codex" }]); + expect(updateSettings).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 29d09a5cdc..570c602b3a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2,6 +2,8 @@ import { ArchiveIcon, ArchiveX, ChevronDownIcon, + EyeIcon, + EyeOffIcon, InfoIcon, LoaderIcon, PlusIcon, @@ -544,18 +546,21 @@ export function GeneralSettingsPanel() { codex: Boolean( settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath || - settings.providers.codex.customModels.length > 0, + settings.providers.codex.customModels.length > 0 || + settings.hiddenModels.some((model) => model.provider === "codex"), ), claudeAgent: Boolean( settings.providers.claudeAgent.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || settings.providers.claudeAgent.customModels.length > 0 || - settings.providers.claudeAgent.launchArgs !== "", + settings.providers.claudeAgent.launchArgs !== "" || + settings.hiddenModels.some((model) => model.provider === "claudeAgent"), ), cursor: Boolean( settings.providers.cursor.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.cursor.binaryPath || - settings.providers.cursor.customModels.length > 0, + settings.providers.cursor.customModels.length > 0 || + settings.hiddenModels.some((model) => model.provider === "cursor"), ), opencode: Boolean( settings.providers.opencode.binaryPath !== @@ -564,7 +569,8 @@ export function GeneralSettingsPanel() { DEFAULT_UNIFIED_SETTINGS.providers.opencode.serverUrl || settings.providers.opencode.serverPassword !== DEFAULT_UNIFIED_SETTINGS.providers.opencode.serverPassword || - settings.providers.opencode.customModels.length > 0, + settings.providers.opencode.customModels.length > 0 || + settings.hiddenModels.some((model) => model.provider === "opencode"), ), }); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< @@ -769,6 +775,22 @@ export function GeneralSettingsPanel() { [settings, updateSettings], ); + const toggleHiddenModel = useCallback( + (provider: ProviderKind, slug: string) => { + const isHidden = settings.hiddenModels.some( + (hiddenModel) => hiddenModel.provider === provider && hiddenModel.model === slug, + ); + updateSettings({ + hiddenModels: isHidden + ? settings.hiddenModels.filter( + (hiddenModel) => hiddenModel.provider !== provider || hiddenModel.model !== slug, + ) + : [...settings.hiddenModels, { provider, model: slug }], + }); + }, + [settings.hiddenModels, updateSettings], + ); + const providerCards = visibleProviderSettings.map((providerSettings) => { const liveProvider = serverProviders.find( (candidate) => candidate.provider === providerSettings.provider, @@ -785,6 +807,11 @@ export function GeneralSettingsPanel() { isCustom: true, capabilities: null, })); + const hiddenModelSlugs = new Set( + settings.hiddenModels + .filter((hiddenModel) => hiddenModel.provider === providerSettings.provider) + .map((hiddenModel) => hiddenModel.model), + ); return { provider: providerSettings.provider, @@ -802,9 +829,10 @@ export function GeneralSettingsPanel() { binaryPathValue: providerConfig.binaryPath, serverUrlValue: "serverUrl" in providerConfig ? providerConfig.serverUrl : "", serverPasswordValue: "serverPassword" in providerConfig ? providerConfig.serverPassword : "", - isDirty: !Equal.equals(providerConfig, defaultProviderConfig), + isDirty: !Equal.equals(providerConfig, defaultProviderConfig) || hiddenModelSlugs.size > 0, liveProvider, models, + hiddenModelSlugs, providerConfig, statusStyle: PROVIDER_STATUS_STYLES[statusKey], summary, @@ -1236,6 +1264,9 @@ export function GeneralSettingsPanel() { [providerCard.provider]: DEFAULT_UNIFIED_SETTINGS.providers[providerCard.provider], }, + hiddenModels: settings.hiddenModels.filter( + (hiddenModel) => hiddenModel.provider !== providerCard.provider, + ), }); setCustomModelErrorByProvider((existing) => ({ ...existing, @@ -1501,6 +1532,7 @@ export function GeneralSettingsPanel() { > {providerCard.models.map((model) => { const caps = model.capabilities; + const isHidden = providerCard.hiddenModelSlugs.has(model.slug); const capLabels: string[] = []; const descriptors = caps?.optionDescriptors ?? []; if (descriptors.some((descriptor) => descriptor.id === "fastMode")) { @@ -1565,8 +1597,24 @@ export function GeneralSettingsPanel() { ) : null} + {model.isCustom ? ( -
+
custom