diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e918e7758688..fecbe1c13f03 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -862,6 +862,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) models: selectedProviderModels, promptInjectionState: composerPromptInjectionState, modelOptions: composerModelOptions?.[selectedInstanceId], + planModeEnabled: settings.planModeEnabled, }), [ composerModelOptions, @@ -870,6 +871,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedProvider, selectedProviderModels, + settings.planModeEnabled, ], ); @@ -1206,6 +1208,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelOptions: composerModelOptions?.[selectedInstanceId], prompt, onPromptChange: setPromptFromTraits, + planModeEnabled: settings.planModeEnabled, }); const providerTraitsPicker = renderProviderTraitsPicker({ provider: selectedProvider, @@ -1217,6 +1220,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelOptions: composerModelOptions?.[selectedInstanceId], prompt, onPromptChange: setPromptFromTraits, + planModeEnabled: settings.planModeEnabled, }); const pendingPrimaryAction = useMemo( () => diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 8462757700e7..ac02abc918fa 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -96,8 +96,9 @@ function getSelectedTraits( prompt: string, modelOptions: ProviderOptions | null | undefined, allowPromptInjectedEffort: boolean, + planModeEnabled: boolean, ) { - const caps = getProviderModelCapabilities(models, model, provider); + const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions, @@ -167,6 +168,7 @@ function getTraitsSectionVisibility(input: { prompt: string; modelOptions: ProviderOptions | null | undefined; allowPromptInjectedEffort?: boolean; + planModeEnabled: boolean; }) { const selected = getSelectedTraits( input.provider, @@ -175,6 +177,7 @@ function getTraitsSectionVisibility(input: { input.prompt, input.modelOptions, input.allowPromptInjectedEffort ?? true, + input.planModeEnabled, ); const showEffort = selected.primarySelectDescriptor !== null; @@ -201,6 +204,7 @@ export function shouldRenderTraitsControls(input: { prompt: string; modelOptions: ProviderOptions | null | undefined; allowPromptInjectedEffort?: boolean; + planModeEnabled: boolean; }): boolean { return getTraitsSectionVisibility(input).hasAnyControls; } @@ -214,6 +218,7 @@ export interface TraitsMenuContentProps { onPromptChange: (prompt: string) => void; modelOptions?: ProviderOptions | null | undefined; allowPromptInjectedEffort?: boolean; + planModeEnabled: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; } @@ -227,6 +232,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ onPromptChange, modelOptions, allowPromptInjectedEffort = true, + planModeEnabled, ...persistence }: TraitsMenuContentProps & TraitsPersistence) { const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); @@ -263,6 +269,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ prompt, modelOptions, allowPromptInjectedEffort, + planModeEnabled, }); const updateDescriptors = (nextDescriptors: ReadonlyArray) => { updateModelOptions(buildProviderOptionSelectionsFromDescriptors(nextDescriptors)); @@ -444,6 +451,7 @@ export const TraitsPicker = memo(function TraitsPicker({ onPromptChange, modelOptions, allowPromptInjectedEffort = true, + planModeEnabled, triggerVariant, triggerClassName, ...persistence @@ -457,6 +465,7 @@ export const TraitsPicker = memo(function TraitsPicker({ prompt, modelOptions, allowPromptInjectedEffort, + planModeEnabled, }); if ( !shouldRenderTraitsControls({ @@ -466,6 +475,7 @@ export const TraitsPicker = memo(function TraitsPicker({ prompt, modelOptions, allowPromptInjectedEffort, + planModeEnabled, }) ) { return null; @@ -536,6 +546,7 @@ export const TraitsPicker = memo(function TraitsPicker({ onPromptChange={onPromptChange} modelOptions={modelOptions} allowPromptInjectedEffort={allowPromptInjectedEffort} + planModeEnabled={planModeEnabled} {...persistence} /> diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 067e71ef1bfc..6d928c77ef02 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -80,6 +80,7 @@ describe("getComposerProviderState", () => { ]), ]), modelOptions: undefined, + planModeEnabled: true, }); expect(state).toEqual({ @@ -101,6 +102,7 @@ describe("getComposerProviderState", () => { booleanDescriptor("fastMode"), ]), modelOptions: selections(["effort", "low"], ["fastMode", true]), + planModeEnabled: true, }); expect(state).toEqual({ @@ -119,6 +121,7 @@ describe("getComposerProviderState", () => { booleanDescriptor("fastMode"), ]), modelOptions: selections(["effort", "high"], ["fastMode", false]), + planModeEnabled: true, }); expect(state.modelOptionsForDispatch).toEqual( @@ -132,6 +135,7 @@ describe("getComposerProviderState", () => { model: MODEL, models: modelWith([booleanDescriptor("thinking")]), modelOptions: selections(["effort", "max"], ["thinking", false]), + planModeEnabled: true, }); expect(state).toEqual({ @@ -157,6 +161,7 @@ describe("getComposerProviderState", () => { ]), ]), modelOptions: selections(["agent", "plan"]), + planModeEnabled: true, }); expect(state.promptEffort).toBe("high"); @@ -165,12 +170,48 @@ describe("getComposerProviderState", () => { ); }); + it("drops the plan agent from dispatch when legacy plan mode is disabled", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("agent", [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ]), + ]), + modelOptions: selections(["agent", "plan"]), + planModeEnabled: false, + }); + + expect(state.modelOptionsForDispatch).toEqual(selections(["agent", "build"])); + }); + + it("drops the agent descriptor entirely when plan is the only option and plan mode is disabled", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("agent", [{ id: "plan", label: "Plan", isDefault: true }]), + ]), + modelOptions: selections(["agent", "plan"]), + planModeEnabled: false, + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: null, + modelOptionsForDispatch: undefined, + }); + }); + it("returns undefined dispatch options when the model declares no descriptors", () => { const state = getComposerProviderState({ provider: PROVIDER, model: MODEL, models: modelWith([]), modelOptions: selections(["anything", "value"]), + planModeEnabled: true, }); expect(state).toEqual({ @@ -199,6 +240,7 @@ describe("getComposerProviderState", () => { "Ultrathink:\nInvestigate this failure", ), modelOptions: selections(["effort", "medium"]), + planModeEnabled: true, }); expect(state).toEqual({ @@ -220,6 +262,7 @@ describe("getComposerProviderState", () => { "Ultrathink:\nInvestigate this failure", ), modelOptions: undefined, + planModeEnabled: true, }); expect(state).not.toHaveProperty("composerFrameClassName"); @@ -240,6 +283,7 @@ describe("provider traits render guards", () => { modelOptions: undefined, prompt: "", onPromptChange: () => {}, + planModeEnabled: true, }; expect(renderProviderTraitsPicker(args)).toBeNull(); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 1349e2509b7b..459f8e3d669c 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -23,6 +23,7 @@ export type ComposerProviderStateInput = { models: ReadonlyArray; promptInjectionState?: ComposerPromptInjectionState; modelOptions: ReadonlyArray | null | undefined; + planModeEnabled: boolean; }; export type ComposerPromptInjectionState = "none" | "ultrathink"; @@ -46,6 +47,7 @@ type TraitsRenderInput = { modelOptions: ReadonlyArray | undefined; prompt: string; onPromptChange: (prompt: string) => void; + planModeEnabled: boolean; }; export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState { @@ -53,8 +55,15 @@ export function getComposerPromptInjectionState(prompt: string): ComposerPromptI } export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { - const { provider, model, models, modelOptions, promptInjectionState = "none" } = input; - const caps = getProviderModelCapabilities(models, model, provider); + const { + provider, + model, + models, + modelOptions, + promptInjectionState = "none", + planModeEnabled, + } = input; + const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); const primarySelectDescriptor = descriptors.find( (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => @@ -94,11 +103,19 @@ function renderTraitsControl( modelOptions, prompt, onPromptChange, + planModeEnabled, } = input; const hasTarget = threadRef !== undefined || draftId !== undefined; if ( !hasTarget || - !shouldRenderTraitsControls({ provider, models, model, modelOptions, prompt }) + !shouldRenderTraitsControls({ + provider, + models, + model, + modelOptions, + prompt, + planModeEnabled, + }) ) { return null; } @@ -113,6 +130,7 @@ function renderTraitsControl( modelOptions={modelOptions} prompt={prompt} onPromptChange={onPromptChange} + planModeEnabled={planModeEnabled} /> ); } diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 50cf9c318040..90b7825a2b3c 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -848,6 +848,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { onPromptChange={() => {}} modelOptions={resolvedSelection.options ?? []} allowPromptInjectedEffort={false} + planModeEnabled={settings.planModeEnabled} triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onModelOptionsChange={(nextOptions) => { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9df7f88ab1dd..44907bfaf51b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -68,6 +68,7 @@ import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, + withoutPlanAgentSelection, } from "../../modelSelection"; import { applyProviderInstanceSettings, @@ -1688,9 +1689,31 @@ function LegacyFeaturesSection() { control={ - updateSettings({ planModeEnabled: Boolean(checked) }) - } + onCheckedChange={(checked) => { + const planModeEnabled = Boolean(checked); + const textGenerationModelSelection = withoutPlanAgentSelection( + settings.textGenerationModelSelection, + ); + const sourceControlWriterModelSelection = withoutPlanAgentSelection( + settings.sourceControlWriterModelSelection, + ); + updateSettings({ + planModeEnabled, + ...(planModeEnabled + ? {} + : { + ...(textGenerationModelSelection && + textGenerationModelSelection !== settings.textGenerationModelSelection + ? { textGenerationModelSelection } + : {}), + ...(sourceControlWriterModelSelection && + sourceControlWriterModelSelection !== + settings.sourceControlWriterModelSelection + ? { sourceControlWriterModelSelection } + : {}), + }), + }); + }} aria-label="Plan mode (legacy)" /> } @@ -2286,6 +2309,7 @@ export function GeneralSettingsPanel() { onPromptChange={() => {}} modelOptions={textGenModelOptions} allowPromptInjectedEffort={false} + planModeEnabled={settings.planModeEnabled} triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onModelOptionsChange={(nextOptions) => { diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index a35fb752b446..405366d9fcbe 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -1,11 +1,14 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; import { describe, expect, it } from "vite-plus/test"; +import { createModelSelection } from "@t3tools/shared/model"; import { deriveProviderInstanceEntries } from "./providerInstances"; import { getAppModelOptionsForInstance, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, + resolvePlanAgentHealPatch, + withoutPlanAgentSelection, } from "./modelSelection"; function provider(input: { @@ -321,3 +324,82 @@ describe("instance-scoped model selection", () => { }); }); }); + +describe("withoutPlanAgentSelection", () => { + const instance = ProviderInstanceId.make("opencode"); + const model = "opencode/gpt-5.4"; + + it("drops a stored plan agent option", () => { + const selection = createModelSelection(instance, model, [ + { id: "variant", value: "high" }, + { id: "agent", value: "plan" }, + ]); + expect(withoutPlanAgentSelection(selection)).toEqual( + createModelSelection(instance, model, [{ id: "variant", value: "high" }]), + ); + }); + + it("keeps non-plan agent options", () => { + const selection = createModelSelection(instance, model, [{ id: "agent", value: "build" }]); + expect(withoutPlanAgentSelection(selection)).toBe(selection); + }); + + it("omits options entirely when plan was the only stored option", () => { + const selection = createModelSelection(instance, model, [{ id: "agent", value: "plan" }]); + expect(withoutPlanAgentSelection(selection)).toEqual({ instanceId: instance, model }); + }); + + it("returns null and undefined selections unchanged", () => { + expect(withoutPlanAgentSelection(null)).toBeNull(); + expect(withoutPlanAgentSelection(undefined)).toBeUndefined(); + }); +}); + +describe("resolvePlanAgentHealPatch", () => { + const instance = ProviderInstanceId.make("opencode"); + const model = "opencode/gpt-5.4"; + const healed = createModelSelection(instance, model, [{ id: "variant", value: "high" }]); + const storedPlan = createModelSelection(instance, model, [ + { id: "variant", value: "high" }, + { id: "agent", value: "plan" }, + ]); + const nullPatch = { + planModeEnabled: true, + textGenerationModelSelection: storedPlan, + sourceControlWriterModelSelection: null, + }; + + it("returns null when plan mode is on", () => { + expect(resolvePlanAgentHealPatch(nullPatch)).toBeNull(); + }); + + it("returns null when nothing needs healing", () => { + expect( + resolvePlanAgentHealPatch({ + planModeEnabled: false, + textGenerationModelSelection: healed, + sourceControlWriterModelSelection: null, + }), + ).toBeNull(); + }); + + it("patches the stored text generation selection to drop the plan agent", () => { + expect( + resolvePlanAgentHealPatch({ + planModeEnabled: false, + textGenerationModelSelection: storedPlan, + sourceControlWriterModelSelection: null, + }), + ).toEqual({ textGenerationModelSelection: healed }); + }); + + it("patches a stored source control writer selection that uses the plan agent", () => { + expect( + resolvePlanAgentHealPatch({ + planModeEnabled: false, + textGenerationModelSelection: healed, + sourceControlWriterModelSelection: storedPlan, + }), + ).toEqual({ sourceControlWriterModelSelection: healed }); + }); +}); diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 2763245299db..ccdffdda1004 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -6,6 +6,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider, + type ServerSettingsPatch, } from "@t3tools/contracts"; import { createModelSelection, @@ -277,6 +278,51 @@ export function getCustomModelOptionsByInstance( return out; } +/** + * Drop the opencode "plan" agent option from a stored model selection. + * Used when legacy plan mode is turned off so server-side text-generation + * tasks (title, branch, PR) cannot keep dispatching the plan agent. + */ +export function withoutPlanAgentSelection( + selection: ModelSelection | null | undefined, +): ModelSelection | null | undefined { + if (!selection?.options) { + return selection; + } + const options = selection.options.filter( + (option) => !(option.id === "agent" && option.value === "plan"), + ); + if (options.length === selection.options.length) { + return selection; + } + return createModelSelection(selection.instanceId, selection.model, options); +} + +// The dropdown hides the opencode "plan" agent while legacy plan mode is off, +// but the persisted text-generation selections are only healed when the toggle +// flips. Users who already have plan mode off and a stored "plan" selection +// never trip the toggle handler, so resolve the heal once per settings load. +export function resolvePlanAgentHealPatch(input: { + readonly planModeEnabled: boolean; + readonly textGenerationModelSelection: ModelSelection | null | undefined; + readonly sourceControlWriterModelSelection: ModelSelection | null | undefined; +}): ServerSettingsPatch | null { + if (input.planModeEnabled) { + return null; + } + const healedText = withoutPlanAgentSelection(input.textGenerationModelSelection); + const healedSourceControl = withoutPlanAgentSelection(input.sourceControlWriterModelSelection); + const patch: ServerSettingsPatch = { + ...(healedText && healedText !== input.textGenerationModelSelection + ? { textGenerationModelSelection: healedText } + : {}), + ...(healedSourceControl && healedSourceControl !== input.sourceControlWriterModelSelection + ? { sourceControlWriterModelSelection: healedSourceControl } + : {}), + }; + return Object.keys(patch).length > 0 ? patch : null; +} + export function resolveAppModelSelectionState( settings: UnifiedSettings, providers: ReadonlyArray, @@ -308,6 +354,7 @@ export function resolveAppModelSelectionState( model, models: entry.models, modelOptions: selectedEntry ? selection.options : undefined, + planModeEnabled: settings.planModeEnabled, }); return createModelSelection(entry.instanceId, model, modelOptionsForDispatch); @@ -325,6 +372,7 @@ export function resolveAppModelSelectionState( model, models: getProviderModels(providers, provider), modelOptions: keptSelectedProvider ? selection.options : undefined, + planModeEnabled: settings.planModeEnabled, }); return createModelSelection(defaultInstanceIdForDriver(provider), model, modelOptionsForDispatch); diff --git a/apps/web/src/planAgentSelectionHeal.tsx b/apps/web/src/planAgentSelectionHeal.tsx new file mode 100644 index 000000000000..4f6760e50fae --- /dev/null +++ b/apps/web/src/planAgentSelectionHeal.tsx @@ -0,0 +1,52 @@ +import { useEffect } from "react"; + +import { + useClientSettingsHydrated, + usePrimarySettings, + useUpdatePrimarySettings, +} from "./hooks/useSettings"; +import { resolvePlanAgentHealPatch } from "./modelSelection"; + +/** + * Heals persisted text-generation model selections that still reference the + * opencode "plan" agent. The dropdown hides the option while legacy plan mode + * is off, but the toggle handler only runs when the setting flips; users who + * already have plan mode off with a stored "plan" selection need this pass + * whenever the settings load. + */ +export function PlanAgentSelectionHeal() { + const planModeEnabled = usePrimarySettings((settings) => settings.planModeEnabled); + const textGenerationModelSelection = usePrimarySettings( + (settings) => settings.textGenerationModelSelection, + ); + const sourceControlWriterModelSelection = usePrimarySettings( + (settings) => settings.sourceControlWriterModelSelection, + ); + const settingsHydrated = useClientSettingsHydrated(); + const updateSettings = useUpdatePrimarySettings(); + + useEffect(() => { + // planModeEnabled reads as false until client settings hydrate, so never + // heal before then: we would strip a stored plan selection from a user + // whose plan mode is actually on. + if (!settingsHydrated) { + return; + } + const patch = resolvePlanAgentHealPatch({ + planModeEnabled, + textGenerationModelSelection, + sourceControlWriterModelSelection, + }); + if (patch) { + updateSettings(patch); + } + }, [ + planModeEnabled, + settingsHydrated, + textGenerationModelSelection, + sourceControlWriterModelSelection, + updateSettings, + ]); + + return null; +} diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 9715344cba80..5fca20ac457f 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -81,9 +81,31 @@ export function getProviderModelCapabilities( models: ReadonlyArray, model: string | null | undefined, provider: ProviderDriverKind, + planModeEnabled = true, ): ModelCapabilities { const slug = normalizeModelSlug(model, provider); - return models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; + const caps = + models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; + if (planModeEnabled) { + return caps; + } + return withoutPlanAgentOption(caps); +} + +// The opencode "plan" agent is only reachable while legacy plan mode is on. +// With it off, drop the option so it cannot be selected or dispatched, and +// drop the descriptor entirely when nothing remains selectable. +function withoutPlanAgentOption(caps: ModelCapabilities): ModelCapabilities { + return { + ...caps, + optionDescriptors: (caps.optionDescriptors ?? []).flatMap((descriptor) => { + if (descriptor.type !== "select" || descriptor.id !== "agent") { + return [descriptor]; + } + const options = descriptor.options.filter((option) => option.id !== "plan"); + return options.length > 0 ? [{ ...descriptor, options }] : []; + }), + }; } export function getDefaultServerModel( diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c4f65564efc7..91381301418e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -31,6 +31,7 @@ import { import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { useClientSettings } from "../hooks/useSettings"; +import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, @@ -140,6 +141,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {appShell} {/* Above the router: a theme draft is judged by walking the app, so the