| author | |
| committer | |
| log | 4f0f24f055fe5f5346f7e73372e8cdc167e052f9 |
| tree | 59ee6d9de976ebca4b9e573939ae61b082d16d78 |
| parent | d1e85c4e8fdef82fbaded9539532b754080419e0 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 78 insertions(+), 9 deletions(-)
apps/web/src/components/chat/ChatComposer.tsx+13-4| ... | @@ -727,9 +727,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -727,9 +727,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 727 | model: selectedModel, | 727 | model: selectedModel, |
| 728 | models: selectedProviderModels, | 728 | models: selectedProviderModels, |
| 729 | prompt, | 729 | prompt, |
| 730 | modelOptions: composerModelOptions?.[selectedProvider], | 730 | modelOptions: composerModelOptions?.[selectedInstanceId], |
| 731 | }), | 731 | }), |
| 732 | [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], | 732 | [ |
| 733 | composerModelOptions, | ||
| 734 | prompt, | ||
| 735 | selectedInstanceId, | ||
| 736 | selectedModel, | ||
| 737 | selectedProvider, | ||
| 738 | selectedProviderModels, | ||
| 739 | ], | ||
| 733 | ); | 740 | ); |
| 734 | 741 | ||
| 735 | const selectedPromptEffort = composerProviderState.promptEffort; | 742 | const selectedPromptEffort = composerProviderState.promptEffort; |
| ... | @@ -1018,21 +1025,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1018,21 +1025,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1018 | 1025 | ||
| 1019 | const providerTraitsMenuContent = renderProviderTraitsMenuContent({ | 1026 | const providerTraitsMenuContent = renderProviderTraitsMenuContent({ |
| 1020 | provider: selectedProvider, | 1027 | provider: selectedProvider, |
| 1028 | instanceId: selectedInstanceId, | ||
| 1021 | ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), | 1029 | ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), |
| 1022 | ...(routeKind === "draft" && draftId ? { draftId } : {}), | 1030 | ...(routeKind === "draft" && draftId ? { draftId } : {}), |
| 1023 | model: selectedModel, | 1031 | model: selectedModel, |
| 1024 | models: selectedProviderModels, | 1032 | models: selectedProviderModels, |
| 1025 | modelOptions: composerModelOptions?.[selectedProvider], | 1033 | modelOptions: composerModelOptions?.[selectedInstanceId], |
| 1026 | prompt, | 1034 | prompt, |
| 1027 | onPromptChange: setPromptFromTraits, | 1035 | onPromptChange: setPromptFromTraits, |
| 1028 | }); | 1036 | }); |
| 1029 | const providerTraitsPicker = renderProviderTraitsPicker({ | 1037 | const providerTraitsPicker = renderProviderTraitsPicker({ |
| 1030 | provider: selectedProvider, | 1038 | provider: selectedProvider, |
| 1039 | instanceId: selectedInstanceId, | ||
| 1031 | ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), | 1040 | ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), |
| 1032 | ...(routeKind === "draft" && draftId ? { draftId } : {}), | 1041 | ...(routeKind === "draft" && draftId ? { draftId } : {}), |
| 1033 | model: selectedModel, | 1042 | model: selectedModel, |
| 1034 | models: selectedProviderModels, | 1043 | models: selectedProviderModels, |
| 1035 | modelOptions: composerModelOptions?.[selectedProvider], | 1044 | modelOptions: composerModelOptions?.[selectedInstanceId], |
| 1036 | prompt, | 1045 | prompt, |
| 1037 | onPromptChange: setPromptFromTraits, | 1046 | onPromptChange: setPromptFromTraits, |
| 1038 | }); | 1047 | }); |
apps/web/src/components/chat/TraitsPicker.tsx+7-1| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | type ProviderDriverKind, | 2 | type ProviderDriverKind, |
| 3 | type ProviderInstanceId, | ||
| 3 | type ProviderOptionDescriptor, | 4 | type ProviderOptionDescriptor, |
| 4 | type ProviderOptionSelection, | 5 | type ProviderOptionSelection, |
| 5 | type ScopedThreadRef, | 6 | type ScopedThreadRef, |
| ... | @@ -196,6 +197,7 @@ export function shouldRenderTraitsControls(input: { | ... | @@ -196,6 +197,7 @@ export function shouldRenderTraitsControls(input: { |
| 196 | 197 | ||
| 197 | export interface TraitsMenuContentProps { | 198 | export interface TraitsMenuContentProps { |
| 198 | provider: ProviderDriverKind; | 199 | provider: ProviderDriverKind; |
| 200 | instanceId?: ProviderInstanceId; | ||
| 199 | models: ReadonlyArray<ServerProviderModel>; | 201 | models: ReadonlyArray<ServerProviderModel>; |
| 200 | model: string | null | undefined; | 202 | model: string | null | undefined; |
| 201 | prompt: string; | 203 | prompt: string; |
| ... | @@ -208,6 +210,7 @@ export interface TraitsMenuContentProps { | ... | @@ -208,6 +210,7 @@ export interface TraitsMenuContentProps { |
| 208 | 210 | ||
| 209 | export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ | 211 | export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 210 | provider, | 212 | provider, |
| 213 | instanceId, | ||
| 211 | models, | 214 | models, |
| 212 | model, | 215 | model, |
| 213 | prompt, | 216 | prompt, |
| ... | @@ -228,11 +231,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ | ... | @@ -228,11 +231,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 228 | return; | 231 | return; |
| 229 | } | 232 | } |
| 230 | setProviderModelOptions(threadTarget, provider, nextOptions, { | 233 | setProviderModelOptions(threadTarget, provider, nextOptions, { |
| 234 | ...(instanceId ? { instanceId } : {}), | ||
| 231 | model, | 235 | model, |
| 232 | persistSticky: true, | 236 | persistSticky: true, |
| 233 | }); | 237 | }); |
| 234 | }, | 238 | }, |
| 235 | [model, persistence, provider, setProviderModelOptions], | 239 | [instanceId, model, persistence, provider, setProviderModelOptions], |
| 236 | ); | 240 | ); |
| 237 | const { | 241 | const { |
| 238 | descriptors, | 242 | descriptors, |
| ... | @@ -343,6 +347,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ | ... | @@ -343,6 +347,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 343 | 347 | ||
| 344 | export const TraitsPicker = memo(function TraitsPicker({ | 348 | export const TraitsPicker = memo(function TraitsPicker({ |
| 345 | provider, | 349 | provider, |
| 350 | instanceId, | ||
| 346 | models, | 351 | models, |
| 347 | model, | 352 | model, |
| 348 | prompt, | 353 | prompt, |
| ... | @@ -431,6 +436,7 @@ export const TraitsPicker = memo(function TraitsPicker({ | ... | @@ -431,6 +436,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 431 | <MenuPopup align="start"> | 436 | <MenuPopup align="start"> |
| 432 | <TraitsMenuContent | 437 | <TraitsMenuContent |
| 433 | provider={provider} | 438 | provider={provider} |
| 439 | {...(instanceId ? { instanceId } : {})} | ||
| 434 | models={models} | 440 | models={models} |
| 435 | model={model} | 441 | model={model} |
| 436 | prompt={prompt} | 442 | prompt={prompt} |
apps/web/src/components/chat/composerProviderState.tsx+14-2| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | type ProviderDriverKind, | 2 | type ProviderDriverKind, |
| 3 | type ProviderInstanceId, | ||
| 3 | type ProviderOptionSelection, | 4 | type ProviderOptionSelection, |
| 4 | type ScopedThreadRef, | 5 | type ScopedThreadRef, |
| 5 | type ServerProviderModel, | 6 | type ServerProviderModel, |
| ... | @@ -35,6 +36,7 @@ export type ComposerProviderState = { | ... | @@ -35,6 +36,7 @@ export type ComposerProviderState = { |
| 35 | 36 | ||
| 36 | type TraitsRenderInput = { | 37 | type TraitsRenderInput = { |
| 37 | provider: ProviderDriverKind; | 38 | provider: ProviderDriverKind; |
| 39 | instanceId?: ProviderInstanceId; | ||
| 38 | threadRef?: ScopedThreadRef; | 40 | threadRef?: ScopedThreadRef; |
| 39 | draftId?: DraftId; | 41 | draftId?: DraftId; |
| 40 | model: string; | 42 | model: string; |
| ... | @@ -76,8 +78,17 @@ function renderTraitsControl( | ... | @@ -76,8 +78,17 @@ function renderTraitsControl( |
| 76 | Component: typeof TraitsMenuContent | typeof TraitsPicker, | 78 | Component: typeof TraitsMenuContent | typeof TraitsPicker, |
| 77 | input: TraitsRenderInput, | 79 | input: TraitsRenderInput, |
| 78 | ): ReactNode { | 80 | ): ReactNode { |
| 79 | const { provider, threadRef, draftId, model, models, modelOptions, prompt, onPromptChange } = | 81 | const { |
| 80 | input; | 82 | provider, |
| 83 | instanceId, | ||
| 84 | threadRef, | ||
| 85 | draftId, | ||
| 86 | model, | ||
| 87 | models, | ||
| 88 | modelOptions, | ||
| 89 | prompt, | ||
| 90 | onPromptChange, | ||
| 91 | } = input; | ||
| 81 | const hasTarget = threadRef !== undefined || draftId !== undefined; | 92 | const hasTarget = threadRef !== undefined || draftId !== undefined; |
| 82 | if ( | 93 | if ( |
| 83 | !hasTarget || | 94 | !hasTarget || |
| ... | @@ -88,6 +99,7 @@ function renderTraitsControl( | ... | @@ -88,6 +99,7 @@ function renderTraitsControl( |
| 88 | return ( | 99 | return ( |
| 89 | <Component | 100 | <Component |
| 90 | provider={provider} | 101 | provider={provider} |
| 102 | {...(instanceId ? { instanceId } : {})} | ||
| 91 | models={models} | 103 | models={models} |
| 92 | {...(threadRef ? { threadRef } : {})} | 104 | {...(threadRef ? { threadRef } : {})} |
| 93 | {...(draftId ? { draftId } : {})} | 105 | {...(draftId ? { draftId } : {})} |
apps/web/src/composerDraftStore.test.ts+38| ... | @@ -21,6 +21,7 @@ import { createModelSelection } from "@t3tools/shared/model"; | ... | @@ -21,6 +21,7 @@ import { createModelSelection } from "@t3tools/shared/model"; |
| 21 | // `stickyModelSelectionByProvider` maps are keyed by `ProviderInstanceId` | 21 | // `stickyModelSelectionByProvider` maps are keyed by `ProviderInstanceId` |
| 22 | // in production; these aliases keep the legacy-key migration tests concise. | 22 | // in production; these aliases keep the legacy-key migration tests concise. |
| 23 | const CODEX_INSTANCE = ProviderInstanceId.make("codex"); | 23 | const CODEX_INSTANCE = ProviderInstanceId.make("codex"); |
| 24 | const CODEX_SECONDARY_INSTANCE = ProviderInstanceId.make("codex_secondary"); | ||
| 24 | const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent"); | 25 | const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent"); |
| 25 | const CURSOR_INSTANCE = ProviderInstanceId.make("cursor"); | 26 | const CURSOR_INSTANCE = ProviderInstanceId.make("cursor"); |
| 26 | const CODEX_DRIVER = ProviderDriverKind.make("codex"); | 27 | const CODEX_DRIVER = ProviderDriverKind.make("codex"); |
| ... | @@ -1201,6 +1202,43 @@ describe("composerDraftStore modelSelection", () => { | ... | @@ -1201,6 +1202,43 @@ describe("composerDraftStore modelSelection", () => { |
| 1201 | ); | 1202 | ); |
| 1202 | }); | 1203 | }); |
| 1203 | 1204 | ||
| 1205 | it("stores provider option changes on a selected custom instance", () => { | ||
| 1206 | const store = useComposerDraftStore.getState(); | ||
| 1207 | |||
| 1208 | store.setProviderModelOptions( | ||
| 1209 | threadRef, | ||
| 1210 | CODEX_DRIVER, | ||
| 1211 | toSelections({ reasoningEffort: "low" }), | ||
| 1212 | { | ||
| 1213 | instanceId: CODEX_SECONDARY_INSTANCE, | ||
| 1214 | model: "gpt-5-codex", | ||
| 1215 | persistSticky: true, | ||
| 1216 | }, | ||
| 1217 | ); | ||
| 1218 | |||
| 1219 | expect( | ||
| 1220 | draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_SECONDARY_INSTANCE], | ||
| 1221 | ).toEqual( | ||
| 1222 | expect.objectContaining({ | ||
| 1223 | instanceId: CODEX_SECONDARY_INSTANCE, | ||
| 1224 | options: [{ id: "reasoningEffort", value: "low" }], | ||
| 1225 | }), | ||
| 1226 | ); | ||
| 1227 | expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.activeProvider).toBe(CODEX_SECONDARY_INSTANCE); | ||
| 1228 | expect(useComposerDraftStore.getState().stickyActiveProvider).toBe(CODEX_SECONDARY_INSTANCE); | ||
| 1229 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_INSTANCE]).toBe( | ||
| 1230 | undefined, | ||
| 1231 | ); | ||
| 1232 | expect( | ||
| 1233 | useComposerDraftStore.getState().stickyModelSelectionByProvider[CODEX_SECONDARY_INSTANCE], | ||
| 1234 | ).toEqual( | ||
| 1235 | expect.objectContaining({ | ||
| 1236 | instanceId: CODEX_SECONDARY_INSTANCE, | ||
| 1237 | options: [{ id: "reasoningEffort", value: "low" }], | ||
| 1238 | }), | ||
| 1239 | ); | ||
| 1240 | }); | ||
| 1241 | |||
| 1204 | it("updates only the draft when sticky persistence is disabled", () => { | 1242 | it("updates only the draft when sticky persistence is disabled", () => { |
| 1205 | const store = useComposerDraftStore.getState(); | 1243 | const store = useComposerDraftStore.getState(); |
| 1206 | 1244 |
apps/web/src/composerDraftStore.ts+6-2| ... | @@ -371,6 +371,7 @@ interface ComposerDraftStoreState { | ... | @@ -371,6 +371,7 @@ interface ComposerDraftStoreState { |
| 371 | provider: ProviderDriverKind, | 371 | provider: ProviderDriverKind, |
| 372 | nextProviderOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined, | 372 | nextProviderOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined, |
| 373 | options?: { | 373 | options?: { |
| 374 | instanceId?: ProviderInstanceId | null | undefined; | ||
| 374 | model?: string | null | undefined; | 375 | model?: string | null | undefined; |
| 375 | persistSticky?: boolean; | 376 | persistSticky?: boolean; |
| 376 | }, | 377 | }, |
| ... | @@ -2456,7 +2457,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2456,7 +2457,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2456 | if (normalizedProvider === null) { | 2457 | if (normalizedProvider === null) { |
| 2457 | return; | 2458 | return; |
| 2458 | } | 2459 | } |
| 2459 | const instanceKey = defaultInstanceIdForDriver(normalizedProvider); | 2460 | const instanceKey = options?.instanceId ?? defaultInstanceIdForDriver(normalizedProvider); |
| 2460 | const fallbackModel = | 2461 | const fallbackModel = |
| 2461 | normalizeModelSlug(options?.model, normalizedProvider) ?? | 2462 | normalizeModelSlug(options?.model, normalizedProvider) ?? |
| 2462 | DEFAULT_MODEL_BY_PROVIDER[normalizedProvider] ?? | 2463 | DEFAULT_MODEL_BY_PROVIDER[normalizedProvider] ?? |
| ... | @@ -2501,7 +2502,9 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2501,7 +2502,9 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2501 | const { options: _, ...rest } = stickyBase; | 2502 | const { options: _, ...rest } = stickyBase; |
| 2502 | nextStickyMap[instanceKey] = rest as ModelSelection; | 2503 | nextStickyMap[instanceKey] = rest as ModelSelection; |
| 2503 | } | 2504 | } |
| 2504 | nextStickyActiveProvider = base.activeProvider ?? instanceKey; | 2505 | nextStickyActiveProvider = options.instanceId |
| 2506 | ? instanceKey | ||
| 2507 | : (base.activeProvider ?? instanceKey); | ||
| 2505 | } | 2508 | } |
| 2506 | 2509 | ||
| 2507 | if ( | 2510 | if ( |
| ... | @@ -2514,6 +2517,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2514,6 +2517,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2514 | 2517 | ||
| 2515 | const nextDraft: ComposerThreadDraftState = { | 2518 | const nextDraft: ComposerThreadDraftState = { |
| 2516 | ...base, | 2519 | ...base, |
| 2520 | ...(options?.instanceId ? { activeProvider: instanceKey } : {}), | ||
| 2517 | modelSelectionByProvider: nextMap, | 2521 | modelSelectionByProvider: nextMap, |
| 2518 | }; | 2522 | }; |
| 2519 | const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | 2523 | const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; |