diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 326bceaacfa2..ab06699e2257 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -3,8 +3,11 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_TIMESTAMP_FORMAT, getAppModelOptions, + getFavoriteModel, normalizeCustomModelSlugs, resolveAppModelSelection, + toggleFavoriteModel, + type AppSettings, } from "./appSettings"; describe("normalizeCustomModelSlugs", () => { @@ -64,3 +67,102 @@ describe("timestamp format defaults", () => { expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale"); }); }); + +function makeSettings(overrides?: Partial): AppSettings { + return { + codexBinaryPath: "", + codexHomePath: "", + defaultThreadEnvMode: "local", + confirmThreadDelete: true, + enableAssistantStreaming: false, + timestampFormat: "locale", + customCodexModels: [], + customClaudeModels: [], + customCursorModels: [], + favoriteModel: undefined, + ...overrides, + }; +} + +describe("getFavoriteModel", () => { + it("returns null when no favorite is set", () => { + expect(getFavoriteModel(makeSettings())).toBeNull(); + }); + + it("returns the favorite with provider and model when set", () => { + const settings = makeSettings({ + favoriteModel: { provider: "codex", model: "gpt-5.4" }, + }); + const fav = getFavoriteModel(settings); + expect(fav).toEqual({ provider: "codex", model: "gpt-5.4" }); + }); + + it("normalizes aliases to canonical slugs", () => { + const settings = makeSettings({ + favoriteModel: { provider: "claudeCode", model: "opus" }, + }); + const fav = getFavoriteModel(settings); + expect(fav).toEqual({ provider: "claudeCode", model: "claude-opus-4-6" }); + }); + + it("returns null for invalid provider values", () => { + const settings = makeSettings({ + favoriteModel: { provider: "invalid" as any, model: "gpt-5.4" }, + }); + expect(getFavoriteModel(settings)).toBeNull(); + }); + + it("returns null for empty model values", () => { + const settings = makeSettings({ + favoriteModel: { provider: "codex", model: "" }, + }); + expect(getFavoriteModel(settings)).toBeNull(); + }); + + it("returns a single global favorite regardless of provider", () => { + const settings = makeSettings({ + favoriteModel: { provider: "claudeCode", model: "claude-opus-4-6" }, + }); + const fav = getFavoriteModel(settings); + expect(fav?.provider).toBe("claudeCode"); + expect(fav?.model).toBe("claude-opus-4-6"); + }); +}); + +describe("toggleFavoriteModel", () => { + it("sets a favorite model when none is set", () => { + const settings = makeSettings(); + const patch = toggleFavoriteModel(settings, "codex", "gpt-5.4"); + expect(patch.favoriteModel).toEqual({ provider: "codex", model: "gpt-5.4" }); + }); + + it("removes the favorite when toggling the same model", () => { + const settings = makeSettings({ + favoriteModel: { provider: "codex", model: "gpt-5.4" }, + }); + const patch = toggleFavoriteModel(settings, "codex", "gpt-5.4"); + expect(patch.favoriteModel).toBeUndefined(); + }); + + it("switches the favorite to a different model in the same provider", () => { + const settings = makeSettings({ + favoriteModel: { provider: "codex", model: "gpt-5.4" }, + }); + const patch = toggleFavoriteModel(settings, "codex", "gpt-5.3-codex"); + expect(patch.favoriteModel).toEqual({ provider: "codex", model: "gpt-5.3-codex" }); + }); + + it("switches the favorite to a different provider entirely", () => { + const settings = makeSettings({ + favoriteModel: { provider: "codex", model: "gpt-5.4" }, + }); + const patch = toggleFavoriteModel(settings, "claudeCode", "claude-opus-4-6"); + expect(patch.favoriteModel).toEqual({ provider: "claudeCode", model: "claude-opus-4-6" }); + }); + + it("normalizes aliases when toggling", () => { + const settings = makeSettings(); + const patch = toggleFavoriteModel(settings, "claudeCode", "opus"); + expect(patch.favoriteModel).toEqual({ provider: "claudeCode", model: "claude-opus-4-6" }); + }); +}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 80ffb527f41a..f36a999a2d56 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -16,6 +16,12 @@ const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record cursor: new Set(getModelOptions("cursor").map((option) => option.slug)), }; +const FavoriteModelSchema = Schema.Struct({ + provider: Schema.Literals(["codex", "claudeCode", "cursor"]), + model: Schema.String, +}); +export type FavoriteModel = typeof FavoriteModelSchema.Type; + const AppSettingsSchema = Schema.Struct({ codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( Schema.withConstructorDefault(() => Option.some("")), @@ -42,6 +48,7 @@ const AppSettingsSchema = Schema.Struct({ customCursorModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + favoriteModel: Schema.optional(FavoriteModelSchema), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { @@ -159,6 +166,39 @@ export function resolveAppModelSelection( ); } +/** + * Returns the global favorite model, or `null` if none is set. + */ +export function getFavoriteModel( + settings: AppSettings, +): FavoriteModel | null { + const fav = settings.favoriteModel; + if (!fav || !fav.provider || !fav.model) return null; + const provider = fav.provider; + if (!(provider in BUILT_IN_MODEL_SLUGS_BY_PROVIDER)) return null; + const normalized = normalizeModelSlug(fav.model, provider); + if (!normalized) return null; + return { provider, model: normalized }; +} + +/** + * Returns a partial `AppSettings` patch that toggles the global favorite model. + * If the model is already the favorite, it removes it. Otherwise, it sets it as the new favorite. + */ +export function toggleFavoriteModel( + settings: AppSettings, + provider: ProviderKind, + modelSlug: string, +): Partial { + const current = getFavoriteModel(settings); + const normalized = normalizeModelSlug(modelSlug, provider); + if (!normalized) return {}; + const isSameFavorite = current?.provider === provider && current?.model === normalized; + return { + favoriteModel: isSameFavorite ? undefined : { provider, model: normalized }, + }; +} + export function useAppSettings() { const [settings, setSettings] = useLocalStorage( APP_SETTINGS_STORAGE_KEY, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e62447907b1a..43546b454173 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -122,7 +122,12 @@ import { import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { resolveAppModelSelection, useAppSettings } from "../appSettings"; +import { + getFavoriteModel, + resolveAppModelSelection, + toggleFavoriteModel, + useAppSettings, +} from "../appSettings"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, @@ -203,7 +208,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); - const { settings } = useAppSettings(); + const { settings, updateSettings } = useAppSettings(); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ @@ -367,17 +372,14 @@ export default function ChatView({ threadId }: ChatViewProps) { const serverThread = threads.find((t) => t.id === threadId); const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); + const draftFallbackModel = + fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex; const localDraftThread = useMemo( () => draftThread - ? buildLocalDraftThread( - threadId, - draftThread, - fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex, - localDraftError, - ) + ? buildLocalDraftThread(threadId, draftThread, draftFallbackModel, localDraftError) : undefined, - [draftThread, fallbackDraftProject?.model, localDraftError, threadId], + [draftThread, draftFallbackModel, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; const runtimeMode = @@ -503,11 +505,17 @@ export default function ChatView({ threadId }: ChatViewProps) { ? (sessionProvider ?? selectedProviderByThreadId ?? null) : null; const inferredProviderFromDraftModel = inferProviderFromModel(composerDraft.model); + const globalFavorite = getFavoriteModel(settings); + const favoriteProvider = isLocalDraftThread && globalFavorite + ? globalFavorite.provider + : null; const selectedProvider: ProviderKind = - lockedProvider ?? selectedProviderByThreadId ?? inferredProviderFromDraftModel ?? "codex"; + lockedProvider ?? selectedProviderByThreadId ?? inferredProviderFromDraftModel ?? favoriteProvider ?? "codex"; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, - activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), + isLocalDraftThread && globalFavorite && globalFavorite.provider === selectedProvider + ? globalFavorite.model + : (activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider)), ); const customModelsForSelectedProvider = settings.customCodexModels; const selectedModel = useMemo(() => { @@ -3052,6 +3060,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModel || (activeThread.model as ModelSlug) || (activeProject.model as ModelSlug) || + (globalFavorite?.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex; sendInFlightRef.current = true; @@ -3081,6 +3090,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProposedPlan, activeThread, beginSendPhase, + globalFavorite, isConnecting, isSendBusy, isServerThread, @@ -3112,6 +3122,24 @@ export default function ChatView({ threadId }: ChatViewProps) { settings.customCodexModels, ], ); + const onToggleFavorite = useCallback( + (provider: ProviderKind, model: ModelSlug) => { + const patch = toggleFavoriteModel(settings, provider, model); + updateSettings(patch); + const isFavoriting = patch.favoriteModel !== undefined; + const modelOptions = modelOptionsByProvider[provider]; + const modelName = modelOptions.find((opt) => opt.slug === model)?.name ?? model; + toastManager.add({ + type: isFavoriting ? "success" : "info", + title: isFavoriting ? "Set as default model" : "Default model removed", + description: isFavoriting + ? `New chats will use ${modelName}` + : "New chats will use the system default", + data: { dismissAfterVisibleMs: 2000 }, + }); + }, + [settings, updateSettings, modelOptionsByProvider], + ); const onEffortSelect = useCallback( (effort: CodexReasoningEffort) => { setComposerDraftEffort(threadId, effort); @@ -3783,7 +3811,9 @@ export default function ChatView({ threadId }: ChatViewProps) { model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} modelOptionsByProvider={modelOptionsByProvider} + favoriteModel={globalFavorite} onProviderModelChange={onProviderModelSelect} + onToggleFavorite={onToggleFavorite} /> {isComposerFooterCompact ? ( diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index abec3419d310..97aa4faaee20 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -2,7 +2,8 @@ import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { memo, useState } from "react"; import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; -import { ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon, StarIcon } from "lucide-react"; +import type { FavoriteModel } from "../../appSettings"; import { Button } from "../ui/button"; import { Menu, @@ -81,7 +82,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { modelOptionsByProvider: Record>; compact?: boolean; disabled?: boolean; + favoriteModel?: FavoriteModel | null; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; + onToggleFavorite?: (provider: ProviderKind, model: ModelSlug) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); const selectedProviderOptions = props.modelOptionsByProvider[props.provider]; @@ -153,15 +156,53 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { setIsMenuOpen(false); }} > - {props.modelOptionsByProvider[option.value].map((modelOption) => ( - setIsMenuOpen(false)} - > - {modelOption.name} - - ))} + {props.modelOptionsByProvider[option.value].map((modelOption) => { + const isFavorite = + props.favoriteModel?.provider === option.value && + props.favoriteModel?.model === modelOption.slug; + return ( + setIsMenuOpen(false)} + > + + {modelOption.name} + {props.onToggleFavorite && ( + + )} + + + ); + })}