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