From d8820902dc2c047058a6d4237e7998adc673e1d1 Mon Sep 17 00:00:00 2001 From: Enzo Tironi Date: Wed, 5 Aug 2026 13:22:39 -0300 Subject: [PATCH 1/3] feat(web): sticky provider options and Grok composer affordances --- apps/web/src/components/chat/ChatComposer.tsx | 43 +++-- .../components/settings/providerDriverMeta.ts | 1 - apps/web/src/composerDraftStore.test.ts | 170 ++++++++++++++++++ apps/web/src/composerDraftStore.ts | 47 ++++- 4 files changed, 237 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 201fb79c566..fb808b60af6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1084,6 +1084,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) })); } if (composerTrigger.kind === "slash-command") { + // Plan/default only matter when the provider honors interactionMode. + // Grok maps plan → `/plan` text on send; OpenCode and others with + // showInteractionModeToggle=false hide these no-op built-ins. const builtInSlashCommandItems = [ { id: "slash:model", @@ -1092,20 +1095,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) label: "/model", description: "Switch response model for this thread", }, - { - id: "slash:plan", - type: "slash-command", - command: "plan", - label: "/plan", - description: "Switch this thread into plan mode", - }, - { - id: "slash:default", - type: "slash-command", - command: "default", - label: "/default", - description: "Switch this thread back to normal build mode", - }, + ...(composerProviderControls.showInteractionModeToggle + ? ([ + { + id: "slash:plan", + type: "slash-command", + command: "plan", + label: "/plan", + description: "Switch this thread into plan mode", + }, + { + id: "slash:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch this thread back to normal build mode", + }, + ] as const) + : []), ] satisfies ReadonlyArray>; const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( (command) => ({ @@ -1140,7 +1147,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); } return []; - }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); + }, [ + composerProviderControls.showInteractionModeToggle, + composerTrigger, + selectedProvider, + selectedProviderStatus, + workspaceEntries.entries, + ]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d680..13edb2bb2b0 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -58,7 +58,6 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = value: ProviderDriverKind.make("grok"), label: "Grok", icon: GrokIcon, - badgeLabel: "Early Access", settingsSchema: GrokSettings, }, { diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 19822b8b7ee..32ea626fd60 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -60,6 +60,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { COMPOSER_DRAFT_STORAGE_KEY, clearComposerDraftsEnvironment, + deriveEffectiveComposerModelState, finalizePromotedDraftThreadByRef, markPromotedDraftThread, markPromotedDraftThreadByRef, @@ -1155,11 +1156,180 @@ describe("composerDraftStore project draft thread mapping", () => { describe("composerDraftStore modelSelection", () => { const threadId = ThreadId.make("thread-model-options"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const GROK_INSTANCE = ProviderInstanceId.make("grok"); + const GROK_DRIVER = ProviderDriverKind.make("grok"); beforeEach(() => { resetComposerDraftStore(); }); + it("prefers sticky effort options over stale thread modelSelection", () => { + const derived = deriveEffectiveComposerModelState({ + draft: { + modelSelectionByProvider: {}, + activeProvider: null, + }, + providers: [ + { + instanceId: GROK_INSTANCE, + driver: GROK_DRIVER, + enabled: true, + isAvailable: true, + models: [ + { + slug: "grok-4.5", + name: "Grok 4.5", + isCustom: false, + capabilities: { optionDescriptors: [] }, + }, + ], + } as never, + ], + selectedProvider: GROK_DRIVER, + selectedInstanceId: GROK_INSTANCE, + threadModelSelection: modelSelection(GROK_DRIVER, "grok-4.5", { + reasoningEffort: "high", + }), + projectModelSelection: null, + stickyModelSelectionByProvider: { + [GROK_INSTANCE]: modelSelection(GROK_DRIVER, "grok-4.5", { + reasoningEffort: "low", + }), + }, + settings: { + providers: { + grok: { customModels: [] }, + }, + providerInstances: {}, + models: {}, + } as never, + }); + expect(derived.modelOptions?.[String(GROK_INSTANCE)]).toEqual( + toSelections({ reasoningEffort: "low" }), + ); + }); + + it("uses sticky options only and keeps thread model when draft is empty", () => { + const derived = deriveEffectiveComposerModelState({ + draft: { + modelSelectionByProvider: {}, + activeProvider: null, + }, + providers: [ + { + instanceId: GROK_INSTANCE, + driver: GROK_DRIVER, + enabled: true, + isAvailable: true, + models: [ + { + slug: "grok-4.5", + name: "Grok 4.5", + isCustom: false, + capabilities: { optionDescriptors: [] }, + }, + { + slug: "grok-4", + name: "Grok 4", + isCustom: false, + capabilities: { optionDescriptors: [] }, + }, + ], + } as never, + ], + selectedProvider: GROK_DRIVER, + selectedInstanceId: GROK_INSTANCE, + threadModelSelection: modelSelection(GROK_DRIVER, "grok-4.5", { + reasoningEffort: "high", + }), + projectModelSelection: null, + stickyModelSelectionByProvider: { + [GROK_INSTANCE]: modelSelection(GROK_DRIVER, "grok-4", { + reasoningEffort: "low", + }), + }, + settings: { + providers: { + grok: { customModels: [] }, + }, + providerInstances: {}, + models: {}, + } as never, + }); + expect(derived.selectedModel).toBe("grok-4.5"); + expect(derived.modelOptions?.[String(GROK_INSTANCE)]).toEqual( + toSelections({ reasoningEffort: "low" }), + ); + }); + + it("re-keys sticky options under selected custom instance", () => { + const derived = deriveEffectiveComposerModelState({ + draft: { + modelSelectionByProvider: {}, + activeProvider: null, + }, + providers: [ + { + instanceId: CODEX_SECONDARY_INSTANCE, + driver: CODEX_DRIVER, + enabled: true, + isAvailable: true, + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { optionDescriptors: [] }, + }, + ], + } as never, + ], + selectedProvider: CODEX_DRIVER, + selectedInstanceId: CODEX_SECONDARY_INSTANCE, + threadModelSelection: modelSelection(CODEX_DRIVER, "gpt-5.4", { + reasoningEffort: "high", + }), + projectModelSelection: null, + stickyModelSelectionByProvider: { + [CODEX_INSTANCE]: modelSelection(CODEX_DRIVER, "gpt-5.3-codex", { + reasoningEffort: "low", + }), + }, + settings: { + providers: { + codex: { customModels: [] }, + }, + providerInstances: {}, + models: {}, + } as never, + }); + expect(derived.selectedModel).toBe("gpt-5.4"); + expect(derived.modelOptions?.[String(CODEX_SECONDARY_INSTANCE)]).toEqual( + toSelections({ reasoningEffort: "low" }), + ); + expect(derived.modelOptions?.[String(CODEX_INSTANCE)]).toBeUndefined(); + }); + + it("persists grok option selections on the draft and sticky map", () => { + const store = useComposerDraftStore.getState(); + store.setProviderModelOptions( + threadRef, + GROK_DRIVER, + toSelections({ reasoningEffort: "low" }), + { + instanceId: GROK_INSTANCE, + model: "grok-4.5", + persistSticky: true, + }, + ); + expect( + draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[GROK_INSTANCE], + ).toEqual(modelSelection(GROK_DRIVER, "grok-4.5", { reasoningEffort: "low" })); + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[GROK_INSTANCE]).toEqual( + modelSelection(GROK_DRIVER, "grok-4.5", { reasoningEffort: "low" }), + ); + }); + it("stores a model selection in the draft", () => { const store = useComposerDraftStore.getState(); store.setModelSelection( diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 95dde6187c8..9911552fa5b 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -974,6 +974,12 @@ export function deriveEffectiveComposerModelState(input: { selectedInstanceId?: ProviderInstanceId | null | undefined; threadModelSelection: ModelSelection | null | undefined; projectModelSelection: ModelSelection | null | undefined; + /** + * Cross-thread sticky selection options (e.g. last chosen Grok effort). + * Only supplies options when the per-thread draft has none; does not + * override the thread/project model slug. + */ + stickyModelSelectionByProvider?: Partial> | null; settings: UnifiedSettings; }): EffectiveComposerModelState { const baseModelCandidate = @@ -998,32 +1004,46 @@ export function deriveEffectiveComposerModelState(input: { // Look up the instance's saved selection first; fall back to the // driver-kind bucket so legacy kind-keyed drafts still resolve. Every // `ProviderDriverKind` literal is a valid `ProviderInstanceId` slug, so the - // cast to the branded type is safe. + // cast to the branded type is safe. Sticky is options-only and never + // participates in the model path. const instanceSelection = input.selectedInstanceId ? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId] : undefined; const legacySelection = input.draft?.modelSelectionByProvider?.[ProviderInstanceId.make(input.selectedProvider)]; - const activeSelection = instanceSelection ?? legacySelection; - const activeSelectionInstanceId = instanceSelection + const draftSelection = instanceSelection ?? legacySelection; + const draftSelectionInstanceId = instanceSelection ? (input.selectedInstanceId ?? ProviderInstanceId.make(input.selectedProvider)) : ProviderInstanceId.make(input.selectedProvider); - const selectedModel = activeSelection?.model + const stickyInstanceKey = + input.selectedInstanceId ?? ProviderInstanceId.make(input.selectedProvider); + const stickySelection = + input.stickyModelSelectionByProvider?.[stickyInstanceKey] ?? + input.stickyModelSelectionByProvider?.[ProviderInstanceId.make(input.selectedProvider)]; + const selectedModel = draftSelection?.model ? (resolveAppModelSelectionForInstance( - activeSelectionInstanceId, + draftSelectionInstanceId, input.settings, input.providers, - activeSelection.model, + draftSelection.model, ) ?? resolveAppModelSelection( input.selectedProvider, input.settings, input.providers, - activeSelection.model, + draftSelection.model, )) : baseModel; + // Prefer draft options, then sticky (user's last picker choice) re-keyed + // under the selected instance so custom instances resolve options, then + // thread/project so changing effort on an existing thread reaches sendTurn. + const stickyOptions = + stickySelection?.options && stickySelection.options.length > 0 + ? { [stickyInstanceKey]: stickySelection.options } + : null; const modelOptions = modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ?? + stickyOptions ?? providerSelectionsFromModelSelection(input.threadModelSelection) ?? providerSelectionsFromModelSelection(input.projectModelSelection) ?? null; @@ -2665,7 +2685,13 @@ const composerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const provider of [ + "codex", + "claudeAgent", + "cursor", + "opencode", + "grok", + ] as const) { if (!modelOptions || !(provider in modelOptions)) continue; const opts = modelOptions[provider]; const driverKind = ProviderDriverKind.make(provider); @@ -3506,6 +3532,9 @@ export function useEffectiveComposerModelState(input: { settings: UnifiedSettings; }): EffectiveComposerModelState { const draft = useComposerDraftModelState(input.threadRef ?? input.draftId ?? DraftId.make("")); + const stickyModelSelectionByProvider = useComposerDraftStore( + (state) => state.stickyModelSelectionByProvider, + ); return useMemo( () => @@ -3516,6 +3545,7 @@ export function useEffectiveComposerModelState(input: { selectedInstanceId: input.selectedInstanceId, threadModelSelection: input.threadModelSelection, projectModelSelection: input.projectModelSelection, + stickyModelSelectionByProvider, settings: input.settings, }), [ @@ -3526,6 +3556,7 @@ export function useEffectiveComposerModelState(input: { input.selectedInstanceId, input.selectedProvider, input.threadModelSelection, + stickyModelSelectionByProvider, ], ); } From cb97299bf5b3629c74e06d35b6b21389e0cf1e8a Mon Sep 17 00:00:00 2001 From: Enzo Tironi Date: Wed, 5 Aug 2026 13:47:14 -0300 Subject: [PATCH 2/3] fix(web): resolve sticky options only for selected instance Draft options for another provider/instance no longer short-circuit sticky or thread options for the currently selected instance. --- apps/web/src/composerDraftStore.test.ts | 53 +++++++++++++++++++++++++ apps/web/src/composerDraftStore.ts | 45 +++++++++++++++------ 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 32ea626fd60..b8dbb7a32fd 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1209,6 +1209,59 @@ describe("composerDraftStore modelSelection", () => { ); }); + it("ignores draft options for other instances when resolving sticky for selected", () => { + // Draft has Codex effort; selected is Grok with sticky low — must not let + // the codex map entry short-circuit sticky Grok options. + const derived = deriveEffectiveComposerModelState({ + draft: { + modelSelectionByProvider: { + [CODEX_INSTANCE]: modelSelection(CODEX_DRIVER, "gpt-5.4", { + reasoningEffort: "high", + }), + }, + activeProvider: null, + }, + providers: [ + { + instanceId: GROK_INSTANCE, + driver: GROK_DRIVER, + enabled: true, + isAvailable: true, + models: [ + { + slug: "grok-4.5", + name: "Grok 4.5", + isCustom: false, + capabilities: { optionDescriptors: [] }, + }, + ], + } as never, + ], + selectedProvider: GROK_DRIVER, + selectedInstanceId: GROK_INSTANCE, + threadModelSelection: modelSelection(GROK_DRIVER, "grok-4.5", { + reasoningEffort: "high", + }), + projectModelSelection: null, + stickyModelSelectionByProvider: { + [GROK_INSTANCE]: modelSelection(GROK_DRIVER, "grok-4.5", { + reasoningEffort: "low", + }), + }, + settings: { + providers: { + grok: { customModels: [] }, + }, + providerInstances: {}, + models: {}, + } as never, + }); + expect(derived.modelOptions?.[String(GROK_INSTANCE)]).toEqual( + toSelections({ reasoningEffort: "low" }), + ); + expect(derived.modelOptions?.[String(CODEX_INSTANCE)]).toBeUndefined(); + }); + it("uses sticky options only and keeps thread model when draft is empty", () => { const derived = deriveEffectiveComposerModelState({ draft: { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 9911552fa5b..f06f6c739b9 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -527,17 +527,20 @@ function providerSelectionsFromModelSelection( return { [modelSelection.instanceId]: options }; } -function modelSelectionByProviderToOptions( +/** + * Options for a single instance key from a modelSelectionByProvider map. + * Returns null when that key has no non-empty options — callers must not + * treat options for other instances as a hit for the selected one. + */ +function optionsForInstance( map: Partial> | null | undefined, + instanceId: string, ): ProviderOptionSelectionsByProvider | null { - if (!map) return null; - const result: ProviderOptionSelectionsByProvider = {}; - for (const [provider, selection] of Object.entries(map)) { - if (selection?.options && selection.options.length > 0) { - result[provider] = selection.options; - } + const selection = map?.[instanceId]; + if (!selection?.options || selection.options.length === 0) { + return null; } - return Object.keys(result).length > 0 ? result : null; + return { [instanceId]: selection.options }; } function cloneModelSelection(selection: ModelSelection): DeepMutable { @@ -1034,15 +1037,33 @@ export function deriveEffectiveComposerModelState(input: { draftSelection.model, )) : baseModel; - // Prefer draft options, then sticky (user's last picker choice) re-keyed - // under the selected instance so custom instances resolve options, then - // thread/project so changing effort on an existing thread reaches sendTurn. + // Prefer draft options for the *selected* instance only. Options for + // another instance (e.g. codex effort on a draft while Grok is selected) + // must not short-circuit sticky/thread/project for the current selection. + // Sticky (and legacy kind-keyed draft hits) are re-keyed under + // stickyInstanceKey so custom instances resolve options. + const legacyProviderKey = ProviderInstanceId.make(input.selectedProvider); + const draftOptionsAtInstance = optionsForInstance( + input.draft?.modelSelectionByProvider, + stickyInstanceKey, + ); + const legacyDraftOptions = + String(stickyInstanceKey) !== String(legacyProviderKey) + ? optionsForInstance(input.draft?.modelSelectionByProvider, legacyProviderKey)?.[ + String(legacyProviderKey) + ] + : undefined; + const draftOptionsForSelected = + draftOptionsAtInstance ?? + (legacyDraftOptions && legacyDraftOptions.length > 0 + ? { [stickyInstanceKey]: legacyDraftOptions } + : null); const stickyOptions = stickySelection?.options && stickySelection.options.length > 0 ? { [stickyInstanceKey]: stickySelection.options } : null; const modelOptions = - modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ?? + draftOptionsForSelected ?? stickyOptions ?? providerSelectionsFromModelSelection(input.threadModelSelection) ?? providerSelectionsFromModelSelection(input.projectModelSelection) ?? From 1d77552aa8f7c1719673f70a87f2607136726186 Mon Sep 17 00:00:00 2001 From: Enzo Tironi Date: Wed, 5 Aug 2026 14:02:32 -0300 Subject: [PATCH 3/3] fix(web): do not let kind-keyed draft options outrank sticky MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the selected instance already has a draft entry without options, skip legacy kind-key option fallback so sticky/thread options can apply — matching the model path which only falls back when the instance entry is missing. --- apps/web/src/composerDraftStore.test.ts | 52 +++++++++++++++++++++++++ apps/web/src/composerDraftStore.ts | 8 +++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index b8dbb7a32fd..cf44f11e62a 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1262,6 +1262,58 @@ describe("composerDraftStore modelSelection", () => { expect(derived.modelOptions?.[String(CODEX_INSTANCE)]).toBeUndefined(); }); + it("does not let kind-keyed draft options outrank sticky when instance entry has no options", () => { + // Selected custom Codex instance has a model-only draft entry; kind-keyed + // codex draft holds high effort. Sticky for the custom instance is low — + // sticky must win (same rule as the model path: legacy only when entry missing). + const derived = deriveEffectiveComposerModelState({ + draft: { + modelSelectionByProvider: { + [CODEX_SECONDARY_INSTANCE]: modelSelection(CODEX_DRIVER, "gpt-5.4"), + [CODEX_INSTANCE]: modelSelection(CODEX_DRIVER, "gpt-5.3-codex", { + reasoningEffort: "high", + }), + }, + activeProvider: null, + }, + providers: [ + { + instanceId: CODEX_SECONDARY_INSTANCE, + driver: CODEX_DRIVER, + enabled: true, + isAvailable: true, + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { optionDescriptors: [] }, + }, + ], + } as never, + ], + selectedProvider: CODEX_DRIVER, + selectedInstanceId: CODEX_SECONDARY_INSTANCE, + threadModelSelection: null, + projectModelSelection: null, + stickyModelSelectionByProvider: { + [CODEX_SECONDARY_INSTANCE]: modelSelection(CODEX_DRIVER, "gpt-5.4", { + reasoningEffort: "low", + }), + }, + settings: { + providers: { + codex: { customModels: [] }, + }, + providerInstances: {}, + models: {}, + } as never, + }); + expect(derived.modelOptions?.[String(CODEX_SECONDARY_INSTANCE)]).toEqual( + toSelections({ reasoningEffort: "low" }), + ); + }); + it("uses sticky options only and keeps thread model when draft is empty", () => { const derived = deriveEffectiveComposerModelState({ draft: { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f06f6c739b9..b1de062a861 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1042,13 +1042,19 @@ export function deriveEffectiveComposerModelState(input: { // must not short-circuit sticky/thread/project for the current selection. // Sticky (and legacy kind-keyed draft hits) are re-keyed under // stickyInstanceKey so custom instances resolve options. + // + // Mirror the model path: only fall back to the kind-keyed draft when the + // selected instance has *no* draft entry. An entry that exists without + // options must not pull sibling/kind options ahead of sticky. const legacyProviderKey = ProviderInstanceId.make(input.selectedProvider); + const hasInstanceDraftEntry = + input.draft?.modelSelectionByProvider?.[stickyInstanceKey] !== undefined; const draftOptionsAtInstance = optionsForInstance( input.draft?.modelSelectionByProvider, stickyInstanceKey, ); const legacyDraftOptions = - String(stickyInstanceKey) !== String(legacyProviderKey) + !hasInstanceDraftEntry && String(stickyInstanceKey) !== String(legacyProviderKey) ? optionsForInstance(input.draft?.modelSelectionByProvider, legacyProviderKey)?.[ String(legacyProviderKey) ]