diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index 192d7ac106..e03cc3bc54 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -54,6 +54,7 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, diffWordWrap: true, favorites: [], + hiddenModels: [], sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", 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..e54d73faca 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -8,6 +8,7 @@ import { render } from "vitest-browser-react"; import { ProviderModelPicker } from "./ProviderModelPicker"; import { getCustomModelOptionsByProvider } from "../../modelSelection"; import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { __resetClientSettingsPersistenceForTests } from "../../hooks/useSettings"; import { __resetLocalApiForTests } from "../../localApi"; // Mock the environments/runtime module to provide a mock primary environment connection @@ -294,12 +295,16 @@ function getSidebarProviderOrder() { describe("ProviderModelPicker", () => { beforeEach(async () => { // Reset test environment before each test + localStorage.clear(); await __resetLocalApiForTests(); + __resetClientSettingsPersistenceForTests(); }); afterEach(async () => { document.body.innerHTML = ""; + localStorage.clear(); await __resetLocalApiForTests(); + __resetClientSettingsPersistenceForTests(); }); it("shows provider sidebar in unlocked mode", async () => { @@ -908,6 +913,75 @@ 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 page.getByRole("button", { name: "Favorites", exact: true }).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(() => { + const visibleModelNames = getVisibleModelNames(); + expect(visibleModelNames).toContain("GPT-5 Codex"); + expect(visibleModelNames).not.toContain("GPT-5.3 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..8625a06a70 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -3,6 +3,7 @@ import "../../index.css"; import { type AuthAccessStreamEvent, type AuthAccessSnapshot, + type ClientSettings, AuthSessionId, DEFAULT_SERVER_SETTINGS, EnvironmentId, @@ -10,6 +11,7 @@ import { type DesktopUpdateChannel, type DesktopUpdateState, type LocalApi, + type ServerProvider, type ServerConfig, } from "@t3tools/contracts"; import { DateTime } from "effect"; @@ -22,6 +24,7 @@ import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; import { ConnectionsSettings } from "./ConnectionsSettings"; import { GeneralSettingsPanel } from "./SettingsPanels"; +import { __resetClientSettingsPersistenceForTests } from "../../hooks/useSettings"; const authAccessHarness = vi.hoisted(() => { type Snapshot = AuthAccessSnapshot; @@ -201,6 +204,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)); } @@ -342,6 +382,7 @@ describe("GeneralSettingsPanel observability", () => { beforeEach(async () => { resetServerStateForTests(); await __resetLocalApiForTests(); + __resetClientSettingsPersistenceForTests(); localStorage.clear(); authAccessHarness.reset(); }); @@ -358,6 +399,7 @@ describe("GeneralSettingsPanel observability", () => { document.body.innerHTML = ""; resetServerStateForTests(); await __resetLocalApiForTests(); + __resetClientSettingsPersistenceForTests(); authAccessHarness.reset(); }); @@ -718,4 +760,61 @@ 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() + .mockImplementation(async () => DEFAULT_SERVER_SETTINGS); + const config: ServerConfig = { + ...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 = { + persistence: { + getClientSettings: async () => null, + setClientSettings: async (settings: ClientSettings) => { + localStorage.setItem("t3code:client-settings:v1", JSON.stringify(settings)); + }, + }, + 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