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