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/...@@ -17,4 +17,5 @@ release/
17apps/web/.playwright17apps/web/.playwright
18apps/web/playwright-report18apps/web/playwright-report
19apps/web/src/components/__screenshots__19apps/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 {...@@ -5,7 +5,13 @@ import {
5 AppSettingsSchema,5 AppSettingsSchema,
6 DEFAULT_TIMESTAMP_FORMAT,6 DEFAULT_TIMESTAMP_FORMAT,
7 getAppModelOptions,7 getAppModelOptions,
8 getCustomModelOptionsByProvider,
9 getCustomModelsByProvider,
10 getCustomModelsForProvider,
11 getDefaultCustomModelsForProvider,
12 MODEL_PROVIDER_SETTINGS,
8 normalizeCustomModelSlugs,13 normalizeCustomModelSlugs,
14 patchCustomModels,
9 resolveAppModelSelection,15 resolveAppModelSelection,
10} from "./appSettings";16} from "./appSettings";
1117
...@@ -66,13 +72,35 @@ describe("getAppModelOptions", () => {...@@ -66,13 +72,35 @@ describe("getAppModelOptions", () => {
6672
67describe("resolveAppModelSelection", () => {73describe("resolveAppModelSelection", () => {
68 it("preserves saved custom model slugs instead of falling back to the default", () => {74 it("preserves saved custom model slugs instead of falling back to the default", () => {
69 expect(resolveAppModelSelection("codex", ["galapagos-alpha"], "galapagos-alpha")).toBe(75 expect(
70 "galapagos-alpha",76 resolveAppModelSelection(
71 );77 "codex",
78 { codex: ["galapagos-alpha"], claudeAgent: [] },
79 "galapagos-alpha",
80 ),
81 ).toBe("galapagos-alpha");
72 });82 });
7383
74 it("falls back to the provider default when no model is selected", () => {84 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");
76 });104 });
77});105});
78106
...@@ -90,6 +118,85 @@ describe("provider-specific custom models", () => {...@@ -90,6 +118,85 @@ describe("provider-specific custom models", () => {
90 });118 });
91});119});
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
93describe("AppSettingsSchema", () => {200describe("AppSettingsSchema", () => {
94 it("fills decoding defaults for persisted settings that predate newer keys", () => {201 it("fills decoding defaults for persisted settings that predate newer keys", () => {
95 const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema));202 const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema));
apps/web/src/appSettings.ts+92-27
...@@ -1,7 +1,12 @@...@@ -1,7 +1,12 @@
1import { useCallback } from "react";1import { useCallback } from "react";
2import { Option, Schema } from "effect";2import { Option, Schema } from "effect";
3import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts";3import { 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";
5import { useLocalStorage } from "./hooks/useLocalStorage";10import { useLocalStorage } from "./hooks/useLocalStorage";
6import { EnvMode } from "./components/BranchToolbar.logic";11import { EnvMode } from "./components/BranchToolbar.logic";
712
...@@ -12,6 +17,16 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256;...@@ -12,6 +17,16 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256;
12export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);17export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
13export type TimestampFormat = typeof TimestampFormat.Type;18export type TimestampFormat = typeof TimestampFormat.Type;
14export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";19export 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
16const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {31const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
17 codex: new Set(getModelOptions("codex").map((option) => option.slug)),32 codex: new Set(getModelOptions("codex").map((option) => option.slug)),
...@@ -50,6 +65,27 @@ export interface AppModelOption {...@@ -50,6 +65,27 @@ export interface AppModelOption {
50}65}
5166
52const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});67const 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
54export function normalizeCustomModelSlugs(90export function normalizeCustomModelSlugs(
55 models: Iterable<string | null | undefined>,91 models: Iterable<string | null | undefined>,
...@@ -87,6 +123,39 @@ function normalizeAppSettings(settings: AppSettings): AppSettings {...@@ -87,6 +123,39 @@ function normalizeAppSettings(settings: AppSettings): AppSettings {
87 customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"),123 customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"),
88 };124 };
89}125}
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
90export function getAppModelOptions(159export function getAppModelOptions(
91 provider: ProviderKind,160 provider: ProviderKind,
92 customModels: readonly string[],161 customModels: readonly string[],
...@@ -98,6 +167,7 @@ export function getAppModelOptions(...@@ -98,6 +167,7 @@ export function getAppModelOptions(
98 isCustom: false,167 isCustom: false,
99 }));168 }));
100 const seen = new Set(options.map((option) => option.slug));169 const seen = new Set(options.map((option) => option.slug));
170 const trimmedSelectedModel = selectedModel?.trim().toLowerCase();
101171
102 for (const slug of normalizeCustomModelSlugs(customModels, provider)) {172 for (const slug of normalizeCustomModelSlugs(customModels, provider)) {
103 if (seen.has(slug)) {173 if (seen.has(slug)) {
...@@ -113,7 +183,14 @@ export function getAppModelOptions(...@@ -113,7 +183,14 @@ export function getAppModelOptions(
113 }183 }
114184
115 const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);185 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 ) {
117 options.push({194 options.push({
118 slug: normalizedSelectedModel,195 slug: normalizedSelectedModel,
119 name: normalizedSelectedModel,196 name: normalizedSelectedModel,
...@@ -126,34 +203,22 @@ export function getAppModelOptions(...@@ -126,34 +203,22 @@ export function getAppModelOptions(
126203
127export function resolveAppModelSelection(204export function resolveAppModelSelection(
128 provider: ProviderKind,205 provider: ProviderKind,
129 customModels: readonly string[],206 customModels: Record<ProviderKind, readonly string[]>,
130 selectedModel: string | null | undefined,207 selectedModel: string | null | undefined,
131): string {208): string {
132 const options = getAppModelOptions(provider, customModels, selectedModel);209 const customModelsForProvider = customModels[provider];
133 const trimmedSelectedModel = selectedModel?.trim();210 const options = getAppModelOptions(provider, customModelsForProvider, selectedModel);
134 if (trimmedSelectedModel) {211 return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider);
135 const direct = options.find((option) => option.slug === trimmedSelectedModel);212}
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 }
152213
153 return (214export function getCustomModelOptionsByProvider(
154 options.find((option) => option.slug === normalizedSelectedModel)?.slug ??215 settings: Pick<AppSettings, CustomModelSettingsKey>,
155 getDefaultModel(provider)216): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
156 );217 const customModelsByProvider = getCustomModelsByProvider(settings);
218 return {
219 codex: getAppModelOptions("codex", customModelsByProvider.codex),
220 claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent),
221 };
157}222}
158223
159export function useAppSettings() {224export function useAppSettings() {
apps/web/src/components/ChatView.browser.tsx+194
...@@ -752,6 +752,8 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -752,6 +752,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
752 draftsByThreadId: {},752 draftsByThreadId: {},
753 draftThreadsByThreadId: {},753 draftThreadsByThreadId: {},
754 projectDraftThreadIdByProjectId: {},754 projectDraftThreadIdByProjectId: {},
755 stickyModel: null,
756 stickyModelOptions: {},
755 });757 });
756 useStore.setState({758 useStore.setState({
757 projects: [],759 projects: [],
...@@ -1279,6 +1281,198 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1279,6 +1281,198 @@ describe("ChatView timeline estimator parity (full app)", () => {
1279 }1281 }
1280 });1282 });
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
1282 it("creates a new thread from the global chat.new shortcut", async () => {1476 it("creates a new thread from the global chat.new shortcut", async () => {
1283 const mounted = await mountChatView({1477 const mounted = await mountChatView({
1284 viewport: DEFAULT_VIEWPORT,1478 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/ChatView.logic.ts+1-12
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import { ProjectId, type ProviderKind, type ThreadId } from "@t3tools/contracts";1import { ProjectId, type ThreadId } from "@t3tools/contracts";
2import { type ChatMessage, type Thread } from "../types";2import { type ChatMessage, type Thread } from "../types";
3import { randomUUID } from "~/lib/utils";3import { randomUUID } from "~/lib/utils";
4import { getAppModelOptions } from "../appSettings";
5import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";4import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
6import { Schema } from "effect";5import { Schema } from "effect";
7import {6import {
...@@ -121,16 +120,6 @@ export function cloneComposerImageForRetry(...@@ -121,16 +120,6 @@ export function cloneComposerImageForRetry(
121 }120 }
122}121}
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
134export function deriveComposerSendState(options: {123export function deriveComposerSendState(options: {
135 prompt: string;124 prompt: string;
136 imageCount: number;125 imageCount: number;
apps/web/src/components/ChatView.tsx+55-104
...@@ -24,15 +24,8 @@ import {...@@ -24,15 +24,8 @@ import {
24import {24import {
25 applyClaudePromptEffortPrefix,25 applyClaudePromptEffortPrefix,
26 getDefaultModel,26 getDefaultModel,
27 getDefaultReasoningEffort,
28 getReasoningEffortOptions,
29 isClaudeUltrathinkPrompt,
30 normalizeClaudeModelOptions,
31 normalizeCodexModelOptions,
32 normalizeModelSlug,27 normalizeModelSlug,
33 resolveReasoningEffortForProvider,
34 resolveModelSlugForProvider,28 resolveModelSlugForProvider,
35 supportsClaudeUltrathinkKeyword,
36} from "@t3tools/shared/model";29} from "@t3tools/shared/model";
37import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";30import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
38import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";31import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
...@@ -126,7 +119,12 @@ import {...@@ -126,7 +119,12 @@ import {
126import { SidebarTrigger } from "./ui/sidebar";119import { SidebarTrigger } from "./ui/sidebar";
127import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";120import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
128import { readNativeApi } from "~/nativeApi";121import { readNativeApi } from "~/nativeApi";
129import { resolveAppModelSelection, useAppSettings } from "../appSettings";122import {
123 getCustomModelOptionsByProvider,
124 getCustomModelsByProvider,
125 resolveAppModelSelection,
126 useAppSettings,
127} from "../appSettings";
130import { isTerminalFocused } from "../lib/terminalFocus";128import { isTerminalFocused } from "../lib/terminalFocus";
131import {129import {
132 type ComposerImageAttachment,130 type ComposerImageAttachment,
...@@ -153,12 +151,15 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/Expanded...@@ -153,12 +151,15 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/Expanded
153import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";151import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";
154import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";152import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";
155import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions";153import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions";
156import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./chat/ClaudeTraitsPicker";
157import { CodexTraitsMenuContent, CodexTraitsPicker } from "./chat/CodexTraitsPicker";
158import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu";154import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu";
159import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel";155import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel";
160import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel";156import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel";
161import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner";157import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner";
158import {
159 getComposerProviderState,
160 renderProviderTraitsMenuContent,
161 renderProviderTraitsPicker,
162} from "./chat/composerProviderRegistry";
162import { ProviderHealthBanner } from "./chat/ProviderHealthBanner";163import { ProviderHealthBanner } from "./chat/ProviderHealthBanner";
163import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";164import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
164import {165import {
...@@ -168,7 +169,6 @@ import {...@@ -168,7 +169,6 @@ import {
168 cloneComposerImageForRetry,169 cloneComposerImageForRetry,
169 collectUserMessageBlobPreviewUrls,170 collectUserMessageBlobPreviewUrls,
170 deriveComposerSendState,171 deriveComposerSendState,
171 getCustomModelOptionsByProvider,
172 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,172 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
173 LastInvokedScriptByProjectSchema,173 LastInvokedScriptByProjectSchema,
174 PullRequestDialogState,174 PullRequestDialogState,
...@@ -244,6 +244,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -244,6 +244,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
244 const setStoreThreadError = useStore((store) => store.setError);244 const setStoreThreadError = useStore((store) => store.setError);
245 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);245 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
246 const { settings } = useAppSettings();246 const { settings } = useAppSettings();
247 const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel);
247 const timestampFormat = settings.timestampFormat;248 const timestampFormat = settings.timestampFormat;
248 const navigate = useNavigate();249 const navigate = useNavigate();
249 const rawSearch = useSearch({250 const rawSearch = useSearch({
...@@ -596,72 +597,27 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -596,72 +597,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
596 selectedProvider,597 selectedProvider,
597 activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider),598 activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider),
598 );599 );
599 const customModelsByProvider = useMemo(600 const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]);
600 () => ({
601 codex: settings.customCodexModels,
602 claudeAgent: settings.customClaudeModels,
603 }),
604 [settings.customClaudeModels, settings.customCodexModels],
605 );
606 const customModelsForSelectedProvider = customModelsByProvider[selectedProvider];
607 const selectedModel = useMemo(() => {601 const selectedModel = useMemo(() => {
608 const draftModel = composerDraft.model;602 const draftModel = composerDraft.model;
609 if (!draftModel) {603 if (!draftModel) {
610 return baseThreadModel;604 return baseThreadModel;
611 }605 }
612 return resolveAppModelSelection(606 return resolveAppModelSelection(selectedProvider, customModelsByProvider, draftModel);
613 selectedProvider,607 }, [baseThreadModel, composerDraft.model, customModelsByProvider, selectedProvider]);
614 customModelsForSelectedProvider,
615 draftModel,
616 ) as ModelSlug;
617 }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]);
618 const draftModelOptions = composerDraft.modelOptions;608 const draftModelOptions = composerDraft.modelOptions;
619 const selectedCodexEffort =609 const composerProviderState = useMemo(
620 selectedProvider === "codex"610 () =>
621 ? (resolveReasoningEffortForProvider("codex", draftModelOptions?.codex?.reasoningEffort) ??611 getComposerProviderState({
622 getDefaultReasoningEffort("codex"))612 provider: selectedProvider,
623 : null;613 model: selectedModel,
624 const selectedClaudeReasoningOptions =614 prompt,
625 selectedProvider === "claudeAgent"615 modelOptions: draftModelOptions,
626 ? getReasoningEffortOptions("claudeAgent", selectedModel)616 }),
627 : ([] as const);617 [draftModelOptions, prompt, selectedModel, selectedProvider],
628 const selectedClaudeBaseEffort =618 );
629 selectedProvider === "claudeAgent" && selectedClaudeReasoningOptions.length > 0619 const selectedPromptEffort = composerProviderState.promptEffort;
630 ? (() => {620 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
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]);
665 const providerOptionsForDispatch = useMemo(() => {621 const providerOptionsForDispatch = useMemo(() => {
666 if (!settings.codexBinaryPath && !settings.codexHomePath) {622 if (!settings.codexBinaryPath && !settings.codexHomePath) {
667 return undefined;623 return undefined;
...@@ -3141,20 +3097,20 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3141,20 +3097,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
3141 scheduleComposerFocus();3097 scheduleComposerFocus();
3142 return;3098 return;
3143 }3099 }
3100 const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model);
3144 setComposerDraftProvider(activeThread.id, provider);3101 setComposerDraftProvider(activeThread.id, provider);
3145 setComposerDraftModel(3102 setComposerDraftModel(activeThread.id, resolvedModel);
3146 activeThread.id,3103 setStickyComposerModel(resolvedModel);
3147 resolveAppModelSelection(provider, customModelsByProvider[provider], model),
3148 );
3149 scheduleComposerFocus();3104 scheduleComposerFocus();
3150 },3105 },
3151 [3106 [
3152 activeThread,3107 activeThread,
3153 customModelsByProvider,
3154 lockedProvider,3108 lockedProvider,
3155 scheduleComposerFocus,3109 scheduleComposerFocus,
3156 setComposerDraftModel,3110 setComposerDraftModel,
3157 setComposerDraftProvider,3111 setComposerDraftProvider,
3112 setStickyComposerModel,
3113 customModelsByProvider,
3158 ],3114 ],
3159 );3115 );
3160 const setPromptFromTraits = useCallback(3116 const setPromptFromTraits = useCallback(
...@@ -3173,6 +3129,18 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3173,6 +3129,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
3173 },3129 },
3174 [scheduleComposerFocus, setPrompt],3130 [scheduleComposerFocus, setPrompt],
3175 );3131 );
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 });
3176 const onEnvModeChange = useCallback(3144 const onEnvModeChange = useCallback(
3177 (mode: DraftThreadEnvMode) => {3145 (mode: DraftThreadEnvMode) => {
3178 if (isLocalDraftThread) {3146 if (isLocalDraftThread) {
...@@ -3606,7 +3574,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3606,7 +3574,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3606 <div3574 <div
3607 className={cn(3575 className={cn(
3608 "group rounded-[22px] p-px transition-colors duration-200",3576 "group rounded-[22px] p-px transition-colors duration-200",
3609 isClaudeUltrathink ? "ultrathink-frame" : undefined,3577 composerProviderState.composerFrameClassName,
3610 )}3578 )}
3611 onDragEnter={onComposerDragEnter}3579 onDragEnter={onComposerDragEnter}
3612 onDragOver={onComposerDragOver}3580 onDragOver={onComposerDragOver}
...@@ -3617,7 +3585,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3617,7 +3585,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3617 className={cn(3585 className={cn(
3618 "rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring/45",3586 "rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring/45",
3619 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border",3587 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,
3621 )}3589 )}
3622 >3590 >
3623 {activePendingApproval ? (3591 {activePendingApproval ? (
...@@ -3806,7 +3774,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3806,7 +3774,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
3806 model={selectedModelForPickerWithCustomFallback}3774 model={selectedModelForPickerWithCustomFallback}
3807 lockedProvider={lockedProvider}3775 lockedProvider={lockedProvider}
3808 modelOptionsByProvider={modelOptionsByProvider}3776 modelOptionsByProvider={modelOptionsByProvider}
3809 ultrathinkActive={isClaudeUltrathink}3777 {...(composerProviderState.modelPickerIconClassName
3778 ? {
3779 activeProviderIconClassName:
3780 composerProviderState.modelPickerIconClassName,
3781 }
3782 : {})}
3810 onProviderModelChange={onProviderModelSelect}3783 onProviderModelChange={onProviderModelSelect}
3811 />3784 />
38123785
...@@ -3818,42 +3791,20 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3818,42 +3791,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
3818 interactionMode={interactionMode}3791 interactionMode={interactionMode}
3819 planSidebarOpen={planSidebarOpen}3792 planSidebarOpen={planSidebarOpen}
3820 runtimeMode={runtimeMode}3793 runtimeMode={runtimeMode}
3821 traitsMenuContent={3794 traitsMenuContent={providerTraitsMenuContent}
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 }
3832 onToggleInteractionMode={toggleInteractionMode}3795 onToggleInteractionMode={toggleInteractionMode}
3833 onTogglePlanSidebar={togglePlanSidebar}3796 onTogglePlanSidebar={togglePlanSidebar}
3834 onToggleRuntimeMode={toggleRuntimeMode}3797 onToggleRuntimeMode={toggleRuntimeMode}
3835 />3798 />
3836 ) : (3799 ) : (
3837 <>3800 <>
3838 {selectedProvider === "codex" ? (3801 {providerTraitsPicker ? (
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" ? (
3847 <>3802 <>
3848 <Separator3803 <Separator
3849 orientation="vertical"3804 orientation="vertical"
3850 className="mx-0.5 hidden h-4 sm:block"3805 className="mx-0.5 hidden h-4 sm:block"
3851 />3806 />
3852 <ClaudeTraitsPicker3807 {providerTraitsPicker}
3853 threadId={threadId}
3854 model={selectedModel}
3855 onPromptChange={setPromptFromTraits}
3856 />
3857 </>3808 </>
3858 ) : null}3809 ) : null}
38593810
apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx+21
...@@ -173,4 +173,25 @@ describe("ClaudeTraitsPicker", () => {...@@ -173,4 +173,25 @@ describe("ClaudeTraitsPicker", () => {
173 await mounted.cleanup();173 await mounted.cleanup();
174 }174 }
175 });175 });
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 });
176});197});
apps/web/src/components/chat/ClaudeTraitsPicker.tsx+78-63
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import {1import {
2 ProviderKind,
2 type ClaudeCodeEffort,3 type ClaudeCodeEffort,
3 type ClaudeModelOptions,4 type ClaudeModelOptions,
4 type ProviderModelOptions,
5 type ThreadId,5 type ThreadId,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import {7import {
...@@ -15,7 +15,7 @@ import {...@@ -15,7 +15,7 @@ import {
15 supportsClaudeUltrathinkKeyword,15 supportsClaudeUltrathinkKeyword,
16 isClaudeUltrathinkPrompt,16 isClaudeUltrathinkPrompt,
17} from "@t3tools/shared/model";17} from "@t3tools/shared/model";
18import { memo, useState } from "react";18import { memo, useCallback, useState } from "react";
19import { ChevronDownIcon } from "lucide-react";19import { ChevronDownIcon } from "lucide-react";
20import { Button } from "../ui/button";20import { Button } from "../ui/button";
21import {21import {
...@@ -29,6 +29,8 @@ import {...@@ -29,6 +29,8 @@ import {
29} from "../ui/menu";29} from "../ui/menu";
30import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore";30import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore";
3131
32const PROVIDER = "claudeAgent" as const satisfies ProviderKind;
33
32const CLAUDE_EFFORT_LABELS: Record<ClaudeCodeEffort, string> = {34const CLAUDE_EFFORT_LABELS: Record<ClaudeCodeEffort, string> = {
33 low: "Low",35 low: "Low",
34 medium: "Medium",36 medium: "Medium",
...@@ -51,12 +53,12 @@ function getSelectedClaudeTraits(...@@ -51,12 +53,12 @@ function getSelectedClaudeTraits(
51 ultrathinkPromptControlled: boolean;53 ultrathinkPromptControlled: boolean;
52 supportsFastMode: boolean;54 supportsFastMode: boolean;
53} {55} {
54 const options = getReasoningEffortOptions("claudeAgent", model);56 const options = getReasoningEffortOptions(PROVIDER, model);
55 const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent") as Exclude<57 const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER) as Exclude<
56 ClaudeCodeEffort,58 ClaudeCodeEffort,
57 "ultrathink"59 "ultrathink"
58 >;60 >;
59 const resolvedEffort = resolveReasoningEffortForProvider("claudeAgent", modelOptions?.effort);61 const resolvedEffort = resolveReasoningEffortForProvider(PROVIDER, modelOptions?.effort);
60 const effort =62 const effort =
61 resolvedEffort && resolvedEffort !== "ultrathink" && options.includes(resolvedEffort)63 resolvedEffort && resolvedEffort !== "ultrathink" && options.includes(resolvedEffort)
62 ? resolvedEffort64 ? resolvedEffort
...@@ -78,15 +80,21 @@ function getSelectedClaudeTraits(...@@ -78,15 +80,21 @@ function getSelectedClaudeTraits(
78 };80 };
79}81}
8082
81function ClaudeTraitsMenuContentImpl(props: {83interface ClaudeTraitsMenuContentProps {
82 threadId: ThreadId;84 threadId: ThreadId;
83 model: string | null | undefined;85 model: string | null | undefined;
84 onPromptChange: (prompt: string) => void;86 onPromptChange: (prompt: string) => void;
85}) {87}
86 const draft = useComposerThreadDraft(props.threadId);88
89export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl({
90 threadId,
91 model,
92 onPromptChange,
93}: ClaudeTraitsMenuContentProps) {
94 const draft = useComposerThreadDraft(threadId);
87 const prompt = draft.prompt;95 const prompt = draft.prompt;
88 const modelOptions = draft.modelOptions?.claudeAgent;96 const modelOptions = draft.modelOptions?.[PROVIDER];
89 const setModelOptions = useComposerDraftStore((store) => store.setModelOptions);97 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);
90 const {98 const {
91 effort,99 effort,
92 thinkingEnabled,100 thinkingEnabled,
...@@ -94,19 +102,44 @@ function ClaudeTraitsMenuContentImpl(props: {...@@ -94,19 +102,44 @@ function ClaudeTraitsMenuContentImpl(props: {
94 options,102 options,
95 ultrathinkPromptControlled,103 ultrathinkPromptControlled,
96 supportsFastMode,104 supportsFastMode,
97 } = getSelectedClaudeTraits(props.model, prompt, modelOptions);105 } = getSelectedClaudeTraits(model, prompt, modelOptions);
98 const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent");106 const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER);
99107
100 const setClaudeModelOptions = (nextClaudeModelOptions: ClaudeModelOptions | undefined) => {108 const handleEffortChange = useCallback(
101 const { claudeAgent: _discardedClaude, ...otherProviderModelOptions } =109 (value: ClaudeCodeEffort) => {
102 draft.modelOptions ?? {};110 if (ultrathinkPromptControlled) return;
103 const nextProviderModelOptions: ProviderModelOptions | undefined = nextClaudeModelOptions111 if (!value) return;
104 ? { ...otherProviderModelOptions, claudeAgent: nextClaudeModelOptions }112 const nextEffort = options.find((option) => option === value);
105 : Object.keys(otherProviderModelOptions).length > 0113 if (!nextEffort) return;
106 ? otherProviderModelOptions114 if (nextEffort === "ultrathink") {
107 : undefined;115 const nextPrompt =
108 setModelOptions(props.threadId, nextProviderModelOptions);116 prompt.trim().length === 0
109 };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
111 if (effort === null && thinkingEnabled === null) {144 if (effort === null && thinkingEnabled === null) {
112 return null;145 return null;
...@@ -123,29 +156,7 @@ function ClaudeTraitsMenuContentImpl(props: {...@@ -123,29 +156,7 @@ function ClaudeTraitsMenuContentImpl(props: {
123 Remove Ultrathink from the prompt to change effort.156 Remove Ultrathink from the prompt to change effort.
124 </div>157 </div>
125 ) : null}158 ) : null}
126 <MenuRadioGroup159 <MenuRadioGroup value={effort} onValueChange={handleEffortChange}>
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 >
149 {options.map((option) => (160 {options.map((option) => (
150 <MenuRadioItem key={option} value={option} disabled={ultrathinkPromptControlled}>161 <MenuRadioItem key={option} value={option} disabled={ultrathinkPromptControlled}>
151 {CLAUDE_EFFORT_LABELS[option]}162 {CLAUDE_EFFORT_LABELS[option]}
...@@ -161,11 +172,14 @@ function ClaudeTraitsMenuContentImpl(props: {...@@ -161,11 +172,14 @@ function ClaudeTraitsMenuContentImpl(props: {
161 <MenuRadioGroup172 <MenuRadioGroup
162 value={thinkingEnabled ? "on" : "off"}173 value={thinkingEnabled ? "on" : "off"}
163 onValueChange={(value) => {174 onValueChange={(value) => {
164 setClaudeModelOptions(175 setProviderModelOptions(
165 normalizeClaudeModelOptions(props.model, {176 threadId,
177 PROVIDER,
178 normalizeClaudeModelOptions(model, {
166 ...modelOptions,179 ...modelOptions,
167 thinking: value === "on",180 thinking: value === "on",
168 }),181 }),
182 { persistSticky: true },
169 );183 );
170 }}184 }}
171 >185 >
...@@ -182,11 +196,14 @@ function ClaudeTraitsMenuContentImpl(props: {...@@ -182,11 +196,14 @@ function ClaudeTraitsMenuContentImpl(props: {
182 <MenuRadioGroup196 <MenuRadioGroup
183 value={fastModeEnabled ? "on" : "off"}197 value={fastModeEnabled ? "on" : "off"}
184 onValueChange={(value) => {198 onValueChange={(value) => {
185 setClaudeModelOptions(199 setProviderModelOptions(
186 normalizeClaudeModelOptions(props.model, {200 threadId,
201 PROVIDER,
202 normalizeClaudeModelOptions(model, {
187 ...modelOptions,203 ...modelOptions,
188 fastMode: value === "on",204 fastMode: value === "on",
189 }),205 }),
206 { persistSticky: true },
190 );207 );
191 }}208 }}
192 >209 >
...@@ -198,21 +215,19 @@ function ClaudeTraitsMenuContentImpl(props: {...@@ -198,21 +215,19 @@ function ClaudeTraitsMenuContentImpl(props: {
198 ) : null}215 ) : null}
199 </>216 </>
200 );217 );
201}218});
202
203export const ClaudeTraitsMenuContent = memo(ClaudeTraitsMenuContentImpl);
204219
205export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker(props: {220export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker({
206 threadId: ThreadId;221 threadId,
207 model: string | null | undefined;222 model,
208 onPromptChange: (prompt: string) => void;223 onPromptChange,
209}) {224}: ClaudeTraitsMenuContentProps) {
210 const [isMenuOpen, setIsMenuOpen] = useState(false);225 const [isMenuOpen, setIsMenuOpen] = useState(false);
211 const draft = useComposerThreadDraft(props.threadId);226 const draft = useComposerThreadDraft(threadId);
212 const prompt = draft.prompt;227 const prompt = draft.prompt;
213 const modelOptions = draft.modelOptions?.claudeAgent;228 const modelOptions = draft.modelOptions?.[PROVIDER];
214 const { effort, thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, supportsFastMode } =229 const { effort, thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, supportsFastMode } =
215 getSelectedClaudeTraits(props.model, prompt, modelOptions);230 getSelectedClaudeTraits(model, prompt, modelOptions);
216 const triggerLabel = [231 const triggerLabel = [
217 ultrathinkPromptControlled232 ultrathinkPromptControlled
218 ? "Ultrathink"233 ? "Ultrathink"
...@@ -247,9 +262,9 @@ export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker(props: {...@@ -247,9 +262,9 @@ export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker(props: {
247 </MenuTrigger>262 </MenuTrigger>
248 <MenuPopup align="start">263 <MenuPopup align="start">
249 <ClaudeTraitsMenuContent264 <ClaudeTraitsMenuContent
250 threadId={props.threadId}265 threadId={threadId}
251 model={props.model}266 model={model}
252 onPromptChange={props.onPromptChange}267 onPromptChange={onPromptChange}
253 />268 />
254 </MenuPopup>269 </MenuPopup>
255 </Menu>270 </Menu>
apps/web/src/components/chat/CodexTraitsPicker.browser.tsx+19
...@@ -116,6 +116,25 @@ describe("CodexTraitsPicker", () => {...@@ -116,6 +116,25 @@ describe("CodexTraitsPicker", () => {
116 }116 }
117 });117 });
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
119 it("hydrates legacy codex persisted state into modelOptions through the picker", async () => {138 it("hydrates legacy codex persisted state into modelOptions through the picker", async () => {
120 const threadId = ThreadId.makeUnsafe("thread-codex-legacy");139 const threadId = ThreadId.makeUnsafe("thread-codex-legacy");
121 localStorage.setItem(140 localStorage.setItem(
apps/web/src/components/chat/CodexTraitsPicker.tsx+17-19
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type {1import type {
2 CodexModelOptions,2 CodexModelOptions,
3 CodexReasoningEffort,3 CodexReasoningEffort,
4 ProviderModelOptions,4 ProviderKind,
5 ThreadId,5 ThreadId,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import {7import {
...@@ -24,6 +24,8 @@ import {...@@ -24,6 +24,8 @@ import {
24 MenuTrigger,24 MenuTrigger,
25} from "../ui/menu";25} from "../ui/menu";
2626
27const PROVIDER = "codex" as const satisfies ProviderKind;
28
27const CODEX_REASONING_LABELS: Record<CodexReasoningEffort, string> = {29const CODEX_REASONING_LABELS: Record<CodexReasoningEffort, string> = {
28 low: "Low",30 low: "Low",
29 medium: "Medium",31 medium: "Medium",
...@@ -35,10 +37,10 @@ function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefin...@@ -35,10 +37,10 @@ function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefin
35 effort: CodexReasoningEffort;37 effort: CodexReasoningEffort;
36 fastModeEnabled: boolean;38 fastModeEnabled: boolean;
37} {39} {
38 const defaultReasoningEffort = getDefaultReasoningEffort("codex");40 const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER);
39 return {41 return {
40 effort:42 effort:
41 resolveReasoningEffortForProvider("codex", modelOptions?.reasoningEffort) ??43 resolveReasoningEffortForProvider(PROVIDER, modelOptions?.reasoningEffort) ??
42 defaultReasoningEffort,44 defaultReasoningEffort,
43 fastModeEnabled: modelOptions?.fastMode === true,45 fastModeEnabled: modelOptions?.fastMode === true,
44 };46 };
...@@ -46,22 +48,12 @@ function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefin...@@ -46,22 +48,12 @@ function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefin
4648
47function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) {49function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) {
48 const draft = useComposerThreadDraft(props.threadId);50 const draft = useComposerThreadDraft(props.threadId);
49 const modelOptions = draft.modelOptions?.codex;51 const modelOptions = draft.modelOptions?.[PROVIDER];
50 const setModelOptions = useComposerDraftStore((store) => store.setModelOptions);52 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);
51 const options = getReasoningEffortOptions("codex");53 const options = getReasoningEffortOptions(PROVIDER);
52 const defaultReasoningEffort = getDefaultReasoningEffort("codex");54 const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER);
53 const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions);55 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
65 return (57 return (
66 <>58 <>
67 <MenuGroup>59 <MenuGroup>
...@@ -72,11 +64,14 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) {...@@ -72,11 +64,14 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) {
72 if (!value) return;64 if (!value) return;
73 const nextEffort = options.find((option) => option === value);65 const nextEffort = options.find((option) => option === value);
74 if (!nextEffort) return;66 if (!nextEffort) return;
75 setCodexModelOptions(67 setProviderModelOptions(
68 props.threadId,
69 PROVIDER,
76 normalizeCodexModelOptions({70 normalizeCodexModelOptions({
77 ...modelOptions,71 ...modelOptions,
78 reasoningEffort: nextEffort,72 reasoningEffort: nextEffort,
79 }),73 }),
74 { persistSticky: true },
80 );75 );
81 }}76 }}
82 >77 >
...@@ -94,11 +89,14 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) {...@@ -94,11 +89,14 @@ function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) {
94 <MenuRadioGroup89 <MenuRadioGroup
95 value={fastModeEnabled ? "on" : "off"}90 value={fastModeEnabled ? "on" : "off"}
96 onValueChange={(value) => {91 onValueChange={(value) => {
97 setCodexModelOptions(92 setProviderModelOptions(
93 props.threadId,
94 PROVIDER,
98 normalizeCodexModelOptions({95 normalizeCodexModelOptions({
99 ...modelOptions,96 ...modelOptions,
100 fastMode: value === "on",97 fastMode: value === "on",
101 }),98 }),
99 { persistSticky: true },
102 );100 );
103 }}101 }}
104 >102 >
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+20
...@@ -91,4 +91,24 @@ describe("ProviderModelPicker", () => {...@@ -91,4 +91,24 @@ describe("ProviderModelPicker", () => {
91 await mounted.cleanup();91 await mounted.cleanup();
92 }92 }
93 });93 });
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 });
94});114});
apps/web/src/components/chat/ProviderModelPicker.tsx+4-39
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";1import { type ModelSlug, type ProviderKind } from "@t3tools/contracts";
2import { normalizeModelSlug } from "@t3tools/shared/model";2import { resolveSelectableModel } from "@t3tools/shared/model";
3import { memo, useState } from "react";3import { memo, useState } from "react";
4import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic";4import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic";
5import { ChevronDownIcon } from "lucide-react";5import { ChevronDownIcon } from "lucide-react";
...@@ -28,39 +28,6 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o...@@ -28,39 +28,6 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o
28 return option.available;28 return option.available;
29}29}
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
64const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {31const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {
65 codex: OpenAI,32 codex: OpenAI,
66 claudeAgent: ClaudeAI,33 claudeAgent: ClaudeAI,
...@@ -86,7 +53,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -86,7 +53,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
86 model: ModelSlug;53 model: ModelSlug;
87 lockedProvider: ProviderKind | null;54 lockedProvider: ProviderKind | null;
88 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;55 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
89 ultrathinkActive?: boolean;56 activeProviderIconClassName?: string;
90 compact?: boolean;57 compact?: boolean;
91 disabled?: boolean;58 disabled?: boolean;
92 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;59 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
...@@ -100,7 +67,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -100,7 +67,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
100 const handleModelChange = (provider: ProviderKind, value: string) => {67 const handleModelChange = (provider: ProviderKind, value: string) => {
101 if (props.disabled) return;68 if (props.disabled) return;
102 if (!value) return;69 if (!value) return;
103 const resolvedModel = resolveModelForProviderPicker(70 const resolvedModel = resolveSelectableModel(
104 provider,71 provider,
105 value,72 value,
106 props.modelOptionsByProvider[provider],73 props.modelOptionsByProvider[provider],
...@@ -145,9 +112,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -145,9 +112,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
145 className={cn(112 className={cn(
146 "size-4 shrink-0",113 "size-4 shrink-0",
147 providerIconClassName(activeProvider, "text-muted-foreground/70"),114 providerIconClassName(activeProvider, "text-muted-foreground/70"),
148 activeProvider === "claudeAgent" && props.ultrathinkActive115 props.activeProviderIconClassName,
149 ? "ultrathink-chroma"
150 : undefined,
151 )}116 )}
152 />117 />
153 <span className="min-w-0 flex-1 truncate">{selectedModelLabel}</span>118 <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: {...@@ -62,17 +62,23 @@ function makeTerminalContext(input: {
62 };62 };
63}63}
6464
65function resetComposerDraftStore() {
66 useComposerDraftStore.setState({
67 draftsByThreadId: {},
68 draftThreadsByThreadId: {},
69 projectDraftThreadIdByProjectId: {},
70 stickyModel: null,
71 stickyModelOptions: {},
72 });
73}
74
65describe("composerDraftStore addImages", () => {75describe("composerDraftStore addImages", () => {
66 const threadId = ThreadId.makeUnsafe("thread-dedupe");76 const threadId = ThreadId.makeUnsafe("thread-dedupe");
67 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;77 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
68 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;78 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
6979
70 beforeEach(() => {80 beforeEach(() => {
71 useComposerDraftStore.setState({81 resetComposerDraftStore();
72 draftsByThreadId: {},
73 draftThreadsByThreadId: {},
74 projectDraftThreadIdByProjectId: {},
75 });
76 originalRevokeObjectUrl = URL.revokeObjectURL;82 originalRevokeObjectUrl = URL.revokeObjectURL;
77 revokeSpy = vi.fn();83 revokeSpy = vi.fn();
78 URL.revokeObjectURL = revokeSpy;84 URL.revokeObjectURL = revokeSpy;
...@@ -157,11 +163,7 @@ describe("composerDraftStore clearComposerContent", () => {...@@ -157,11 +163,7 @@ describe("composerDraftStore clearComposerContent", () => {
157 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;163 let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
158164
159 beforeEach(() => {165 beforeEach(() => {
160 useComposerDraftStore.setState({166 resetComposerDraftStore();
161 draftsByThreadId: {},
162 draftThreadsByThreadId: {},
163 projectDraftThreadIdByProjectId: {},
164 });
165 originalRevokeObjectUrl = URL.revokeObjectURL;167 originalRevokeObjectUrl = URL.revokeObjectURL;
166 revokeSpy = vi.fn();168 revokeSpy = vi.fn();
167 URL.revokeObjectURL = revokeSpy;169 URL.revokeObjectURL = revokeSpy;
...@@ -423,11 +425,7 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -423,11 +425,7 @@ describe("composerDraftStore project draft thread mapping", () => {
423 const otherThreadId = ThreadId.makeUnsafe("thread-b");425 const otherThreadId = ThreadId.makeUnsafe("thread-b");
424426
425 beforeEach(() => {427 beforeEach(() => {
426 useComposerDraftStore.setState({428 resetComposerDraftStore();
427 draftsByThreadId: {},
428 draftThreadsByThreadId: {},
429 projectDraftThreadIdByProjectId: {},
430 });
431 });429 });
432430
433 it("stores and reads project draft thread ids via actions", () => {431 it("stores and reads project draft thread ids via actions", () => {
...@@ -599,11 +597,7 @@ describe("composerDraftStore modelOptions", () => {...@@ -599,11 +597,7 @@ describe("composerDraftStore modelOptions", () => {
599 const threadId = ThreadId.makeUnsafe("thread-model-options");597 const threadId = ThreadId.makeUnsafe("thread-model-options");
600598
601 beforeEach(() => {599 beforeEach(() => {
602 useComposerDraftStore.setState({600 resetComposerDraftStore();
603 draftsByThreadId: {},
604 draftThreadsByThreadId: {},
605 projectDraftThreadIdByProjectId: {},
606 });
607 });601 });
608602
609 it("stores provider-scoped model options in the draft", () => {603 it("stores provider-scoped model options in the draft", () => {
...@@ -642,17 +636,165 @@ describe("composerDraftStore modelOptions", () => {...@@ -642,17 +636,165 @@ describe("composerDraftStore modelOptions", () => {
642636
643 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();637 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
644 });638 });
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 });
645});791});
646792
647describe("composerDraftStore setModel", () => {793describe("composerDraftStore setModel", () => {
648 const threadId = ThreadId.makeUnsafe("thread-model");794 const threadId = ThreadId.makeUnsafe("thread-model");
649795
650 beforeEach(() => {796 beforeEach(() => {
651 useComposerDraftStore.setState({797 resetComposerDraftStore();
652 draftsByThreadId: {},
653 draftThreadsByThreadId: {},
654 projectDraftThreadIdByProjectId: {},
655 });
656 });798 });
657799
658 it("keeps explicit DEFAULT_MODEL overrides instead of coercing to null", () => {800 it("keeps explicit DEFAULT_MODEL overrides instead of coercing to null", () => {
...@@ -666,15 +808,51 @@ describe("composerDraftStore setModel", () => {...@@ -666,15 +808,51 @@ describe("composerDraftStore setModel", () => {
666 });808 });
667});809});
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
669describe("composerDraftStore setProvider", () => {851describe("composerDraftStore setProvider", () => {
670 const threadId = ThreadId.makeUnsafe("thread-provider");852 const threadId = ThreadId.makeUnsafe("thread-provider");
671853
672 beforeEach(() => {854 beforeEach(() => {
673 useComposerDraftStore.setState({855 resetComposerDraftStore();
674 draftsByThreadId: {},
675 draftThreadsByThreadId: {},
676 projectDraftThreadIdByProjectId: {},
677 });
678 });856 });
679857
680 it("persists provider-only selection even when prompt/model are empty", () => {858 it("persists provider-only selection even when prompt/model are empty", () => {
...@@ -699,11 +877,7 @@ describe("composerDraftStore runtime and interaction settings", () => {...@@ -699,11 +877,7 @@ describe("composerDraftStore runtime and interaction settings", () => {
699 const threadId = ThreadId.makeUnsafe("thread-settings");877 const threadId = ThreadId.makeUnsafe("thread-settings");
700878
701 beforeEach(() => {879 beforeEach(() => {
702 useComposerDraftStore.setState({880 resetComposerDraftStore();
703 draftsByThreadId: {},
704 draftThreadsByThreadId: {},
705 projectDraftThreadIdByProjectId: {},
706 });
707 });881 });
708882
709 it("stores runtime mode overrides in the composer draft", () => {883 it("stores runtime mode overrides in the composer draft", () => {
apps/web/src/composerDraftStore.ts+133-3
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 ThreadId,11 ThreadId,
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import * as Schema from "effect/Schema";13import * as Schema from "effect/Schema";
14import * as Equal from "effect/Equal";
14import { DeepMutable } from "effect/Types";15import { DeepMutable } from "effect/Types";
15import { normalizeModelSlug } from "@t3tools/shared/model";16import { normalizeModelSlug } from "@t3tools/shared/model";
16import { getLocalStorageItem } from "./hooks/useLocalStorage";17import { getLocalStorageItem } from "./hooks/useLocalStorage";
...@@ -104,6 +105,8 @@ const PersistedComposerDraftStoreState = Schema.Struct({...@@ -104,6 +105,8 @@ const PersistedComposerDraftStoreState = Schema.Struct({
104 draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState),105 draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState),
105 draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState),106 draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState),
106 projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId),107 projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId),
108 stickyModel: Schema.NullOr(Schema.String),
109 stickyModelOptions: ProviderModelOptions,
107});110});
108type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type;111type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type;
109112
...@@ -143,6 +146,8 @@ interface ComposerDraftStoreState {...@@ -143,6 +146,8 @@ interface ComposerDraftStoreState {
143 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;146 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;
144 draftThreadsByThreadId: Record<ThreadId, DraftThreadState>;147 draftThreadsByThreadId: Record<ThreadId, DraftThreadState>;
145 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;148 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
149 stickyModel: string | null;
150 stickyModelOptions: ProviderModelOptions;
146 getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null;151 getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null;
147 getDraftThread: (threadId: ThreadId) => DraftThreadState | null;152 getDraftThread: (threadId: ThreadId) => DraftThreadState | null;
148 setProjectDraftThreadId: (153 setProjectDraftThreadId: (
...@@ -172,6 +177,8 @@ interface ComposerDraftStoreState {...@@ -172,6 +177,8 @@ interface ComposerDraftStoreState {
172 clearProjectDraftThreadId: (projectId: ProjectId) => void;177 clearProjectDraftThreadId: (projectId: ProjectId) => void;
173 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;178 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;
174 clearDraftThread: (threadId: ThreadId) => void;179 clearDraftThread: (threadId: ThreadId) => void;
180 setStickyModel: (model: string | null | undefined) => void;
181 setStickyModelOptions: (modelOptions: ProviderModelOptions | null | undefined) => void;
175 setPrompt: (threadId: ThreadId, prompt: string) => void;182 setPrompt: (threadId: ThreadId, prompt: string) => void;
176 setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void;183 setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void;
177 setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void;184 setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void;
...@@ -180,6 +187,14 @@ interface ComposerDraftStoreState {...@@ -180,6 +187,14 @@ interface ComposerDraftStoreState {
180 threadId: ThreadId,187 threadId: ThreadId,
181 modelOptions: ProviderModelOptions | null | undefined,188 modelOptions: ProviderModelOptions | null | undefined,
182 ) => void;189 ) => void;
190 setProviderModelOptions: (
191 threadId: ThreadId,
192 provider: ProviderKind,
193 nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined,
194 options?: {
195 persistSticky?: boolean;
196 },
197 ) => void;
183 setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void;198 setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void;
184 setInteractionMode: (199 setInteractionMode: (
185 threadId: ThreadId,200 threadId: ThreadId,
...@@ -207,11 +222,15 @@ interface ComposerDraftStoreState {...@@ -207,11 +222,15 @@ interface ComposerDraftStoreState {
207 clearThreadDraft: (threadId: ThreadId) => void;222 clearThreadDraft: (threadId: ThreadId) => void;
208}223}
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>({
211 draftsByThreadId: {},228 draftsByThreadId: {},
212 draftThreadsByThreadId: {},229 draftThreadsByThreadId: {},
213 projectDraftThreadIdByProjectId: {},230 projectDraftThreadIdByProjectId: {},
214};231 stickyModel: null,
232 stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS,
233});
215234
216const EMPTY_IMAGES: ComposerImageAttachment[] = [];235const EMPTY_IMAGES: ComposerImageAttachment[] = [];
217const EMPTY_IDS: string[] = [];236const EMPTY_IDS: string[] = [];
...@@ -397,6 +416,24 @@ function normalizeProviderModelOptions(...@@ -397,6 +416,24 @@ function normalizeProviderModelOptions(
397 };416 };
398}417}
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
400function revokeObjectPreviewUrl(previewUrl: string): void {437function revokeObjectPreviewUrl(previewUrl: string): void {
401 if (typeof URL === "undefined") {438 if (typeof URL === "undefined") {
402 return;439 return;
...@@ -674,6 +711,12 @@ function migratePersistedComposerDraftStoreState(...@@ -674,6 +711,12 @@ function migratePersistedComposerDraftStoreState(
674 const rawDraftMap = candidate.draftsByThreadId;711 const rawDraftMap = candidate.draftsByThreadId;
675 const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId;712 const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId;
676 const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId;713 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;
677 const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =720 const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =
678 normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId);721 normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId);
679 const draftsByThreadId = normalizePersistedDraftsByThreadId(722 const draftsByThreadId = normalizePersistedDraftsByThreadId(
...@@ -691,6 +734,8 @@ function migratePersistedComposerDraftStoreState(...@@ -691,6 +734,8 @@ function migratePersistedComposerDraftStoreState(
691 draftsByThreadId,734 draftsByThreadId,
692 draftThreadsByThreadId,735 draftThreadsByThreadId,
693 projectDraftThreadIdByProjectId,736 projectDraftThreadIdByProjectId,
737 stickyModel,
738 stickyModelOptions,
694 };739 };
695}740}
696741
...@@ -744,6 +789,8 @@ function partializeComposerDraftStoreState(...@@ -744,6 +789,8 @@ function partializeComposerDraftStoreState(
744 draftsByThreadId: persistedDraftsByThreadId,789 draftsByThreadId: persistedDraftsByThreadId,
745 draftThreadsByThreadId: state.draftThreadsByThreadId,790 draftThreadsByThreadId: state.draftThreadsByThreadId,
746 projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,791 projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,
792 stickyModel: state.stickyModel,
793 stickyModelOptions: state.stickyModelOptions,
747 };794 };
748}795}
749796
...@@ -759,6 +806,13 @@ function normalizeCurrentPersistedComposerDraftStoreState(...@@ -759,6 +806,13 @@ function normalizeCurrentPersistedComposerDraftStoreState(
759 normalizedPersistedState.draftThreadsByThreadId,806 normalizedPersistedState.draftThreadsByThreadId,
760 normalizedPersistedState.projectDraftThreadIdByProjectId,807 normalizedPersistedState.projectDraftThreadIdByProjectId,
761 );808 );
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;
762 return {816 return {
763 draftsByThreadId: normalizePersistedDraftsByThreadId(817 draftsByThreadId: normalizePersistedDraftsByThreadId(
764 normalizedPersistedState.draftsByThreadId,818 normalizedPersistedState.draftsByThreadId,
...@@ -767,6 +821,8 @@ function normalizeCurrentPersistedComposerDraftStoreState(...@@ -767,6 +821,8 @@ function normalizeCurrentPersistedComposerDraftStoreState(
767 ),821 ),
768 draftThreadsByThreadId,822 draftThreadsByThreadId,
769 projectDraftThreadIdByProjectId,823 projectDraftThreadIdByProjectId,
824 stickyModel,
825 stickyModelOptions,
770 };826 };
771}827}
772828
...@@ -870,6 +926,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -870,6 +926,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
870 draftsByThreadId: {},926 draftsByThreadId: {},
871 draftThreadsByThreadId: {},927 draftThreadsByThreadId: {},
872 projectDraftThreadIdByProjectId: {},928 projectDraftThreadIdByProjectId: {},
929 stickyModel: null,
930 stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS,
873 getDraftThreadByProjectId: (projectId) => {931 getDraftThreadByProjectId: (projectId) => {
874 if (projectId.length === 0) {932 if (projectId.length === 0) {
875 return null;933 return null;
...@@ -1105,6 +1163,29 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1105,6 +1163,29 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1105 };1163 };
1106 });1164 });
1107 },1165 },
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 },
1108 setPrompt: (threadId, prompt) => {1189 setPrompt: (threadId, prompt) => {
1109 if (threadId.length === 0) {1190 if (threadId.length === 0) {
1110 return;1191 return;
...@@ -1214,7 +1295,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1214,7 +1295,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1214 return state;1295 return state;
1215 }1296 }
1216 const base = existing ?? createEmptyThreadDraft();1297 const base = existing ?? createEmptyThreadDraft();
1217 if (JSON.stringify(base.modelOptions) === JSON.stringify(nextModelOptions)) {1298 if (Equal.equals(base.modelOptions, nextModelOptions)) {
1218 return state;1299 return state;
1219 }1300 }
1220 const nextDraft: ComposerThreadDraftState = {1301 const nextDraft: ComposerThreadDraftState = {
...@@ -1230,6 +1311,53 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1230,6 +1311,53 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1230 return { draftsByThreadId: nextDraftsByThreadId };1311 return { draftsByThreadId: nextDraftsByThreadId };
1231 });1312 });
1232 },1313 },
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 },
1233 setRuntimeMode: (threadId, runtimeMode) => {1361 setRuntimeMode: (threadId, runtimeMode) => {
1234 if (threadId.length === 0) {1362 if (threadId.length === 0) {
1235 return;1363 return;
...@@ -1644,6 +1772,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1644,6 +1772,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1644 draftsByThreadId,1772 draftsByThreadId,
1645 draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId,1773 draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId,
1646 projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId,1774 projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId,
1775 stickyModel: normalizedPersisted.stickyModel,
1776 stickyModelOptions: normalizedPersisted.stickyModelOptions,
1647 };1777 };
1648 },1778 },
1649 },1779 },
apps/web/src/hooks/useHandleNewThread.ts+14-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";1import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";
2import { useNavigate, useParams } from "@tanstack/react-router";2import { useNavigate, useParams } from "@tanstack/react-router";
3import { useCallback } from "react";3import { useCallback } from "react";
4import { inferProviderForModel } from "@t3tools/shared/model";
4import {5import {
5 type DraftThreadEnvMode,6 type DraftThreadEnvMode,
6 type DraftThreadState,7 type DraftThreadState,
...@@ -12,6 +13,8 @@ import { useStore } from "../store";...@@ -12,6 +13,8 @@ import { useStore } from "../store";
12export function useHandleNewThread() {13export function useHandleNewThread() {
13 const projects = useStore((store) => store.projects);14 const projects = useStore((store) => store.projects);
14 const threads = useStore((store) => store.threads);15 const threads = useStore((store) => store.threads);
16 const stickyModel = useComposerDraftStore((store) => store.stickyModel);
17 const stickyModelOptions = useComposerDraftStore((store) => store.stickyModelOptions);
15 const navigate = useNavigate();18 const navigate = useNavigate();
16 const routeThreadId = useParams({19 const routeThreadId = useParams({
17 strict: false,20 strict: false,
...@@ -38,6 +41,9 @@ export function useHandleNewThread() {...@@ -38,6 +41,9 @@ export function useHandleNewThread() {
38 clearProjectDraftThreadId,41 clearProjectDraftThreadId,
39 getDraftThread,42 getDraftThread,
40 getDraftThreadByProjectId,43 getDraftThreadByProjectId,
44 setModel,
45 setModelOptions,
46 setProvider,
41 setDraftThreadContext,47 setDraftThreadContext,
42 setProjectDraftThreadId,48 setProjectDraftThreadId,
43 } = useComposerDraftStore.getState();49 } = useComposerDraftStore.getState();
...@@ -96,6 +102,13 @@ export function useHandleNewThread() {...@@ -96,6 +102,13 @@ export function useHandleNewThread() {
96 envMode: options?.envMode ?? "local",102 envMode: options?.envMode ?? "local",
97 runtimeMode: DEFAULT_RUNTIME_MODE,103 runtimeMode: DEFAULT_RUNTIME_MODE,
98 });104 });
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
100 await navigate({113 await navigate({
101 to: "/$threadId",114 to: "/$threadId",
...@@ -103,7 +116,7 @@ export function useHandleNewThread() {...@@ -103,7 +116,7 @@ export function useHandleNewThread() {
103 });116 });
104 })();117 })();
105 },118 },
106 [navigate, routeThreadId],119 [navigate, routeThreadId, stickyModel, stickyModelOptions],
107 );120 );
108121
109 return {122 return {
apps/web/src/routes/_chat.settings.tsx+9-60
...@@ -3,7 +3,15 @@ import { useQuery } from "@tanstack/react-query";...@@ -3,7 +3,15 @@ import { useQuery } from "@tanstack/react-query";
3import { useCallback, useState } from "react";3import { useCallback, useState } from "react";
4import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";4import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";
5import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";5import { 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";
7import { resolveAndPersistPreferredEditor } from "../editorPreferences";15import { resolveAndPersistPreferredEditor } from "../editorPreferences";
8import { isElectron } from "../env";16import { isElectron } from "../env";
9import { useTheme } from "../hooks/useTheme";17import { useTheme } from "../hooks/useTheme";
...@@ -40,71 +48,12 @@ const THEME_OPTIONS = [...@@ -40,71 +48,12 @@ const THEME_OPTIONS = [
40 },48 },
41] as const;49] 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
66const TIMESTAMP_FORMAT_LABELS = {51const TIMESTAMP_FORMAT_LABELS = {
67 locale: "System default",52 locale: "System default",
68 "12-hour": "12-hour",53 "12-hour": "12-hour",
69 "24-hour": "24-hour",54 "24-hour": "24-hour",
70} as const;55} 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
108function SettingsRouteView() {57function SettingsRouteView() {
109 const { theme, setTheme, resolvedTheme } = useTheme();58 const { theme, setTheme, resolvedTheme } = useTheme();
110 const { settings, defaults, updateSettings } = useAppSettings();59 const { settings, defaults, updateSettings } = useAppSettings();
packages/shared/src/model.test.ts+65
...@@ -21,6 +21,7 @@ import {...@@ -21,6 +21,7 @@ import {
21 normalizeCodexModelOptions,21 normalizeCodexModelOptions,
22 normalizeModelSlug,22 normalizeModelSlug,
23 resolveReasoningEffortForProvider,23 resolveReasoningEffortForProvider,
24 resolveSelectableModel,
24 resolveModelSlug,25 resolveModelSlug,
25 resolveModelSlugForProvider,26 resolveModelSlugForProvider,
26 supportsClaudeAdaptiveReasoning,27 supportsClaudeAdaptiveReasoning,
...@@ -94,6 +95,70 @@ describe("resolveModelSlug", () => {...@@ -94,6 +95,70 @@ describe("resolveModelSlug", () => {
94 });95 });
95});96});
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
97describe("getReasoningEffortOptions", () => {162describe("getReasoningEffortOptions", () => {
98 it("returns codex reasoning options for codex", () => {163 it("returns codex reasoning options for codex", () => {
99 expect(getReasoningEffortOptions("codex")).toEqual(REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex);164 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";...@@ -24,6 +24,11 @@ const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6";
24const CLAUDE_SONNET_4_6_MODEL = "claude-sonnet-4-6";24const CLAUDE_SONNET_4_6_MODEL = "claude-sonnet-4-6";
25const CLAUDE_HAIKU_4_5_MODEL = "claude-haiku-4-5";25const CLAUDE_HAIKU_4_5_MODEL = "claude-haiku-4-5";
2626
27export interface SelectableModelOption {
28 slug: string;
29 name: string;
30}
31
27export function getModelOptions(provider: ProviderKind = "codex") {32export function getModelOptions(provider: ProviderKind = "codex") {
28 return MODEL_OPTIONS_BY_PROVIDER[provider];33 return MODEL_OPTIONS_BY_PROVIDER[provider];
29}34}
...@@ -77,6 +82,39 @@ export function normalizeModelSlug(...@@ -77,6 +82,39 @@ export function normalizeModelSlug(
77 return typeof aliased === "string" ? aliased : (trimmed as ModelSlug);82 return typeof aliased === "string" ? aliased : (trimmed as ModelSlug);
78}83}
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
80export function resolveModelSlug(118export function resolveModelSlug(
81 model: string | null | undefined,119 model: string | null | undefined,
82 provider: ProviderKind = "codex",120 provider: ProviderKind = "codex",