Hide advanced recipe options under expandable content (#6021)

This commit is contained in:
Zane
2026-01-06 11:04:54 -08:00
committed by GitHub
parent 345366648e
commit 7be90cdacc
5 changed files with 265 additions and 189 deletions
@@ -1,9 +1,9 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useForm } from '@tanstack/react-form';
import { Recipe, generateDeepLink, Parameter } from '../../recipe';
import { Check, ExternalLink, Play, Save, X } from 'lucide-react';
import { Geese } from '../icons/Geese';
import Copy from '../icons/Copy';
import { Check, Save, X, Play } from 'lucide-react';
import { ExtensionConfig } from '../ConfigContext';
import { Button } from '../ui/button';
@@ -342,8 +342,17 @@ export default function CreateEditRecipeModal({
</h1>
<p className="text-textSubtle text-sm">
{isCreateMode
? 'Create a new recipe to define agent behavior and capabilities.'
: "You can edit the recipe below to change the agent's behavior in a new session."}
? 'Create a new recipe to define agent behavior and capabilities for reusable chat sessions.'
: "You can edit the recipe below to change the agent's behavior in a new session."}{' '}
<a
href="https://block.github.io/goose/docs/guides/recipes/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-500 hover:text-blue-600 hover:underline"
>
Learn more
<ExternalLink className="w-3 h-3" />
</a>
</p>
</div>
</div>
@@ -263,7 +263,7 @@ export default function CreateRecipeFromSessionModal({
className="text-lg font-medium text-textProminent"
data-testid="analyzing-title"
>
Analyzing your conversation...
Analyzing your conversation
</div>
</div>
<div className="text-textSubtle text-center max-w-md" data-testid="analysis-stage">
@@ -111,7 +111,7 @@ Use {{parameter_name}} syntax for any user-provided values.`;
className={`w-full h-full min-h-[500px] p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none font-mono text-sm ${
error ? 'border-red-500' : 'border-border-subtle'
}`}
placeholder="Detailed instructions for the AI, hidden from the user..."
placeholder="Detailed instructions for the AI, hidden from the user"
/>
{error && <p className="text-red-500 text-sm mt-2">{error}</p>}
</div>
@@ -1,12 +1,14 @@
import React, { useState } from 'react';
import { Parameter } from '../../../recipe';
import { ChevronDown } from 'lucide-react';
import ParameterInput from '../../parameter/ParameterInput';
import RecipeActivityEditor from '../RecipeActivityEditor';
import JsonSchemaEditor from './JsonSchemaEditor';
import InstructionsEditor from './InstructionsEditor';
import { Button } from '../../ui/button';
import { RecipeFormApi } from './recipeFormSchema';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../../ui/collapsible';
import { RecipeFormApi, RecipeFormData } from './recipeFormSchema';
// Type for field API to avoid linting issues - use any to bypass complex type constraints
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -143,6 +145,15 @@ export function RecipeFormFields({
return usedInInstructions || usedInPrompt || usedInActivities;
};
const checkHasAdvancedData = React.useCallback((values: RecipeFormData) => {
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 [advancedOpen, setAdvancedOpen] = useState(() => checkHasAdvancedData(form.state.values));
return (
<div className="space-y-4" data-testid="recipe-form">
{/* Title Field */}
@@ -244,12 +255,13 @@ export function RecipeFormFields({
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none font-mono text-sm ${
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
}`}
placeholder="Detailed instructions for the AI, hidden from the user..."
placeholder="Detailed instructions for the AI, hidden from the user"
rows={8}
data-testid="instructions-input"
/>
<p className="text-xs text-text-muted mt-1">
Use {`{{parameter_name}}`} to define parameters that users can fill in
Use {`{{parameter_name}}`} to define parameters that can be filled in when running the
recipe.
</p>
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm mt-1">{field.state.meta.errors[0]}</p>
@@ -296,7 +308,7 @@ export function RecipeFormFields({
updateParametersFromFields();
}}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
placeholder="Pre-filled prompt when the recipe starts..."
placeholder="Pre-filled prompt when the recipe starts"
rows={3}
data-testid="prompt-input"
/>
@@ -304,187 +316,206 @@ export function RecipeFormFields({
)}
</form.Field>
{/* Activities Field */}
<form.Field name="activities">
{(field: FormFieldApi<string[]>) => (
<div>
<RecipeActivityEditor
activities={field.state.value}
setActivities={(activities) => field.handleChange(activities)}
onBlur={updateParametersFromFields}
/>
</div>
)}
</form.Field>
{/* Advanced Section - Collapsible */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="mt-6">
<CollapsibleTrigger className="flex items-baseline gap-2 w-full py-3 px-4 bg-bgSubtle hover:bg-bgSubtle/80 rounded-lg transition-colors border border-borderSubtle">
<ChevronDown
className={`w-4 h-4 text-textSubtle transition-transform duration-200 flex-shrink-0 relative top-0.5 ${
advancedOpen ? 'rotate-0' : '-rotate-90'
}`}
/>
<span className="text-sm font-medium text-textStandard">Advanced Options</span>
<span className="text-xs text-textSubtle">Activities, parameters, response schema</span>
</CollapsibleTrigger>
{/* Parameters Field */}
<form.Field name="parameters">
{(field: FormFieldApi<Parameter[]>) => {
const handleAddParameter = () => {
if (newParameterName.trim()) {
const newParam: Parameter = {
key: newParameterName.trim(),
description: `Enter value for ${newParameterName.trim()}`,
input_type: 'string',
requirement: 'required',
};
field.handleChange([...field.state.value, newParam]);
setNewParameterName('');
// Expand the newly added parameter by default
setExpandedParameters((prev) => {
const newSet = new Set(prev);
newSet.add(newParam.key);
return newSet;
});
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddParameter();
}
};
const handleDeleteParameter = (parameterKey: string) => {
const updatedParams = field.state.value.filter(
(param: Parameter) => param.key !== parameterKey
);
field.handleChange(updatedParams);
// Remove from expanded set if it was expanded
setExpandedParameters((prev) => {
const newSet = new Set(prev);
newSet.delete(parameterKey);
return newSet;
});
};
const handleToggleExpanded = (parameterKey: string) => {
setExpandedParameters((prev) => {
const newSet = new Set(prev);
if (newSet.has(parameterKey)) {
newSet.delete(parameterKey);
} else {
newSet.add(parameterKey);
}
return newSet;
});
};
return (
<div>
<label className="block text-md text-textProminent mb-2 font-bold">Parameters</label>
<p className="text-textSubtle text-sm space-y-2 pb-4">
Parameters will be automatically detected from {`{{parameter_name}}`} syntax in
instructions/prompt/activities or you can manually add them below.
</p>
{/* Add Parameter Input - Always Visible */}
<div className="flex gap-2 mb-4">
<input
type="text"
value={newParameterName}
onChange={(e) => setNewParameterName(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Enter parameter name..."
className="flex-1 px-3 py-2 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
<CollapsibleContent className="mt-4 space-y-4 pl-6 border-l-2 border-borderSubtle ml-2">
{/* Activities Field */}
<form.Field name="activities">
{(field: FormFieldApi<string[]>) => (
<div>
<RecipeActivityEditor
activities={field.state.value}
setActivities={(activities) => field.handleChange(activities)}
onBlur={updateParametersFromFields}
/>
<button
type="button"
onClick={handleAddParameter}
disabled={!newParameterName.trim()}
className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-600 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Add parameter
</button>
</div>
{field.state.value.length > 0 &&
field.state.value
.filter((parameter: Parameter) => parameter.key && parameter.key.trim()) // Filter out empty parameters
.map((parameter: Parameter) => {
const currentValues = form.state.values;
const isUnused = !isParameterUsed(
parameter.key,
currentValues.instructions,
currentValues.prompt,
currentValues.activities
);
return (
<ParameterInput
key={parameter.key}
parameter={parameter}
isUnused={isUnused}
isExpanded={expandedParameters.has(parameter.key)}
onToggleExpanded={handleToggleExpanded}
onDelete={handleDeleteParameter}
onChange={(name, value) => {
const updatedParams = field.state.value.map((param: Parameter) =>
param.key === name ? { ...param, ...value } : param
);
field.handleChange(updatedParams);
}}
/>
);
})}
</div>
);
}}
</form.Field>
{/* JSON Schema Field */}
<form.Field name="jsonSchema">
{(field: FormFieldApi<string | undefined>) => (
<div>
<label className="block text-md text-textProminent mb-2 font-bold">
Response JSON Schema
</label>
<p className="text-textSubtle text-sm space-y-2 pb-4">
Define the expected structure of the AI's response using JSON Schema format
</p>
<div className="flex items-center justify-between mb-2">
<Button
type="button"
onClick={() => setShowJsonSchemaEditor(true)}
variant="outline"
size="sm"
className="text-xs"
>
Open Editor
</Button>
</div>
{field.state.value && field.state.value.trim() && (
<div
className={`border rounded-lg p-3 bg-background-muted ${
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
}`}
>
<pre className="text-xs font-mono text-text-standard whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
{field.state.value}
</pre>
</div>
)}
</form.Field>
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm mt-1">{field.state.meta.errors[0]}</p>
{/* Parameters Field */}
<form.Field name="parameters">
{(field: FormFieldApi<Parameter[]>) => {
const handleAddParameter = () => {
if (newParameterName.trim()) {
const newParam: Parameter = {
key: newParameterName.trim(),
description: `Enter value for ${newParameterName.trim()}`,
input_type: 'string',
requirement: 'required',
};
field.handleChange([...field.state.value, newParam]);
setNewParameterName('');
// Expand the newly added parameter by default
setExpandedParameters((prev) => {
const newSet = new Set(prev);
newSet.add(newParam.key);
return newSet;
});
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddParameter();
}
};
const handleDeleteParameter = (parameterKey: string) => {
const updatedParams = field.state.value.filter(
(param: Parameter) => param.key !== parameterKey
);
field.handleChange(updatedParams);
// Remove from expanded set if it was expanded
setExpandedParameters((prev) => {
const newSet = new Set(prev);
newSet.delete(parameterKey);
return newSet;
});
};
const handleToggleExpanded = (parameterKey: string) => {
setExpandedParameters((prev) => {
const newSet = new Set(prev);
if (newSet.has(parameterKey)) {
newSet.delete(parameterKey);
} else {
newSet.add(parameterKey);
}
return newSet;
});
};
return (
<div>
<label className="block text-md text-textProminent mb-2 font-bold">
Parameters
</label>
<p className="text-textSubtle text-sm space-y-2 pb-4">
Parameters will be automatically detected from {`{{parameter_name}}`} syntax in
instructions/prompt/activities or you can manually add them below.
</p>
{/* Add Parameter Input - Always Visible */}
<div className="flex gap-2 mb-4">
<input
type="text"
value={newParameterName}
onChange={(e) => setNewParameterName(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Enter parameter name..."
className="flex-1 px-3 py-2 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
<button
type="button"
onClick={handleAddParameter}
disabled={!newParameterName.trim()}
className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-600 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
>
Add parameter
</button>
</div>
{field.state.value.length > 0 &&
field.state.value
.filter((parameter: Parameter) => parameter.key && parameter.key.trim()) // Filter out empty parameters
.map((parameter: Parameter) => {
const currentValues = form.state.values;
const isUnused = !isParameterUsed(
parameter.key,
currentValues.instructions,
currentValues.prompt,
currentValues.activities
);
return (
<ParameterInput
key={parameter.key}
parameter={parameter}
isUnused={isUnused}
isExpanded={expandedParameters.has(parameter.key)}
onToggleExpanded={handleToggleExpanded}
onDelete={handleDeleteParameter}
onChange={(name, value) => {
const updatedParams = field.state.value.map((param: Parameter) =>
param.key === name ? { ...param, ...value } : param
);
field.handleChange(updatedParams);
}}
/>
);
})}
</div>
);
}}
</form.Field>
{/* JSON Schema Field */}
<form.Field name="jsonSchema">
{(field: FormFieldApi<string | undefined>) => (
<div>
<label className="block text-md text-textProminent mb-2 font-bold">
Response JSON Schema
</label>
<p className="text-textSubtle text-sm space-y-2 pb-4">
Define the expected structure of the AI's response using JSON Schema format
</p>
<div className="flex items-center justify-between mb-2">
<Button
type="button"
onClick={() => setShowJsonSchemaEditor(true)}
variant="outline"
size="sm"
className="text-xs"
>
Open Editor
</Button>
</div>
{field.state.value && field.state.value.trim() && (
<div
className={`border rounded-lg p-3 bg-background-muted ${
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
}`}
>
<pre className="text-xs font-mono text-text-standard whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
{field.state.value}
</pre>
</div>
)}
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm mt-1">{field.state.meta.errors[0]}</p>
)}
{/* JSON Schema Editor Modal */}
<JsonSchemaEditor
isOpen={showJsonSchemaEditor}
onClose={() => setShowJsonSchemaEditor(false)}
value={field.state.value || ''}
onChange={(value) => {
field.handleChange(value);
onJsonSchemaChange?.(value);
}}
error={
field.state.meta.errors.length > 0 ? field.state.meta.errors[0] : undefined
}
/>
</div>
)}
{/* JSON Schema Editor Modal */}
<JsonSchemaEditor
isOpen={showJsonSchemaEditor}
onClose={() => setShowJsonSchemaEditor(false)}
value={field.state.value || ''}
onChange={(value) => {
field.handleChange(value);
onJsonSchemaChange?.(value);
}}
error={field.state.meta.errors.length > 0 ? field.state.meta.errors[0] : undefined}
/>
</div>
)}
</form.Field>
</form.Field>
</CollapsibleContent>
</Collapsible>
</div>
);
}
@@ -6,6 +6,14 @@ import { useForm } from '@tanstack/react-form';
import { RecipeFormFields, extractTemplateVariables } from '../RecipeFormFields';
import { type RecipeFormData } from '../recipeFormSchema';
const expandAdvancedSection = async (user: ReturnType<typeof userEvent.setup>) => {
const advancedTrigger = screen.getByRole('button', { name: /advanced options/i });
const activitiesField = screen.queryByText('Activities');
if (!activitiesField) {
await user.click(advancedTrigger);
}
};
describe('RecipeFormFields', () => {
const useTestForm = (initialValues?: Partial<RecipeFormData>) => {
const defaultValues: RecipeFormData = {
@@ -48,13 +56,19 @@ describe('RecipeFormFields', () => {
expect(screen.getByLabelText(/title/i)).toBeInTheDocument();
});
it('renders required form fields', () => {
it('renders required form fields', async () => {
const user = userEvent.setup();
render(<TestWrapper />);
expect(screen.getByLabelText(/title/i)).toBeInTheDocument();
expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
expect(screen.getByLabelText(/instructions/i)).toBeInTheDocument();
expect(screen.getByLabelText(/prompt/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /advanced options/i })).toBeInTheDocument();
await expandAdvancedSection(user);
expect(screen.getAllByText(/activities/i)[0]).toBeInTheDocument();
expect(screen.getAllByText(/parameters/i)[0]).toBeInTheDocument();
expect(screen.getByText(/response json schema/i)).toBeInTheDocument();
@@ -99,9 +113,12 @@ describe('RecipeFormFields', () => {
});
describe('Parameter Management', () => {
it('shows parameter input section', () => {
it('shows parameter input section', async () => {
const user = userEvent.setup();
render(<TestWrapper />);
await expandAdvancedSection(user);
expect(screen.getByPlaceholderText('Enter parameter name...')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /add parameter/i })).toBeInTheDocument();
});
@@ -110,6 +127,8 @@ describe('RecipeFormFields', () => {
const user = userEvent.setup();
render(<TestWrapper />);
await expandAdvancedSection(user);
const parameterInput = screen.getByPlaceholderText('Enter parameter name...');
const addButton = screen.getByRole('button', { name: /add parameter/i });
@@ -160,7 +179,7 @@ describe('RecipeFormFields', () => {
render(<TestWrapper />);
const instructionsInput = screen.getByPlaceholderText(
'Detailed instructions for the AI, hidden from the user...'
'Detailed instructions for the AI, hidden from the user'
);
// Type instructions with template variables - use paste to avoid curly brace issues
@@ -173,6 +192,8 @@ describe('RecipeFormFields', () => {
// Just verify the component doesn't crash and the text is there
expect(instructionsInput).toHaveValue('Hello {{name}}, please {{action}} the {{item}}');
await expandAdvancedSection(user);
// Check that the parameter section exists
expect(screen.getByText('Parameters')).toBeInTheDocument();
expect(
@@ -186,6 +207,8 @@ describe('RecipeFormFields', () => {
const user = userEvent.setup();
render(<TestWrapper />);
await expandAdvancedSection(user);
// Add a manual parameter
const parameterInput = screen.getByPlaceholderText('Enter parameter name...');
const addButton = screen.getByText('Add parameter');
@@ -200,9 +223,12 @@ describe('RecipeFormFields', () => {
expect(parameterInput).toHaveValue('');
});
it('shows parameter management UI', () => {
it('shows parameter management UI', async () => {
const user = userEvent.setup();
render(<TestWrapper />);
await expandAdvancedSection(user);
// Check parameter section exists
expect(screen.getByText('Parameters')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter parameter name...')).toBeInTheDocument();
@@ -216,6 +242,8 @@ describe('RecipeFormFields', () => {
const user = userEvent.setup();
render(<TestWrapper />);
await expandAdvancedSection(user);
// Check that activities section exists
expect(screen.getByText('Activities')).toBeInTheDocument();
expect(screen.getByText('Message')).toBeInTheDocument();
@@ -259,7 +287,7 @@ describe('RecipeFormFields', () => {
render(<TestComponent />);
const instructionsInput = screen.getByPlaceholderText(
'Detailed instructions for the AI, hidden from the user...'
'Detailed instructions for the AI, hidden from the user'
);
// Add instructions with template variables
@@ -272,6 +300,8 @@ describe('RecipeFormFields', () => {
// Wait a moment for the parameter detection to process
await new Promise((resolve) => setTimeout(resolve, 100));
await expandAdvancedSection(user);
// Check if parameters were detected and added
// The parameters should appear as text in the parameter section
const parameterSection = screen.getByText('Parameters').closest('div');
@@ -408,6 +438,8 @@ describe('RecipeFormFields', () => {
const user = userEvent.setup();
render(<TestWrapper />);
await expandAdvancedSection(user);
// Add a manual parameter
const parameterInput = screen.getByPlaceholderText('Enter parameter name...');
const addButton = screen.getByText('Add parameter');
@@ -617,6 +649,8 @@ describe('RecipeFormFields', () => {
const user = userEvent.setup();
render(<TestWrapper />);
await expandAdvancedSection(user);
// Add a manual parameter
const parameterInput = screen.getByPlaceholderText('Enter parameter name...');
const addButton = screen.getByText('Add parameter');
@@ -648,6 +682,8 @@ describe('RecipeFormFields', () => {
const user = userEvent.setup();
render(<TestWrapper />);
await expandAdvancedSection(user);
// Add a parameter and test changing its type
const parameterInput = screen.getByPlaceholderText('Enter parameter name...');
const addButton = screen.getByText('Add parameter');