Skip to content
Merged
54 changes: 45 additions & 9 deletions ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Geese } from '../icons/Geese';
import Copy from '../icons/Copy';
import { ExtensionConfig } from '../ConfigContext';
import { Button } from '../ui/button';
import type { Settings } from '../../api';

import { RecipeFormFields } from './shared/RecipeFormFields';
import { RecipeFormData } from './shared/recipeFormSchema';
Expand Down Expand Up @@ -40,6 +41,9 @@ export default function CreateEditRecipeModal({
jsonSchema: recipe.response?.json_schema
? JSON.stringify(recipe.response.json_schema, null, 2)
: '',
model: recipe.settings?.goose_model ?? undefined,
provider: recipe.settings?.goose_provider ?? undefined,
extensions: recipe.extensions || undefined,
};
}
return {
Expand All @@ -50,6 +54,9 @@ export default function CreateEditRecipeModal({
activities: [],
parameters: [],
jsonSchema: '',
model: undefined,
provider: undefined,
extensions: undefined,
};
}, [recipe]);

Expand All @@ -65,6 +72,9 @@ export default function CreateEditRecipeModal({
const [activities, setActivities] = useState(form.state.values.activities);
const [parameters, setParameters] = useState(form.state.values.parameters);
const [jsonSchema, setJsonSchema] = useState(form.state.values.jsonSchema);
const [model, setModel] = useState(form.state.values.model);
const [provider, setProvider] = useState(form.state.values.provider);
const [extensions, setExtensions] = useState(form.state.values.extensions);

// Subscribe to form changes to update local state
useEffect(() => {
Expand All @@ -76,15 +86,14 @@ export default function CreateEditRecipeModal({
setActivities(form.state.values.activities);
setParameters(form.state.values.parameters);
setJsonSchema(form.state.values.jsonSchema);
setModel(form.state.values.model);
setProvider(form.state.values.provider);
setExtensions(form.state.values.extensions);
});
}, [form]);
const [copied, setCopied] = useState(false);
const [isSaving, setIsSaving] = useState(false);

const [recipeExtensions] = useState<ExtensionConfig[] | undefined>(() => {
return recipe?.extensions ?? undefined;
});

// Reset form when recipe changes
useEffect(() => {
if (recipe) {
Expand Down Expand Up @@ -128,10 +137,32 @@ export default function CreateEditRecipeModal({
}
}

const extensions = recipeExtensions?.map((extension) =>
'envs' in extension ? { ...extension, envs: undefined } : extension
const cleanedExtensions = extensions?.map(
(extension: ExtensionConfig & { envs?: unknown; enabled?: boolean }) => {
const { envs: _envs, enabled: _enabled, ...rest } = extension;
return rest;
}
) as ExtensionConfig[] | undefined;

const mergedSettings: Settings = {
...(recipe?.settings || {}),
};
if (model !== undefined) {
mergedSettings.goose_model = model || null;
} else if ('goose_model' in mergedSettings) {
delete mergedSettings.goose_model;
}
if (provider !== undefined) {
mergedSettings.goose_provider = provider || null;
} else if ('goose_provider' in mergedSettings) {
delete mergedSettings.goose_provider;
}
const settings = Object.values(mergedSettings).some(
(value) => value !== undefined && value !== null
)
? mergedSettings
: undefined;

return {
...recipe,
title,
Expand All @@ -141,7 +172,8 @@ export default function CreateEditRecipeModal({
prompt: prompt || undefined,
parameters: formattedParameters,
response: responseConfig,
extensions,
extensions: cleanedExtensions,
settings,
};
}, [
recipe,
Expand All @@ -152,7 +184,9 @@ export default function CreateEditRecipeModal({
prompt,
parameters,
jsonSchema,
recipeExtensions,
model,
provider,
extensions,
]);

const requiredFieldsAreFilled = () => {
Expand Down Expand Up @@ -224,7 +258,9 @@ export default function CreateEditRecipeModal({
activities,
parameters,
jsonSchema,
recipeExtensions,
model,
provider,
extensions,
getCurrentRecipe,
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ vi.mock('../../../recipe/recipe_management', () => ({
saveRecipe: vi.fn(),
}));

vi.mock('../../ConfigContext', () => ({
useConfig: () => ({
extensionsList: [],
getExtensions: vi.fn().mockResolvedValue([]),
getProviders: vi.fn().mockResolvedValue([]),
}),
}));

const mockCreateRecipe = vi.mocked(createRecipe);

describe('CreateRecipeFromSessionModal', () => {
Expand Down
134 changes: 134 additions & 0 deletions ui/desktop/src/components/recipes/shared/RecipeExtensionSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { useState } from 'react';
import { ExtensionConfig } from '../../../api';
import { useConfig } from '../../ConfigContext';
import { Input } from '../../ui/input';
import { Switch } from '../../ui/switch';
import { formatExtensionName } from '../../settings/extensions/subcomponents/ExtensionList';

interface RecipeExtensionSelectorProps {
selectedExtensions: ExtensionConfig[];
onExtensionsChange: (extensions: ExtensionConfig[]) => void;
}

export const RecipeExtensionSelector = ({
selectedExtensions,
onExtensionsChange,
}: RecipeExtensionSelectorProps) => {
const { extensionsList: allExtensions } = useConfig();
const [searchQuery, setSearchQuery] = useState('');

const selectedExtensionNames = new Set(selectedExtensions.map((ext) => ext.name));

const extensionMap = new Map(allExtensions.map((ext) => [ext.name, ext]));

selectedExtensions.forEach((ext) => {
if (!extensionMap.has(ext.name)) {
extensionMap.set(ext.name, { ...ext, enabled: true });
}
});

const displayExtensions = Array.from(extensionMap.values());

const handleToggle = (extensionConfig: ExtensionConfig) => {
const isSelected = selectedExtensionNames.has(extensionConfig.name);

if (isSelected) {
onExtensionsChange(selectedExtensions.filter((ext) => ext.name !== extensionConfig.name));
} else {
const { enabled: _enabled, ...cleanExtension } = extensionConfig as ExtensionConfig & {
enabled?: boolean;
};
onExtensionsChange([...selectedExtensions, cleanExtension]);
}
};

const filteredExtensions = displayExtensions.filter((ext) => {
const query = searchQuery.toLowerCase();
return (
ext.name.toLowerCase().includes(query) ||
(ext.description && ext.description.toLowerCase().includes(query))
);
});

const sortedExtensions = [...filteredExtensions].sort((a, b) => {
const aSelected = selectedExtensionNames.has(a.name);
const bSelected = selectedExtensionNames.has(b.name);

if (aSelected !== bSelected) return aSelected ? -1 : 1;

return a.name.localeCompare(b.name);
});

const activeCount = selectedExtensions.length;

return (
<div className="space-y-4">
<div>
<label className="block text-md text-textProminent mb-2 font-bold">
Extensions (Optional)
</label>
<p className="text-textSubtle text-sm mb-4">
Select which extensions should be available when running this recipe. Leave empty to use
default extensions.
</p>

<Input
type="text"
placeholder="Search extensions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="mb-3"
/>

<p className="text-xs text-textSubtle mb-3 text-right">
{activeCount} extension{activeCount !== 1 ? 's' : ''} selected
</p>
</div>

<div className="max-h-[300px] overflow-y-auto border border-borderSubtle rounded-lg">
{sortedExtensions.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-textSubtle">
{searchQuery ? 'No extensions found' : 'No extensions available'}
</div>
) : (
sortedExtensions.map((ext) => {
const isSelected = selectedExtensionNames.has(ext.name);
return (
<div
key={ext.name}
className="flex items-center justify-between px-4 py-3 hover:bg-bgSubtle transition-colors cursor-pointer border-b border-borderSubtle last:border-b-0"
role="button"
tabIndex={0}
aria-pressed={isSelected}
onClick={() => handleToggle(ext)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleToggle(ext);
}
}}
title={ext.description || ext.name}
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-textStandard">
{formatExtensionName(ext.name)}
</div>
{ext.description && (
<div className="text-xs text-textSubtle truncate mt-1">{ext.description}</div>
)}
</div>
<div onClick={(e) => e.stopPropagation()} className="ml-4">
<Switch
checked={isSelected}
onCheckedChange={() => handleToggle(ext)}
variant="mono"
/>
</div>
</div>
);
})
)}
</div>
</div>
);
};
44 changes: 41 additions & 3 deletions ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { Parameter } from '../../../recipe';
import { ChevronDown } from 'lucide-react';
import { ExtensionConfig } from '../../../api';

import ParameterInput from '../../parameter/ParameterInput';
import RecipeActivityEditor from '../RecipeActivityEditor';
Expand All @@ -9,6 +10,8 @@ import InstructionsEditor from './InstructionsEditor';
import { Button } from '../../ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../../ui/collapsible';
import { RecipeFormApi, RecipeFormData } from './recipeFormSchema';
import { RecipeModelSelector } from './RecipeModelSelector';
import { RecipeExtensionSelector } from './RecipeExtensionSelector';

// Type for field API to avoid linting issues - use any to bypass complex type constraints
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -149,7 +152,12 @@ export function RecipeFormFields({
const hasActivities = Boolean(values.activities && values.activities.length > 0);
const hasParameters = Boolean(values.parameters && values.parameters.length > 0);
const hasJsonSchema = Boolean(values.jsonSchema && values.jsonSchema.trim());
return hasActivities || hasParameters || hasJsonSchema;
const hasModel = Boolean(values.model && values.model.trim());
const hasProvider = Boolean(values.provider && values.provider.trim());
const hasExtensions = Boolean(values.extensions && values.extensions.length > 0);
return (
hasActivities || hasParameters || hasJsonSchema || hasModel || hasProvider || hasExtensions
);
}, []);

const [advancedOpen, setAdvancedOpen] = useState(() => checkHasAdvancedData(form.state.values));
Expand Down Expand Up @@ -324,8 +332,10 @@ export function RecipeFormFields({
advancedOpen ? 'rotate-0' : '-rotate-90'
}`}
/>
<span className="text-sm font-medium text-text-default">Advanced Options</span>
<span className="text-xs text-text-muted">Activities, parameters, response schema</span>
<span className="text-sm font-medium text-textStandard">Advanced Options</span>
<span className="text-xs text-textSubtle">
Activities, parameters, model, extensions, response schema
</span>
</CollapsibleTrigger>

<CollapsibleContent className="mt-4 space-y-4 pl-6 border-l-2 border-border-default ml-2">
Expand Down Expand Up @@ -460,6 +470,34 @@ export function RecipeFormFields({
}}
</form.Field>

{/* Model and Provider Fields */}
<form.Field name="provider">
{(providerField: FormFieldApi<string | undefined>) => (
<form.Field name="model">
{(modelField: FormFieldApi<string | undefined>) => (
<RecipeModelSelector
selectedProvider={providerField.state.value}
selectedModel={modelField.state.value}
onProviderChange={(provider) => providerField.handleChange(provider)}
onModelChange={(model) => modelField.handleChange(model)}
/>
)}
</form.Field>
)}
</form.Field>

{/* Extensions Field */}
<form.Field name="extensions">
{(field: FormFieldApi<ExtensionConfig[] | undefined>) => (
<RecipeExtensionSelector
selectedExtensions={field.state.value || []}
onExtensionsChange={(extensions) =>
field.handleChange(extensions.length > 0 ? extensions : undefined)
}
/>
)}
</form.Field>
Comment on lines +474 to +499
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RecipeFormFields now always renders provider/model/extensions fields, but some submit flows (e.g., CreateRecipeFromSessionModal builds the Recipe payload without mapping these fields) will silently drop user selections; consider either plumbing these values through all consumers or making these selectors opt-in via props for contexts that don't persist them.

Suggested change
<form.Field name="provider">
{(providerField: FormFieldApi<string | undefined>) => (
<form.Field name="model">
{(modelField: FormFieldApi<string | undefined>) => (
<RecipeModelSelector
selectedProvider={providerField.state.value}
selectedModel={modelField.state.value}
onProviderChange={(provider) => providerField.handleChange(provider)}
onModelChange={(model) => modelField.handleChange(model)}
/>
)}
</form.Field>
)}
</form.Field>
{/* Extensions Field */}
<form.Field name="extensions">
{(field: FormFieldApi<ExtensionConfig[] | undefined>) => (
<RecipeExtensionSelector
selectedExtensions={field.state.value || []}
onExtensionsChange={(extensions) =>
field.handleChange(extensions.length > 0 ? extensions : undefined)
}
/>
)}
</form.Field>
{(() => {
const values = (form as any)?.state?.values ?? {};
const showProviderModelFields = 'provider' in values || 'model' in values;
if (!showProviderModelFields) {
return null;
}
return (
<form.Field name="provider">
{(providerField: FormFieldApi<string | undefined>) => (
<form.Field name="model">
{(modelField: FormFieldApi<string | undefined>) => (
<RecipeModelSelector
selectedProvider={providerField.state.value}
selectedModel={modelField.state.value}
onProviderChange={(provider) => providerField.handleChange(provider)}
onModelChange={(model) => modelField.handleChange(model)}
/>
)}
</form.Field>
)}
</form.Field>
);
})()}
{/* Extensions Field */}
{(() => {
const values = (form as any)?.state?.values ?? {};
const showExtensionsField = 'extensions' in values;
if (!showExtensionsField) {
return null;
}
return (
<form.Field name="extensions">
{(field: FormFieldApi<ExtensionConfig[] | undefined>) => (
<RecipeExtensionSelector
selectedExtensions={field.state.value || []}
onExtensionsChange={(extensions) =>
field.handleChange(extensions.length > 0 ? extensions : undefined)
}
/>
)}
</form.Field>
);
})()}

Copilot uses AI. Check for mistakes.

{/* JSON Schema Field */}
<form.Field name="jsonSchema">
{(field: FormFieldApi<string | undefined>) => (
Expand Down
Loading