authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-03-20 15:17:56-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-20 11:17:56-07:00
logd37338970326c44a183ef4650f8b6985f76a23c7
treec6b52b9bf075eb2d22be104875662fca497e5243
parentbc12421736fdf014953726b3a1e604652c45b088
signature Signed by PGP key B5690EEEBB952194

feat(web): persist modelOptions, refactor provider specific logic (#1121)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

20 files changed, 1368 insertions(+), 368 deletions(-)

.gitignore+2-1
......@@ -17,4 +17,5 @@ release/
1717apps/web/.playwright
1818apps/web/playwright-report
1919apps/web/src/components/__screenshots__
20.vitest-*
\ No newline at end of file
20.vitest-*
21__screenshots__/
\ No newline at end of file
apps/web/src/appSettings.test.ts+111-4
......@@ -5,7 +5,13 @@ import {
55 AppSettingsSchema,
66 DEFAULT_TIMESTAMP_FORMAT,
77 getAppModelOptions,
8 getCustomModelOptionsByProvider,
9 getCustomModelsByProvider,
10 getCustomModelsForProvider,
11 getDefaultCustomModelsForProvider,
12 MODEL_PROVIDER_SETTINGS,
813 normalizeCustomModelSlugs,
14 patchCustomModels,
915 resolveAppModelSelection,
1016} from "./appSettings";
1117
......@@ -66,13 +72,35 @@ describe("getAppModelOptions", () => {
6672
6773describe("resolveAppModelSelection", () => {
6874 it("preserves saved custom model slugs instead of falling back to the default", () => {
69 expect(resolveAppModelSelection("codex", ["galapagos-alpha"], "galapagos-alpha")).toBe(
70 "galapagos-alpha",
71 );
75 expect(
76 resolveAppModelSelection(
77 "codex",
78 { codex: ["galapagos-alpha"], claudeAgent: [] },
79 "galapagos-alpha",
80 ),
81 ).toBe("galapagos-alpha");
7282 });
7383
7484 it("falls back to the provider default when no model is selected", () => {
75 expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4");
85 expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "")).toBe("gpt-5.4");
86 });
87
88 it("resolves display names through the shared resolver", () => {
89 expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "GPT-5.3 Codex")).toBe(
90 "gpt-5.3-codex",
91 );
92 });
93
94 it("resolves aliases through the shared resolver", () => {
95 expect(resolveAppModelSelection("claudeAgent", { codex: [], claudeAgent: [] }, "sonnet")).toBe(
96 "claude-sonnet-4-6",
97 );
98 });
99
100 it("resolves transient selected custom models included in app model options", () => {
101 expect(
102 resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "custom/selected-model"),
103 ).toBe("custom/selected-model");
76104 });
77105});
78106
......@@ -90,6 +118,85 @@ describe("provider-specific custom models", () => {
90118 });
91119});
92120
121describe("provider-indexed custom model settings", () => {
122 const settings = {
123 customCodexModels: ["custom/codex-model"],
124 customClaudeModels: ["claude/custom-opus"],
125 } as const;
126
127 it("exports one provider config per provider", () => {
128 expect(MODEL_PROVIDER_SETTINGS.map((config) => config.provider)).toEqual([
129 "codex",
130 "claudeAgent",
131 ]);
132 });
133
134 it("reads custom models for each provider", () => {
135 expect(getCustomModelsForProvider(settings, "codex")).toEqual(["custom/codex-model"]);
136 expect(getCustomModelsForProvider(settings, "claudeAgent")).toEqual(["claude/custom-opus"]);
137 });
138
139 it("reads default custom models for each provider", () => {
140 const defaults = {
141 customCodexModels: ["default/codex-model"],
142 customClaudeModels: ["claude/default-opus"],
143 } as const;
144
145 expect(getDefaultCustomModelsForProvider(defaults, "codex")).toEqual(["default/codex-model"]);
146 expect(getDefaultCustomModelsForProvider(defaults, "claudeAgent")).toEqual([
147 "claude/default-opus",
148 ]);
149 });
150
151 it("patches custom models for codex", () => {
152 expect(patchCustomModels("codex", ["custom/codex-model"])).toEqual({
153 customCodexModels: ["custom/codex-model"],
154 });
155 });
156
157 it("patches custom models for claude", () => {
158 expect(patchCustomModels("claudeAgent", ["claude/custom-opus"])).toEqual({
159 customClaudeModels: ["claude/custom-opus"],
160 });
161 });
162
163 it("builds a complete provider-indexed custom model record", () => {
164 expect(getCustomModelsByProvider(settings)).toEqual({
165 codex: ["custom/codex-model"],
166 claudeAgent: ["claude/custom-opus"],
167 });
168 });
169
170 it("builds provider-indexed model options including custom models", () => {
171 const modelOptionsByProvider = getCustomModelOptionsByProvider(settings);
172
173 expect(
174 modelOptionsByProvider.codex.some((option) => option.slug === "custom/codex-model"),
175 ).toBe(true);
176 expect(
177 modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude/custom-opus"),
178 ).toBe(true);
179 });
180
181 it("normalizes and deduplicates custom model options per provider", () => {
182 const modelOptionsByProvider = getCustomModelOptionsByProvider({
183 customCodexModels: [" custom/codex-model ", "gpt-5.4", "custom/codex-model"],
184 customClaudeModels: [" sonnet ", "claude/custom-opus", "claude/custom-opus"],
185 });
186
187 expect(
188 modelOptionsByProvider.codex.filter((option) => option.slug === "custom/codex-model"),
189 ).toHaveLength(1);
190 expect(modelOptionsByProvider.codex.some((option) => option.slug === "gpt-5.4")).toBe(true);
191 expect(
192 modelOptionsByProvider.claudeAgent.filter((option) => option.slug === "claude/custom-opus"),
193 ).toHaveLength(1);
194 expect(
195 modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude-sonnet-4-6"),
196 ).toBe(true);
197 });
198});
199
93200describe("AppSettingsSchema", () => {
94201 it("fills decoding defaults for persisted settings that predate newer keys", () => {
95202 const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema));
apps/web/src/appSettings.ts+92-27
......@@ -1,7 +1,12 @@
11import { useCallback } from "react";
22import { Option, Schema } from "effect";
33import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts";
4import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
4import {
5 getDefaultModel,
6 getModelOptions,
7 normalizeModelSlug,
8 resolveSelectableModel,
9} from "@t3tools/shared/model";
510import { useLocalStorage } from "./hooks/useLocalStorage";
611import { EnvMode } from "./components/BranchToolbar.logic";
712
......@@ -12,6 +17,16 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256;
1217export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
1318export type TimestampFormat = typeof TimestampFormat.Type;
1419export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
20type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels";
21export type ProviderCustomModelConfig = {
22 provider: ProviderKind;
23 settingsKey: CustomModelSettingsKey;
24 defaultSettingsKey: CustomModelSettingsKey;
25 title: string;
26 description: string;
27 placeholder: string;
28 example: string;
29};
1530
1631const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
1732 codex: new Set(getModelOptions("codex").map((option) => option.slug)),
......@@ -50,6 +65,27 @@ export interface AppModelOption {
5065}
5166
5267const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
68const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConfig> = {
69 codex: {
70 provider: "codex",
71 settingsKey: "customCodexModels",
72 defaultSettingsKey: "customCodexModels",
73 title: "Codex",
74 description: "Save additional Codex model slugs for the picker and `/model` command.",
75 placeholder: "your-codex-model-slug",
76 example: "gpt-6.7-codex-ultra-preview",
77 },
78 claudeAgent: {
79 provider: "claudeAgent",
80 settingsKey: "customClaudeModels",
81 defaultSettingsKey: "customClaudeModels",
82 title: "Claude",
83 description: "Save additional Claude model slugs for the picker and `/model` command.",
84 placeholder: "your-claude-model-slug",
85 example: "claude-sonnet-5-0",
86 },
87};
88export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG);
5389
5490export function normalizeCustomModelSlugs(
5591 models: Iterable<string | null | undefined>,
......@@ -87,6 +123,39 @@ function normalizeAppSettings(settings: AppSettings): AppSettings {
87123 customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"),
88124 };
89125}
126
127export function getCustomModelsForProvider(
128 settings: Pick<AppSettings, CustomModelSettingsKey>,
129 provider: ProviderKind,
130): readonly string[] {
131 return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey];
132}
133
134export function getDefaultCustomModelsForProvider(
135 defaults: Pick<AppSettings, CustomModelSettingsKey>,
136 provider: ProviderKind,
137): readonly string[] {
138 return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey];
139}
140
141export function patchCustomModels(
142 provider: ProviderKind,
143 models: string[],
144): Partial<Pick<AppSettings, CustomModelSettingsKey>> {
145 return {
146 [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models,
147 };
148}
149
150export function getCustomModelsByProvider(
151 settings: Pick<AppSettings, CustomModelSettingsKey>,
152): Record<ProviderKind, readonly string[]> {
153 return {
154 codex: getCustomModelsForProvider(settings, "codex"),
155 claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"),
156 };
157}
158
90159export function getAppModelOptions(
91160 provider: ProviderKind,
92161 customModels: readonly string[],
......@@ -98,6 +167,7 @@ export function getAppModelOptions(
98167 isCustom: false,
99168 }));
100169 const seen = new Set(options.map((option) => option.slug));
170 const trimmedSelectedModel = selectedModel?.trim().toLowerCase();
101171
102172 for (const slug of normalizeCustomModelSlugs(customModels, provider)) {
103173 if (seen.has(slug)) {
......@@ -113,7 +183,14 @@ export function getAppModelOptions(
113183 }
114184
115185 const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);
116 if (normalizedSelectedModel && !seen.has(normalizedSelectedModel)) {
186 const selectedModelMatchesExistingName =
187 typeof trimmedSelectedModel === "string" &&
188 options.some((option) => option.name.toLowerCase() === trimmedSelectedModel);
189 if (
190 normalizedSelectedModel &&
191 !seen.has(normalizedSelectedModel) &&
192 !selectedModelMatchesExistingName
193 ) {
117194 options.push({
118195 slug: normalizedSelectedModel,
119196 name: normalizedSelectedModel,
......@@ -126,34 +203,22 @@ export function getAppModelOptions(
126203
127204export function resolveAppModelSelection(
128205 provider: ProviderKind,
129 customModels: readonly string[],
206 customModels: Record<ProviderKind, readonly string[]>,
130207 selectedModel: string | null | undefined,
131208): string {
132 const options = getAppModelOptions(provider, customModels, selectedModel);
133 const trimmedSelectedModel = selectedModel?.trim();
134 if (trimmedSelectedModel) {
135 const direct = options.find((option) => option.slug === trimmedSelectedModel);
136 if (direct) {
137 return direct.slug;
138 }
139
140 const byName = options.find(
141 (option) => option.name.toLowerCase() === trimmedSelectedModel.toLowerCase(),
142 );
143 if (byName) {
144 return byName.slug;
145 }
146 }
147
148 const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);
149 if (!normalizedSelectedModel) {
150 return getDefaultModel(provider);
151 }
209 const customModelsForProvider = customModels[provider];
210 const options = getAppModelOptions(provider, customModelsForProvider, selectedModel);
211 return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider);
212}
152213
153 return (
154 options.find((option) => option.slug === normalizedSelectedModel)?.slug ??
155 getDefaultModel(provider)
156 );
214export function getCustomModelOptionsByProvider(
215 settings: Pick<AppSettings, CustomModelSettingsKey>,
216): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
217 const customModelsByProvider = getCustomModelsByProvider(settings);
218 return {
219 codex: getAppModelOptions("codex", customModelsByProvider.codex),
220 claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent),
221 };
157222}
158223
159224export function useAppSettings() {
apps/web/src/components/ChatView.browser.tsx+194
......@@ -752,6 +752,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
752752 draftsByThreadId: {},
753753 draftThreadsByThreadId: {},
754754 projectDraftThreadIdByProjectId: {},
755 stickyModel: null,
756 stickyModelOptions: {},
755757 });
756758 useStore.setState({
757759 projects: [],
......@@ -1279,6 +1281,198 @@ describe("ChatView timeline estimator parity (full app)", () => {
12791281 }
12801282 });
12811283
1284 it("snapshots sticky codex settings into a new draft thread", async () => {
1285 useComposerDraftStore.setState({
1286 stickyModel: "gpt-5.3-codex",
1287 stickyModelOptions: {
1288 codex: {
1289 reasoningEffort: "medium",
1290 fastMode: true,
1291 },
1292 },
1293 });
1294
1295 const mounted = await mountChatView({
1296 viewport: DEFAULT_VIEWPORT,
1297 snapshot: createSnapshotForTargetUser({
1298 targetMessageId: "msg-user-sticky-codex-traits-test" as MessageId,
1299 targetText: "sticky codex traits test",
1300 }),
1301 });
1302
1303 try {
1304 const newThreadButton = page.getByTestId("new-thread-button");
1305 await expect.element(newThreadButton).toBeInTheDocument();
1306
1307 await newThreadButton.click();
1308
1309 const newThreadPath = await waitForURL(
1310 mounted.router,
1311 (path) => UUID_ROUTE_RE.test(path),
1312 "Route should have changed to a new draft thread UUID.",
1313 );
1314 const newThreadId = newThreadPath.slice(1) as ThreadId;
1315
1316 expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({
1317 model: "gpt-5.3-codex",
1318 provider: "codex",
1319 modelOptions: {
1320 codex: {
1321 fastMode: true,
1322 },
1323 },
1324 });
1325 } finally {
1326 await mounted.cleanup();
1327 }
1328 });
1329
1330 it("hydrates the provider alongside a sticky claude model", async () => {
1331 useComposerDraftStore.setState({
1332 stickyModel: "claude-opus-4-6",
1333 stickyModelOptions: {
1334 claudeAgent: {
1335 effort: "max",
1336 fastMode: true,
1337 },
1338 },
1339 });
1340
1341 const mounted = await mountChatView({
1342 viewport: DEFAULT_VIEWPORT,
1343 snapshot: createSnapshotForTargetUser({
1344 targetMessageId: "msg-user-sticky-claude-model-test" as MessageId,
1345 targetText: "sticky claude model test",
1346 }),
1347 });
1348
1349 try {
1350 const newThreadButton = page.getByTestId("new-thread-button");
1351 await expect.element(newThreadButton).toBeInTheDocument();
1352
1353 await newThreadButton.click();
1354
1355 const newThreadPath = await waitForURL(
1356 mounted.router,
1357 (path) => UUID_ROUTE_RE.test(path),
1358 "Route should have changed to a new sticky claude draft thread UUID.",
1359 );
1360 const newThreadId = newThreadPath.slice(1) as ThreadId;
1361
1362 expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({
1363 provider: "claudeAgent",
1364 model: "claude-opus-4-6",
1365 modelOptions: {
1366 claudeAgent: {
1367 effort: "max",
1368 fastMode: true,
1369 },
1370 },
1371 });
1372 await expect.element(page.getByText("Claude Opus 4.6")).toBeInTheDocument();
1373 } finally {
1374 await mounted.cleanup();
1375 }
1376 });
1377
1378 it("falls back to defaults when no sticky composer settings exist", async () => {
1379 const mounted = await mountChatView({
1380 viewport: DEFAULT_VIEWPORT,
1381 snapshot: createSnapshotForTargetUser({
1382 targetMessageId: "msg-user-default-codex-traits-test" as MessageId,
1383 targetText: "default codex traits test",
1384 }),
1385 });
1386
1387 try {
1388 const newThreadButton = page.getByTestId("new-thread-button");
1389 await expect.element(newThreadButton).toBeInTheDocument();
1390
1391 await newThreadButton.click();
1392
1393 const newThreadPath = await waitForURL(
1394 mounted.router,
1395 (path) => UUID_ROUTE_RE.test(path),
1396 "Route should have changed to a new draft thread UUID.",
1397 );
1398 const newThreadId = newThreadPath.slice(1) as ThreadId;
1399
1400 expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toBeUndefined();
1401 } finally {
1402 await mounted.cleanup();
1403 }
1404 });
1405
1406 it("prefers draft state over sticky composer settings and defaults", async () => {
1407 useComposerDraftStore.setState({
1408 stickyModel: "gpt-5.3-codex",
1409 stickyModelOptions: {
1410 codex: {
1411 reasoningEffort: "medium",
1412 fastMode: true,
1413 },
1414 },
1415 });
1416
1417 const mounted = await mountChatView({
1418 viewport: DEFAULT_VIEWPORT,
1419 snapshot: createSnapshotForTargetUser({
1420 targetMessageId: "msg-user-draft-codex-traits-precedence-test" as MessageId,
1421 targetText: "draft codex traits precedence test",
1422 }),
1423 });
1424
1425 try {
1426 const newThreadButton = page.getByTestId("new-thread-button");
1427 await expect.element(newThreadButton).toBeInTheDocument();
1428
1429 await newThreadButton.click();
1430
1431 const threadPath = await waitForURL(
1432 mounted.router,
1433 (path) => UUID_ROUTE_RE.test(path),
1434 "Route should have changed to a sticky draft thread UUID.",
1435 );
1436 const threadId = threadPath.slice(1) as ThreadId;
1437
1438 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({
1439 model: "gpt-5.3-codex",
1440 modelOptions: {
1441 codex: {
1442 fastMode: true,
1443 },
1444 },
1445 });
1446
1447 useComposerDraftStore.getState().setModel(threadId, "gpt-5.4");
1448 useComposerDraftStore.getState().setModelOptions(threadId, {
1449 codex: {
1450 reasoningEffort: "low",
1451 fastMode: true,
1452 },
1453 });
1454
1455 await newThreadButton.click();
1456
1457 await waitForURL(
1458 mounted.router,
1459 (path) => path === threadPath,
1460 "New-thread should reuse the existing project draft thread.",
1461 );
1462 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({
1463 model: "gpt-5.4",
1464 modelOptions: {
1465 codex: {
1466 reasoningEffort: "low",
1467 fastMode: true,
1468 },
1469 },
1470 });
1471 } finally {
1472 await mounted.cleanup();
1473 }
1474 });
1475
12821476 it("creates a new thread from the global chat.new shortcut", async () => {
12831477 const mounted = await mountChatView({
12841478 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/ChatView.logic.ts+1-12
......@@ -1,7 +1,6 @@
1import { ProjectId, type ProviderKind, type ThreadId } from "@t3tools/contracts";
1import { ProjectId, type ThreadId } from "@t3tools/contracts";
22import { type ChatMessage, type Thread } from "../types";
33import { randomUUID } from "~/lib/utils";
4import { getAppModelOptions } from "../appSettings";
54import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
65import { Schema } from "effect";
76import {
......@@ -121,16 +120,6 @@ export function cloneComposerImageForRetry(
121120 }
122121}
123122
124export function getCustomModelOptionsByProvider(settings: {
125 customCodexModels: readonly string[];
126 customClaudeModels: readonly string[];
127}): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
128 return {
129 codex: getAppModelOptions("codex", settings.customCodexModels),
130 claudeAgent: getAppModelOptions("claudeAgent", settings.customClaudeModels),
131 };
132}
133
134123export function deriveComposerSendState(options: {
135124 prompt: string;
136125 imageCount: number;
apps/web/src/components/ChatView.tsx+55-104
......@@ -24,15 +24,8 @@ import {
2424import {
2525 applyClaudePromptEffortPrefix,
2626 getDefaultModel,
27 getDefaultReasoningEffort,
28 getReasoningEffortOptions,
29 isClaudeUltrathinkPrompt,
30 normalizeClaudeModelOptions,
31 normalizeCodexModelOptions,
3227 normalizeModelSlug,
33 resolveReasoningEffortForProvider,
3428 resolveModelSlugForProvider,
35 supportsClaudeUltrathinkKeyword,
3629} from "@t3tools/shared/model";
3730import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3831import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
......@@ -126,7 +119,12 @@ import {
126119import { SidebarTrigger } from "./ui/sidebar";
127120import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
128121import { readNativeApi } from "~/nativeApi";
129import { resolveAppModelSelection, useAppSettings } from "../appSettings";
122import {
123 getCustomModelOptionsByProvider,
124 getCustomModelsByProvider,
125 resolveAppModelSelection,
126 useAppSettings,
127} from "../appSettings";
130128import { isTerminalFocused } from "../lib/terminalFocus";
131129import {
132130 type ComposerImageAttachment,
......@@ -153,12 +151,15 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/Expanded
153151import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";
154152import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";
155153import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions";
156import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./chat/ClaudeTraitsPicker";
157import { CodexTraitsMenuContent, CodexTraitsPicker } from "./chat/CodexTraitsPicker";
158154import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu";
159155import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel";
160156import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel";
161157import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner";
158import {
159 getComposerProviderState,
160 renderProviderTraitsMenuContent,
161 renderProviderTraitsPicker,
162} from "./chat/composerProviderRegistry";
162163import { ProviderHealthBanner } from "./chat/ProviderHealthBanner";
163164import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
164165import {
......@@ -168,7 +169,6 @@ import {
168169 cloneComposerImageForRetry,
169170 collectUserMessageBlobPreviewUrls,
170171 deriveComposerSendState,
171 getCustomModelOptionsByProvider,
172172 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
173173 LastInvokedScriptByProjectSchema,
174174 PullRequestDialogState,
......@@ -244,6 +244,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
244244 const setStoreThreadError = useStore((store) => store.setError);
245245 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
246246 const { settings } = useAppSettings();
247 const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel);
247248 const timestampFormat = settings.timestampFormat;
248249 const navigate = useNavigate();
249250 const rawSearch = useSearch({
......@@ -596,72 +597,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
596597 selectedProvider,
597598 activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider),
598599 );
599 const customModelsByProvider = useMemo(
600 () => ({
601 codex: settings.customCodexModels,
602 claudeAgent: settings.customClaudeModels,
603 }),
604 [settings.customClaudeModels, settings.customCodexModels],
605 );
606 const customModelsForSelectedProvider = customModelsByProvider[selectedProvider];
600 const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]);
607601 const selectedModel = useMemo(() => {
608602 const draftModel = composerDraft.model;
609603 if (!draftModel) {
610604 return baseThreadModel;
611605 }
612 return resolveAppModelSelection(
613 selectedProvider,
614 customModelsForSelectedProvider,
615 draftModel,
616 ) as ModelSlug;
617 }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]);
606 return resolveAppModelSelection(selectedProvider, customModelsByProvider, draftModel);
607 }, [baseThreadModel, composerDraft.model, customModelsByProvider, selectedProvider]);
618608 const draftModelOptions = composerDraft.modelOptions;
619 const selectedCodexEffort =
620 selectedProvider === "codex"
621 ? (resolveReasoningEffortForProvider("codex", draftModelOptions?.codex?.reasoningEffort) ??
622 getDefaultReasoningEffort("codex"))
623 : null;
624 const selectedClaudeReasoningOptions =
625 selectedProvider === "claudeAgent"
626 ? getReasoningEffortOptions("claudeAgent", selectedModel)
627 : ([] as const);
628 const selectedClaudeBaseEffort =
629 selectedProvider === "claudeAgent" && selectedClaudeReasoningOptions.length > 0
630 ? (() => {
631 const draftEffort = resolveReasoningEffortForProvider(
632 "claudeAgent",
633 draftModelOptions?.claudeAgent?.effort,
634 );
635 if (
636 draftEffort &&
637 draftEffort !== "ultrathink" &&
638 selectedClaudeReasoningOptions.includes(draftEffort)
639 ) {
640 return draftEffort;
641 }
642 const defaultEffort = getDefaultReasoningEffort("claudeAgent");
643 return selectedClaudeReasoningOptions.includes(defaultEffort) ? defaultEffort : null;
644 })()
645 : null;
646 const isClaudeUltrathink =
647 selectedProvider === "claudeAgent" &&
648 supportsClaudeUltrathinkKeyword(selectedModel) &&
649 isClaudeUltrathinkPrompt(prompt);
650 const selectedPromptEffort = selectedCodexEffort ?? selectedClaudeBaseEffort;
651 const selectedModelOptionsForDispatch = useMemo(() => {
652 if (selectedProvider === "codex") {
653 const codexOptions = normalizeCodexModelOptions(draftModelOptions?.codex);
654 return codexOptions ? { codex: codexOptions } : undefined;
655 }
656 if (selectedProvider === "claudeAgent") {
657 const claudeOptions = normalizeClaudeModelOptions(
658 selectedModel,
659 draftModelOptions?.claudeAgent,
660 );
661 return claudeOptions ? { claudeAgent: claudeOptions } : undefined;
662 }
663 return undefined;
664 }, [draftModelOptions, selectedModel, selectedProvider]);
609 const composerProviderState = useMemo(
610 () =>
611 getComposerProviderState({
612 provider: selectedProvider,
613 model: selectedModel,
614 prompt,
615 modelOptions: draftModelOptions,
616 }),
617 [draftModelOptions, prompt, selectedModel, selectedProvider],
618 );
619 const selectedPromptEffort = composerProviderState.promptEffort;
620 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
665621 const providerOptionsForDispatch = useMemo(() => {
666622 if (!settings.codexBinaryPath && !settings.codexHomePath) {
667623 return undefined;
......@@ -3141,20 +3097,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
31413097 scheduleComposerFocus();
31423098 return;
31433099 }
3100 const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model);
31443101 setComposerDraftProvider(activeThread.id, provider);
3145 setComposerDraftModel(
3146 activeThread.id,
3147 resolveAppModelSelection(provider, customModelsByProvider[provider], model),
3148 );
3102 setComposerDraftModel(activeThread.id, resolvedModel);
3103 setStickyComposerModel(resolvedModel);
31493104 scheduleComposerFocus();
31503105 },
31513106 [
31523107 activeThread,
3153 customModelsByProvider,
31543108 lockedProvider,
31553109 scheduleComposerFocus,
31563110 setComposerDraftModel,
31573111 setComposerDraftProvider,
3112 setStickyComposerModel,
3113 customModelsByProvider,
31583114 ],
31593115 );
31603116 const setPromptFromTraits = useCallback(
......@@ -3173,6 +3129,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
31733129 },
31743130 [scheduleComposerFocus, setPrompt],
31753131 );
3132 const providerTraitsMenuContent = renderProviderTraitsMenuContent({
3133 provider: selectedProvider,
3134 threadId,
3135 model: selectedModel,
3136 onPromptChange: setPromptFromTraits,
3137 });
3138 const providerTraitsPicker = renderProviderTraitsPicker({
3139 provider: selectedProvider,
3140 threadId,
3141 model: selectedModel,
3142 onPromptChange: setPromptFromTraits,
3143 });
31763144 const onEnvModeChange = useCallback(
31773145 (mode: DraftThreadEnvMode) => {
31783146 if (isLocalDraftThread) {
......@@ -3606,7 +3574,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
36063574 <div
36073575 className={cn(
36083576 "group rounded-[22px] p-px transition-colors duration-200",
3609 isClaudeUltrathink ? "ultrathink-frame" : undefined,
3577 composerProviderState.composerFrameClassName,
36103578 )}
36113579 onDragEnter={onComposerDragEnter}
36123580 onDragOver={onComposerDragOver}
......@@ -3617,7 +3585,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
36173585 className={cn(
36183586 "rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring/45",
36193587 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",
3620 isClaudeUltrathink ? "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" : "",
3588 composerProviderState.composerSurfaceClassName,
36213589 )}
36223590 >
36233591 {activePendingApproval ? (
......@@ -3806,7 +3774,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
38063774 model={selectedModelForPickerWithCustomFallback}
38073775 lockedProvider={lockedProvider}
38083776 modelOptionsByProvider={modelOptionsByProvider}
3809 ultrathinkActive={isClaudeUltrathink}
3777 {...(composerProviderState.modelPickerIconClassName
3778 ? {
3779 activeProviderIconClassName:
3780 composerProviderState.modelPickerIconClassName,
3781 }
3782 : {})}
38103783 onProviderModelChange={onProviderModelSelect}
38113784 />
38123785
......@@ -3818,42 +3791,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
38183791 interactionMode={interactionMode}
38193792 planSidebarOpen={planSidebarOpen}
38203793 runtimeMode={runtimeMode}
3821 traitsMenuContent={
3822 selectedProvider === "codex" ? (
3823 <CodexTraitsMenuContent threadId={threadId} />
3824 ) : selectedProvider === "claudeAgent" ? (
3825 <ClaudeTraitsMenuContent
3826 threadId={threadId}
3827 model={selectedModel}
3828 onPromptChange={setPromptFromTraits}
3829 />
3830 ) : null
3831 }
3794 traitsMenuContent={providerTraitsMenuContent}
38323795 onToggleInteractionMode={toggleInteractionMode}
38333796 onTogglePlanSidebar={togglePlanSidebar}
38343797 onToggleRuntimeMode={toggleRuntimeMode}
38353798 />
38363799 ) : (
38373800 <>
3838 {selectedProvider === "codex" ? (
3839 <>
3840 <Separator
3841 orientation="vertical"
3842 className="mx-0.5 hidden h-4 sm:block"
3843 />
3844 <CodexTraitsPicker threadId={threadId} />
3845 </>
3846 ) : selectedProvider === "claudeAgent" ? (
3801 {providerTraitsPicker ? (
38473802 <>
38483803 <Separator
38493804 orientation="vertical"
38503805 className="mx-0.5 hidden h-4 sm:block"
38513806 />
3852 <ClaudeTraitsPicker
3853 threadId={threadId}
3854 model={selectedModel}
3855 onPromptChange={setPromptFromTraits}
3856 />
3807 {providerTraitsPicker}
38573808 </>
38583809 ) : null}
38593810
apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx+21
......@@ -173,4 +173,25 @@ describe("ClaudeTraitsPicker", () => {
173173 await mounted.cleanup();
174174 }
175175 });
176
177 it("persists sticky claude model options when traits change", async () => {
178 const mounted = await mountPicker({
179 model: "claude-opus-4-6",
180 effort: "medium",
181 fastModeEnabled: false,
182 });
183
184 try {
185 await page.getByRole("button").click();
186 await page.getByRole("menuitemradio", { name: "Max" }).click();
187
188 expect(useComposerDraftStore.getState().stickyModelOptions).toMatchObject({
189 claudeAgent: {
190 effort: "max",
191 },
192 });
193 } finally {
194 await mounted.cleanup();
195 }
196 });
176197});
apps/web/src/components/chat/ClaudeTraitsPicker.tsx+78-63
......@@ -1,7 +1,7 @@
11import {
2 ProviderKind,
23 type ClaudeCodeEffort,
34 type ClaudeModelOptions,
4 type ProviderModelOptions,
55 type ThreadId,
66} from "@t3tools/contracts";
77import {
......@@ -15,7 +15,7 @@ import {
1515 supportsClaudeUltrathinkKeyword,
1616 isClaudeUltrathinkPrompt,
1717} from "@t3tools/shared/model";
18import { memo, useState } from "react";
18import { memo, useCallback, useState } from "react";
1919import { ChevronDownIcon } from "lucide-react";
2020import { Button } from "../ui/button";
2121import {
......@@ -29,6 +29,8 @@ import {
2929} from "../ui/menu";
3030import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore";
3131
32const PROVIDER = "claudeAgent" as const satisfies ProviderKind;
33
3234const CLAUDE_EFFORT_LABELS: Record<ClaudeCodeEffort, string> = {
3335 low: "Low",
3436 medium: "Medium",
......@@ -51,12 +53,12 @@ function getSelectedClaudeTraits(
5153 ultrathinkPromptControlled: boolean;
5254 supportsFastMode: boolean;
5355} {
54 const options = getReasoningEffortOptions("claudeAgent", model);
55 const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent") as Exclude<
56 const options = getReasoningEffortOptions(PROVIDER, model);
57 const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER) as Exclude<
5658 ClaudeCodeEffort,
5759 "ultrathink"
5860 >;
59 const resolvedEffort = resolveReasoningEffortForProvider("claudeAgent", modelOptions?.effort);
61 const resolvedEffort = resolveReasoningEffortForProvider(PROVIDER, modelOptions?.effort);
6062 const effort =
6163 resolvedEffort && resolvedEffort !== "ultrathink" && options.includes(resolvedEffort)
6264 ? resolvedEffort
......@@ -78,15 +80,21 @@ function getSelectedClaudeTraits(
7880 };
7981}
8082
81function ClaudeTraitsMenuContentImpl(props: {
83interface ClaudeTraitsMenuContentProps {
8284 threadId: ThreadId;
8385 model: string | null | undefined;
8486 onPromptChange: (prompt: string) => void;
85}) {
86 const draft = useComposerThreadDraft(props.threadId);
87}
88
89export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl({
90 threadId,
91 model,
92 onPromptChange,
93}: ClaudeTraitsMenuContentProps) {
94 const draft = useComposerThreadDraft(threadId);
8795 const prompt = draft.prompt;
88 const modelOptions = draft.modelOptions?.claudeAgent;
89 const setModelOptions = useComposerDraftStore((store) => store.setModelOptions);
96 const modelOptions = draft.modelOptions?.[PROVIDER];
97 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);
9098 const {
9199 effort,
92100 thinkingEnabled,
......@@ -94,19 +102,44 @@ function ClaudeTraitsMenuContentImpl(props: {
94102 options,
95103 ultrathinkPromptControlled,
96104 supportsFastMode,
97 } = getSelectedClaudeTraits(props.model, prompt, modelOptions);
98 const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent");
105 } = getSelectedClaudeTraits(model, prompt, modelOptions);
106 const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER);
99107
100 const setClaudeModelOptions = (nextClaudeModelOptions: ClaudeModelOptions | undefined) => {
101 const { claudeAgent: _discardedClaude, ...otherProviderModelOptions } =
102 draft.modelOptions ?? {};
103 const nextProviderModelOptions: ProviderModelOptions | undefined = nextClaudeModelOptions
104 ? { ...otherProviderModelOptions, claudeAgent: nextClaudeModelOptions }
105 : Object.keys(otherProviderModelOptions).length > 0
106 ? otherProviderModelOptions
107 : undefined;
108 setModelOptions(props.threadId, nextProviderModelOptions);
109 };
108 const handleEffortChange = useCallback(
109 (value: ClaudeCodeEffort) => {
110 if (ultrathinkPromptControlled) return;
111 if (!value) return;
112 const nextEffort = options.find((option) => option === value);
113 if (!nextEffort) return;
114 if (nextEffort === "ultrathink") {
115 const nextPrompt =
116 prompt.trim().length === 0
117 ? ULTRATHINK_PROMPT_PREFIX
118 : applyClaudePromptEffortPrefix(prompt, "ultrathink");
119 onPromptChange(nextPrompt);
120 return;
121 }
122 setProviderModelOptions(
123 threadId,
124 PROVIDER,
125 normalizeClaudeModelOptions(model, {
126 ...modelOptions,
127 effort: nextEffort,
128 }),
129 { persistSticky: true },
130 );
131 },
132 [
133 ultrathinkPromptControlled,
134 model,
135 modelOptions,
136 onPromptChange,
137 threadId,
138 setProviderModelOptions,
139 options,
140 prompt,
141 ],
142 );
110143
111144 if (effort === null && thinkingEnabled === null) {
112145 return null;
......@@ -123,29 +156,7 @@ function ClaudeTraitsMenuContentImpl(props: {
123156 Remove Ultrathink from the prompt to change effort.
124157 </div>
125158 ) : null}
126 <MenuRadioGroup
127 value={effort}
128 onValueChange={(value) => {
129 if (ultrathinkPromptControlled) return;
130 if (!value) return;
131 const nextEffort = options.find((option) => option === value);
132 if (!nextEffort) return;
133 if (nextEffort === "ultrathink") {
134 const nextPrompt =
135 prompt.trim().length === 0
136 ? ULTRATHINK_PROMPT_PREFIX
137 : applyClaudePromptEffortPrefix(prompt, "ultrathink");
138 props.onPromptChange(nextPrompt);
139 return;
140 }
141 setClaudeModelOptions(
142 normalizeClaudeModelOptions(props.model, {
143 ...modelOptions,
144 effort: nextEffort,
145 }),
146 );
147 }}
148 >
159 <MenuRadioGroup value={effort} onValueChange={handleEffortChange}>
149160 {options.map((option) => (
150161 <MenuRadioItem key={option} value={option} disabled={ultrathinkPromptControlled}>
151162 {CLAUDE_EFFORT_LABELS[option]}
......@@ -161,11 +172,14 @@ function ClaudeTraitsMenuContentImpl(props: {
161172 <MenuRadioGroup
162173 value={thinkingEnabled ? "on" : "off"}
163174 onValueChange={(value) => {
164 setClaudeModelOptions(
165 normalizeClaudeModelOptions(props.model, {
175 setProviderModelOptions(
176 threadId,
177 PROVIDER,
178 normalizeClaudeModelOptions(model, {
166179 ...modelOptions,
167180 thinking: value === "on",
168181 }),
182 { persistSticky: true },
169183 );
170184 }}
171185 >
......@@ -182,11 +196,14 @@ function ClaudeTraitsMenuContentImpl(props: {
182196 <MenuRadioGroup
183197 value={fastModeEnabled ? "on" : "off"}
184198 onValueChange={(value) => {
185 setClaudeModelOptions(
186 normalizeClaudeModelOptions(props.model, {
199 setProviderModelOptions(
200 threadId,
201 PROVIDER,
202 normalizeClaudeModelOptions(model, {
187203 ...modelOptions,
188204 fastMode: value === "on",
189205 }),
206 { persistSticky: true },
190207 );
191208 }}
192209 >
......@@ -198,21 +215,19 @@ function ClaudeTraitsMenuContentImpl(props: {
198215 ) : null}
199216 </>
200217 );
201}
202
203export const ClaudeTraitsMenuContent = memo(ClaudeTraitsMenuContentImpl);
218});
204219
205export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker(props: {
206 threadId: ThreadId;
207 model: string | null | undefined;
208 onPromptChange: (prompt: string) => void;
209}) {
220export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker({
221 threadId,
222 model,
223 onPromptChange,
224}: ClaudeTraitsMenuContentProps) {
210225 const [isMenuOpen, setIsMenuOpen] = useState(false);
211 const draft = useComposerThreadDraft(props.threadId);
226 const draft = useComposerThreadDraft(threadId);
212227 const prompt = draft.prompt;
213 const modelOptions = draft.modelOptions?.claudeAgent;
228 const modelOptions = draft.modelOptions?.[PROVIDER];
214229 const { effort, thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, supportsFastMode } =
215 getSelectedClaudeTraits(props.model, prompt, modelOptions);
230 getSelectedClaudeTraits(model, prompt, modelOptions);
216231 const triggerLabel = [
217232 ultrathinkPromptControlled
218233 ? "Ultrathink"
......@@ -247,9 +262,9 @@ export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker(props: {
247262 </MenuTrigger>
248263 <MenuPopup align="start">
249264 <ClaudeTraitsMenuContent
250 threadId={props.threadId}
251 model={props.model}
252 onPromptChange={props.onPromptChange}
265 threadId={threadId}
266 model={model}
267 onPromptChange={onPromptChange}
253268 />
254269 </MenuPopup>
255270 </Menu>
apps/web/src/components/chat/CodexTraitsPicker.browser.tsx+19
......@@ -116,6 +116,25 @@ describe("CodexTraitsPicker", () => {
116116 }
117117 });
118118
119 it("persists sticky codex model options when traits change", async () => {
120 const mounted = await mountPicker({
121 fastModeEnabled: false,
122 });
123
124 try {
125 await page.getByRole("button").click();
126 await page.getByRole("menuitemradio", { name: "on" }).click();
127
128 expect(useComposerDraftStore.getState().stickyModelOptions).toMatchObject({
129 codex: {
130 fastMode: true,
131 },
132 });
133 } finally {
134 await mounted.cleanup();
135 }
136 });
137
119138 it("hydrates legacy codex persisted state into modelOptions through the picker", async () => {
120139 const threadId = ThreadId.makeUnsafe("thread-codex-legacy");
121140 localStorage.setItem(
apps/web/src/components/chat/CodexTraitsPicker.tsx+17-19
......@@ -1,7 +1,7 @@
11import type {
22 CodexModelOptions,
33 CodexReasoningEffort,
4 ProviderModelOptions,
4 ProviderKind,
55 ThreadId,
66} from "@t3tools/contracts";
77import {
......@@ -24,6 +24,8 @@ import {
2424 MenuTrigger,
2525} from "../ui/menu";
2626
27const PROVIDER = "codex" as const satisfies ProviderKind;
28
2729const CODEX_REASONING_LABELS: Record<CodexReasoningEffort, string> = {
2830 low: "Low",
2931 medium: "Medium",
......@@ -35,10 +37,10 @@ function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefin
3537 effort: CodexReasoningEffort;
3638 fastModeEnabled: boolean;
3739} {
38 const defaultReasoningEffort = getDefaultReasoningEffort("codex");
40 const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER);
3941 return {
4042 effort:
41 resolveReasoningEffortForProvider("codex", modelOptions?.reasoningEffort) ??
43 resolveReasoningEffortForProvider(PROVIDER, modelOptions?.reasoningEffort) ??
4244 defaultReasoningEffort,
4345 fastModeEnabled: modelOptions?.fastMode === true,
4446 };
......@@ -46,22 +48,12 @@ function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefin
4648
4749function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) {
4850 const draft = useComposerThreadDraft(props.threadId);
49 const modelOptions = draft.modelOptions?.codex;
50 const setModelOptions = useComposerDraftStore((store) => store.setModelOptions);
51 const options = getReasoningEffortOptions("codex");
52 const defaultReasoningEffort = getDefaultReasoningEffort("codex");
51 const modelOptions = draft.modelOptions?.[PROVIDER];
52 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);
53 const options = getReasoningEffortOptions(PROVIDER);
54 const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER);
5355 const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions);
5456
55 const setCodexModelOptions = (nextCodexModelOptions: CodexModelOptions | undefined) => {
56 const { codex: _discardedCodex, ...otherProviderModelOptions } = draft.modelOptions ?? {};
57 const nextProviderModelOptions: ProviderModelOptions | undefined = nextCodexModelOptions
58 ? { ...otherProviderModelOptions, codex: nextCodexModelOptions }
59 : Object.keys(otherProviderModelOptions).length > 0
60 ? otherProviderModelOptions
61 : undefined;
62 setModelOptions(props.threadId, nextProviderModelOptions);
63 };
64
6557 return (
6658 <>
6759 <MenuGroup>
......@@ -72,11 +64,14 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) {
7264 if (!value) return;
7365 const nextEffort = options.find((option) => option === value);
7466 if (!nextEffort) return;
75 setCodexModelOptions(
67 setProviderModelOptions(
68 props.threadId,
69 PROVIDER,
7670 normalizeCodexModelOptions({
7771 ...modelOptions,
7872 reasoningEffort: nextEffort,
7973 }),
74 { persistSticky: true },
8075 );
8176 }}
8277 >
......@@ -94,11 +89,14 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) {
9489 <MenuRadioGroup
9590 value={fastModeEnabled ? "on" : "off"}
9691 onValueChange={(value) => {
97 setCodexModelOptions(
92 setProviderModelOptions(
93 props.threadId,
94 PROVIDER,
9895 normalizeCodexModelOptions({
9996 ...modelOptions,
10097 fastMode: value === "on",
10198 }),
99 { persistSticky: true },
102100 );
103101 }}
104102 >
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+20
......@@ -91,4 +91,24 @@ describe("ProviderModelPicker", () => {
9191 await mounted.cleanup();
9292 }
9393 });
94
95 it("dispatches the canonical slug when a model is selected", async () => {
96 const mounted = await mountPicker({
97 provider: "claudeAgent",
98 model: "claude-opus-4-6",
99 lockedProvider: "claudeAgent",
100 });
101
102 try {
103 await page.getByRole("button").click();
104 await page.getByRole("menuitemradio", { name: "Claude Sonnet 4.6" }).click();
105
106 expect(mounted.onProviderModelChange).toHaveBeenCalledWith(
107 "claudeAgent",
108 "claude-sonnet-4-6",
109 );
110 } finally {
111 await mounted.cleanup();
112 }
113 });
94114});
apps/web/src/components/chat/ProviderModelPicker.tsx+4-39
......@@ -1,5 +1,5 @@
11import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";
2import { normalizeModelSlug } from "@t3tools/shared/model";
2import { resolveSelectableModel } from "@t3tools/shared/model";
33import { memo, useState } from "react";
44import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic";
55import { ChevronDownIcon } from "lucide-react";
......@@ -28,39 +28,6 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o
2828 return option.available;
2929}
3030
31function resolveModelForProviderPicker(
32 provider: ProviderKind,
33 value: string,
34 options: ReadonlyArray<{ slug: string; name: string }>,
35): ModelSlug | null {
36 const trimmedValue = value.trim();
37 if (!trimmedValue) {
38 return null;
39 }
40
41 const direct = options.find((option) => option.slug === trimmedValue);
42 if (direct) {
43 return direct.slug;
44 }
45
46 const byName = options.find((option) => option.name.toLowerCase() === trimmedValue.toLowerCase());
47 if (byName) {
48 return byName.slug;
49 }
50
51 const normalized = normalizeModelSlug(trimmedValue, provider);
52 if (!normalized) {
53 return null;
54 }
55
56 const resolved = options.find((option) => option.slug === normalized);
57 if (resolved) {
58 return resolved.slug;
59 }
60
61 return null;
62}
63
6431const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {
6532 codex: OpenAI,
6633 claudeAgent: ClaudeAI,
......@@ -86,7 +53,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
8653 model: ModelSlug;
8754 lockedProvider: ProviderKind | null;
8855 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
89 ultrathinkActive?: boolean;
56 activeProviderIconClassName?: string;
9057 compact?: boolean;
9158 disabled?: boolean;
9259 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
......@@ -100,7 +67,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
10067 const handleModelChange = (provider: ProviderKind, value: string) => {
10168 if (props.disabled) return;
10269 if (!value) return;
103 const resolvedModel = resolveModelForProviderPicker(
70 const resolvedModel = resolveSelectableModel(
10471 provider,
10572 value,
10673 props.modelOptionsByProvider[provider],
......@@ -145,9 +112,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
145112 className={cn(
146113 "size-4 shrink-0",
147114 providerIconClassName(activeProvider, "text-muted-foreground/70"),
148 activeProvider === "claudeAgent" && props.ultrathinkActive
149 ? "ultrathink-chroma"
150 : undefined,
115 props.activeProviderIconClassName,
151116 )}
152117 />
153118 <span className="min-w-0 flex-1 truncate">{selectedModelLabel}</span>
apps/web/src/components/chat/composerProviderRegistry.test.tsx created+149
......@@ -0,0 +1,149 @@
1import { describe, expect, it } from "vitest";
2import { getComposerProviderState } from "./composerProviderRegistry";
3
4describe("getComposerProviderState", () => {
5 it("returns codex defaults when no codex draft options exist", () => {
6 const state = getComposerProviderState({
7 provider: "codex",
8 model: "gpt-5.4",
9 prompt: "",
10 modelOptions: undefined,
11 });
12
13 expect(state).toEqual({
14 provider: "codex",
15 promptEffort: "high",
16 modelOptionsForDispatch: undefined,
17 });
18 });
19
20 it("normalizes codex dispatch options while preserving the selected effort", () => {
21 const state = getComposerProviderState({
22 provider: "codex",
23 model: "gpt-5.4",
24 prompt: "",
25 modelOptions: {
26 codex: {
27 reasoningEffort: "low",
28 fastMode: true,
29 },
30 },
31 });
32
33 expect(state).toEqual({
34 provider: "codex",
35 promptEffort: "low",
36 modelOptionsForDispatch: {
37 codex: {
38 reasoningEffort: "low",
39 fastMode: true,
40 },
41 },
42 });
43 });
44
45 it("returns Claude defaults for effort-capable models", () => {
46 const state = getComposerProviderState({
47 provider: "claudeAgent",
48 model: "claude-sonnet-4-6",
49 prompt: "",
50 modelOptions: undefined,
51 });
52
53 expect(state).toEqual({
54 provider: "claudeAgent",
55 promptEffort: "high",
56 modelOptionsForDispatch: undefined,
57 });
58 });
59
60 it("tracks Claude ultrathink from the prompt without changing dispatch effort", () => {
61 const state = getComposerProviderState({
62 provider: "claudeAgent",
63 model: "claude-sonnet-4-6",
64 prompt: "Ultrathink:\nInvestigate this failure",
65 modelOptions: {
66 claudeAgent: {
67 effort: "medium",
68 },
69 },
70 });
71
72 expect(state).toEqual({
73 provider: "claudeAgent",
74 promptEffort: "medium",
75 modelOptionsForDispatch: {
76 claudeAgent: {
77 effort: "medium",
78 },
79 },
80 composerFrameClassName: "ultrathink-frame",
81 composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]",
82 modelPickerIconClassName: "ultrathink-chroma",
83 });
84 });
85
86 it("drops unsupported Claude effort options for models without effort controls", () => {
87 const state = getComposerProviderState({
88 provider: "claudeAgent",
89 model: "claude-haiku-4-5",
90 prompt: "",
91 modelOptions: {
92 claudeAgent: {
93 effort: "max",
94 thinking: false,
95 },
96 },
97 });
98
99 expect(state).toEqual({
100 provider: "claudeAgent",
101 promptEffort: null,
102 modelOptionsForDispatch: {
103 claudeAgent: {
104 thinking: false,
105 },
106 },
107 });
108 });
109
110 it("ignores codex options while resolving Claude state", () => {
111 const state = getComposerProviderState({
112 provider: "claudeAgent",
113 model: "claude-opus-4-6",
114 prompt: "",
115 modelOptions: {
116 codex: {
117 reasoningEffort: "low",
118 fastMode: true,
119 },
120 },
121 });
122
123 expect(state).toEqual({
124 provider: "claudeAgent",
125 promptEffort: "high",
126 modelOptionsForDispatch: undefined,
127 });
128 });
129
130 it("ignores Claude options while resolving codex state", () => {
131 const state = getComposerProviderState({
132 provider: "codex",
133 model: "gpt-5.4",
134 prompt: "Ultrathink:\nThis should not matter",
135 modelOptions: {
136 claudeAgent: {
137 effort: "max",
138 fastMode: true,
139 },
140 },
141 });
142
143 expect(state).toEqual({
144 provider: "codex",
145 promptEffort: "high",
146 modelOptionsForDispatch: undefined,
147 });
148 });
149});
apps/web/src/components/chat/composerProviderRegistry.tsx created+137
......@@ -0,0 +1,137 @@
1import {
2 type ModelSlug,
3 type ProviderKind,
4 type ProviderModelOptions,
5 type ThreadId,
6} from "@t3tools/contracts";
7import {
8 getDefaultReasoningEffort,
9 getReasoningEffortOptions,
10 isClaudeUltrathinkPrompt,
11 normalizeClaudeModelOptions,
12 normalizeCodexModelOptions,
13 resolveReasoningEffortForProvider,
14 supportsClaudeUltrathinkKeyword,
15} from "@t3tools/shared/model";
16import type { ReactNode } from "react";
17import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./ClaudeTraitsPicker";
18import { CodexTraitsMenuContent, CodexTraitsPicker } from "./CodexTraitsPicker";
19
20export type ComposerProviderStateInput = {
21 provider: ProviderKind;
22 model: ModelSlug;
23 prompt: string;
24 modelOptions: ProviderModelOptions | null | undefined;
25};
26
27export type ComposerProviderState = {
28 provider: ProviderKind;
29 promptEffort: string | null;
30 modelOptionsForDispatch: ProviderModelOptions | undefined;
31 composerFrameClassName?: string;
32 composerSurfaceClassName?: string;
33 modelPickerIconClassName?: string;
34};
35
36type ProviderRegistryEntry = {
37 getState: (input: ComposerProviderStateInput) => ComposerProviderState;
38 renderTraitsMenuContent: (input: {
39 threadId: ThreadId;
40 model: ModelSlug;
41 onPromptChange: (prompt: string) => void;
42 }) => ReactNode;
43 renderTraitsPicker: (input: {
44 threadId: ThreadId;
45 model: ModelSlug;
46 onPromptChange: (prompt: string) => void;
47 }) => ReactNode;
48};
49
50const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
51 codex: {
52 getState: ({ modelOptions }) => {
53 const promptEffort =
54 resolveReasoningEffortForProvider("codex", modelOptions?.codex?.reasoningEffort) ??
55 getDefaultReasoningEffort("codex");
56 const normalizedCodexOptions = normalizeCodexModelOptions(modelOptions?.codex);
57
58 return {
59 provider: "codex",
60 promptEffort,
61 modelOptionsForDispatch: normalizedCodexOptions
62 ? { codex: normalizedCodexOptions }
63 : undefined,
64 };
65 },
66 renderTraitsMenuContent: ({ threadId }) => <CodexTraitsMenuContent threadId={threadId} />,
67 renderTraitsPicker: ({ threadId }) => <CodexTraitsPicker threadId={threadId} />,
68 },
69 claudeAgent: {
70 getState: ({ model, prompt, modelOptions }) => {
71 const reasoningOptions = getReasoningEffortOptions("claudeAgent", model);
72 const draftEffort = resolveReasoningEffortForProvider(
73 "claudeAgent",
74 modelOptions?.claudeAgent?.effort,
75 );
76 const defaultEffort = getDefaultReasoningEffort("claudeAgent");
77 const promptEffort =
78 draftEffort && draftEffort !== "ultrathink" && reasoningOptions.includes(draftEffort)
79 ? draftEffort
80 : reasoningOptions.includes(defaultEffort)
81 ? defaultEffort
82 : null;
83 const normalizedClaudeOptions = normalizeClaudeModelOptions(model, modelOptions?.claudeAgent);
84 const ultrathinkActive =
85 supportsClaudeUltrathinkKeyword(model) && isClaudeUltrathinkPrompt(prompt);
86
87 return {
88 provider: "claudeAgent",
89 promptEffort,
90 modelOptionsForDispatch: normalizedClaudeOptions
91 ? { claudeAgent: normalizedClaudeOptions }
92 : undefined,
93 ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}),
94 ...(ultrathinkActive
95 ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" }
96 : {}),
97 ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}),
98 };
99 },
100 renderTraitsMenuContent: ({ threadId, model, onPromptChange }) => (
101 <ClaudeTraitsMenuContent threadId={threadId} model={model} onPromptChange={onPromptChange} />
102 ),
103 renderTraitsPicker: ({ threadId, model, onPromptChange }) => (
104 <ClaudeTraitsPicker threadId={threadId} model={model} onPromptChange={onPromptChange} />
105 ),
106 },
107};
108
109export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState {
110 return composerProviderRegistry[input.provider].getState(input);
111}
112
113export function renderProviderTraitsMenuContent(input: {
114 provider: ProviderKind;
115 threadId: ThreadId;
116 model: ModelSlug;
117 onPromptChange: (prompt: string) => void;
118}): ReactNode {
119 return composerProviderRegistry[input.provider].renderTraitsMenuContent({
120 threadId: input.threadId,
121 model: input.model,
122 onPromptChange: input.onPromptChange,
123 });
124}
125
126export function renderProviderTraitsPicker(input: {
127 provider: ProviderKind;
128 threadId: ThreadId;
129 model: ModelSlug;
130 onPromptChange: (prompt: string) => void;
131}): ReactNode {
132 return composerProviderRegistry[input.provider].renderTraitsPicker({
133 threadId: input.threadId,
134 model: input.model,
135 onPromptChange: input.onPromptChange,
136 });
137}
apps/web/src/composerDraftStore.test.ts+209-35
......@@ -62,17 +62,23 @@ function makeTerminalContext(input: {
6262 };
6363}
6464
65function resetComposerDraftStore() {
66 useComposerDraftStore.setState({
67 draftsByThreadId: {},
68 draftThreadsByThreadId: {},
69 projectDraftThreadIdByProjectId: {},
70 stickyModel: null,
71 stickyModelOptions: {},
72 });
73}
74
6575describe("composerDraftStore addImages", () => {
6676 const threadId = ThreadId.makeUnsafe("thread-dedupe");
6777 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
6878 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
6979
7080 beforeEach(() => {
71 useComposerDraftStore.setState({
72 draftsByThreadId: {},
73 draftThreadsByThreadId: {},
74 projectDraftThreadIdByProjectId: {},
75 });
81 resetComposerDraftStore();
7682 originalRevokeObjectUrl = URL.revokeObjectURL;
7783 revokeSpy = vi.fn();
7884 URL.revokeObjectURL = revokeSpy;
......@@ -157,11 +163,7 @@ describe("composerDraftStore clearComposerContent", () => {
157163 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
158164
159165 beforeEach(() => {
160 useComposerDraftStore.setState({
161 draftsByThreadId: {},
162 draftThreadsByThreadId: {},
163 projectDraftThreadIdByProjectId: {},
164 });
166 resetComposerDraftStore();
165167 originalRevokeObjectUrl = URL.revokeObjectURL;
166168 revokeSpy = vi.fn();
167169 URL.revokeObjectURL = revokeSpy;
......@@ -423,11 +425,7 @@ describe("composerDraftStore project draft thread mapping", () => {
423425 const otherThreadId = ThreadId.makeUnsafe("thread-b");
424426
425427 beforeEach(() => {
426 useComposerDraftStore.setState({
427 draftsByThreadId: {},
428 draftThreadsByThreadId: {},
429 projectDraftThreadIdByProjectId: {},
430 });
428 resetComposerDraftStore();
431429 });
432430
433431 it("stores and reads project draft thread ids via actions", () => {
......@@ -599,11 +597,7 @@ describe("composerDraftStore modelOptions", () => {
599597 const threadId = ThreadId.makeUnsafe("thread-model-options");
600598
601599 beforeEach(() => {
602 useComposerDraftStore.setState({
603 draftsByThreadId: {},
604 draftThreadsByThreadId: {},
605 projectDraftThreadIdByProjectId: {},
606 });
600 resetComposerDraftStore();
607601 });
608602
609603 it("stores provider-scoped model options in the draft", () => {
......@@ -642,17 +636,165 @@ describe("composerDraftStore modelOptions", () => {
642636
643637 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
644638 });
639
640 it("replaces only the targeted provider model options", () => {
641 const store = useComposerDraftStore.getState();
642
643 store.setModelOptions(threadId, {
644 codex: {
645 reasoningEffort: "xhigh",
646 },
647 claudeAgent: {
648 effort: "max",
649 fastMode: true,
650 },
651 });
652
653 store.setProviderModelOptions(
654 threadId,
655 "claudeAgent",
656 {
657 thinking: false,
658 },
659 { persistSticky: true },
660 );
661
662 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({
663 codex: {
664 reasoningEffort: "xhigh",
665 },
666 claudeAgent: {
667 thinking: false,
668 },
669 });
670 expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({
671 codex: {
672 reasoningEffort: "xhigh",
673 },
674 claudeAgent: {
675 thinking: false,
676 },
677 });
678 });
679
680 it("removes only the targeted provider entry when next options normalize empty", () => {
681 const store = useComposerDraftStore.getState();
682
683 store.setModelOptions(threadId, {
684 codex: {
685 reasoningEffort: "xhigh",
686 },
687 claudeAgent: {
688 effort: "max",
689 },
690 });
691
692 store.setProviderModelOptions(threadId, "claudeAgent", {
693 thinking: true,
694 });
695
696 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({
697 codex: {
698 reasoningEffort: "xhigh",
699 },
700 });
701 expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({});
702 });
703
704 it("removes model options entirely when the last provider entry normalizes empty", () => {
705 const store = useComposerDraftStore.getState();
706
707 store.setModelOptions(threadId, {
708 codex: {
709 fastMode: true,
710 },
711 });
712
713 store.setProviderModelOptions(threadId, "codex", {
714 reasoningEffort: "high",
715 fastMode: false,
716 });
717
718 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
719 });
720
721 it("updates only the draft when sticky persistence is omitted", () => {
722 const store = useComposerDraftStore.getState();
723
724 store.setStickyModelOptions({
725 codex: {
726 fastMode: true,
727 },
728 });
729 store.setModelOptions(threadId, {
730 codex: {
731 fastMode: true,
732 },
733 claudeAgent: {
734 effort: "max",
735 },
736 });
737
738 store.setProviderModelOptions(threadId, "claudeAgent", {
739 thinking: false,
740 });
741
742 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({
743 codex: {
744 fastMode: true,
745 },
746 claudeAgent: {
747 thinking: false,
748 },
749 });
750 expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({
751 codex: {
752 fastMode: true,
753 },
754 });
755 });
756
757 it("updates only the draft when sticky persistence is disabled", () => {
758 const store = useComposerDraftStore.getState();
759
760 store.setStickyModelOptions({
761 claudeAgent: {
762 effort: "max",
763 },
764 });
765 store.setModelOptions(threadId, {
766 claudeAgent: {
767 effort: "max",
768 },
769 });
770
771 store.setProviderModelOptions(
772 threadId,
773 "claudeAgent",
774 {
775 thinking: false,
776 },
777 { persistSticky: false },
778 );
779
780 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({
781 claudeAgent: {
782 thinking: false,
783 },
784 });
785 expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({
786 claudeAgent: {
787 effort: "max",
788 },
789 });
790 });
645791});
646792
647793describe("composerDraftStore setModel", () => {
648794 const threadId = ThreadId.makeUnsafe("thread-model");
649795
650796 beforeEach(() => {
651 useComposerDraftStore.setState({
652 draftsByThreadId: {},
653 draftThreadsByThreadId: {},
654 projectDraftThreadIdByProjectId: {},
655 });
797 resetComposerDraftStore();
656798 });
657799
658800 it("keeps explicit DEFAULT_MODEL overrides instead of coercing to null", () => {
......@@ -666,15 +808,51 @@ describe("composerDraftStore setModel", () => {
666808 });
667809});
668810
811describe("composerDraftStore sticky composer settings", () => {
812 beforeEach(() => {
813 resetComposerDraftStore();
814 });
815
816 it("stores sticky model and codex model options", () => {
817 const store = useComposerDraftStore.getState();
818
819 store.setStickyModel("gpt-5.3-codex");
820 store.setStickyModelOptions({
821 codex: {
822 reasoningEffort: "medium",
823 fastMode: true,
824 },
825 });
826
827 expect(useComposerDraftStore.getState()).toMatchObject({
828 stickyModel: "gpt-5.3-codex",
829 stickyModelOptions: {
830 codex: {
831 reasoningEffort: "medium",
832 fastMode: true,
833 },
834 },
835 });
836 });
837
838 it("normalizes empty sticky model options", () => {
839 const store = useComposerDraftStore.getState();
840
841 store.setStickyModelOptions({
842 codex: {
843 fastMode: false,
844 },
845 });
846
847 expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({});
848 });
849});
850
669851describe("composerDraftStore setProvider", () => {
670852 const threadId = ThreadId.makeUnsafe("thread-provider");
671853
672854 beforeEach(() => {
673 useComposerDraftStore.setState({
674 draftsByThreadId: {},
675 draftThreadsByThreadId: {},
676 projectDraftThreadIdByProjectId: {},
677 });
855 resetComposerDraftStore();
678856 });
679857
680858 it("persists provider-only selection even when prompt/model are empty", () => {
......@@ -699,11 +877,7 @@ describe("composerDraftStore runtime and interaction settings", () => {
699877 const threadId = ThreadId.makeUnsafe("thread-settings");
700878
701879 beforeEach(() => {
702 useComposerDraftStore.setState({
703 draftsByThreadId: {},
704 draftThreadsByThreadId: {},
705 projectDraftThreadIdByProjectId: {},
706 });
880 resetComposerDraftStore();
707881 });
708882
709883 it("stores runtime mode overrides in the composer draft", () => {
apps/web/src/composerDraftStore.ts+133-3
......@@ -11,6 +11,7 @@ import {
1111 ThreadId,
1212} from "@t3tools/contracts";
1313import * as Schema from "effect/Schema";
14import * as Equal from "effect/Equal";
1415import { DeepMutable } from "effect/Types";
1516import { normalizeModelSlug } from "@t3tools/shared/model";
1617import { getLocalStorageItem } from "./hooks/useLocalStorage";
......@@ -104,6 +105,8 @@ const PersistedComposerDraftStoreState = Schema.Struct({
104105 draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState),
105106 draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState),
106107 projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId),
108 stickyModel: Schema.NullOr(Schema.String),
109 stickyModelOptions: ProviderModelOptions,
107110});
108111type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type;
109112
......@@ -143,6 +146,8 @@ interface ComposerDraftStoreState {
143146 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;
144147 draftThreadsByThreadId: Record<ThreadId, DraftThreadState>;
145148 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
149 stickyModel: string | null;
150 stickyModelOptions: ProviderModelOptions;
146151 getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null;
147152 getDraftThread: (threadId: ThreadId) => DraftThreadState | null;
148153 setProjectDraftThreadId: (
......@@ -172,6 +177,8 @@ interface ComposerDraftStoreState {
172177 clearProjectDraftThreadId: (projectId: ProjectId) => void;
173178 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;
174179 clearDraftThread: (threadId: ThreadId) => void;
180 setStickyModel: (model: string | null | undefined) => void;
181 setStickyModelOptions: (modelOptions: ProviderModelOptions | null | undefined) => void;
175182 setPrompt: (threadId: ThreadId, prompt: string) => void;
176183 setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void;
177184 setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void;
......@@ -180,6 +187,14 @@ interface ComposerDraftStoreState {
180187 threadId: ThreadId,
181188 modelOptions: ProviderModelOptions | null | undefined,
182189 ) => void;
190 setProviderModelOptions: (
191 threadId: ThreadId,
192 provider: ProviderKind,
193 nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined,
194 options?: {
195 persistSticky?: boolean;
196 },
197 ) => void;
183198 setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void;
184199 setInteractionMode: (
185200 threadId: ThreadId,
......@@ -207,11 +222,15 @@ interface ComposerDraftStoreState {
207222 clearThreadDraft: (threadId: ThreadId) => void;
208223}
209224
210const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = {
225const EMPTY_PROVIDER_MODEL_OPTIONS = Object.freeze<ProviderModelOptions>({});
226
227const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({
211228 draftsByThreadId: {},
212229 draftThreadsByThreadId: {},
213230 projectDraftThreadIdByProjectId: {},
214};
231 stickyModel: null,
232 stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS,
233});
215234
216235const EMPTY_IMAGES: ComposerImageAttachment[] = [];
217236const EMPTY_IDS: string[] = [];
......@@ -397,6 +416,24 @@ function normalizeProviderModelOptions(
397416 };
398417}
399418
419function replaceProviderModelOptions(
420 currentModelOptions: ProviderModelOptions | null | undefined,
421 provider: ProviderKind,
422 nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined,
423): ProviderModelOptions | null {
424 const { [provider]: _discardedProviderModelOptions, ...otherProviderModelOptions } =
425 currentModelOptions ?? {};
426 const normalizedNextProviderOptions = normalizeProviderModelOptions(
427 { [provider]: nextProviderOptions },
428 provider,
429 );
430
431 return normalizeProviderModelOptions({
432 ...otherProviderModelOptions,
433 ...(normalizedNextProviderOptions ? normalizedNextProviderOptions : {}),
434 });
435}
436
400437function revokeObjectPreviewUrl(previewUrl: string): void {
401438 if (typeof URL === "undefined") {
402439 return;
......@@ -674,6 +711,12 @@ function migratePersistedComposerDraftStoreState(
674711 const rawDraftMap = candidate.draftsByThreadId;
675712 const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId;
676713 const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId;
714 const stickyModel =
715 typeof candidate.stickyModel === "string"
716 ? (normalizeModelSlug(candidate.stickyModel, "codex") ?? null)
717 : null;
718 const stickyModelOptions =
719 normalizeProviderModelOptions(candidate.stickyModelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS;
677720 const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =
678721 normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId);
679722 const draftsByThreadId = normalizePersistedDraftsByThreadId(
......@@ -691,6 +734,8 @@ function migratePersistedComposerDraftStoreState(
691734 draftsByThreadId,
692735 draftThreadsByThreadId,
693736 projectDraftThreadIdByProjectId,
737 stickyModel,
738 stickyModelOptions,
694739 };
695740}
696741
......@@ -744,6 +789,8 @@ function partializeComposerDraftStoreState(
744789 draftsByThreadId: persistedDraftsByThreadId,
745790 draftThreadsByThreadId: state.draftThreadsByThreadId,
746791 projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,
792 stickyModel: state.stickyModel,
793 stickyModelOptions: state.stickyModelOptions,
747794 };
748795}
749796
......@@ -759,6 +806,13 @@ function normalizeCurrentPersistedComposerDraftStoreState(
759806 normalizedPersistedState.draftThreadsByThreadId,
760807 normalizedPersistedState.projectDraftThreadIdByProjectId,
761808 );
809 const stickyModel =
810 typeof normalizedPersistedState.stickyModel === "string"
811 ? (normalizeModelSlug(normalizedPersistedState.stickyModel, "codex") ?? null)
812 : null;
813 const stickyModelOptions =
814 normalizeProviderModelOptions(normalizedPersistedState.stickyModelOptions) ??
815 EMPTY_PROVIDER_MODEL_OPTIONS;
762816 return {
763817 draftsByThreadId: normalizePersistedDraftsByThreadId(
764818 normalizedPersistedState.draftsByThreadId,
......@@ -767,6 +821,8 @@ function normalizeCurrentPersistedComposerDraftStoreState(
767821 ),
768822 draftThreadsByThreadId,
769823 projectDraftThreadIdByProjectId,
824 stickyModel,
825 stickyModelOptions,
770826 };
771827}
772828
......@@ -870,6 +926,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
870926 draftsByThreadId: {},
871927 draftThreadsByThreadId: {},
872928 projectDraftThreadIdByProjectId: {},
929 stickyModel: null,
930 stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS,
873931 getDraftThreadByProjectId: (projectId) => {
874932 if (projectId.length === 0) {
875933 return null;
......@@ -1105,6 +1163,29 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
11051163 };
11061164 });
11071165 },
1166 setStickyModel: (model) => {
1167 const normalizedModel = normalizeModelSlug(model, "codex") ?? null;
1168 set((state) => {
1169 if (state.stickyModel === normalizedModel) {
1170 return state;
1171 }
1172 return {
1173 stickyModel: normalizedModel,
1174 };
1175 });
1176 },
1177 setStickyModelOptions: (modelOptions) => {
1178 const normalizedModelOptions =
1179 normalizeProviderModelOptions(modelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS;
1180 set((state) => {
1181 if (Equal.equals(state.stickyModelOptions, normalizedModelOptions)) {
1182 return state;
1183 }
1184 return {
1185 stickyModelOptions: normalizedModelOptions,
1186 };
1187 });
1188 },
11081189 setPrompt: (threadId, prompt) => {
11091190 if (threadId.length === 0) {
11101191 return;
......@@ -1214,7 +1295,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
12141295 return state;
12151296 }
12161297 const base = existing ?? createEmptyThreadDraft();
1217 if (JSON.stringify(base.modelOptions) === JSON.stringify(nextModelOptions)) {
1298 if (Equal.equals(base.modelOptions, nextModelOptions)) {
12181299 return state;
12191300 }
12201301 const nextDraft: ComposerThreadDraftState = {
......@@ -1230,6 +1311,53 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
12301311 return { draftsByThreadId: nextDraftsByThreadId };
12311312 });
12321313 },
1314 setProviderModelOptions: (threadId, provider, nextProviderOptions, options) => {
1315 if (threadId.length === 0) {
1316 return;
1317 }
1318 const normalizedProvider = normalizeProviderKind(provider);
1319 if (normalizedProvider === null) {
1320 return;
1321 }
1322 set((state) => {
1323 const existing = state.draftsByThreadId[threadId];
1324 const base = existing ?? createEmptyThreadDraft();
1325 const nextModelOptions = replaceProviderModelOptions(
1326 base.modelOptions,
1327 normalizedProvider,
1328 nextProviderOptions,
1329 );
1330 const nextStickyModelOptions =
1331 options?.persistSticky === true
1332 ? (nextModelOptions ?? EMPTY_PROVIDER_MODEL_OPTIONS)
1333 : state.stickyModelOptions;
1334
1335 if (
1336 Equal.equals(base.modelOptions, nextModelOptions) &&
1337 Equal.equals(state.stickyModelOptions, nextStickyModelOptions)
1338 ) {
1339 return state;
1340 }
1341
1342 const nextDraft: ComposerThreadDraftState = {
1343 ...base,
1344 modelOptions: nextModelOptions,
1345 };
1346 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1347 if (shouldRemoveDraft(nextDraft)) {
1348 delete nextDraftsByThreadId[threadId];
1349 } else {
1350 nextDraftsByThreadId[threadId] = nextDraft;
1351 }
1352
1353 return {
1354 draftsByThreadId: nextDraftsByThreadId,
1355 ...(options?.persistSticky === true
1356 ? { stickyModelOptions: nextStickyModelOptions }
1357 : {}),
1358 };
1359 });
1360 },
12331361 setRuntimeMode: (threadId, runtimeMode) => {
12341362 if (threadId.length === 0) {
12351363 return;
......@@ -1644,6 +1772,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
16441772 draftsByThreadId,
16451773 draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId,
16461774 projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId,
1775 stickyModel: normalizedPersisted.stickyModel,
1776 stickyModelOptions: normalizedPersisted.stickyModelOptions,
16471777 };
16481778 },
16491779 },
apps/web/src/hooks/useHandleNewThread.ts+14-1
......@@ -1,6 +1,7 @@
11import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";
22import { useNavigate, useParams } from "@tanstack/react-router";
33import { useCallback } from "react";
4import { inferProviderForModel } from "@t3tools/shared/model";
45import {
56 type DraftThreadEnvMode,
67 type DraftThreadState,
......@@ -12,6 +13,8 @@ import { useStore } from "../store";
1213export function useHandleNewThread() {
1314 const projects = useStore((store) => store.projects);
1415 const threads = useStore((store) => store.threads);
16 const stickyModel = useComposerDraftStore((store) => store.stickyModel);
17 const stickyModelOptions = useComposerDraftStore((store) => store.stickyModelOptions);
1518 const navigate = useNavigate();
1619 const routeThreadId = useParams({
1720 strict: false,
......@@ -38,6 +41,9 @@ export function useHandleNewThread() {
3841 clearProjectDraftThreadId,
3942 getDraftThread,
4043 getDraftThreadByProjectId,
44 setModel,
45 setModelOptions,
46 setProvider,
4147 setDraftThreadContext,
4248 setProjectDraftThreadId,
4349 } = useComposerDraftStore.getState();
......@@ -96,6 +102,13 @@ export function useHandleNewThread() {
96102 envMode: options?.envMode ?? "local",
97103 runtimeMode: DEFAULT_RUNTIME_MODE,
98104 });
105 if (stickyModel) {
106 setProvider(threadId, inferProviderForModel(stickyModel));
107 setModel(threadId, stickyModel);
108 }
109 if (Object.keys(stickyModelOptions).length > 0) {
110 setModelOptions(threadId, stickyModelOptions);
111 }
99112
100113 await navigate({
101114 to: "/$threadId",
......@@ -103,7 +116,7 @@ export function useHandleNewThread() {
103116 });
104117 })();
105118 },
106 [navigate, routeThreadId],
119 [navigate, routeThreadId, stickyModel, stickyModelOptions],
107120 );
108121
109122 return {
apps/web/src/routes/_chat.settings.tsx+9-60
......@@ -3,7 +3,15 @@ import { useQuery } from "@tanstack/react-query";
33import { useCallback, useState } from "react";
44import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";
55import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
6import { getAppModelOptions, MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
6import {
7 getAppModelOptions,
8 getCustomModelsForProvider,
9 getDefaultCustomModelsForProvider,
10 MAX_CUSTOM_MODEL_LENGTH,
11 MODEL_PROVIDER_SETTINGS,
12 patchCustomModels,
13 useAppSettings,
14} from "../appSettings";
715import { resolveAndPersistPreferredEditor } from "../editorPreferences";
816import { isElectron } from "../env";
917import { useTheme } from "../hooks/useTheme";
......@@ -40,71 +48,12 @@ const THEME_OPTIONS = [
4048 },
4149] as const;
4250
43const MODEL_PROVIDER_SETTINGS: Array<{
44 provider: ProviderKind;
45 title: string;
46 description: string;
47 placeholder: string;
48 example: string;
49}> = [
50 {
51 provider: "codex",
52 title: "Codex",
53 description: "Save additional Codex model slugs for the picker and `/model` command.",
54 placeholder: "your-codex-model-slug",
55 example: "gpt-6.7-codex-ultra-preview",
56 },
57 {
58 provider: "claudeAgent",
59 title: "Claude",
60 description: "Save additional Claude model slugs for the picker and `/model` command.",
61 placeholder: "your-claude-model-slug",
62 example: "claude-sonnet-5-0",
63 },
64] as const;
65
6651const TIMESTAMP_FORMAT_LABELS = {
6752 locale: "System default",
6853 "12-hour": "12-hour",
6954 "24-hour": "24-hour",
7055} as const;
7156
72function getCustomModelsForProvider(
73 settings: ReturnType<typeof useAppSettings>["settings"],
74 provider: ProviderKind,
75) {
76 switch (provider) {
77 case "claudeAgent":
78 return settings.customClaudeModels;
79 case "codex":
80 default:
81 return settings.customCodexModels;
82 }
83}
84
85function getDefaultCustomModelsForProvider(
86 defaults: ReturnType<typeof useAppSettings>["defaults"],
87 provider: ProviderKind,
88) {
89 switch (provider) {
90 case "claudeAgent":
91 return defaults.customClaudeModels;
92 case "codex":
93 default:
94 return defaults.customCodexModels;
95 }
96}
97
98function patchCustomModels(provider: ProviderKind, models: string[]) {
99 switch (provider) {
100 case "claudeAgent":
101 return { customClaudeModels: models };
102 case "codex":
103 default:
104 return { customCodexModels: models };
105 }
106}
107
10857function SettingsRouteView() {
10958 const { theme, setTheme, resolvedTheme } = useTheme();
11059 const { settings, defaults, updateSettings } = useAppSettings();
packages/shared/src/model.test.ts+65
......@@ -21,6 +21,7 @@ import {
2121 normalizeCodexModelOptions,
2222 normalizeModelSlug,
2323 resolveReasoningEffortForProvider,
24 resolveSelectableModel,
2425 resolveModelSlug,
2526 resolveModelSlugForProvider,
2627 supportsClaudeAdaptiveReasoning,
......@@ -94,6 +95,70 @@ describe("resolveModelSlug", () => {
9495 });
9596});
9697
98describe("resolveSelectableModel", () => {
99 it("resolves exact slug matches", () => {
100 expect(
101 resolveSelectableModel("codex", "gpt-5.3-codex", [
102 { slug: "gpt-5.4", name: "GPT-5.4" },
103 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
104 ]),
105 ).toBe("gpt-5.3-codex");
106 });
107
108 it("resolves case-insensitive display-name matches", () => {
109 expect(
110 resolveSelectableModel("codex", "gpt-5.3 codex", [
111 { slug: "gpt-5.4", name: "GPT-5.4" },
112 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
113 ]),
114 ).toBe("gpt-5.3-codex");
115 });
116
117 it("resolves provider-specific aliases after normalization", () => {
118 expect(
119 resolveSelectableModel("claudeAgent", "sonnet", [
120 { slug: "claude-opus-4-6", name: "Claude Opus 4.6" },
121 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
122 ]),
123 ).toBe("claude-sonnet-4-6");
124 });
125
126 it("returns null for empty input", () => {
127 expect(resolveSelectableModel("codex", "", [{ slug: "gpt-5.4", name: "GPT-5.4" }])).toBeNull();
128 expect(
129 resolveSelectableModel("codex", " ", [{ slug: "gpt-5.4", name: "GPT-5.4" }]),
130 ).toBeNull();
131 expect(
132 resolveSelectableModel("codex", null, [{ slug: "gpt-5.4", name: "GPT-5.4" }]),
133 ).toBeNull();
134 });
135
136 it("returns null for unknown values that are not present in options", () => {
137 expect(
138 resolveSelectableModel("codex", "gpt-4.1", [{ slug: "gpt-5.4", name: "GPT-5.4" }]),
139 ).toBeNull();
140 });
141
142 it("does not accept normalized custom-looking slugs unless they exist in options", () => {
143 expect(
144 resolveSelectableModel("codex", "custom/internal-model", [
145 { slug: "gpt-5.4", name: "GPT-5.4" },
146 ]),
147 ).toBeNull();
148 });
149
150 it("respects provider boundaries", () => {
151 expect(
152 resolveSelectableModel("codex", "sonnet", [{ slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }]),
153 ).toBeNull();
154 expect(
155 resolveSelectableModel("claudeAgent", "5.3", [
156 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
157 ]),
158 ).toBeNull();
159 });
160});
161
97162describe("getReasoningEffortOptions", () => {
98163 it("returns codex reasoning options for codex", () => {
99164 expect(getReasoningEffortOptions("codex")).toEqual(REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex);
packages/shared/src/model.ts+38
......@@ -24,6 +24,11 @@ const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6";
2424const CLAUDE_SONNET_4_6_MODEL = "claude-sonnet-4-6";
2525const CLAUDE_HAIKU_4_5_MODEL = "claude-haiku-4-5";
2626
27export interface SelectableModelOption {
28 slug: string;
29 name: string;
30}
31
2732export function getModelOptions(provider: ProviderKind = "codex") {
2833 return MODEL_OPTIONS_BY_PROVIDER[provider];
2934}
......@@ -77,6 +82,39 @@ export function normalizeModelSlug(
7782 return typeof aliased === "string" ? aliased : (trimmed as ModelSlug);
7883}
7984
85export function resolveSelectableModel(
86 provider: ProviderKind,
87 value: string | null | undefined,
88 options: ReadonlyArray<SelectableModelOption>,
89): ModelSlug | null {
90 if (typeof value !== "string") {
91 return null;
92 }
93
94 const trimmed = value.trim();
95 if (!trimmed) {
96 return null;
97 }
98
99 const direct = options.find((option) => option.slug === trimmed);
100 if (direct) {
101 return direct.slug;
102 }
103
104 const byName = options.find((option) => option.name.toLowerCase() === trimmed.toLowerCase());
105 if (byName) {
106 return byName.slug;
107 }
108
109 const normalized = normalizeModelSlug(trimmed, provider);
110 if (!normalized) {
111 return null;
112 }
113
114 const resolved = options.find((option) => option.slug === normalized);
115 return resolved ? resolved.slug : null;
116}
117
80118export function resolveModelSlug(
81119 model: string | null | undefined,
82120 provider: ProviderKind = "codex",