diff --git a/.gitignore b/.gitignore index 3e8d287755..f5c9ebb69b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ release/ apps/web/.playwright apps/web/playwright-report apps/web/src/components/__screenshots__ -.vitest-* \ No newline at end of file +.vitest-* +__screenshots__/ \ No newline at end of file diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 605b281df3..26d231537d 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -5,7 +5,13 @@ import { AppSettingsSchema, DEFAULT_TIMESTAMP_FORMAT, getAppModelOptions, + getCustomModelOptionsByProvider, + getCustomModelsByProvider, + getCustomModelsForProvider, + getDefaultCustomModelsForProvider, + MODEL_PROVIDER_SETTINGS, normalizeCustomModelSlugs, + patchCustomModels, resolveAppModelSelection, } from "./appSettings"; @@ -66,13 +72,35 @@ describe("getAppModelOptions", () => { describe("resolveAppModelSelection", () => { it("preserves saved custom model slugs instead of falling back to the default", () => { - expect(resolveAppModelSelection("codex", ["galapagos-alpha"], "galapagos-alpha")).toBe( - "galapagos-alpha", - ); + expect( + resolveAppModelSelection( + "codex", + { codex: ["galapagos-alpha"], claudeAgent: [] }, + "galapagos-alpha", + ), + ).toBe("galapagos-alpha"); }); it("falls back to the provider default when no model is selected", () => { - expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); + expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "")).toBe("gpt-5.4"); + }); + + it("resolves display names through the shared resolver", () => { + expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "GPT-5.3 Codex")).toBe( + "gpt-5.3-codex", + ); + }); + + it("resolves aliases through the shared resolver", () => { + expect(resolveAppModelSelection("claudeAgent", { codex: [], claudeAgent: [] }, "sonnet")).toBe( + "claude-sonnet-4-6", + ); + }); + + it("resolves transient selected custom models included in app model options", () => { + expect( + resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "custom/selected-model"), + ).toBe("custom/selected-model"); }); }); @@ -90,6 +118,85 @@ describe("provider-specific custom models", () => { }); }); +describe("provider-indexed custom model settings", () => { + const settings = { + customCodexModels: ["custom/codex-model"], + customClaudeModels: ["claude/custom-opus"], + } as const; + + it("exports one provider config per provider", () => { + expect(MODEL_PROVIDER_SETTINGS.map((config) => config.provider)).toEqual([ + "codex", + "claudeAgent", + ]); + }); + + it("reads custom models for each provider", () => { + expect(getCustomModelsForProvider(settings, "codex")).toEqual(["custom/codex-model"]); + expect(getCustomModelsForProvider(settings, "claudeAgent")).toEqual(["claude/custom-opus"]); + }); + + it("reads default custom models for each provider", () => { + const defaults = { + customCodexModels: ["default/codex-model"], + customClaudeModels: ["claude/default-opus"], + } as const; + + expect(getDefaultCustomModelsForProvider(defaults, "codex")).toEqual(["default/codex-model"]); + expect(getDefaultCustomModelsForProvider(defaults, "claudeAgent")).toEqual([ + "claude/default-opus", + ]); + }); + + it("patches custom models for codex", () => { + expect(patchCustomModels("codex", ["custom/codex-model"])).toEqual({ + customCodexModels: ["custom/codex-model"], + }); + }); + + it("patches custom models for claude", () => { + expect(patchCustomModels("claudeAgent", ["claude/custom-opus"])).toEqual({ + customClaudeModels: ["claude/custom-opus"], + }); + }); + + it("builds a complete provider-indexed custom model record", () => { + expect(getCustomModelsByProvider(settings)).toEqual({ + codex: ["custom/codex-model"], + claudeAgent: ["claude/custom-opus"], + }); + }); + + it("builds provider-indexed model options including custom models", () => { + const modelOptionsByProvider = getCustomModelOptionsByProvider(settings); + + expect( + modelOptionsByProvider.codex.some((option) => option.slug === "custom/codex-model"), + ).toBe(true); + expect( + modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude/custom-opus"), + ).toBe(true); + }); + + it("normalizes and deduplicates custom model options per provider", () => { + const modelOptionsByProvider = getCustomModelOptionsByProvider({ + customCodexModels: [" custom/codex-model ", "gpt-5.4", "custom/codex-model"], + customClaudeModels: [" sonnet ", "claude/custom-opus", "claude/custom-opus"], + }); + + expect( + modelOptionsByProvider.codex.filter((option) => option.slug === "custom/codex-model"), + ).toHaveLength(1); + expect(modelOptionsByProvider.codex.some((option) => option.slug === "gpt-5.4")).toBe(true); + expect( + modelOptionsByProvider.claudeAgent.filter((option) => option.slug === "claude/custom-opus"), + ).toHaveLength(1); + expect( + modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude-sonnet-4-6"), + ).toBe(true); + }); +}); + describe("AppSettingsSchema", () => { it("fills decoding defaults for persisted settings that predate newer keys", () => { const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema)); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e4f4d8b1ca..14b6a6a92d 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,7 +1,12 @@ import { useCallback } from "react"; import { Option, Schema } from "effect"; import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts"; -import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; +import { + getDefaultModel, + getModelOptions, + normalizeModelSlug, + resolveSelectableModel, +} from "@t3tools/shared/model"; import { useLocalStorage } from "./hooks/useLocalStorage"; import { EnvMode } from "./components/BranchToolbar.logic"; @@ -12,6 +17,16 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels"; +export type ProviderCustomModelConfig = { + provider: ProviderKind; + settingsKey: CustomModelSettingsKey; + defaultSettingsKey: CustomModelSettingsKey; + title: string; + description: string; + placeholder: string; + example: string; +}; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), @@ -50,6 +65,27 @@ export interface AppModelOption { } const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); +const PROVIDER_CUSTOM_MODEL_CONFIG: Record = { + codex: { + provider: "codex", + settingsKey: "customCodexModels", + defaultSettingsKey: "customCodexModels", + title: "Codex", + description: "Save additional Codex model slugs for the picker and `/model` command.", + placeholder: "your-codex-model-slug", + example: "gpt-6.7-codex-ultra-preview", + }, + claudeAgent: { + provider: "claudeAgent", + settingsKey: "customClaudeModels", + defaultSettingsKey: "customClaudeModels", + title: "Claude", + description: "Save additional Claude model slugs for the picker and `/model` command.", + placeholder: "your-claude-model-slug", + example: "claude-sonnet-5-0", + }, +}; +export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); export function normalizeCustomModelSlugs( models: Iterable, @@ -87,6 +123,39 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), }; } + +export function getCustomModelsForProvider( + settings: Pick, + provider: ProviderKind, +): readonly string[] { + return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]; +} + +export function getDefaultCustomModelsForProvider( + defaults: Pick, + provider: ProviderKind, +): readonly string[] { + return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey]; +} + +export function patchCustomModels( + provider: ProviderKind, + models: string[], +): Partial> { + return { + [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models, + }; +} + +export function getCustomModelsByProvider( + settings: Pick, +): Record { + return { + codex: getCustomModelsForProvider(settings, "codex"), + claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"), + }; +} + export function getAppModelOptions( provider: ProviderKind, customModels: readonly string[], @@ -98,6 +167,7 @@ export function getAppModelOptions( isCustom: false, })); const seen = new Set(options.map((option) => option.slug)); + const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); for (const slug of normalizeCustomModelSlugs(customModels, provider)) { if (seen.has(slug)) { @@ -113,7 +183,14 @@ export function getAppModelOptions( } const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider); - if (normalizedSelectedModel && !seen.has(normalizedSelectedModel)) { + const selectedModelMatchesExistingName = + typeof trimmedSelectedModel === "string" && + options.some((option) => option.name.toLowerCase() === trimmedSelectedModel); + if ( + normalizedSelectedModel && + !seen.has(normalizedSelectedModel) && + !selectedModelMatchesExistingName + ) { options.push({ slug: normalizedSelectedModel, name: normalizedSelectedModel, @@ -126,34 +203,22 @@ export function getAppModelOptions( export function resolveAppModelSelection( provider: ProviderKind, - customModels: readonly string[], + customModels: Record, selectedModel: string | null | undefined, ): string { - const options = getAppModelOptions(provider, customModels, selectedModel); - const trimmedSelectedModel = selectedModel?.trim(); - if (trimmedSelectedModel) { - const direct = options.find((option) => option.slug === trimmedSelectedModel); - if (direct) { - return direct.slug; - } - - const byName = options.find( - (option) => option.name.toLowerCase() === trimmedSelectedModel.toLowerCase(), - ); - if (byName) { - return byName.slug; - } - } - - const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider); - if (!normalizedSelectedModel) { - return getDefaultModel(provider); - } + const customModelsForProvider = customModels[provider]; + const options = getAppModelOptions(provider, customModelsForProvider, selectedModel); + return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider); +} - return ( - options.find((option) => option.slug === normalizedSelectedModel)?.slug ?? - getDefaultModel(provider) - ); +export function getCustomModelOptionsByProvider( + settings: Pick, +): Record> { + const customModelsByProvider = getCustomModelsByProvider(settings); + return { + codex: getAppModelOptions("codex", customModelsByProvider.codex), + claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent), + }; } export function useAppSettings() { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 6cbef09bd6..48c627747d 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -752,6 +752,8 @@ describe("ChatView timeline estimator parity (full app)", () => { draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModel: null, + stickyModelOptions: {}, }); useStore.setState({ projects: [], @@ -1279,6 +1281,198 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("snapshots sticky codex settings into a new draft thread", async () => { + useComposerDraftStore.setState({ + stickyModel: "gpt-5.3-codex", + stickyModelOptions: { + codex: { + reasoningEffort: "medium", + fastMode: true, + }, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-sticky-codex-traits-test" as MessageId, + targetText: "sticky codex traits test", + }), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID.", + ); + const newThreadId = newThreadPath.slice(1) as ThreadId; + + expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + model: "gpt-5.3-codex", + provider: "codex", + modelOptions: { + codex: { + fastMode: true, + }, + }, + }); + } finally { + await mounted.cleanup(); + } + }); + + it("hydrates the provider alongside a sticky claude model", async () => { + useComposerDraftStore.setState({ + stickyModel: "claude-opus-4-6", + stickyModelOptions: { + claudeAgent: { + effort: "max", + fastMode: true, + }, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-sticky-claude-model-test" as MessageId, + targetText: "sticky claude model test", + }), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new sticky claude draft thread UUID.", + ); + const newThreadId = newThreadPath.slice(1) as ThreadId; + + expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + provider: "claudeAgent", + model: "claude-opus-4-6", + modelOptions: { + claudeAgent: { + effort: "max", + fastMode: true, + }, + }, + }); + await expect.element(page.getByText("Claude Opus 4.6")).toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + + it("falls back to defaults when no sticky composer settings exist", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-default-codex-traits-test" as MessageId, + targetText: "default codex traits test", + }), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID.", + ); + const newThreadId = newThreadPath.slice(1) as ThreadId; + + expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toBeUndefined(); + } finally { + await mounted.cleanup(); + } + }); + + it("prefers draft state over sticky composer settings and defaults", async () => { + useComposerDraftStore.setState({ + stickyModel: "gpt-5.3-codex", + stickyModelOptions: { + codex: { + reasoningEffort: "medium", + fastMode: true, + }, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-draft-codex-traits-precedence-test" as MessageId, + targetText: "draft codex traits precedence test", + }), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const threadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a sticky draft thread UUID.", + ); + const threadId = threadPath.slice(1) as ThreadId; + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + model: "gpt-5.3-codex", + modelOptions: { + codex: { + fastMode: true, + }, + }, + }); + + useComposerDraftStore.getState().setModel(threadId, "gpt-5.4"); + useComposerDraftStore.getState().setModelOptions(threadId, { + codex: { + reasoningEffort: "low", + fastMode: true, + }, + }); + + await newThreadButton.click(); + + await waitForURL( + mounted.router, + (path) => path === threadPath, + "New-thread should reuse the existing project draft thread.", + ); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + model: "gpt-5.4", + modelOptions: { + codex: { + reasoningEffort: "low", + fastMode: true, + }, + }, + }); + } finally { + await mounted.cleanup(); + } + }); + it("creates a new thread from the global chat.new shortcut", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 80567927b3..ddc84718e6 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,7 +1,6 @@ -import { ProjectId, type ProviderKind, type ThreadId } from "@t3tools/contracts"; +import { ProjectId, type ThreadId } from "@t3tools/contracts"; import { type ChatMessage, type Thread } from "../types"; import { randomUUID } from "~/lib/utils"; -import { getAppModelOptions } from "../appSettings"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import { Schema } from "effect"; import { @@ -121,16 +120,6 @@ export function cloneComposerImageForRetry( } } -export function getCustomModelOptionsByProvider(settings: { - customCodexModels: readonly string[]; - customClaudeModels: readonly string[]; -}): Record> { - return { - codex: getAppModelOptions("codex", settings.customCodexModels), - claudeAgent: getAppModelOptions("claudeAgent", settings.customClaudeModels), - }; -} - export function deriveComposerSendState(options: { prompt: string; imageCount: number; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index eaead424fb..498f3203d1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -24,15 +24,8 @@ import { import { applyClaudePromptEffortPrefix, getDefaultModel, - getDefaultReasoningEffort, - getReasoningEffortOptions, - isClaudeUltrathinkPrompt, - normalizeClaudeModelOptions, - normalizeCodexModelOptions, normalizeModelSlug, - resolveReasoningEffortForProvider, resolveModelSlugForProvider, - supportsClaudeUltrathinkKeyword, } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -126,7 +119,12 @@ import { import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { resolveAppModelSelection, useAppSettings } from "../appSettings"; +import { + getCustomModelOptionsByProvider, + getCustomModelsByProvider, + resolveAppModelSelection, + useAppSettings, +} from "../appSettings"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, @@ -153,12 +151,15 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/Expanded import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; -import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./chat/ClaudeTraitsPicker"; -import { CodexTraitsMenuContent, CodexTraitsPicker } from "./chat/CodexTraitsPicker"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; +import { + getComposerProviderState, + renderProviderTraitsMenuContent, + renderProviderTraitsPicker, +} from "./chat/composerProviderRegistry"; import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { @@ -168,7 +169,6 @@ import { cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, deriveComposerSendState, - getCustomModelOptionsByProvider, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, PullRequestDialogState, @@ -244,6 +244,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); + const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ @@ -596,72 +597,27 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), ); - const customModelsByProvider = useMemo( - () => ({ - codex: settings.customCodexModels, - claudeAgent: settings.customClaudeModels, - }), - [settings.customClaudeModels, settings.customCodexModels], - ); - const customModelsForSelectedProvider = customModelsByProvider[selectedProvider]; + const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { return baseThreadModel; } - return resolveAppModelSelection( - selectedProvider, - customModelsForSelectedProvider, - draftModel, - ) as ModelSlug; - }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]); + return resolveAppModelSelection(selectedProvider, customModelsByProvider, draftModel); + }, [baseThreadModel, composerDraft.model, customModelsByProvider, selectedProvider]); const draftModelOptions = composerDraft.modelOptions; - const selectedCodexEffort = - selectedProvider === "codex" - ? (resolveReasoningEffortForProvider("codex", draftModelOptions?.codex?.reasoningEffort) ?? - getDefaultReasoningEffort("codex")) - : null; - const selectedClaudeReasoningOptions = - selectedProvider === "claudeAgent" - ? getReasoningEffortOptions("claudeAgent", selectedModel) - : ([] as const); - const selectedClaudeBaseEffort = - selectedProvider === "claudeAgent" && selectedClaudeReasoningOptions.length > 0 - ? (() => { - const draftEffort = resolveReasoningEffortForProvider( - "claudeAgent", - draftModelOptions?.claudeAgent?.effort, - ); - if ( - draftEffort && - draftEffort !== "ultrathink" && - selectedClaudeReasoningOptions.includes(draftEffort) - ) { - return draftEffort; - } - const defaultEffort = getDefaultReasoningEffort("claudeAgent"); - return selectedClaudeReasoningOptions.includes(defaultEffort) ? defaultEffort : null; - })() - : null; - const isClaudeUltrathink = - selectedProvider === "claudeAgent" && - supportsClaudeUltrathinkKeyword(selectedModel) && - isClaudeUltrathinkPrompt(prompt); - const selectedPromptEffort = selectedCodexEffort ?? selectedClaudeBaseEffort; - const selectedModelOptionsForDispatch = useMemo(() => { - if (selectedProvider === "codex") { - const codexOptions = normalizeCodexModelOptions(draftModelOptions?.codex); - return codexOptions ? { codex: codexOptions } : undefined; - } - if (selectedProvider === "claudeAgent") { - const claudeOptions = normalizeClaudeModelOptions( - selectedModel, - draftModelOptions?.claudeAgent, - ); - return claudeOptions ? { claudeAgent: claudeOptions } : undefined; - } - return undefined; - }, [draftModelOptions, selectedModel, selectedProvider]); + const composerProviderState = useMemo( + () => + getComposerProviderState({ + provider: selectedProvider, + model: selectedModel, + prompt, + modelOptions: draftModelOptions, + }), + [draftModelOptions, prompt, selectedModel, selectedProvider], + ); + const selectedPromptEffort = composerProviderState.promptEffort; + const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; const providerOptionsForDispatch = useMemo(() => { if (!settings.codexBinaryPath && !settings.codexHomePath) { return undefined; @@ -3141,20 +3097,20 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus(); return; } + const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); setComposerDraftProvider(activeThread.id, provider); - setComposerDraftModel( - activeThread.id, - resolveAppModelSelection(provider, customModelsByProvider[provider], model), - ); + setComposerDraftModel(activeThread.id, resolvedModel); + setStickyComposerModel(resolvedModel); scheduleComposerFocus(); }, [ activeThread, - customModelsByProvider, lockedProvider, scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, + setStickyComposerModel, + customModelsByProvider, ], ); const setPromptFromTraits = useCallback( @@ -3173,6 +3129,18 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [scheduleComposerFocus, setPrompt], ); + const providerTraitsMenuContent = renderProviderTraitsMenuContent({ + provider: selectedProvider, + threadId, + model: selectedModel, + onPromptChange: setPromptFromTraits, + }); + const providerTraitsPicker = renderProviderTraitsPicker({ + provider: selectedProvider, + threadId, + model: selectedModel, + onPromptChange: setPromptFromTraits, + }); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { @@ -3606,7 +3574,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
{activePendingApproval ? ( @@ -3806,7 +3774,12 @@ export default function ChatView({ threadId }: ChatViewProps) { model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} modelOptionsByProvider={modelOptionsByProvider} - ultrathinkActive={isClaudeUltrathink} + {...(composerProviderState.modelPickerIconClassName + ? { + activeProviderIconClassName: + composerProviderState.modelPickerIconClassName, + } + : {})} onProviderModelChange={onProviderModelSelect} /> @@ -3818,42 +3791,20 @@ export default function ChatView({ threadId }: ChatViewProps) { interactionMode={interactionMode} planSidebarOpen={planSidebarOpen} runtimeMode={runtimeMode} - traitsMenuContent={ - selectedProvider === "codex" ? ( - - ) : selectedProvider === "claudeAgent" ? ( - - ) : null - } + traitsMenuContent={providerTraitsMenuContent} onToggleInteractionMode={toggleInteractionMode} onTogglePlanSidebar={togglePlanSidebar} onToggleRuntimeMode={toggleRuntimeMode} /> ) : ( <> - {selectedProvider === "codex" ? ( - <> - - - - ) : selectedProvider === "claudeAgent" ? ( + {providerTraitsPicker ? ( <> - + {providerTraitsPicker} ) : null} diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx index fd80e10473..a675a82d89 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx @@ -173,4 +173,25 @@ describe("ClaudeTraitsPicker", () => { await mounted.cleanup(); } }); + + it("persists sticky claude model options when traits change", async () => { + const mounted = await mountPicker({ + model: "claude-opus-4-6", + effort: "medium", + fastModeEnabled: false, + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "Max" }).click(); + + expect(useComposerDraftStore.getState().stickyModelOptions).toMatchObject({ + claudeAgent: { + effort: "max", + }, + }); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx index 2baf197ab0..d6585d43d8 100644 --- a/apps/web/src/components/chat/ClaudeTraitsPicker.tsx +++ b/apps/web/src/components/chat/ClaudeTraitsPicker.tsx @@ -1,7 +1,7 @@ import { + ProviderKind, type ClaudeCodeEffort, type ClaudeModelOptions, - type ProviderModelOptions, type ThreadId, } from "@t3tools/contracts"; import { @@ -15,7 +15,7 @@ import { supportsClaudeUltrathinkKeyword, isClaudeUltrathinkPrompt, } from "@t3tools/shared/model"; -import { memo, useState } from "react"; +import { memo, useCallback, useState } from "react"; import { ChevronDownIcon } from "lucide-react"; import { Button } from "../ui/button"; import { @@ -29,6 +29,8 @@ import { } from "../ui/menu"; import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; +const PROVIDER = "claudeAgent" as const satisfies ProviderKind; + const CLAUDE_EFFORT_LABELS: Record = { low: "Low", medium: "Medium", @@ -51,12 +53,12 @@ function getSelectedClaudeTraits( ultrathinkPromptControlled: boolean; supportsFastMode: boolean; } { - const options = getReasoningEffortOptions("claudeAgent", model); - const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent") as Exclude< + const options = getReasoningEffortOptions(PROVIDER, model); + const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER) as Exclude< ClaudeCodeEffort, "ultrathink" >; - const resolvedEffort = resolveReasoningEffortForProvider("claudeAgent", modelOptions?.effort); + const resolvedEffort = resolveReasoningEffortForProvider(PROVIDER, modelOptions?.effort); const effort = resolvedEffort && resolvedEffort !== "ultrathink" && options.includes(resolvedEffort) ? resolvedEffort @@ -78,15 +80,21 @@ function getSelectedClaudeTraits( }; } -function ClaudeTraitsMenuContentImpl(props: { +interface ClaudeTraitsMenuContentProps { threadId: ThreadId; model: string | null | undefined; onPromptChange: (prompt: string) => void; -}) { - const draft = useComposerThreadDraft(props.threadId); +} + +export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl({ + threadId, + model, + onPromptChange, +}: ClaudeTraitsMenuContentProps) { + const draft = useComposerThreadDraft(threadId); const prompt = draft.prompt; - const modelOptions = draft.modelOptions?.claudeAgent; - const setModelOptions = useComposerDraftStore((store) => store.setModelOptions); + const modelOptions = draft.modelOptions?.[PROVIDER]; + const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); const { effort, thinkingEnabled, @@ -94,19 +102,44 @@ function ClaudeTraitsMenuContentImpl(props: { options, ultrathinkPromptControlled, supportsFastMode, - } = getSelectedClaudeTraits(props.model, prompt, modelOptions); - const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent"); + } = getSelectedClaudeTraits(model, prompt, modelOptions); + const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER); - const setClaudeModelOptions = (nextClaudeModelOptions: ClaudeModelOptions | undefined) => { - const { claudeAgent: _discardedClaude, ...otherProviderModelOptions } = - draft.modelOptions ?? {}; - const nextProviderModelOptions: ProviderModelOptions | undefined = nextClaudeModelOptions - ? { ...otherProviderModelOptions, claudeAgent: nextClaudeModelOptions } - : Object.keys(otherProviderModelOptions).length > 0 - ? otherProviderModelOptions - : undefined; - setModelOptions(props.threadId, nextProviderModelOptions); - }; + const handleEffortChange = useCallback( + (value: ClaudeCodeEffort) => { + if (ultrathinkPromptControlled) return; + if (!value) return; + const nextEffort = options.find((option) => option === value); + if (!nextEffort) return; + if (nextEffort === "ultrathink") { + const nextPrompt = + prompt.trim().length === 0 + ? ULTRATHINK_PROMPT_PREFIX + : applyClaudePromptEffortPrefix(prompt, "ultrathink"); + onPromptChange(nextPrompt); + return; + } + setProviderModelOptions( + threadId, + PROVIDER, + normalizeClaudeModelOptions(model, { + ...modelOptions, + effort: nextEffort, + }), + { persistSticky: true }, + ); + }, + [ + ultrathinkPromptControlled, + model, + modelOptions, + onPromptChange, + threadId, + setProviderModelOptions, + options, + prompt, + ], + ); if (effort === null && thinkingEnabled === null) { return null; @@ -123,29 +156,7 @@ function ClaudeTraitsMenuContentImpl(props: { Remove Ultrathink from the prompt to change effort.
) : null} - { - if (ultrathinkPromptControlled) return; - if (!value) return; - const nextEffort = options.find((option) => option === value); - if (!nextEffort) return; - if (nextEffort === "ultrathink") { - const nextPrompt = - prompt.trim().length === 0 - ? ULTRATHINK_PROMPT_PREFIX - : applyClaudePromptEffortPrefix(prompt, "ultrathink"); - props.onPromptChange(nextPrompt); - return; - } - setClaudeModelOptions( - normalizeClaudeModelOptions(props.model, { - ...modelOptions, - effort: nextEffort, - }), - ); - }} - > + {options.map((option) => ( {CLAUDE_EFFORT_LABELS[option]} @@ -161,11 +172,14 @@ function ClaudeTraitsMenuContentImpl(props: { { - setClaudeModelOptions( - normalizeClaudeModelOptions(props.model, { + setProviderModelOptions( + threadId, + PROVIDER, + normalizeClaudeModelOptions(model, { ...modelOptions, thinking: value === "on", }), + { persistSticky: true }, ); }} > @@ -182,11 +196,14 @@ function ClaudeTraitsMenuContentImpl(props: { { - setClaudeModelOptions( - normalizeClaudeModelOptions(props.model, { + setProviderModelOptions( + threadId, + PROVIDER, + normalizeClaudeModelOptions(model, { ...modelOptions, fastMode: value === "on", }), + { persistSticky: true }, ); }} > @@ -198,21 +215,19 @@ function ClaudeTraitsMenuContentImpl(props: { ) : null} ); -} - -export const ClaudeTraitsMenuContent = memo(ClaudeTraitsMenuContentImpl); +}); -export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker(props: { - threadId: ThreadId; - model: string | null | undefined; - onPromptChange: (prompt: string) => void; -}) { +export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker({ + threadId, + model, + onPromptChange, +}: ClaudeTraitsMenuContentProps) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const draft = useComposerThreadDraft(props.threadId); + const draft = useComposerThreadDraft(threadId); const prompt = draft.prompt; - const modelOptions = draft.modelOptions?.claudeAgent; + const modelOptions = draft.modelOptions?.[PROVIDER]; const { effort, thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, supportsFastMode } = - getSelectedClaudeTraits(props.model, prompt, modelOptions); + getSelectedClaudeTraits(model, prompt, modelOptions); const triggerLabel = [ ultrathinkPromptControlled ? "Ultrathink" @@ -247,9 +262,9 @@ export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker(props: { diff --git a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx index d717f91923..9d2b73989d 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.browser.tsx @@ -116,6 +116,25 @@ describe("CodexTraitsPicker", () => { } }); + it("persists sticky codex model options when traits change", async () => { + const mounted = await mountPicker({ + fastModeEnabled: false, + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "on" }).click(); + + expect(useComposerDraftStore.getState().stickyModelOptions).toMatchObject({ + codex: { + fastMode: true, + }, + }); + } finally { + await mounted.cleanup(); + } + }); + it("hydrates legacy codex persisted state into modelOptions through the picker", async () => { const threadId = ThreadId.makeUnsafe("thread-codex-legacy"); localStorage.setItem( diff --git a/apps/web/src/components/chat/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx index 641d39277e..7b37063bff 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx @@ -1,7 +1,7 @@ import type { CodexModelOptions, CodexReasoningEffort, - ProviderModelOptions, + ProviderKind, ThreadId, } from "@t3tools/contracts"; import { @@ -24,6 +24,8 @@ import { MenuTrigger, } from "../ui/menu"; +const PROVIDER = "codex" as const satisfies ProviderKind; + const CODEX_REASONING_LABELS: Record = { low: "Low", medium: "Medium", @@ -35,10 +37,10 @@ function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefin effort: CodexReasoningEffort; fastModeEnabled: boolean; } { - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); + const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER); return { effort: - resolveReasoningEffortForProvider("codex", modelOptions?.reasoningEffort) ?? + resolveReasoningEffortForProvider(PROVIDER, modelOptions?.reasoningEffort) ?? defaultReasoningEffort, fastModeEnabled: modelOptions?.fastMode === true, }; @@ -46,22 +48,12 @@ function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefin function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { const draft = useComposerThreadDraft(props.threadId); - const modelOptions = draft.modelOptions?.codex; - const setModelOptions = useComposerDraftStore((store) => store.setModelOptions); - const options = getReasoningEffortOptions("codex"); - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); + const modelOptions = draft.modelOptions?.[PROVIDER]; + const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); + const options = getReasoningEffortOptions(PROVIDER); + const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER); const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions); - const setCodexModelOptions = (nextCodexModelOptions: CodexModelOptions | undefined) => { - const { codex: _discardedCodex, ...otherProviderModelOptions } = draft.modelOptions ?? {}; - const nextProviderModelOptions: ProviderModelOptions | undefined = nextCodexModelOptions - ? { ...otherProviderModelOptions, codex: nextCodexModelOptions } - : Object.keys(otherProviderModelOptions).length > 0 - ? otherProviderModelOptions - : undefined; - setModelOptions(props.threadId, nextProviderModelOptions); - }; - return ( <> @@ -72,11 +64,14 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { if (!value) return; const nextEffort = options.find((option) => option === value); if (!nextEffort) return; - setCodexModelOptions( + setProviderModelOptions( + props.threadId, + PROVIDER, normalizeCodexModelOptions({ ...modelOptions, reasoningEffort: nextEffort, }), + { persistSticky: true }, ); }} > @@ -94,11 +89,14 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) { { - setCodexModelOptions( + setProviderModelOptions( + props.threadId, + PROVIDER, normalizeCodexModelOptions({ ...modelOptions, fastMode: value === "on", }), + { persistSticky: true }, ); }} > diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 11a3289f45..1694b374c8 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -91,4 +91,24 @@ describe("ProviderModelPicker", () => { await mounted.cleanup(); } }); + + it("dispatches the canonical slug when a model is selected", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: "claudeAgent", + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "Claude Sonnet 4.6" }).click(); + + expect(mounted.onProviderModelChange).toHaveBeenCalledWith( + "claudeAgent", + "claude-sonnet-4-6", + ); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index e61ac6da5b..95f27f39cd 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,5 +1,5 @@ import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useState } from "react"; import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; import { ChevronDownIcon } from "lucide-react"; @@ -28,39 +28,6 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o return option.available; } -function resolveModelForProviderPicker( - provider: ProviderKind, - value: string, - options: ReadonlyArray<{ slug: string; name: string }>, -): ModelSlug | null { - const trimmedValue = value.trim(); - if (!trimmedValue) { - return null; - } - - const direct = options.find((option) => option.slug === trimmedValue); - if (direct) { - return direct.slug; - } - - const byName = options.find((option) => option.name.toLowerCase() === trimmedValue.toLowerCase()); - if (byName) { - return byName.slug; - } - - const normalized = normalizeModelSlug(trimmedValue, provider); - if (!normalized) { - return null; - } - - const resolved = options.find((option) => option.slug === normalized); - if (resolved) { - return resolved.slug; - } - - return null; -} - const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, claudeAgent: ClaudeAI, @@ -86,7 +53,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { model: ModelSlug; lockedProvider: ProviderKind | null; modelOptionsByProvider: Record>; - ultrathinkActive?: boolean; + activeProviderIconClassName?: string; compact?: boolean; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; @@ -100,7 +67,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const handleModelChange = (provider: ProviderKind, value: string) => { if (props.disabled) return; if (!value) return; - const resolvedModel = resolveModelForProviderPicker( + const resolvedModel = resolveSelectableModel( provider, value, props.modelOptionsByProvider[provider], @@ -145,9 +112,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { className={cn( "size-4 shrink-0", providerIconClassName(activeProvider, "text-muted-foreground/70"), - activeProvider === "claudeAgent" && props.ultrathinkActive - ? "ultrathink-chroma" - : undefined, + props.activeProviderIconClassName, )} /> {selectedModelLabel} diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx new file mode 100644 index 0000000000..139876d6fa --- /dev/null +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import { getComposerProviderState } from "./composerProviderRegistry"; + +describe("getComposerProviderState", () => { + it("returns codex defaults when no codex draft options exist", () => { + const state = getComposerProviderState({ + provider: "codex", + model: "gpt-5.4", + prompt: "", + modelOptions: undefined, + }); + + expect(state).toEqual({ + provider: "codex", + promptEffort: "high", + modelOptionsForDispatch: undefined, + }); + }); + + it("normalizes codex dispatch options while preserving the selected effort", () => { + const state = getComposerProviderState({ + provider: "codex", + model: "gpt-5.4", + prompt: "", + modelOptions: { + codex: { + reasoningEffort: "low", + fastMode: true, + }, + }, + }); + + expect(state).toEqual({ + provider: "codex", + promptEffort: "low", + modelOptionsForDispatch: { + codex: { + reasoningEffort: "low", + fastMode: true, + }, + }, + }); + }); + + it("returns Claude defaults for effort-capable models", () => { + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + prompt: "", + modelOptions: undefined, + }); + + expect(state).toEqual({ + provider: "claudeAgent", + promptEffort: "high", + modelOptionsForDispatch: undefined, + }); + }); + + it("tracks Claude ultrathink from the prompt without changing dispatch effort", () => { + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + prompt: "Ultrathink:\nInvestigate this failure", + modelOptions: { + claudeAgent: { + effort: "medium", + }, + }, + }); + + expect(state).toEqual({ + provider: "claudeAgent", + promptEffort: "medium", + modelOptionsForDispatch: { + claudeAgent: { + effort: "medium", + }, + }, + composerFrameClassName: "ultrathink-frame", + composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", + modelPickerIconClassName: "ultrathink-chroma", + }); + }); + + it("drops unsupported Claude effort options for models without effort controls", () => { + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-haiku-4-5", + prompt: "", + modelOptions: { + claudeAgent: { + effort: "max", + thinking: false, + }, + }, + }); + + expect(state).toEqual({ + provider: "claudeAgent", + promptEffort: null, + modelOptionsForDispatch: { + claudeAgent: { + thinking: false, + }, + }, + }); + }); + + it("ignores codex options while resolving Claude state", () => { + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-opus-4-6", + prompt: "", + modelOptions: { + codex: { + reasoningEffort: "low", + fastMode: true, + }, + }, + }); + + expect(state).toEqual({ + provider: "claudeAgent", + promptEffort: "high", + modelOptionsForDispatch: undefined, + }); + }); + + it("ignores Claude options while resolving codex state", () => { + const state = getComposerProviderState({ + provider: "codex", + model: "gpt-5.4", + prompt: "Ultrathink:\nThis should not matter", + modelOptions: { + claudeAgent: { + effort: "max", + fastMode: true, + }, + }, + }); + + expect(state).toEqual({ + provider: "codex", + promptEffort: "high", + modelOptionsForDispatch: undefined, + }); + }); +}); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx new file mode 100644 index 0000000000..c1ad0156ad --- /dev/null +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -0,0 +1,137 @@ +import { + type ModelSlug, + type ProviderKind, + type ProviderModelOptions, + type ThreadId, +} from "@t3tools/contracts"; +import { + getDefaultReasoningEffort, + getReasoningEffortOptions, + isClaudeUltrathinkPrompt, + normalizeClaudeModelOptions, + normalizeCodexModelOptions, + resolveReasoningEffortForProvider, + supportsClaudeUltrathinkKeyword, +} from "@t3tools/shared/model"; +import type { ReactNode } from "react"; +import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./ClaudeTraitsPicker"; +import { CodexTraitsMenuContent, CodexTraitsPicker } from "./CodexTraitsPicker"; + +export type ComposerProviderStateInput = { + provider: ProviderKind; + model: ModelSlug; + prompt: string; + modelOptions: ProviderModelOptions | null | undefined; +}; + +export type ComposerProviderState = { + provider: ProviderKind; + promptEffort: string | null; + modelOptionsForDispatch: ProviderModelOptions | undefined; + composerFrameClassName?: string; + composerSurfaceClassName?: string; + modelPickerIconClassName?: string; +}; + +type ProviderRegistryEntry = { + getState: (input: ComposerProviderStateInput) => ComposerProviderState; + renderTraitsMenuContent: (input: { + threadId: ThreadId; + model: ModelSlug; + onPromptChange: (prompt: string) => void; + }) => ReactNode; + renderTraitsPicker: (input: { + threadId: ThreadId; + model: ModelSlug; + onPromptChange: (prompt: string) => void; + }) => ReactNode; +}; + +const composerProviderRegistry: Record = { + codex: { + getState: ({ modelOptions }) => { + const promptEffort = + resolveReasoningEffortForProvider("codex", modelOptions?.codex?.reasoningEffort) ?? + getDefaultReasoningEffort("codex"); + const normalizedCodexOptions = normalizeCodexModelOptions(modelOptions?.codex); + + return { + provider: "codex", + promptEffort, + modelOptionsForDispatch: normalizedCodexOptions + ? { codex: normalizedCodexOptions } + : undefined, + }; + }, + renderTraitsMenuContent: ({ threadId }) => , + renderTraitsPicker: ({ threadId }) => , + }, + claudeAgent: { + getState: ({ model, prompt, modelOptions }) => { + const reasoningOptions = getReasoningEffortOptions("claudeAgent", model); + const draftEffort = resolveReasoningEffortForProvider( + "claudeAgent", + modelOptions?.claudeAgent?.effort, + ); + const defaultEffort = getDefaultReasoningEffort("claudeAgent"); + const promptEffort = + draftEffort && draftEffort !== "ultrathink" && reasoningOptions.includes(draftEffort) + ? draftEffort + : reasoningOptions.includes(defaultEffort) + ? defaultEffort + : null; + const normalizedClaudeOptions = normalizeClaudeModelOptions(model, modelOptions?.claudeAgent); + const ultrathinkActive = + supportsClaudeUltrathinkKeyword(model) && isClaudeUltrathinkPrompt(prompt); + + return { + provider: "claudeAgent", + promptEffort, + modelOptionsForDispatch: normalizedClaudeOptions + ? { claudeAgent: normalizedClaudeOptions } + : undefined, + ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}), + ...(ultrathinkActive + ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" } + : {}), + ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}), + }; + }, + renderTraitsMenuContent: ({ threadId, model, onPromptChange }) => ( + + ), + renderTraitsPicker: ({ threadId, model, onPromptChange }) => ( + + ), + }, +}; + +export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { + return composerProviderRegistry[input.provider].getState(input); +} + +export function renderProviderTraitsMenuContent(input: { + provider: ProviderKind; + threadId: ThreadId; + model: ModelSlug; + onPromptChange: (prompt: string) => void; +}): ReactNode { + return composerProviderRegistry[input.provider].renderTraitsMenuContent({ + threadId: input.threadId, + model: input.model, + onPromptChange: input.onPromptChange, + }); +} + +export function renderProviderTraitsPicker(input: { + provider: ProviderKind; + threadId: ThreadId; + model: ModelSlug; + onPromptChange: (prompt: string) => void; +}): ReactNode { + return composerProviderRegistry[input.provider].renderTraitsPicker({ + threadId: input.threadId, + model: input.model, + onPromptChange: input.onPromptChange, + }); +} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 773f16ceab..98a6f17331 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -62,17 +62,23 @@ function makeTerminalContext(input: { }; } +function resetComposerDraftStore() { + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + stickyModel: null, + stickyModelOptions: {}, + }); +} + describe("composerDraftStore addImages", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; let revokeSpy: ReturnType void>>; beforeEach(() => { - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); + resetComposerDraftStore(); originalRevokeObjectUrl = URL.revokeObjectURL; revokeSpy = vi.fn(); URL.revokeObjectURL = revokeSpy; @@ -157,11 +163,7 @@ describe("composerDraftStore clearComposerContent", () => { let revokeSpy: ReturnType void>>; beforeEach(() => { - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); + resetComposerDraftStore(); originalRevokeObjectUrl = URL.revokeObjectURL; revokeSpy = vi.fn(); URL.revokeObjectURL = revokeSpy; @@ -423,11 +425,7 @@ describe("composerDraftStore project draft thread mapping", () => { const otherThreadId = ThreadId.makeUnsafe("thread-b"); beforeEach(() => { - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); + resetComposerDraftStore(); }); it("stores and reads project draft thread ids via actions", () => { @@ -599,11 +597,7 @@ describe("composerDraftStore modelOptions", () => { const threadId = ThreadId.makeUnsafe("thread-model-options"); beforeEach(() => { - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); + resetComposerDraftStore(); }); it("stores provider-scoped model options in the draft", () => { @@ -642,17 +636,165 @@ describe("composerDraftStore modelOptions", () => { expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); + + it("replaces only the targeted provider model options", () => { + const store = useComposerDraftStore.getState(); + + store.setModelOptions(threadId, { + codex: { + reasoningEffort: "xhigh", + }, + claudeAgent: { + effort: "max", + fastMode: true, + }, + }); + + store.setProviderModelOptions( + threadId, + "claudeAgent", + { + thinking: false, + }, + { persistSticky: true }, + ); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ + codex: { + reasoningEffort: "xhigh", + }, + claudeAgent: { + thinking: false, + }, + }); + expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ + codex: { + reasoningEffort: "xhigh", + }, + claudeAgent: { + thinking: false, + }, + }); + }); + + it("removes only the targeted provider entry when next options normalize empty", () => { + const store = useComposerDraftStore.getState(); + + store.setModelOptions(threadId, { + codex: { + reasoningEffort: "xhigh", + }, + claudeAgent: { + effort: "max", + }, + }); + + store.setProviderModelOptions(threadId, "claudeAgent", { + thinking: true, + }); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ + codex: { + reasoningEffort: "xhigh", + }, + }); + expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({}); + }); + + it("removes model options entirely when the last provider entry normalizes empty", () => { + const store = useComposerDraftStore.getState(); + + store.setModelOptions(threadId, { + codex: { + fastMode: true, + }, + }); + + store.setProviderModelOptions(threadId, "codex", { + reasoningEffort: "high", + fastMode: false, + }); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + }); + + it("updates only the draft when sticky persistence is omitted", () => { + const store = useComposerDraftStore.getState(); + + store.setStickyModelOptions({ + codex: { + fastMode: true, + }, + }); + store.setModelOptions(threadId, { + codex: { + fastMode: true, + }, + claudeAgent: { + effort: "max", + }, + }); + + store.setProviderModelOptions(threadId, "claudeAgent", { + thinking: false, + }); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ + codex: { + fastMode: true, + }, + claudeAgent: { + thinking: false, + }, + }); + expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ + codex: { + fastMode: true, + }, + }); + }); + + it("updates only the draft when sticky persistence is disabled", () => { + const store = useComposerDraftStore.getState(); + + store.setStickyModelOptions({ + claudeAgent: { + effort: "max", + }, + }); + store.setModelOptions(threadId, { + claudeAgent: { + effort: "max", + }, + }); + + store.setProviderModelOptions( + threadId, + "claudeAgent", + { + thinking: false, + }, + { persistSticky: false }, + ); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ + claudeAgent: { + thinking: false, + }, + }); + expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ + claudeAgent: { + effort: "max", + }, + }); + }); }); describe("composerDraftStore setModel", () => { const threadId = ThreadId.makeUnsafe("thread-model"); beforeEach(() => { - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); + resetComposerDraftStore(); }); it("keeps explicit DEFAULT_MODEL overrides instead of coercing to null", () => { @@ -666,15 +808,51 @@ describe("composerDraftStore setModel", () => { }); }); +describe("composerDraftStore sticky composer settings", () => { + beforeEach(() => { + resetComposerDraftStore(); + }); + + it("stores sticky model and codex model options", () => { + const store = useComposerDraftStore.getState(); + + store.setStickyModel("gpt-5.3-codex"); + store.setStickyModelOptions({ + codex: { + reasoningEffort: "medium", + fastMode: true, + }, + }); + + expect(useComposerDraftStore.getState()).toMatchObject({ + stickyModel: "gpt-5.3-codex", + stickyModelOptions: { + codex: { + reasoningEffort: "medium", + fastMode: true, + }, + }, + }); + }); + + it("normalizes empty sticky model options", () => { + const store = useComposerDraftStore.getState(); + + store.setStickyModelOptions({ + codex: { + fastMode: false, + }, + }); + + expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({}); + }); +}); + describe("composerDraftStore setProvider", () => { const threadId = ThreadId.makeUnsafe("thread-provider"); beforeEach(() => { - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); + resetComposerDraftStore(); }); it("persists provider-only selection even when prompt/model are empty", () => { @@ -699,11 +877,7 @@ describe("composerDraftStore runtime and interaction settings", () => { const threadId = ThreadId.makeUnsafe("thread-settings"); beforeEach(() => { - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - }); + resetComposerDraftStore(); }); it("stores runtime mode overrides in the composer draft", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 7733bfd62f..e1c3c0b5cd 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -11,6 +11,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; @@ -104,6 +105,8 @@ const PersistedComposerDraftStoreState = Schema.Struct({ draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), + stickyModel: Schema.NullOr(Schema.String), + stickyModelOptions: ProviderModelOptions, }); type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; @@ -143,6 +146,8 @@ interface ComposerDraftStoreState { draftsByThreadId: Record; draftThreadsByThreadId: Record; projectDraftThreadIdByProjectId: Record; + stickyModel: string | null; + stickyModelOptions: ProviderModelOptions; getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; getDraftThread: (threadId: ThreadId) => DraftThreadState | null; setProjectDraftThreadId: ( @@ -172,6 +177,8 @@ interface ComposerDraftStoreState { clearProjectDraftThreadId: (projectId: ProjectId) => void; clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; clearDraftThread: (threadId: ThreadId) => void; + setStickyModel: (model: string | null | undefined) => void; + setStickyModelOptions: (modelOptions: ProviderModelOptions | null | undefined) => void; setPrompt: (threadId: ThreadId, prompt: string) => void; setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void; @@ -180,6 +187,14 @@ interface ComposerDraftStoreState { threadId: ThreadId, modelOptions: ProviderModelOptions | null | undefined, ) => void; + setProviderModelOptions: ( + threadId: ThreadId, + provider: ProviderKind, + nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, + options?: { + persistSticky?: boolean; + }, + ) => void; setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void; setInteractionMode: ( threadId: ThreadId, @@ -207,11 +222,15 @@ interface ComposerDraftStoreState { clearThreadDraft: (threadId: ThreadId) => void; } -const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { +const EMPTY_PROVIDER_MODEL_OPTIONS = Object.freeze({}); + +const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze({ draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, -}; + stickyModel: null, + stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS, +}); const EMPTY_IMAGES: ComposerImageAttachment[] = []; const EMPTY_IDS: string[] = []; @@ -397,6 +416,24 @@ function normalizeProviderModelOptions( }; } +function replaceProviderModelOptions( + currentModelOptions: ProviderModelOptions | null | undefined, + provider: ProviderKind, + nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, +): ProviderModelOptions | null { + const { [provider]: _discardedProviderModelOptions, ...otherProviderModelOptions } = + currentModelOptions ?? {}; + const normalizedNextProviderOptions = normalizeProviderModelOptions( + { [provider]: nextProviderOptions }, + provider, + ); + + return normalizeProviderModelOptions({ + ...otherProviderModelOptions, + ...(normalizedNextProviderOptions ? normalizedNextProviderOptions : {}), + }); +} + function revokeObjectPreviewUrl(previewUrl: string): void { if (typeof URL === "undefined") { return; @@ -674,6 +711,12 @@ function migratePersistedComposerDraftStoreState( const rawDraftMap = candidate.draftsByThreadId; const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; + const stickyModel = + typeof candidate.stickyModel === "string" + ? (normalizeModelSlug(candidate.stickyModel, "codex") ?? null) + : null; + const stickyModelOptions = + normalizeProviderModelOptions(candidate.stickyModelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS; const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId); const draftsByThreadId = normalizePersistedDraftsByThreadId( @@ -691,6 +734,8 @@ function migratePersistedComposerDraftStoreState( draftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId, + stickyModel, + stickyModelOptions, }; } @@ -744,6 +789,8 @@ function partializeComposerDraftStoreState( draftsByThreadId: persistedDraftsByThreadId, draftThreadsByThreadId: state.draftThreadsByThreadId, projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, + stickyModel: state.stickyModel, + stickyModelOptions: state.stickyModelOptions, }; } @@ -759,6 +806,13 @@ function normalizeCurrentPersistedComposerDraftStoreState( normalizedPersistedState.draftThreadsByThreadId, normalizedPersistedState.projectDraftThreadIdByProjectId, ); + const stickyModel = + typeof normalizedPersistedState.stickyModel === "string" + ? (normalizeModelSlug(normalizedPersistedState.stickyModel, "codex") ?? null) + : null; + const stickyModelOptions = + normalizeProviderModelOptions(normalizedPersistedState.stickyModelOptions) ?? + EMPTY_PROVIDER_MODEL_OPTIONS; return { draftsByThreadId: normalizePersistedDraftsByThreadId( normalizedPersistedState.draftsByThreadId, @@ -767,6 +821,8 @@ function normalizeCurrentPersistedComposerDraftStoreState( ), draftThreadsByThreadId, projectDraftThreadIdByProjectId, + stickyModel, + stickyModelOptions, }; } @@ -870,6 +926,8 @@ export const useComposerDraftStore = create()( draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyModel: null, + stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS, getDraftThreadByProjectId: (projectId) => { if (projectId.length === 0) { return null; @@ -1105,6 +1163,29 @@ export const useComposerDraftStore = create()( }; }); }, + setStickyModel: (model) => { + const normalizedModel = normalizeModelSlug(model, "codex") ?? null; + set((state) => { + if (state.stickyModel === normalizedModel) { + return state; + } + return { + stickyModel: normalizedModel, + }; + }); + }, + setStickyModelOptions: (modelOptions) => { + const normalizedModelOptions = + normalizeProviderModelOptions(modelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS; + set((state) => { + if (Equal.equals(state.stickyModelOptions, normalizedModelOptions)) { + return state; + } + return { + stickyModelOptions: normalizedModelOptions, + }; + }); + }, setPrompt: (threadId, prompt) => { if (threadId.length === 0) { return; @@ -1214,7 +1295,7 @@ export const useComposerDraftStore = create()( return state; } const base = existing ?? createEmptyThreadDraft(); - if (JSON.stringify(base.modelOptions) === JSON.stringify(nextModelOptions)) { + if (Equal.equals(base.modelOptions, nextModelOptions)) { return state; } const nextDraft: ComposerThreadDraftState = { @@ -1230,6 +1311,53 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, + setProviderModelOptions: (threadId, provider, nextProviderOptions, options) => { + if (threadId.length === 0) { + return; + } + const normalizedProvider = normalizeProviderKind(provider); + if (normalizedProvider === null) { + return; + } + set((state) => { + const existing = state.draftsByThreadId[threadId]; + const base = existing ?? createEmptyThreadDraft(); + const nextModelOptions = replaceProviderModelOptions( + base.modelOptions, + normalizedProvider, + nextProviderOptions, + ); + const nextStickyModelOptions = + options?.persistSticky === true + ? (nextModelOptions ?? EMPTY_PROVIDER_MODEL_OPTIONS) + : state.stickyModelOptions; + + if ( + Equal.equals(base.modelOptions, nextModelOptions) && + Equal.equals(state.stickyModelOptions, nextStickyModelOptions) + ) { + return state; + } + + const nextDraft: ComposerThreadDraftState = { + ...base, + modelOptions: nextModelOptions, + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + + return { + draftsByThreadId: nextDraftsByThreadId, + ...(options?.persistSticky === true + ? { stickyModelOptions: nextStickyModelOptions } + : {}), + }; + }); + }, setRuntimeMode: (threadId, runtimeMode) => { if (threadId.length === 0) { return; @@ -1644,6 +1772,8 @@ export const useComposerDraftStore = create()( draftsByThreadId, draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, + stickyModel: normalizedPersisted.stickyModel, + stickyModelOptions: normalizedPersisted.stickyModelOptions, }; }, }, diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 35f92d98e9..e31809cdd2 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,6 +1,7 @@ import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; import { useNavigate, useParams } from "@tanstack/react-router"; import { useCallback } from "react"; +import { inferProviderForModel } from "@t3tools/shared/model"; import { type DraftThreadEnvMode, type DraftThreadState, @@ -12,6 +13,8 @@ import { useStore } from "../store"; export function useHandleNewThread() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); + const stickyModel = useComposerDraftStore((store) => store.stickyModel); + const stickyModelOptions = useComposerDraftStore((store) => store.stickyModelOptions); const navigate = useNavigate(); const routeThreadId = useParams({ strict: false, @@ -38,6 +41,9 @@ export function useHandleNewThread() { clearProjectDraftThreadId, getDraftThread, getDraftThreadByProjectId, + setModel, + setModelOptions, + setProvider, setDraftThreadContext, setProjectDraftThreadId, } = useComposerDraftStore.getState(); @@ -96,6 +102,13 @@ export function useHandleNewThread() { envMode: options?.envMode ?? "local", runtimeMode: DEFAULT_RUNTIME_MODE, }); + if (stickyModel) { + setProvider(threadId, inferProviderForModel(stickyModel)); + setModel(threadId, stickyModel); + } + if (Object.keys(stickyModelOptions).length > 0) { + setModelOptions(threadId, stickyModelOptions); + } await navigate({ to: "/$threadId", @@ -103,7 +116,7 @@ export function useHandleNewThread() { }); })(); }, - [navigate, routeThreadId], + [navigate, routeThreadId, stickyModel, stickyModelOptions], ); return { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index f3dee29096..acc8763fb4 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -3,7 +3,15 @@ import { useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; -import { getAppModelOptions, MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; +import { + getAppModelOptions, + getCustomModelsForProvider, + getDefaultCustomModelsForProvider, + MAX_CUSTOM_MODEL_LENGTH, + MODEL_PROVIDER_SETTINGS, + patchCustomModels, + useAppSettings, +} from "../appSettings"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; @@ -40,71 +48,12 @@ const THEME_OPTIONS = [ }, ] as const; -const MODEL_PROVIDER_SETTINGS: Array<{ - provider: ProviderKind; - title: string; - description: string; - placeholder: string; - example: string; -}> = [ - { - provider: "codex", - title: "Codex", - description: "Save additional Codex model slugs for the picker and `/model` command.", - placeholder: "your-codex-model-slug", - example: "gpt-6.7-codex-ultra-preview", - }, - { - provider: "claudeAgent", - title: "Claude", - description: "Save additional Claude model slugs for the picker and `/model` command.", - placeholder: "your-claude-model-slug", - example: "claude-sonnet-5-0", - }, -] as const; - const TIMESTAMP_FORMAT_LABELS = { locale: "System default", "12-hour": "12-hour", "24-hour": "24-hour", } as const; -function getCustomModelsForProvider( - settings: ReturnType["settings"], - provider: ProviderKind, -) { - switch (provider) { - case "claudeAgent": - return settings.customClaudeModels; - case "codex": - default: - return settings.customCodexModels; - } -} - -function getDefaultCustomModelsForProvider( - defaults: ReturnType["defaults"], - provider: ProviderKind, -) { - switch (provider) { - case "claudeAgent": - return defaults.customClaudeModels; - case "codex": - default: - return defaults.customCodexModels; - } -} - -function patchCustomModels(provider: ProviderKind, models: string[]) { - switch (provider) { - case "claudeAgent": - return { customClaudeModels: models }; - case "codex": - default: - return { customCodexModels: models }; - } -} - function SettingsRouteView() { const { theme, setTheme, resolvedTheme } = useTheme(); const { settings, defaults, updateSettings } = useAppSettings(); diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index be1009c5e1..2c8aaf1986 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -21,6 +21,7 @@ import { normalizeCodexModelOptions, normalizeModelSlug, resolveReasoningEffortForProvider, + resolveSelectableModel, resolveModelSlug, resolveModelSlugForProvider, supportsClaudeAdaptiveReasoning, @@ -94,6 +95,70 @@ describe("resolveModelSlug", () => { }); }); +describe("resolveSelectableModel", () => { + it("resolves exact slug matches", () => { + expect( + resolveSelectableModel("codex", "gpt-5.3-codex", [ + { slug: "gpt-5.4", name: "GPT-5.4" }, + { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + ]), + ).toBe("gpt-5.3-codex"); + }); + + it("resolves case-insensitive display-name matches", () => { + expect( + resolveSelectableModel("codex", "gpt-5.3 codex", [ + { slug: "gpt-5.4", name: "GPT-5.4" }, + { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + ]), + ).toBe("gpt-5.3-codex"); + }); + + it("resolves provider-specific aliases after normalization", () => { + expect( + resolveSelectableModel("claudeAgent", "sonnet", [ + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ]), + ).toBe("claude-sonnet-4-6"); + }); + + it("returns null for empty input", () => { + expect(resolveSelectableModel("codex", "", [{ slug: "gpt-5.4", name: "GPT-5.4" }])).toBeNull(); + expect( + resolveSelectableModel("codex", " ", [{ slug: "gpt-5.4", name: "GPT-5.4" }]), + ).toBeNull(); + expect( + resolveSelectableModel("codex", null, [{ slug: "gpt-5.4", name: "GPT-5.4" }]), + ).toBeNull(); + }); + + it("returns null for unknown values that are not present in options", () => { + expect( + resolveSelectableModel("codex", "gpt-4.1", [{ slug: "gpt-5.4", name: "GPT-5.4" }]), + ).toBeNull(); + }); + + it("does not accept normalized custom-looking slugs unless they exist in options", () => { + expect( + resolveSelectableModel("codex", "custom/internal-model", [ + { slug: "gpt-5.4", name: "GPT-5.4" }, + ]), + ).toBeNull(); + }); + + it("respects provider boundaries", () => { + expect( + resolveSelectableModel("codex", "sonnet", [{ slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }]), + ).toBeNull(); + expect( + resolveSelectableModel("claudeAgent", "5.3", [ + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ]), + ).toBeNull(); + }); +}); + describe("getReasoningEffortOptions", () => { it("returns codex reasoning options for codex", () => { expect(getReasoningEffortOptions("codex")).toEqual(REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 625dfd5adf..2d46320753 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -24,6 +24,11 @@ const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6"; const CLAUDE_SONNET_4_6_MODEL = "claude-sonnet-4-6"; const CLAUDE_HAIKU_4_5_MODEL = "claude-haiku-4-5"; +export interface SelectableModelOption { + slug: string; + name: string; +} + export function getModelOptions(provider: ProviderKind = "codex") { return MODEL_OPTIONS_BY_PROVIDER[provider]; } @@ -77,6 +82,39 @@ export function normalizeModelSlug( return typeof aliased === "string" ? aliased : (trimmed as ModelSlug); } +export function resolveSelectableModel( + provider: ProviderKind, + value: string | null | undefined, + options: ReadonlyArray, +): ModelSlug | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const direct = options.find((option) => option.slug === trimmed); + if (direct) { + return direct.slug; + } + + const byName = options.find((option) => option.name.toLowerCase() === trimmed.toLowerCase()); + if (byName) { + return byName.slug; + } + + const normalized = normalizeModelSlug(trimmed, provider); + if (!normalized) { + return null; + } + + const resolved = options.find((option) => option.slug === normalized); + return resolved ? resolved.slug : null; +} + export function resolveModelSlug( model: string | null | undefined, provider: ProviderKind = "codex",