Create / edit recipe form unification and improvements (#4693)

This commit is contained in:
Zane
2025-10-05 09:00:59 -07:00
committed by GitHub
parent ebb3888ff1
commit f4d79404ee
55 changed files with 4486 additions and 3074 deletions
+1 -1
View File
@@ -278,7 +278,7 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
process::exit(1);
});
// Handle session file resolution and resuming
// Handle session resolution and resuming
let session_id: Option<String> = if session_config.no_session {
None
} else if session_config.resume {
+2 -1
View File
@@ -6,7 +6,6 @@ use goose::config::ExtensionEntry;
use goose::conversation::Conversation;
use goose::permission::permission_confirmation::PrincipalType;
use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata};
use goose::session::{Session, SessionInsights};
use rmcp::model::{
Annotations, Content, EmbeddedResource, Icon, ImageContent, JsonObject, RawAudioContent,
@@ -353,6 +352,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::session::get_session_insights,
super::routes::session::update_session_description,
super::routes::session::delete_session,
super::routes::session::update_session_user_recipe_values,
super::routes::schedule::create_schedule,
super::routes::schedule::list_schedules,
super::routes::schedule::delete_schedule,
@@ -391,6 +391,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::context::ContextManageResponse,
super::routes::session::SessionListResponse,
super::routes::session::UpdateSessionDescriptionRequest,
super::routes::session::UpdateSessionUserRecipeValuesRequest,
Message,
MessageContent,
MessageMetadata,
+32 -21
View File
@@ -4,10 +4,10 @@ use std::sync::Arc;
use axum::routing::get;
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use goose::conversation::{message::Message, Conversation};
use goose::recipe::recipe_library;
use goose::recipe::Recipe;
use goose::recipe_deeplink;
use goose::session::SessionManager;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
@@ -18,16 +18,10 @@ use crate::state::AppState;
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateRecipeRequest {
messages: Vec<Message>,
// Required metadata
title: String,
description: String,
session_id: String,
// Optional fields
#[serde(default)]
activities: Option<Vec<String>>,
#[serde(default)]
author: Option<AuthorRequest>,
session_id: String,
}
#[derive(Debug, Deserialize, ToSchema)]
@@ -127,25 +121,38 @@ async fn create_recipe(
Json(request): Json<CreateRecipeRequest>,
) -> Result<Json<CreateRecipeResponse>, StatusCode> {
tracing::info!(
"Recipe creation request received with {} messages",
request.messages.len()
"Recipe creation request received for session_id: {}",
request.session_id
);
// Load messages from session
let session = match SessionManager::get_session(&request.session_id, true).await {
Ok(session) => session,
Err(e) => {
tracing::error!("Failed to get session: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let conversation = match session.conversation {
Some(conversation) => conversation,
None => {
let error_message = "Session has no conversation".to_string();
let error_response = CreateRecipeResponse {
recipe: None,
error: Some(error_message),
};
return Ok(Json(error_response));
}
};
let agent = state.get_agent_for_route(request.session_id).await?;
// Create base recipe from agent state and messages
let recipe_result = agent
.create_recipe(Conversation::new_unvalidated(request.messages))
.await;
let recipe_result = agent.create_recipe(conversation).await;
match recipe_result {
Ok(mut recipe) => {
recipe.title = request.title;
recipe.description = request.description;
if request.activities.is_some() {
recipe.activities = request.activities
};
if let Some(author_req) = request.author {
recipe.author = Some(goose::recipe::Author {
contact: author_req.contact,
@@ -160,7 +167,11 @@ async fn create_recipe(
}
Err(e) => {
tracing::error!("Error details: {:?}", e);
Err(StatusCode::BAD_REQUEST)
let error_response = CreateRecipeResponse {
recipe: None,
error: Some(format!("Failed to create recipe: {}", e)),
};
Ok(Json(error_response))
}
}
}
@@ -241,7 +252,7 @@ async fn scan_recipe(
async fn list_recipes(
State(state): State<Arc<AppState>>,
) -> Result<Json<ListRecipeResponse>, StatusCode> {
let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap();
let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap_or_default();
let mut recipe_file_hash_map = HashMap::new();
let recipe_manifest_responses = recipe_manifest_with_paths
.iter()
+44
View File
@@ -8,6 +8,7 @@ use axum::{
use goose::session::session_manager::SessionInsights;
use goose::session::{Session, SessionManager};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use utoipa::ToSchema;
@@ -25,6 +26,13 @@ pub struct UpdateSessionDescriptionRequest {
description: String,
}
#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSessionUserRecipeValuesRequest {
/// Recipe parameter values entered by the user
user_recipe_values: HashMap<String, String>,
}
const MAX_DESCRIPTION_LENGTH: usize = 200;
#[utoipa::path(
@@ -128,6 +136,38 @@ async fn update_session_description(
Ok(StatusCode::OK)
}
#[utoipa::path(
put,
path = "/sessions/{session_id}/user_recipe_values",
request_body = UpdateSessionUserRecipeValuesRequest,
params(
("session_id" = String, Path, description = "Unique identifier for the session")
),
responses(
(status = 200, description = "Session user recipe values updated successfully"),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 404, description = "Session not found"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Session Management"
)]
// Update session user recipe parameter values
async fn update_session_user_recipe_values(
Path(session_id): Path<String>,
Json(request): Json<UpdateSessionUserRecipeValuesRequest>,
) -> Result<StatusCode, StatusCode> {
SessionManager::update_session(&session_id)
.user_recipe_values(Some(request.user_recipe_values))
.apply()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::OK)
}
#[utoipa::path(
delete,
path = "/sessions/{session_id}",
@@ -169,5 +209,9 @@ pub fn routes(state: Arc<AppState>) -> Router {
"/sessions/{session_id}/description",
put(update_session_description),
)
.route(
"/sessions/{session_id}/user_recipe_values",
put(update_session_user_recipe_values),
)
.with_state(state)
}
+24 -2
View File
@@ -1609,9 +1609,31 @@ impl Agent {
extension_configs.len()
);
let (title, description) =
if let Ok(json_content) = serde_json::from_str::<Value>(&clean_content) {
let title = json_content
.get("title")
.and_then(|t| t.as_str())
.unwrap_or("Custom recipe from chat")
.to_string();
let description = json_content
.get("description")
.and_then(|d| d.as_str())
.unwrap_or("a custom recipe instance from this chat session")
.to_string();
(title, description)
} else {
(
"Custom recipe from chat".to_string(),
"a custom recipe instance from this chat session".to_string(),
)
};
let recipe = Recipe::builder()
.title("Custom recipe from chat")
.description("a custom recipe instance from this chat session")
.title(title)
.description(description)
.instructions(instructions)
.activities(activities)
.extensions(extension_configs)
@@ -330,6 +330,7 @@ mod tests {
extension_data: extension_data::ExtensionData::new(),
conversation: Some(conversation),
message_count,
user_recipe_values: None,
}
}
+7 -4
View File
@@ -1,13 +1,16 @@
Based on our conversation so far, could you create:
1. A concise set of instructions (1-2 paragraphs) that describe what you've been helping with. Make the instructions generic, and higher-level so that can be re-used across various similar tasks. Pay special attention if any output styles or formats are requested (and make it clear), and note any non standard tools used or required.
1. A concise title (5-10 words) that captures the main topic or task
2. A brief description (1-2 sentences) that summarizes what this recipe helps with
3. A concise set of instructions (1-2 paragraphs) that describe what you've been helping with. Make the instructions generic, and higher-level so that can be re-used across various similar tasks. Pay special attention if any output styles or formats are requested (and make it clear), and note any non standard tools used or required.
4. A list of 3-5 example activities (as a few words each at most) that would be relevant to this topic
2. A list of 3-5 example activities (as a few words each at most) that would be relevant to this topic
Format your response in _VALID_ json, with one key being `instructions` which contains a string, and the other key `activities` as an array of strings.
Format your response in _VALID_ json, with keys being `title`, `description`, `instructions` (string), and `activities` (array of strings).
For example, perhaps we have been discussing fruit and you might write:
{
"title": "Fruit Information Assistant",
"description": "A recipe for finding and sharing information about different types of fruit.",
"instructions": "Using web searches we find pictures of fruit, and always check what language to reply in.",
"activities": [
"Show pics of apples",
+47 -6
View File
@@ -11,6 +11,7 @@ use rmcp::model::Role;
use serde::{Deserialize, Serialize};
use sqlx::sqlite::SqliteConnectOptions;
use sqlx::{Pool, Sqlite};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -18,7 +19,7 @@ use tokio::sync::OnceCell;
use tracing::{info, warn};
use utoipa::ToSchema;
const CURRENT_SCHEMA_VERSION: i32 = 1;
const CURRENT_SCHEMA_VERSION: i32 = 2;
static SESSION_STORAGE: OnceCell<Arc<SessionStorage>> = OnceCell::const_new();
@@ -39,6 +40,7 @@ pub struct Session {
pub accumulated_output_tokens: Option<i32>,
pub schedule_id: Option<String>,
pub recipe: Option<Recipe>,
pub user_recipe_values: Option<HashMap<String, String>>,
pub conversation: Option<Conversation>,
pub message_count: usize,
}
@@ -56,6 +58,7 @@ pub struct SessionUpdateBuilder {
accumulated_output_tokens: Option<Option<i32>>,
schedule_id: Option<Option<String>>,
recipe: Option<Option<Recipe>>,
user_recipe_values: Option<Option<HashMap<String, String>>>,
}
#[derive(Serialize, ToSchema, Debug)]
@@ -82,6 +85,7 @@ impl SessionUpdateBuilder {
accumulated_output_tokens: None,
schedule_id: None,
recipe: None,
user_recipe_values: None,
}
}
@@ -140,6 +144,14 @@ impl SessionUpdateBuilder {
self
}
pub fn user_recipe_values(
mut self,
user_recipe_values: Option<HashMap<String, String>>,
) -> Self {
self.user_recipe_values = Some(user_recipe_values);
self
}
pub async fn apply(self) -> Result<()> {
SessionManager::apply_update(self).await
}
@@ -265,6 +277,7 @@ impl Default for Session {
accumulated_output_tokens: None,
schedule_id: None,
recipe: None,
user_recipe_values: None,
conversation: None,
message_count: 0,
}
@@ -285,6 +298,10 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session {
let recipe_json: Option<String> = row.try_get("recipe_json")?;
let recipe = recipe_json.and_then(|json| serde_json::from_str(&json).ok());
let user_recipe_values_json: Option<String> = row.try_get("user_recipe_values_json")?;
let user_recipe_values =
user_recipe_values_json.and_then(|json| serde_json::from_str(&json).ok());
Ok(Session {
id: row.try_get("id")?,
working_dir: PathBuf::from(row.try_get::<String, _>("working_dir")?),
@@ -301,6 +318,7 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session {
accumulated_output_tokens: row.try_get("accumulated_output_tokens")?,
schedule_id: row.try_get("schedule_id")?,
recipe,
user_recipe_values,
conversation: None,
message_count: row.try_get("message_count").unwrap_or(0) as usize,
})
@@ -386,7 +404,8 @@ impl SessionStorage {
accumulated_input_tokens INTEGER,
accumulated_output_tokens INTEGER,
schedule_id TEXT,
recipe_json TEXT
recipe_json TEXT,
user_recipe_values_json TEXT
)
"#,
)
@@ -472,14 +491,19 @@ impl SessionStorage {
None => None,
};
let user_recipe_values_json = match &session.user_recipe_values {
Some(user_recipe_values) => Some(serde_json::to_string(user_recipe_values)?),
None => None,
};
sqlx::query(
r#"
INSERT INTO sessions (
id, description, working_dir, created_at, updated_at, extension_data,
total_tokens, input_tokens, output_tokens,
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
schedule_id, recipe_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
schedule_id, recipe_json, user_recipe_values_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&session.id)
@@ -496,6 +520,7 @@ impl SessionStorage {
.bind(session.accumulated_output_tokens)
.bind(&session.schedule_id)
.bind(recipe_json)
.bind(user_recipe_values_json)
.execute(&self.pool)
.await?;
@@ -572,6 +597,15 @@ impl SessionStorage {
.execute(&self.pool)
.await?;
}
2 => {
sqlx::query(
r#"
ALTER TABLE sessions ADD COLUMN user_recipe_values_json TEXT
"#,
)
.execute(&self.pool)
.await?;
}
_ => {
anyhow::bail!("Unknown migration version: {}", version);
}
@@ -612,7 +646,7 @@ impl SessionStorage {
SELECT id, working_dir, description, created_at, updated_at, extension_data,
total_tokens, input_tokens, output_tokens,
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
schedule_id, recipe_json
schedule_id, recipe_json, user_recipe_values_json
FROM sessions
WHERE id = ?
"#,
@@ -669,6 +703,7 @@ impl SessionStorage {
);
add_update!(builder.schedule_id, "schedule_id");
add_update!(builder.recipe, "recipe_json");
add_update!(builder.user_recipe_values, "user_recipe_values_json");
if updates.is_empty() {
return Ok(());
@@ -715,6 +750,12 @@ impl SessionStorage {
let recipe_json = recipe.map(|r| serde_json::to_string(&r)).transpose()?;
q = q.bind(recipe_json);
}
if let Some(user_recipe_values) = builder.user_recipe_values {
let user_recipe_values_json = user_recipe_values
.map(|urv| serde_json::to_string(&urv))
.transpose()?;
q = q.bind(user_recipe_values_json);
}
q = q.bind(&builder.session_id);
q.execute(&self.pool).await?;
@@ -805,7 +846,7 @@ impl SessionStorage {
SELECT s.id, s.working_dir, s.description, s.created_at, s.updated_at, s.extension_data,
s.total_tokens, s.input_tokens, s.output_tokens,
s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens,
s.schedule_id, s.recipe_json,
s.schedule_id, s.recipe_json, s.user_recipe_values_json,
COUNT(m.id) as message_count
FROM sessions s
INNER JOIN messages m ON s.id = m.session_id
+1
View File
@@ -395,5 +395,6 @@ pub fn create_test_session_metadata(message_count: usize, working_dir: &str) ->
updated_at: Default::default(),
conversation: None,
message_count,
user_recipe_values: None,
}
}
+70 -22
View File
@@ -1778,6 +1778,54 @@
]
}
},
"/sessions/{session_id}/user_recipe_values": {
"put": {
"tags": [
"Session Management"
],
"operationId": "update_session_user_recipe_values",
"parameters": [
{
"name": "session_id",
"in": "path",
"description": "Unique identifier for the session",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateSessionUserRecipeValuesRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Session user recipe values updated successfully"
},
"401": {
"description": "Unauthorized - Invalid or missing API key"
},
"404": {
"description": "Session not found"
},
"500": {
"description": "Internal server error"
}
},
"security": [
{
"api_key": []
}
]
}
},
"/status": {
"get": {
"tags": [
@@ -2104,19 +2152,9 @@
"CreateRecipeRequest": {
"type": "object",
"required": [
"messages",
"title",
"description",
"session_id"
],
"properties": {
"activities": {
"type": "array",
"items": {
"type": "string"
},
"nullable": true
},
"author": {
"allOf": [
{
@@ -2125,20 +2163,8 @@
],
"nullable": true
},
"description": {
"type": "string"
},
"messages": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Message"
}
},
"session_id": {
"type": "string"
},
"title": {
"type": "string"
}
}
},
@@ -3875,6 +3901,13 @@
"type": "string",
"format": "date-time"
},
"user_recipe_values": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"nullable": true
},
"working_dir": {
"type": "string"
}
@@ -4379,6 +4412,21 @@
}
}
},
"UpdateSessionUserRecipeValuesRequest": {
"type": "object",
"required": [
"userRecipeValues"
],
"properties": {
"userRecipeValues": {
"type": "object",
"description": "Recipe parameter values entered by the user",
"additionalProperties": {
"type": "string"
}
}
}
},
"UpsertConfigQuery": {
"type": "object",
"required": [
+3 -5
View File
@@ -123,16 +123,14 @@ vi.mock('./contexts/ChatContext', () => ({
title: 'Test Chat',
messages: [],
messageHistoryIndex: 0,
recipeConfig: null,
recipe: null,
},
setChat: vi.fn(),
setPairChat: vi.fn(), // Keep this from HEAD
resetChat: vi.fn(),
hasActiveSession: false,
setRecipeConfig: vi.fn(),
clearRecipeConfig: vi.fn(),
setRecipeParameters: vi.fn(),
clearRecipeParameters: vi.fn(),
setRecipe: vi.fn(),
clearRecipe: vi.fn(),
draft: '',
setDraft: vi.fn(),
clearDraft: vi.fn(),
+2 -30
View File
@@ -36,7 +36,6 @@ import PermissionSettingsView from './components/settings/permission/PermissionS
import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView';
import RecipesView from './components/recipes/RecipesView';
import RecipeEditor from './components/recipes/RecipeEditor';
import { View, ViewOptions } from './utils/navigationUtils';
import {
AgentState,
@@ -122,9 +121,7 @@ const SettingsRoute = () => {
};
const SessionsRoute = () => {
const setView = useNavigation();
return <SessionsView setView={setView} />;
return <SessionsView />;
};
const SchedulesRoute = () => {
@@ -136,30 +133,6 @@ const RecipesRoute = () => {
return <RecipesView />;
};
const RecipeEditorRoute = () => {
// Check for config from multiple sources:
// 1. localStorage (from "View Recipe" button)
// 2. Window electron config (from deeplinks)
let config;
const storedConfig = localStorage.getItem('viewRecipeConfig');
if (storedConfig) {
try {
config = JSON.parse(storedConfig);
// Clear the stored config after using it
localStorage.removeItem('viewRecipeConfig');
} catch (error) {
console.error('Failed to parse stored recipe config:', error);
}
}
if (!config) {
const electronConfig = window.electron.getConfig();
config = electronConfig.recipe;
}
return <RecipeEditor config={config} />;
};
const PermissionRoute = () => {
const location = useLocation();
const navigate = useNavigate();
@@ -322,7 +295,7 @@ export function AppInner() {
title: 'Pair Chat',
messages: [],
messageHistoryIndex: 0,
recipeConfig: null,
recipe: null,
});
const { addExtension } = useConfig();
@@ -598,7 +571,6 @@ export function AppInner() {
<Route path="sessions" element={<SessionsRoute />} />
<Route path="schedules" element={<SchedulesRoute />} />
<Route path="recipes" element={<RecipesRoute />} />
<Route path="recipe-editor" element={<RecipeEditorRoute />} />
<Route
path="shared-session"
element={
+12 -1
View File
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from './client';
import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ParseRecipeData, ParseRecipeResponses, ParseRecipeErrors, SaveRecipeData, SaveRecipeResponses, SaveRecipeErrors, ScanRecipeData, ScanRecipeResponses, ReplyData, ReplyResponses, ReplyErrors, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionInsightsData, GetSessionInsightsResponses, GetSessionInsightsErrors, DeleteSessionData, DeleteSessionResponses, DeleteSessionErrors, GetSessionData, GetSessionResponses, GetSessionErrors, UpdateSessionDescriptionData, UpdateSessionDescriptionResponses, UpdateSessionDescriptionErrors, StatusData, StatusResponses } from './types.gen';
import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ParseRecipeData, ParseRecipeResponses, ParseRecipeErrors, SaveRecipeData, SaveRecipeResponses, SaveRecipeErrors, ScanRecipeData, ScanRecipeResponses, ReplyData, ReplyResponses, ReplyErrors, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionInsightsData, GetSessionInsightsResponses, GetSessionInsightsErrors, DeleteSessionData, DeleteSessionResponses, DeleteSessionErrors, GetSessionData, GetSessionResponses, GetSessionErrors, UpdateSessionDescriptionData, UpdateSessionDescriptionResponses, UpdateSessionDescriptionErrors, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesResponses, UpdateSessionUserRecipeValuesErrors, StatusData, StatusResponses } from './types.gen';
import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -489,6 +489,17 @@ export const updateSessionDescription = <ThrowOnError extends boolean = false>(o
});
};
export const updateSessionUserRecipeValues = <ThrowOnError extends boolean = false>(options: Options<UpdateSessionUserRecipeValuesData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).put<UpdateSessionUserRecipeValuesResponses, UpdateSessionUserRecipeValuesErrors, ThrowOnError>({
url: '/sessions/{session_id}/user_recipe_values',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const status = <ThrowOnError extends boolean = false>(options?: Options<StatusData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<StatusResponses, unknown, ThrowOnError>({
url: '/status',
+46 -4
View File
@@ -120,12 +120,8 @@ export type CreateCustomProviderRequest = {
};
export type CreateRecipeRequest = {
activities?: Array<string> | null;
author?: AuthorRequest | null;
description: string;
messages: Array<Message>;
session_id: string;
title: string;
};
export type CreateRecipeResponse = {
@@ -743,6 +739,9 @@ export type Session = {
schedule_id?: string | null;
total_tokens?: number | null;
updated_at: string;
user_recipe_values?: {
[key: string]: string;
} | null;
working_dir: string;
};
@@ -925,6 +924,15 @@ export type UpdateSessionDescriptionRequest = {
description: string;
};
export type UpdateSessionUserRecipeValuesRequest = {
/**
* Recipe parameter values entered by the user
*/
userRecipeValues: {
[key: string]: string;
};
};
export type UpsertConfigQuery = {
is_secret: boolean;
key: string;
@@ -2357,6 +2365,40 @@ export type UpdateSessionDescriptionResponses = {
200: unknown;
};
export type UpdateSessionUserRecipeValuesData = {
body: UpdateSessionUserRecipeValuesRequest;
path: {
/**
* Unique identifier for the session
*/
session_id: string;
};
query?: never;
url: '/sessions/{session_id}/user_recipe_values';
};
export type UpdateSessionUserRecipeValuesErrors = {
/**
* Unauthorized - Invalid or missing API key
*/
401: unknown;
/**
* Session not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type UpdateSessionUserRecipeValuesResponses = {
/**
* Session user recipe values updated successfully
*/
200: unknown;
};
export type StatusData = {
body?: never;
path?: never;
+31 -50
View File
@@ -45,7 +45,6 @@ import React, { createContext, useContext, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { SearchView } from './conversation/SearchView';
import { AgentHeader } from './AgentHeader';
import LayingEggLoader from './LayingEggLoader';
import LoadingGoose from './LoadingGoose';
import RecipeActivities from './recipes/RecipeActivities';
import PopularChatTopics from './PopularChatTopics';
@@ -57,6 +56,7 @@ import ChatInput from './ChatInput';
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
import { RecipeWarningModal } from './ui/RecipeWarningModal';
import ParameterInputModal from './ParameterInputModal';
import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal';
import { useChatEngine } from '../hooks/useChatEngine';
import { useRecipeManager } from '../hooks/useRecipeManager';
import { useFileDrop } from '../hooks/useFileDrop';
@@ -148,7 +148,7 @@ function BaseChatContent({
},
onMessageSent: () => {
// Mark that user has started using the recipe
if (recipeConfig) {
if (recipe) {
setHasStartedUsingRecipe(true);
}
},
@@ -156,28 +156,28 @@ function BaseChatContent({
// Use shared recipe manager
const {
recipeConfig,
recipe,
recipeParameters,
filteredParameters,
initialPrompt,
isGeneratingRecipe,
isParameterModalOpen,
setIsParameterModalOpen,
recipeParameters,
handleParameterSubmit,
handleAutoExecution,
recipeError,
setRecipeError,
isRecipeWarningModalOpen,
recipeAccepted,
handleRecipeAccept,
handleRecipeCancel,
hasSecurityWarnings,
} = useRecipeManager(chat, location.state?.recipeConfig);
isCreateRecipeModalOpen,
setIsCreateRecipeModalOpen,
handleRecipeCreated,
} = useRecipeManager(chat, location.state?.recipe);
// Reset recipe usage tracking when recipe changes
useEffect(() => {
const previousTitle = currentRecipeTitle;
const newTitle = recipeConfig?.title || null;
const newTitle = recipe?.title || null;
const hasRecipeChanged = newTitle !== currentRecipeTitle;
if (hasRecipeChanged) {
@@ -197,7 +197,7 @@ function BaseChatContent({
setHasStartedUsingRecipe(true);
}
}
}, [recipeConfig?.title, currentRecipeTitle, messages.length, setMessages]);
}, [recipe?.title, currentRecipeTitle, messages.length, setMessages]);
// Handle recipe auto-execution
useEffect(() => {
@@ -251,7 +251,7 @@ function BaseChatContent({
const combinedTextFromInput = customEvent.detail?.value || '';
// Mark that user has started using the recipe when they submit a message
if (recipeConfig && combinedTextFromInput.trim()) {
if (recipe && combinedTextFromInput.trim()) {
setHasStartedUsingRecipe(true);
}
@@ -268,7 +268,7 @@ function BaseChatContent({
// Wrapper for append that tracks recipe usage
const appendWithTracking = (text: string | Message) => {
// Mark that user has started using the recipe when they use append
if (recipeConfig) {
if (recipe) {
setHasStartedUsingRecipe(true);
}
append(text);
@@ -296,9 +296,6 @@ function BaseChatContent({
removeTopPadding={true}
{...customMainLayoutProps}
>
{/* Loader when generating recipe */}
{isGeneratingRecipe && <LayingEggLoader />}
{/* Custom header */}
{renderHeader && renderHeader()}
@@ -315,14 +312,12 @@ function BaseChatContent({
paddingY={0}
>
{/* Recipe agent header - sticky at top of chat container */}
{recipeConfig?.title && (
{recipe?.title && (
<div className="sticky top-0 z-10 bg-background-default px-0 -mx-6 mb-6 pt-6">
<AgentHeader
title={recipeConfig.title}
title={recipe.title}
profileInfo={
recipeConfig.profile
? `${recipeConfig.profile} - ${recipeConfig.mcps || 12} MCPs`
: undefined
recipe.profile ? `${recipe.profile} - ${recipe.mcps || 12} MCPs` : undefined
}
onChangeProfile={() => {
console.log('Change profile clicked');
@@ -336,14 +331,12 @@ function BaseChatContent({
{renderBeforeMessages && renderBeforeMessages()}
{/* Recipe Activities - always show when recipe is active and accepted */}
{recipeConfig && recipeAccepted && !suppressEmptyState && (
{recipe && recipeAccepted && !suppressEmptyState && (
<div className={hasStartedUsingRecipe ? 'mb-6' : ''}>
<RecipeActivities
append={(text: string) => appendWithTracking(text)}
activities={
Array.isArray(recipeConfig.activities) ? recipeConfig.activities : null
}
title={recipeConfig.title}
activities={Array.isArray(recipe.activities) ? recipe.activities : null}
title={recipe.title}
parameterValues={recipeParameters || {}}
/>
</div>
@@ -352,7 +345,7 @@ function BaseChatContent({
{/* Messages or Popular Topics */}
{
loadingChat ? null : filteredMessages.length > 0 ||
(recipeConfig && recipeAccepted && hasStartedUsingRecipe) ? (
(recipe && recipeAccepted && hasStartedUsingRecipe) ? (
<>
{disableSearch ? (
// Render messages without SearchView wrapper when search is disabled
@@ -436,7 +429,7 @@ function BaseChatContent({
<div className="block h-8" />
</>
) : !recipeConfig && showPopularTopics ? (
) : !recipe && showPopularTopics ? (
/* Show PopularChatTopics when no messages, no recipe, and showPopularTopics is true (Pair view) */
<PopularChatTopics append={(text: string) => append(text)} />
) : null /* Show nothing when messages.length === 0 && suppressEmptyState === true */
@@ -484,7 +477,7 @@ function BaseChatContent({
disableAnimation={disableAnimation}
sessionCosts={sessionCosts}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
recipeConfig={recipeConfig}
recipe={recipe}
recipeAccepted={recipeAccepted}
initialPrompt={initialPrompt}
toolCount={toolCount || 0}
@@ -501,9 +494,9 @@ function BaseChatContent({
onConfirm={handleRecipeAccept}
onCancel={handleRecipeCancel}
recipeDetails={{
title: recipeConfig?.title,
description: recipeConfig?.description,
instructions: recipeConfig?.instructions || undefined,
title: recipe?.title,
description: recipe?.description,
instructions: recipe?.instructions || undefined,
}}
hasSecurityWarnings={hasSecurityWarnings}
/>
@@ -517,25 +510,13 @@ function BaseChatContent({
/>
)}
{/* Recipe Error Modal */}
{recipeError && (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-borderSubtle rounded-lg p-6 w-96 max-w-[90vw]">
<h3 className="text-lg font-medium text-textProminent mb-4">Recipe Creation Failed</h3>
<p className="text-textStandard mb-6">{recipeError}</p>
<div className="flex justify-end">
<button
onClick={() => setRecipeError(null)}
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors"
>
OK
</button>
</div>
</div>
</div>
)}
{/* No modals needed for the new simplified context manager */}
{/* Create Recipe from Session Modal */}
<CreateRecipeFromSessionModal
isOpen={isCreateRecipeModalOpen}
onClose={() => setIsCreateRecipeModalOpen(false)}
sessionId={chat.sessionId}
onRecipeCreated={handleRecipeCreated}
/>
</div>
);
}
+5 -5
View File
@@ -81,7 +81,7 @@ interface ChatInputProps {
};
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
disableAnimation?: boolean;
recipeConfig?: Recipe | null;
recipe?: Recipe | null;
recipeAccepted?: boolean;
initialPrompt?: string;
toolCount: number;
@@ -108,7 +108,7 @@ export default function ChatInput({
disableAnimation = false,
sessionCosts,
setIsGoosehintsModalOpen,
recipeConfig,
recipe,
recipeAccepted,
initialPrompt,
toolCount,
@@ -318,7 +318,7 @@ export default function ChatInput({
useEffect(() => {
// Only load draft once and if conditions are met
if (!initialValue && !recipeConfig && !draftLoadedRef.current && chatContext) {
if (!initialValue && !recipe && !draftLoadedRef.current && chatContext) {
const draftText = chatContext.draft || '';
if (draftText) {
@@ -329,7 +329,7 @@ export default function ChatInput({
// Always mark as loaded after checking, regardless of whether we found a draft
draftLoadedRef.current = true;
}
}, [chatContext, initialValue, recipeConfig]);
}, [chatContext, initialValue, recipe]);
// Save draft when user types (debounced)
const debouncedSaveDraft = useMemo(
@@ -1624,7 +1624,7 @@ export default function ChatInput({
dropdownRef={dropdownRef}
setView={setView}
alerts={alerts}
recipeConfig={recipeConfig}
recipe={recipe}
hasMessages={messages.length > 0}
/>
</div>
@@ -1,30 +0,0 @@
import { useEffect, useState } from 'react';
import { Geese } from './icons/Geese';
export default function LayingEggLoader() {
const [dots, setDots] = useState('');
useEffect(() => {
const interval = setInterval(() => {
setDots((prev) => (prev.length >= 3 ? '' : prev + '.'));
}, 500);
return () => clearInterval(interval);
}, []);
return (
<div className="fixed inset-0 flex items-center justify-center z-50 bg-background-default">
<div className="flex flex-col items-center max-w-3xl w-full px-6 pt-10">
<div className="w-16 h-16 bg-background-default rounded-full flex items-center justify-center mb-4">
<Geese className="w-12 h-12 text-iconProminent" />
</div>
<h1 className="text-2xl font-medium text-center mb-2 text-textProminent">
Laying an egg{dots}
</h1>
<p className="text-textSubtle text-center text-sm">
Please wait while we process your request
</p>
</div>
</div>
);
}
@@ -57,9 +57,6 @@ const AppLayoutContent: React.FC<AppLayoutProps> = ({ setIsGoosehintsModalOpen }
case 'sharedSession':
navigate('/shared-session', { state: viewOptions });
break;
case 'recipeEditor':
navigate('/recipe-editor', { state: viewOptions });
break;
case 'welcome':
navigate('/welcome');
break;
+1 -1
View File
@@ -98,7 +98,7 @@ export default function Pair({
}
}, [agentState, setView]);
const { initialPrompt: recipeInitialPrompt } = useRecipeManager(chat, chat.recipeConfig || null);
const { initialPrompt: recipeInitialPrompt } = useRecipeManager(chat, chat.recipe || null);
const handleMessageSubmit = (message: string) => {
// Clean up any auto submit state:
@@ -1,110 +1,192 @@
import React from 'react';
import { AlertTriangle, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
import { Parameter } from '../../recipe';
interface ParameterInputProps {
parameter: Parameter;
onChange: (name: string, updatedParameter: Partial<Parameter>) => void;
onDelete?: (parameterKey: string) => void;
isUnused?: boolean;
isExpanded?: boolean;
onToggleExpanded?: (parameterKey: string) => void;
}
const ParameterInput: React.FC<ParameterInputProps> = ({ parameter, onChange }) => {
// All values are derived directly from props, maintaining the controlled component pattern
const ParameterInput: React.FC<ParameterInputProps> = ({
parameter,
onChange,
onDelete,
isUnused = false,
isExpanded = true,
onToggleExpanded,
}) => {
const { key, description, requirement } = parameter;
const defaultValue = parameter.default || '';
const handleToggleExpanded = (e: React.MouseEvent) => {
// Only toggle if we're not clicking on the delete button
if (onToggleExpanded && !(e.target as HTMLElement).closest('button')) {
onToggleExpanded(key);
}
};
return (
<div className="parameter-input my-4 p-4 border rounded-lg bg-bgSubtle shadow-sm">
<h3 className="text-lg font-bold text-textProminent mb-4">
Parameter:{' '}
<code className="bg-background-default px-2 py-1 rounded-md">{parameter.key}</code>
</h3>
<div className="parameter-input my-4 border rounded-lg bg-bgSubtle shadow-sm relative">
{/* Collapsed header - always visible */}
<div
className={`flex items-center justify-between p-4 ${onToggleExpanded ? 'cursor-pointer hover:bg-background-default/50' : ''} transition-colors`}
onClick={handleToggleExpanded}
>
<div className="flex items-center gap-2 flex-1">
{onToggleExpanded && (
<button
type="button"
className="p-1 hover:bg-background-default rounded transition-colors"
onClick={(e) => {
e.stopPropagation();
onToggleExpanded(key);
}}
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-textSubtle" />
) : (
<ChevronRight className="w-4 h-4 text-textSubtle" />
)}
</button>
)}
<div className="mb-4">
<label className="block text-md text-textStandard mb-2 font-semibold">description</label>
<input
type="text"
value={description || ''}
onChange={(e) => onChange(key, { description: e.target.value })}
className="w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
placeholder={`E.g., "Enter the name for the new component"`}
/>
<p className="text-sm text-textSubtle mt-1">This is the message the end-user will see.</p>
</div>
{/* Controls for requirement, input type, and default value */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-md text-textStandard mb-2 font-semibold">Input Type</label>
<select
className="w-full p-3 border rounded-lg bg-background-default text-textStandard"
value={parameter.input_type || 'string'}
onChange={(e) =>
onChange(key, { input_type: e.target.value as Parameter['input_type'] })
}
>
<option value="string">String</option>
<option value="select">Select</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
</select>
</div>
<div>
<label className="block text-md text-textStandard mb-2 font-semibold">Requirement</label>
<select
className="w-full p-3 border rounded-lg bg-background-default text-textStandard"
value={requirement}
onChange={(e) =>
onChange(key, { requirement: e.target.value as Parameter['requirement'] })
}
>
<option value="required">Required</option>
<option value="optional">Optional</option>
</select>
</div>
{/* The default value input is only shown for optional parameters */}
{requirement === 'optional' && (
<div>
<label className="block text-md text-textStandard mb-2 font-semibold">
Default Value
</label>
<input
type="text"
value={defaultValue}
onChange={(e) => onChange(key, { default: e.target.value })}
className="w-full p-3 border rounded-lg bg-background-default text-textStandard"
placeholder="Enter default value"
/>
<div className="flex items-center gap-2">
<span className="text-md font-bold text-textProminent">
<code className="bg-background-default px-2 py-1 rounded-md">{parameter.key}</code>
</span>
{isUnused && (
<div
className="flex items-center gap-1"
title="This parameter is not used in the instructions or prompt. It will be available for manual input but may not be needed."
>
<AlertTriangle className="w-4 h-4 text-orange-500" />
<span className="text-xs text-orange-500 font-normal">Unused</span>
</div>
)}
</div>
</div>
{onDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(key);
}}
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded transition-colors"
title={`Delete parameter: ${key}`}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
{/* Options field for select input type */}
{parameter.input_type === 'select' && (
<div className="mt-4">
<label className="block text-md text-textStandard mb-2 font-semibold">
Options (one per line)
</label>
<textarea
value={(parameter.options || []).join('\n')}
onChange={(e) => {
// Don't filter out empty lines - preserve them so user can type on new lines
const options = e.target.value.split('\n');
onChange(key, { options });
}}
onKeyDown={(e) => {
// Allow Enter key to work normally in textarea (prevent form submission or modal close)
if (e.key === 'Enter') {
e.stopPropagation();
}
}}
className="w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
placeholder="Option 1&#10;Option 2&#10;Option 3"
rows={4}
/>
<p className="text-sm text-textSubtle mt-1">
Enter each option on a new line. These will be shown as dropdown choices.
</p>
{/* Expandable content - only shown when expanded */}
{isExpanded && (
<div className="px-4 pb-4 border-t border-borderSubtle">
<div className="pt-4">
<div className="mb-4">
<label className="block text-md text-textStandard mb-2 font-semibold">
description
</label>
<input
type="text"
value={description || ''}
onChange={(e) => onChange(key, { description: e.target.value })}
className="w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
placeholder={`E.g., "Enter the name for the new component"`}
/>
<p className="text-sm text-textSubtle mt-1">
This is the message the end-user will see.
</p>
</div>
{/* Controls for requirement, input type, and default value */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-md text-textStandard mb-2 font-semibold">
Input Type
</label>
<select
className="w-full p-3 border rounded-lg bg-background-default text-textStandard"
value={parameter.input_type || 'string'}
onChange={(e) =>
onChange(key, { input_type: e.target.value as Parameter['input_type'] })
}
>
<option value="string">String</option>
<option value="select">Select</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
</select>
</div>
<div>
<label className="block text-md text-textStandard mb-2 font-semibold">
Requirement
</label>
<select
className="w-full p-3 border rounded-lg bg-background-default text-textStandard"
value={requirement}
onChange={(e) =>
onChange(key, { requirement: e.target.value as Parameter['requirement'] })
}
>
<option value="required">Required</option>
<option value="optional">Optional</option>
</select>
</div>
{/* The default value input is only shown for optional parameters */}
{requirement === 'optional' && (
<div>
<label className="block text-md text-textStandard mb-2 font-semibold">
Default Value
</label>
<input
type="text"
value={defaultValue}
onChange={(e) => onChange(key, { default: e.target.value })}
className="w-full p-3 border rounded-lg bg-background-default text-textStandard"
placeholder="Enter default value"
/>
</div>
)}
</div>
{/* Options field for select input type */}
{parameter.input_type === 'select' && (
<div className="mt-4">
<label className="block text-md text-textStandard mb-2 font-semibold">
Options (one per line)
</label>
<textarea
value={(parameter.options || []).join('\n')}
onChange={(e) => {
// Don't filter out empty lines - preserve them so user can type on new lines
const options = e.target.value.split('\n');
onChange(key, { options });
}}
onKeyDown={(e) => {
// Allow Enter key to work normally in textarea (prevent form submission or modal close)
if (e.key === 'Enter') {
e.stopPropagation();
}
}}
className="w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
placeholder="Option 1&#10;Option 2&#10;Option 3"
rows={4}
/>
<p className="text-sm text-textSubtle mt-1">
Enter each option on a new line. These will be shown as dropdown choices.
</p>
</div>
)}
</div>
</div>
)}
</div>
@@ -0,0 +1,520 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useForm } from '@tanstack/react-form';
import { Recipe, generateDeepLink, Parameter } from '../../recipe';
import { Geese } from '../icons/Geese';
import Copy from '../icons/Copy';
import { Check, Save, Calendar, X, Play } from 'lucide-react';
import { ExtensionConfig, useConfig } from '../ConfigContext';
import { FixedExtensionEntry } from '../ConfigContext';
import { ScheduleFromRecipeModal } from '../schedule/ScheduleFromRecipeModal';
import { Button } from '../ui/button';
import { RecipeFormFields } from './shared/RecipeFormFields';
import { RecipeFormData } from './shared/recipeFormSchema';
import { saveRecipe, generateRecipeFilename } from '../../recipe/recipeStorage';
import { toastSuccess, toastError } from '../../toasts';
interface CreateEditRecipeModalProps {
isOpen: boolean;
onClose: (wasSaved?: boolean) => void;
recipe?: Recipe;
recipeName?: string;
isCreateMode?: boolean;
}
export default function CreateEditRecipeModal({
isOpen,
onClose,
recipe,
recipeName: initialRecipeName,
isCreateMode = false,
}: CreateEditRecipeModalProps) {
const { getExtensions } = useConfig();
const getInitialValues = React.useCallback((): RecipeFormData => {
if (recipe) {
return {
title: recipe.title || '',
description: recipe.description || '',
instructions: recipe.instructions || '',
prompt: recipe.prompt || '',
activities: recipe.activities || [],
parameters: recipe.parameters || [],
jsonSchema: recipe.response?.json_schema
? JSON.stringify(recipe.response.json_schema, null, 2)
: '',
recipeName: initialRecipeName || '',
global: true,
};
}
return {
title: '',
description: '',
instructions: '',
prompt: '',
activities: [],
parameters: [],
jsonSchema: '',
recipeName: '',
global: true,
};
}, [recipe, initialRecipeName]);
const form = useForm({
defaultValues: getInitialValues(),
});
// Helper functions to get values from form - using state to trigger re-renders
const [title, setTitle] = useState(form.state.values.title);
const [description, setDescription] = useState(form.state.values.description);
const [instructions, setInstructions] = useState(form.state.values.instructions);
const [prompt, setPrompt] = useState(form.state.values.prompt);
const [activities, setActivities] = useState(form.state.values.activities);
const [parameters, setParameters] = useState(form.state.values.parameters);
const [jsonSchema, setJsonSchema] = useState(form.state.values.jsonSchema);
// Subscribe to form changes to update local state
useEffect(() => {
return form.store.subscribe(() => {
setTitle(form.state.values.title);
setDescription(form.state.values.description);
setInstructions(form.state.values.instructions);
setPrompt(form.state.values.prompt);
setActivities(form.state.values.activities);
setParameters(form.state.values.parameters);
setJsonSchema(form.state.values.jsonSchema);
setRecipeName(form.state.values.recipeName);
setGlobal(form.state.values.global);
});
}, [form]);
const [extensionOptions, setExtensionOptions] = useState<FixedExtensionEntry[]>([]);
const [extensionsLoaded, setExtensionsLoaded] = useState(false);
const [copied, setCopied] = useState(false);
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [recipeName, setRecipeName] = useState(form.state.values.recipeName);
const [global, setGlobal] = useState(form.state.values.global);
const [isSaving, setIsSaving] = useState(false);
// Initialize selected extensions for the recipe
const [recipeExtensions] = useState<string[]>(() => {
if (recipe?.extensions) {
return recipe.extensions.map((ext) => ext.name);
}
return [];
});
// Reset form when recipe changes
useEffect(() => {
if (recipe) {
const newValues = getInitialValues();
form.reset(newValues);
}
}, [recipe, form, getInitialValues]);
// Load extensions when modal opens
useEffect(() => {
if (isOpen && !extensionsLoaded) {
const loadExtensions = async () => {
try {
const extensions = await getExtensions(false);
console.log('Loading extensions for recipe modal');
if (extensions && extensions.length > 0) {
const initializedExtensions = extensions.map((ext) => ({
...ext,
enabled: recipeExtensions.includes(ext.name),
}));
setExtensionOptions(initializedExtensions);
setExtensionsLoaded(true);
}
} catch (error) {
console.error('Failed to load extensions:', error);
}
};
loadExtensions();
}
}, [isOpen, getExtensions, recipeExtensions, extensionsLoaded]);
const getCurrentRecipe = useCallback((): Recipe => {
// Transform the internal parameters state into the desired output format.
const formattedParameters = parameters.map((param) => {
const formattedParam: Parameter = {
key: param.key,
input_type: param.input_type || 'string',
requirement: param.requirement,
description: param.description,
};
// Add the 'default' key ONLY if the parameter is optional and has a default value.
if (param.requirement === 'optional' && param.default) {
formattedParam.default = param.default;
}
// Add options for select input type
if (param.input_type === 'select' && param.options) {
formattedParam.options = param.options.filter((opt) => opt.trim() !== ''); // Filter empty options when saving
}
return formattedParam;
});
// Parse response schema if provided
let responseConfig = undefined;
if (jsonSchema && jsonSchema.trim()) {
try {
const parsedSchema = JSON.parse(jsonSchema);
responseConfig = { json_schema: parsedSchema };
} catch (error) {
console.warn('Invalid JSON schema provided:', error);
// If JSON is invalid, don't include response config
}
}
return {
...recipe,
title,
description,
instructions,
activities,
prompt: prompt || undefined,
parameters: formattedParameters,
response: responseConfig,
extensions: recipeExtensions
.map((name) => {
const extension = extensionOptions.find((e) => e.name === name);
if (!extension) return null;
// Create a clean copy of the extension configuration
const { enabled: _enabled, ...cleanExtension } = extension;
// Remove legacy envs which could potentially include secrets
if ('envs' in cleanExtension) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { envs: _envs, ...finalExtension } = cleanExtension as any;
return finalExtension;
}
return cleanExtension;
})
.filter(Boolean) as ExtensionConfig[],
};
}, [
recipe,
title,
description,
instructions,
activities,
prompt,
parameters,
jsonSchema,
recipeExtensions,
extensionOptions,
]);
const requiredFieldsAreFilled = () => {
return title.trim() && description.trim() && (instructions.trim() || (prompt || '').trim());
};
const validateForm = () => {
const basicValidation =
title.trim() && description.trim() && (instructions.trim() || (prompt || '').trim());
// If JSON schema is provided, it must be valid
if (jsonSchema && jsonSchema.trim()) {
try {
JSON.parse(jsonSchema);
} catch {
return false; // Invalid JSON schema fails validation
}
}
return basicValidation;
};
const [deeplink, setDeeplink] = useState('');
const [isGeneratingDeeplink, setIsGeneratingDeeplink] = useState(false);
// Generate deeplink whenever recipe configuration changes
useEffect(() => {
let isCancelled = false;
const generateLink = async () => {
if (
!title.trim() ||
!description.trim() ||
(!instructions.trim() && !(prompt || '').trim())
) {
setDeeplink('');
return;
}
setIsGeneratingDeeplink(true);
try {
const currentRecipe = getCurrentRecipe();
const link = await generateDeepLink(currentRecipe);
if (!isCancelled) {
setDeeplink(link);
}
} catch (error) {
console.error('Failed to generate deeplink:', error);
if (!isCancelled) {
setDeeplink('Error generating deeplink');
}
} finally {
if (!isCancelled) {
setIsGeneratingDeeplink(false);
}
}
};
generateLink();
return () => {
isCancelled = true;
};
}, [
title,
description,
instructions,
prompt,
activities,
parameters,
jsonSchema,
recipeExtensions,
getCurrentRecipe,
]);
const handleCopy = () => {
if (!deeplink || isGeneratingDeeplink || deeplink === 'Error generating deeplink') {
return;
}
navigator.clipboard
.writeText(deeplink)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch((err) => {
console.error('Failed to copy the text:', err);
});
};
const handleSaveRecipeClick = async () => {
if (!validateForm()) {
toastError({
title: 'Validation Failed',
msg: 'Please fill in all required fields and ensure JSON schema is valid.',
});
return;
}
setIsSaving(true);
try {
const recipe = getCurrentRecipe();
await saveRecipe(recipe, {
name: (recipeName || '').trim(),
global: global,
});
onClose(true);
toastSuccess({
title: (recipeName || '').trim(),
msg: 'Recipe saved successfully',
});
} catch (error) {
console.error('Failed to save recipe:', error);
toastError({
title: 'Save Failed',
msg: `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setIsSaving(false);
}
};
const handleSaveAndRunRecipeClick = async () => {
if (!validateForm()) {
toastError({
title: 'Validation Failed',
msg: 'Please fill in all required fields and ensure JSON schema is valid.',
});
return;
}
setIsSaving(true);
try {
const recipe = getCurrentRecipe();
const recipeName = generateRecipeFilename(recipe);
await saveRecipe(recipe, {
name: recipeName,
global: true,
});
// Close modal first
onClose(true);
// Open recipe in a new window instead of navigating in the same window
window.electron.createChatWindow(undefined, undefined, undefined, undefined, recipe);
toastSuccess({
title: recipeName,
msg: 'Recipe saved and launched successfully',
});
} catch (error) {
console.error('Failed to save and run recipe:', error);
toastError({
title: 'Save and Run Failed',
msg: `Failed to save and run recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setIsSaving(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[400] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-borderSubtle rounded-lg w-[90vw] max-w-4xl h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-borderSubtle">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-background-default rounded-full flex items-center justify-center">
<Geese className="w-6 h-6 text-iconProminent" />
</div>
<div>
<h1 className="text-xl font-medium text-textProminent">
{isCreateMode ? 'Create Recipe' : 'View/edit recipe'}
</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."}
</p>
</div>
</div>
<Button
onClick={() => onClose(false)}
variant="ghost"
size="sm"
className="p-2 hover:bg-bgSubtle rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<RecipeFormFields form={form} />
{/* Deep Link Display */}
{requiredFieldsAreFilled() && (
<div className="w-full p-4 bg-bgSubtle rounded-lg mt-6">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-textSubtle">
Copy this link to share with friends or paste directly in Chrome to open
</div>
<Button
onClick={handleCopy}
variant="ghost"
size="sm"
disabled={
!deeplink || isGeneratingDeeplink || deeplink === 'Error generating deeplink'
}
className="ml-4 p-2 hover:bg-background-default rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4 text-iconSubtle" />
)}
<span className="ml-1 text-sm text-textSubtle">
{copied ? 'Copied!' : 'Copy'}
</span>
</Button>
</div>
<div
onClick={handleCopy}
className="text-sm truncate font-mono cursor-pointer text-textStandard"
>
{isGeneratingDeeplink
? 'Generating deeplink...'
: deeplink || 'Click to generate deeplink'}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-borderSubtle">
<Button
onClick={() => onClose(false)}
variant="ghost"
className="px-4 py-2 text-textSubtle rounded-lg hover:bg-bgSubtle transition-colors"
>
Close
</Button>
<div className="flex gap-3">
<Button
onClick={() => setIsScheduleModalOpen(true)}
disabled={!requiredFieldsAreFilled()}
variant="outline"
size="default"
className="inline-flex items-center justify-center gap-2 px-4 py-2"
>
<Calendar className="w-4 h-4" />
Create Schedule
</Button>
<Button
onClick={handleSaveRecipeClick}
disabled={!requiredFieldsAreFilled() || isSaving}
variant="outline"
size="default"
className="inline-flex items-center justify-center gap-2 px-4 py-2"
>
<Save className="w-4 h-4" />
{isSaving ? 'Saving...' : 'Save Recipe'}
</Button>
<Button
onClick={handleSaveAndRunRecipeClick}
disabled={!requiredFieldsAreFilled() || isSaving}
variant="default"
size="default"
className="inline-flex items-center justify-center gap-2 px-4 py-2"
>
<Play className="w-4 h-4" />
{isSaving ? 'Saving...' : 'Save & Run Recipe'}
</Button>
</div>
</div>
</div>
<ScheduleFromRecipeModal
isOpen={isScheduleModalOpen}
onClose={() => setIsScheduleModalOpen(false)}
recipe={getCurrentRecipe()}
onCreateSchedule={(deepLink) => {
// Open the schedules view with the deep link pre-filled
window.electron.createChatWindow(
undefined,
undefined,
undefined,
undefined,
undefined,
'schedules'
);
// Store the deep link in localStorage for the schedules view to pick up
localStorage.setItem('pendingScheduleDeepLink', deepLink);
}}
/>
</div>
);
}
@@ -1,475 +0,0 @@
import { useState, useEffect } from 'react';
import { useForm } from '@tanstack/react-form';
import { z } from 'zod';
import { FileText } from 'lucide-react';
import { Button } from '../ui/button';
import { Recipe } from '../../recipe';
import { saveRecipe } from '../../recipe/recipeStorage';
import { toastSuccess, toastError } from '../../toasts';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { RecipeNameField, recipeNameSchema } from './shared/RecipeNameField';
import { generateRecipeNameFromTitle } from './shared/recipeNameUtils';
import { validateJsonSchema, getValidationErrorMessages } from '../../recipe/validation';
interface CreateRecipeFormProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
// Define Zod schema for the entire form
const createRecipeSchema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters'),
description: z.string().min(10, 'Description must be at least 10 characters'),
instructions: z.string().min(20, 'Instructions must be at least 20 characters'),
prompt: z.string(),
activities: z.string(),
jsonSchema: z.string(),
recipeName: recipeNameSchema,
global: z.boolean(),
});
export default function CreateRecipeForm({ isOpen, onClose, onSuccess }: CreateRecipeFormProps) {
const [creating, setCreating] = useState(false);
// Handle Esc key for modal
useEscapeKey(isOpen, onClose);
const createRecipeForm = useForm({
defaultValues: {
title: '',
description: '',
instructions: '',
prompt: '',
activities: '',
jsonSchema: '',
recipeName: '',
global: true,
},
validators: {
onChange: createRecipeSchema,
},
onSubmit: async ({ value }) => {
setCreating(true);
try {
// Parse activities from comma-separated string
const activities = value.activities
.split(',')
.map((activity) => activity.trim())
.filter((activity) => activity.length > 0);
// Parse and validate JSON schema if provided
let jsonSchemaObj = undefined;
if (value.jsonSchema && value.jsonSchema.trim()) {
try {
jsonSchemaObj = JSON.parse(value.jsonSchema.trim());
// Validate the JSON schema syntax
const validationResult = validateJsonSchema(jsonSchemaObj);
if (!validationResult.success) {
const errorMessages = getValidationErrorMessages(validationResult.errors);
throw new Error(`Invalid JSON schema: ${errorMessages.join(', ')}`);
}
} catch (error) {
throw new Error(
`JSON Schema parsing error: ${error instanceof Error ? error.message : 'Invalid JSON'}`
);
}
}
// Create the recipe object
const recipe: Recipe = {
title: value.title.trim(),
description: value.description.trim(),
instructions: value.instructions.trim(),
prompt: value.prompt.trim() || undefined,
activities: activities.length > 0 ? activities : undefined,
response: jsonSchemaObj ? { json_schema: jsonSchemaObj } : undefined,
};
await saveRecipe(recipe, {
name: value.recipeName.trim(),
global: value.global,
});
// Reset dialog state
createRecipeForm.reset({
title: '',
description: '',
instructions: '',
prompt: '',
activities: '',
jsonSchema: '',
recipeName: '',
global: true,
});
onClose();
onSuccess();
toastSuccess({
title: value.recipeName.trim(),
msg: 'Recipe created successfully',
});
} catch (error) {
console.error('Failed to create recipe:', error);
toastError({
title: 'Create Failed',
msg: `Failed to create recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setCreating(false);
}
},
});
// Set default example values when the modal opens
useEffect(() => {
if (isOpen) {
// Set example values like the original did
createRecipeForm.setFieldValue('title', 'Python Development Assistant');
createRecipeForm.setFieldValue(
'description',
'A helpful assistant for Python development tasks including coding, debugging, and code review.'
);
createRecipeForm.setFieldValue(
'instructions',
`You are an expert Python developer assistant. Help users with:
1. Writing clean, efficient Python code
2. Debugging and troubleshooting issues
3. Code review and optimization suggestions
4. Best practices and design patterns
5. Testing and documentation
Always provide clear explanations and working code examples.
Parameters you can use:
- {{project_type}}: The type of Python project (web, data science, CLI, etc.)
- {{python_version}}: Target Python version`
);
createRecipeForm.setFieldValue(
'prompt',
'What Python development task can I help you with today?'
);
createRecipeForm.setFieldValue('activities', 'coding, debugging, testing, documentation');
createRecipeForm.setFieldValue('recipeName', 'python-development-assistant');
createRecipeForm.setFieldValue('global', true);
}
}, [isOpen, createRecipeForm]);
const handleClose = () => {
// Reset form to default values
createRecipeForm.reset({
title: '',
description: '',
instructions: '',
prompt: '',
activities: '',
jsonSchema: '',
recipeName: '',
global: true,
});
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[700px] max-w-[90vw] max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-medium text-text-standard mb-4">Create New Recipe</h3>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
createRecipeForm.handleSubmit();
}}
>
<div className="space-y-4">
<createRecipeForm.Field name="title">
{(field) => (
<div>
<label
htmlFor="create-title"
className="block text-sm font-medium text-text-standard mb-2"
>
Title <span className="text-red-500">*</span>
</label>
<input
id="create-title"
type="text"
value={field.state.value}
onChange={(e) => {
const value = e.target.value;
field.handleChange(value);
// Auto-generate recipe name when title changes
if (value.trim()) {
const suggestedName = generateRecipeNameFromTitle(value);
createRecipeForm.setFieldValue('recipeName', suggestedName);
}
}}
onBlur={field.handleBlur}
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
}`}
placeholder="Recipe title"
autoFocus
/>
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm mt-1">
{typeof field.state.meta.errors[0] === 'string'
? field.state.meta.errors[0]
: field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
</createRecipeForm.Field>
<createRecipeForm.Field name="description">
{(field) => (
<div>
<label
htmlFor="create-description"
className="block text-sm font-medium text-text-standard mb-2"
>
Description <span className="text-red-500">*</span>
</label>
<input
id="create-description"
type="text"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
}`}
placeholder="Brief description of what this recipe does"
/>
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm mt-1">
{typeof field.state.meta.errors[0] === 'string'
? field.state.meta.errors[0]
: field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
</createRecipeForm.Field>
<createRecipeForm.Field name="instructions">
{(field) => (
<div>
<label
htmlFor="create-instructions"
className="block text-sm font-medium text-text-standard mb-2"
>
Instructions <span className="text-red-500">*</span>
</label>
<textarea
id="create-instructions"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
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 agent..."
rows={8}
/>
<p className="text-xs text-text-muted mt-1">
Use {`{{parameter_name}}`} to define parameters that users can fill in
</p>
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm mt-1">
{typeof field.state.meta.errors[0] === 'string'
? field.state.meta.errors[0]
: field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
</createRecipeForm.Field>
<createRecipeForm.Field name="prompt">
{(field) => (
<div>
<label
htmlFor="create-prompt"
className="block text-sm font-medium text-text-standard mb-2"
>
Initial Prompt (Optional)
</label>
<textarea
id="create-prompt"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
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="First message to send when the recipe starts..."
rows={3}
/>
</div>
)}
</createRecipeForm.Field>
<createRecipeForm.Field name="activities">
{(field) => (
<div>
<label
htmlFor="create-activities"
className="block text-sm font-medium text-text-standard mb-2"
>
Activities (Optional)
</label>
<input
id="create-activities"
type="text"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
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"
placeholder="coding, debugging, testing, documentation (comma-separated)"
/>
<p className="text-xs text-text-muted mt-1">
Comma-separated list of activities this recipe helps with
</p>
</div>
)}
</createRecipeForm.Field>
<createRecipeForm.Field name="jsonSchema">
{(field) => (
<div>
<label
htmlFor="create-json-schema"
className="block text-sm font-medium text-text-standard mb-2"
>
Response JSON Schema (Optional)
</label>
<textarea
id="create-json-schema"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
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={`{
"type": "object",
"properties": {
"result": {
"type": "string",
"description": "The main result"
}
},
"required": ["result"]
}`}
rows={6}
/>
<p className="text-xs text-text-muted mt-1">
Define the expected structure of the AI's response using JSON Schema format
</p>
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm mt-1">
{typeof field.state.meta.errors[0] === 'string'
? field.state.meta.errors[0]
: field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
</createRecipeForm.Field>
<createRecipeForm.Field name="recipeName">
{(field) => (
<RecipeNameField
id="create-recipe-name"
value={field.state.value}
onChange={field.handleChange}
onBlur={field.handleBlur}
errors={field.state.meta.errors.map((error) =>
typeof error === 'string' ? error : error?.message || String(error)
)}
/>
)}
</createRecipeForm.Field>
<createRecipeForm.Field name="global">
{(field) => (
<div>
<label className="block text-sm font-medium text-text-standard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="create-save-location"
checked={field.state.value === true}
onChange={() => field.handleChange(true)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="create-save-location"
checked={field.state.value === false}
onChange={() => field.handleChange(false)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
)}
</createRecipeForm.Field>
</div>
<div className="flex justify-end space-x-3 mt-6">
<Button type="button" onClick={handleClose} variant="ghost" disabled={creating}>
Cancel
</Button>
<createRecipeForm.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting, state.isValid]}
>
{([canSubmit, isSubmitting, isValid]) => {
// Debug logging to see what's happening
console.log('Form state:', { canSubmit, isSubmitting, isValid });
return (
<Button
type="submit"
disabled={!canSubmit || creating || isSubmitting}
variant="default"
>
{creating || isSubmitting ? 'Creating...' : 'Create Recipe'}
</Button>
);
}}
</createRecipeForm.Subscribe>
</div>
</form>
</div>
</div>
);
}
// Export the button component for easy access
export function CreateRecipeButton({ onClick }: { onClick: () => void }) {
return (
<Button onClick={onClick} variant="outline" size="sm" className="flex items-center gap-2">
<FileText className="w-4 h-4" />
Create Recipe
</Button>
);
}
@@ -0,0 +1,330 @@
import { useState, useEffect } from 'react';
import { useForm } from '@tanstack/react-form';
import { Recipe } from '../../recipe';
import { Geese } from '../icons/Geese';
import { X, Save, Play, Loader2 } from 'lucide-react';
import { Button } from '../ui/button';
import { RecipeFormFields } from './shared/RecipeFormFields';
import { RecipeFormData } from './shared/recipeFormSchema';
import { createRecipe } from '../../api/sdk.gen';
import { RecipeParameter } from './shared/recipeFormSchema';
import { toastError } from '../../toasts';
import { generateRecipeFilename } from '../../recipe/recipeStorage';
interface CreateRecipeFromSessionModalProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
onRecipeCreated?: (recipe: Recipe) => void;
}
export default function CreateRecipeFromSessionModal({
isOpen,
onClose,
sessionId,
onRecipeCreated,
}: CreateRecipeFromSessionModalProps) {
const [isCreating, setIsCreating] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analysisStage, setAnalysisStage] = useState<string>('');
const [hasAnalyzed, setHasAnalyzed] = useState(false);
// Initialize form with empty values for new recipe
const form = useForm({
defaultValues: {
title: '',
description: '',
instructions: '',
prompt: '',
activities: [] as string[],
parameters: [] as RecipeParameter[],
jsonSchema: '',
recipeName: '',
global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
await handleCreateRecipe(value);
},
});
// Track form validity with state to make it reactive
const [isFormValid, setIsFormValid] = useState(false);
// Analyze messages and prefill form when modal opens
useEffect(() => {
if (isOpen && sessionId && !hasAnalyzed) {
setIsAnalyzing(true);
// Create a sequence of analysis stages for better UX
const stages = [
'Reading your conversation...',
'Identifying key patterns...',
'Extracting main topics...',
'Generating recipe structure...',
'Finalizing details...',
];
let currentStageIndex = 0;
setAnalysisStage(stages[0]);
// Update stage every 800ms
const stageInterval = setInterval(() => {
currentStageIndex = (currentStageIndex + 1) % stages.length;
setAnalysisStage(stages[currentStageIndex]);
}, 800);
// Call the backend to analyze messages and create a recipe
createRecipe({
body: { session_id: sessionId },
throwOnError: true,
})
.then((response) => {
clearInterval(stageInterval);
setAnalysisStage('Complete!');
if (response.data?.recipe) {
const recipe = response.data.recipe;
// Prefill the form with the analyzed recipe information
form.setFieldValue('title', recipe.title || '');
form.setFieldValue('description', recipe.description || '');
form.setFieldValue('instructions', recipe.instructions || '');
form.setFieldValue('activities', recipe.activities || []);
form.setFieldValue('parameters', recipe.parameters || []);
form.setFieldValue('recipeName', generateRecipeFilename(recipe));
if (recipe.response?.json_schema) {
form.setFieldValue(
'jsonSchema',
JSON.stringify(recipe.response.json_schema, null, 2)
);
}
} else {
console.error('No recipe in response:', response);
}
setHasAnalyzed(true);
})
.catch((error) => {
console.error('Failed to analyze messages:', error);
setAnalysisStage('Analysis failed');
})
.finally(() => {
clearInterval(stageInterval);
setHasAnalyzed(true);
setTimeout(() => {
setIsAnalyzing(false);
setAnalysisStage('');
}, 500); // Brief delay to show completion
});
}
}, [isOpen, sessionId, hasAnalyzed, form]);
// Reset analysis state when modal closes
useEffect(() => {
if (!isOpen) {
setHasAnalyzed(false);
setIsAnalyzing(false);
setAnalysisStage('');
}
}, [isOpen]);
// Subscribe to form changes using the form's subscribe method
useEffect(() => {
const unsubscribe = form.store.subscribe(() => {
const hasTitle = form.state.values.title?.trim();
const hasDescription = form.state.values.description?.trim();
const hasInstructions = form.state.values.instructions?.trim();
const valid = !!(hasTitle && hasDescription && hasInstructions);
setIsFormValid(valid);
});
// Initial validation check
const hasTitle = form.state.values.title?.trim();
const hasDescription = form.state.values.description?.trim();
const hasInstructions = form.state.values.instructions?.trim();
const valid = !!(hasTitle && hasDescription && hasInstructions);
setIsFormValid(valid);
return unsubscribe;
}, [form]);
const handleCreateRecipe = async (formData: RecipeFormData, runAfterSave = false) => {
if (!isFormValid) {
return;
}
setIsCreating(true);
try {
// Create the recipe object from form data
const recipe: Recipe = {
title: formData.title,
description: formData.description,
instructions: formData.instructions,
prompt: formData.prompt || undefined,
activities: formData.activities.filter((activity) => activity.trim() !== ''),
parameters: formData.parameters.map((param) => ({
key: param.key,
input_type: param.input_type || 'string',
requirement: param.requirement,
description: param.description,
...(param.requirement === 'optional' && param.default ? { default: param.default } : {}),
...(param.input_type === 'select' && param.options
? {
options: param.options.filter((opt: string) => opt.trim() !== ''),
}
: {}),
})),
response:
formData.jsonSchema && formData.jsonSchema.trim()
? {
json_schema: JSON.parse(formData.jsonSchema),
}
: undefined,
extensions: [], // Will be populated based on current extensions
};
const { saveRecipe } = await import('../../recipe/recipeStorage');
await saveRecipe(recipe, {
name: formData.recipeName || formData.title,
title: formData.title,
global: formData.global,
});
onRecipeCreated?.(recipe);
onClose();
if (runAfterSave) {
window.electron.createChatWindow(undefined, undefined, undefined, undefined, recipe);
}
} catch (error) {
console.error('Failed to create recipe:', error);
toastError({
title: 'Failed to create recipe',
msg:
error instanceof Error
? error.message
: 'An unexpected error occurred while creating the recipe. Please try again.',
});
} finally {
setIsCreating(false);
}
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-[400] flex items-center justify-center bg-black/50 p-4"
data-testid="create-recipe-modal"
>
<div className="bg-background-default border border-borderSubtle rounded-lg w-full max-w-4xl h-full max-h-[90vh] flex flex-col shadow-xl">
{/* Header */}
<div
className="flex items-center justify-between p-6 border-b border-borderSubtle shrink-0"
data-testid="modal-header"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-background-default rounded-full flex items-center justify-center">
<Geese className="w-6 h-6 text-iconProminent" />
</div>
<div>
<h1 className="text-xl font-medium text-textProminent">Create Recipe from Session</h1>
<p className="text-textSubtle text-sm">
Create a reusable recipe based on your current conversation.
</p>
</div>
</div>
<Button
onClick={onClose}
variant="ghost"
size="sm"
className="p-2 hover:bg-bgSubtle rounded-lg transition-colors"
data-testid="close-button"
>
<X className="w-5 h-5" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0" data-testid="modal-content">
{isAnalyzing ? (
<div
className="flex flex-col items-center justify-center h-full min-h-[300px] space-y-4"
data-testid="analyzing-state"
>
<div className="flex items-center space-x-3">
<Loader2
className="w-6 h-6 animate-spin text-iconProminent"
data-testid="analysis-spinner"
/>
<div
className="text-lg font-medium text-textProminent"
data-testid="analyzing-title"
>
Analyzing your conversation...
</div>
</div>
<div className="text-textSubtle text-center max-w-md" data-testid="analysis-stage">
{analysisStage}
</div>
<div className="flex items-center space-x-2 text-textSubtle">
<Geese className="w-5 h-5 animate-pulse" />
<span className="text-sm">Extracting insights from your chat</span>
</div>
</div>
) : (
<div data-testid="form-state">
<RecipeFormFields form={form} />
</div>
)}
</div>
{/* Footer */}
<div
className="flex items-center justify-between p-6 border-t border-borderSubtle shrink-0"
data-testid="modal-footer"
>
<Button
onClick={onClose}
variant="ghost"
className="px-4 py-2 text-textSubtle rounded-lg hover:bg-bgSubtle transition-colors"
data-testid="cancel-button"
>
Cancel
</Button>
<div className="flex gap-3">
{!isAnalyzing && (
<>
<Button
onClick={() => {
form.handleSubmit();
}}
disabled={!isFormValid || isCreating}
variant="outline"
className="px-4 py-2 border border-borderStandard rounded-lg hover:bg-bgSubtle transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="create-recipe-button"
>
<Save className="w-4 h-4 mr-2" />
{isCreating ? 'Creating...' : 'Create Recipe'}
</Button>
<Button
onClick={() => {
handleCreateRecipe(form.state.values, true);
}}
disabled={!isFormValid || isCreating}
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="create-and-run-recipe-button"
>
<Play className="w-4 h-4 mr-2" />
{isCreating ? 'Creating...' : 'Create & Run Recipe'}
</Button>
</>
)}
</div>
</div>
</div>
</div>
);
}
@@ -59,10 +59,8 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
const [importing, setImporting] = useState(false);
const [showSchemaModal, setShowSchemaModal] = useState(false);
// Handle Esc key for modal
useEscapeKey(isOpen, onClose);
// Function to parse deeplink and extract recipe
const parseDeeplink = async (deeplink: string): Promise<Recipe | null> => {
try {
const cleanLink = deeplink.trim();
@@ -214,7 +212,6 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
});
const handleClose = () => {
// Reset form to default values
importRecipeForm.reset({
deeplink: '',
recipeUploadFile: null,
@@ -224,22 +221,18 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
onClose();
};
// Store reference to recipe title field for programmatic updates
let recipeTitleFieldRef: { handleChange: (value: string) => void } | null = null;
// Auto-populate recipe title when deeplink changes
const handleDeeplinkChange = async (
value: string,
field: { handleChange: (value: string) => void }
) => {
// Use the proper field change handler to trigger validation
field.handleChange(value);
if (value.trim()) {
try {
const recipe = await parseDeeplink(value.trim());
if (recipe && recipe.title) {
// Use the recipe title field's handleChange method if available
if (recipeTitleFieldRef) {
recipeTitleFieldRef.handleChange(recipe.title);
} else {
@@ -247,11 +240,12 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
}
}
} catch (error) {
// Silently handle parsing errors during auto-suggest
console.log('Could not parse deeplink for auto-suggest:', error);
toastError({
title: 'Invalid Deeplink',
msg: `The deeplink format is invalid: ${error instanceof Error ? error.message : 'Unknown error'}`,
});
}
} else {
// Clear the recipe title when deeplink is empty
if (recipeTitleFieldRef) {
recipeTitleFieldRef.handleChange('');
} else {
@@ -268,7 +262,6 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
const fileContent = await file.text();
const recipe = await parseRecipeUploadFile(fileContent, file.name);
if (recipe.title) {
// Use the recipe title field's handleChange method if available
if (recipeTitleFieldRef) {
recipeTitleFieldRef.handleChange(recipe.title);
} else {
@@ -276,11 +269,12 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
}
}
} catch (error) {
// Silently handle parsing errors during auto-suggest
console.log('Could not parse recipe file for auto-suggest:', error);
toastError({
title: 'Invalid Recipe File',
msg: `The recipe file format is invalid: ${error instanceof Error ? error.message : 'Unknown error'}`,
});
}
} else {
// Clear the recipe title when file is removed
if (recipeTitleFieldRef) {
recipeTitleFieldRef.handleChange('');
} else {
@@ -511,7 +505,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
<div className="fixed inset-0 z-[400] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[800px] max-w-[90vw] max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-text-standard">Recipe Schema</h3>
<h3 className="text-lg font-medium text-text-standard">Expected Recipe Structure</h3>
<button
type="button"
onClick={() => setShowSchemaModal(false)}
@@ -520,15 +514,14 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
</button>
</div>
<p className="mt-4 text-blue-700 text-sm">
Your YAML or JSON file should follow this structure. Required fields are: title,
description, and either instructions or prompt.
</p>
<div className="flex-1 overflow-auto">
<p className="font-medium mb-3 text-text-standard">Expected Recipe Structure:</p>
<pre className="text-xs bg-gray-800 p-4 rounded overflow-auto whitespace-pre font-mono">
<pre className="text-xs bg-whitedark:bg-gray-800 p-4 rounded overflow-auto whitespace-pre font-mono">
{JSON.stringify(getRecipeJsonSchema(), null, 2)}
</pre>
<p className="mt-4 text-blue-700 text-sm">
Your YAML or JSON file should follow this structure. Required fields are: title,
description, and either instructions or prompt.
</p>
</div>
</div>
</div>
@@ -537,7 +530,6 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
);
}
// Export the button component for easy access
export function ImportRecipeButton({ onClick }: { onClick: () => void }) {
return (
<Button onClick={onClick} variant="default" size="sm" className="flex items-center gap-2">
@@ -2,11 +2,13 @@ import { useState, useEffect } from 'react';
import { Button } from '../ui/button';
export default function RecipeActivityEditor({
activities,
activities = [],
setActivities,
onBlur,
}: {
activities: string[];
activities?: string[];
setActivities: (prev: string[]) => void;
onBlur?: () => void;
}) {
const [newActivity, setNewActivity] = useState('');
const [messageContent, setMessageContent] = useState('');
@@ -30,6 +32,10 @@ export default function RecipeActivityEditor({
if (newActivity.trim()) {
setActivities([...activities, newActivity.trim()]);
setNewActivity('');
// Trigger parameter extraction after adding activity
if (onBlur) {
onBlur();
}
}
};
@@ -57,14 +63,14 @@ export default function RecipeActivityEditor({
<label htmlFor="activities" className="block text-md text-textProminent mb-2 font-bold">
Activities
</label>
<p className="text-textSubtle space-y-2 pb-4">
The top-line prompts and activities that will display within your goose home page.
<p className="text-sm text-textSubtle space-y-2 pb-4">
The top-line prompts and activity buttons that will display in the recipe chat window.
</p>
{/* Message Field */}
<div className="mb-6">
<div>
<label htmlFor="message" className="block text-sm font-medium text-textStandard mb-2">
Message (Optional)
Message
</label>
<p className="text-xs text-textSubtle mb-2">
A formatted message that will appear at the top of the recipe. Supports markdown
@@ -74,8 +80,9 @@ export default function RecipeActivityEditor({
id="message"
value={messageContent}
onChange={(e) => handleMessageChange(e.target.value)}
onBlur={onBlur}
className="w-full px-4 py-3 border rounded-lg bg-background-default text-textStandard placeholder-textPlaceholder focus:outline-none focus:ring-2 focus:ring-borderProminent resize-vertical"
placeholder="Enter a message for your recipe (supports **bold**, *italic*, `code`, etc.)"
placeholder="Enter a user facing introduction message for your recipe (supports **bold**, *italic*, `code`, etc.)"
rows={3}
autoCorrect="off"
autoCapitalize="off"
@@ -90,44 +97,51 @@ export default function RecipeActivityEditor({
Activity Buttons
</label>
<p className="text-xs text-textSubtle mb-3">
Clickable buttons that will appear below the message.
Clickable buttons that will appear below the message to help users interact with your
recipe.
</p>
</div>
<div className="flex flex-wrap gap-3">
{nonMessageActivities.map((activity, index) => (
<div
key={index}
className="inline-flex items-center bg-background-default border-2 border-borderSubtle rounded-full px-4 py-2 text-sm text-textStandard"
title={activity.length > 100 ? activity : undefined}
>
<span>{activity.length > 100 ? activity.slice(0, 100) + '...' : activity}</span>
<Button
onClick={() => handleRemoveActivity(activity)}
variant="ghost"
size="sm"
className="ml-2 text-textStandard hover:text-textSubtle transition-colors p-0 h-auto"
{nonMessageActivities.length > 0 && (
<div className="flex flex-wrap gap-3">
{nonMessageActivities.map((activity, index) => (
<div
key={index}
className="inline-flex items-center bg-background-default border-2 border-borderSubtle rounded-full px-4 py-2 text-sm text-textStandard"
title={activity.length > 100 ? activity : undefined}
>
×
</Button>
</div>
))}
</div>
<div className="flex gap-3 mt-6">
<span>{activity.length > 100 ? activity.slice(0, 100) + '...' : activity}</span>
<Button
type="button"
onClick={() => handleRemoveActivity(activity)}
variant="ghost"
size="sm"
className="ml-2 text-textStandard hover:text-textSubtle transition-colors p-0 h-auto"
>
×
</Button>
</div>
))}
</div>
)}
<div className="flex gap-2 mt-3">
<input
type="text"
value={newActivity}
onChange={(e) => setNewActivity(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleAddActivity()}
className="flex-1 px-4 py-3 border rounded-lg bg-background-default text-textStandard placeholder-textPlaceholder focus:outline-none focus:ring-2 focus:ring-borderProminent"
onBlur={onBlur}
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"
placeholder="Add new activity..."
/>
<Button
<button
type="button"
onClick={handleAddActivity}
className="px-5 py-1.5 text-sm bg-background-defaultInverse text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors"
disabled={!newActivity.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 activity
</Button>
</button>
</div>
</div>
</div>
@@ -1,707 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Recipe, generateDeepLink } from '../../recipe';
import { Parameter } from '../../recipe/index';
import { Geese } from '../icons/Geese';
import Copy from '../icons/Copy';
import { Check, Save, Calendar } from 'lucide-react';
import { ExtensionConfig, useConfig } from '../ConfigContext';
import { FixedExtensionEntry } from '../ConfigContext';
import RecipeActivityEditor from './RecipeActivityEditor';
import RecipeInfoModal from './RecipeInfoModal';
import RecipeExpandableInfo from './RecipeExpandableInfo';
import { ScheduleFromRecipeModal } from '../schedule/ScheduleFromRecipeModal';
import ParameterInput from '../parameter/ParameterInput';
import { saveRecipe, generateRecipeFilename } from '../../recipe/recipeStorage';
import { toastSuccess, toastError } from '../../toasts';
import { Button } from '../ui/button';
import { useEscapeKey } from '../../hooks/useEscapeKey';
interface RecipeEditorProps {
config?: Recipe;
}
export default function RecipeEditor({ config }: RecipeEditorProps) {
const { getExtensions } = useConfig();
const navigate = useNavigate();
const [recipeConfig] = useState<Recipe | undefined>(config);
const [title, setTitle] = useState(config?.title || '');
const [description, setDescription] = useState(config?.description || '');
const [instructions, setInstructions] = useState(config?.instructions || '');
const [prompt, setPrompt] = useState(config?.prompt || '');
const [activities, setActivities] = useState<string[]>(config?.activities || []);
const [parameters, setParameters] = useState<Parameter[]>(
parseParametersFromInstructions(instructions)
);
const [extensionOptions, setExtensionOptions] = useState<FixedExtensionEntry[]>([]);
const [extensionsLoaded, setExtensionsLoaded] = useState(false);
const [copied, setCopied] = useState(false);
const [isRecipeInfoModalOpen, setRecipeInfoModalOpen] = useState(false);
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [saveRecipeName, setSaveRecipeName] = useState('');
const [saveGlobal, setSaveGlobal] = useState(true);
const [saving, setSaving] = useState(false);
const [recipeInfoModelProps, setRecipeInfoModelProps] = useState<{
label: string;
value: string;
setValue: (value: string) => void;
} | null>(null);
const [deeplink, setDeeplink] = useState('');
const [isGeneratingDeeplink, setIsGeneratingDeeplink] = useState(false);
// Initialize selected extensions for the recipe from config or localStorage
const [recipeExtensions] = useState<string[]>(() => {
// First try to get from localStorage
const stored = localStorage.getItem('recipe_editor_extensions');
if (stored) {
try {
const parsed = JSON.parse(stored);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
console.error('Failed to parse localStorage recipe extensions:', e);
return [];
}
}
// Fall back to config if available, using extension names
const exts: string[] = [];
return exts;
});
// Section visibility state
const [activeSection, _] = useState<'none' | 'activities' | 'instructions' | 'extensions'>(
'none'
);
// Load extensions when component mounts and when switching to extensions section
useEffect(() => {
if (activeSection === 'extensions' && !extensionsLoaded) {
const loadExtensions = async () => {
try {
const extensions = await getExtensions(false); // force refresh to get latest
console.log('Loading extensions for recipe editor');
if (extensions && extensions.length > 0) {
// Map the extensions with the current selection state from recipeExtensions
const initializedExtensions = extensions.map((ext) => ({
...ext,
enabled: recipeExtensions.includes(ext.name),
}));
setExtensionOptions(initializedExtensions);
setExtensionsLoaded(true);
}
} catch (error) {
console.error('Failed to load extensions:', error);
}
};
loadExtensions();
}
}, [activeSection, getExtensions, recipeExtensions, extensionsLoaded]);
// Effect for updating extension options when recipeExtensions change
useEffect(() => {
if (extensionsLoaded && extensionOptions.length > 0) {
const updatedOptions = extensionOptions.map((ext) => ({
...ext,
enabled: recipeExtensions.includes(ext.name),
}));
setExtensionOptions(updatedOptions);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recipeExtensions, extensionsLoaded]);
// Use effect to set parameters whenever instructions or prompt changes
useEffect(() => {
const instructionsParams = parseParametersFromInstructions(instructions);
const promptParams = parseParametersFromInstructions(prompt);
// Combine parameters, ensuring no duplicates by key
const allParams = [...instructionsParams];
promptParams.forEach((promptParam) => {
if (!allParams.some((param) => param.key === promptParam.key)) {
allParams.push(promptParam);
}
});
setParameters(allParams);
}, [instructions, prompt]);
// Handle Esc key for Save Recipe Dialog
useEscapeKey(showSaveDialog, () => {
setShowSaveDialog(false);
setSaveRecipeName('');
});
const getCurrentConfig = useCallback((): Recipe => {
// Transform the internal parameters state into the desired output format.
const formattedParameters = parameters.map((param) => {
const formattedParam: Parameter = {
key: param.key,
input_type: param.input_type || 'string', // Use actual input_type instead of hardcoded 'string'
requirement: param.requirement,
description: param.description,
};
// Add the 'default' key ONLY if the parameter is optional and has a default value.
if (param.requirement === 'optional' && param.default) {
// Note: `default` is a reserved keyword in JS, but assigning it as a property key like this is valid.
formattedParam.default = param.default;
}
// Add options for select input type
if (param.input_type === 'select' && param.options) {
formattedParam.options = param.options.filter((opt) => opt.trim() !== ''); // Filter empty options when saving
}
return formattedParam;
});
const config = {
...recipeConfig,
title,
description,
instructions,
activities,
prompt,
// Use the newly formatted parameters array in the final config object.
parameters: formattedParameters,
extensions: recipeExtensions
.map((name) => {
const extension = extensionOptions.find((e) => e.name === name);
console.log('Looking for extension:', name, 'Found:', extension);
if (!extension) return null;
// Create a clean copy of the extension configuration
const { enabled: _enabled, ...cleanExtension } = extension;
// Remove legacy envs which could potentially include secrets
// env_keys will work but rely on the end user having setup those keys themselves
if ('envs' in cleanExtension) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { envs: _envs, ...finalExtension } = cleanExtension as any;
return finalExtension;
}
return cleanExtension;
})
.filter(Boolean) as ExtensionConfig[],
};
console.log('Final config extensions:', config.extensions);
return config;
}, [
recipeConfig,
title,
description,
instructions,
activities,
prompt,
parameters,
recipeExtensions,
extensionOptions,
]);
// Generate deeplink whenever recipe configuration changes
useEffect(() => {
let isCancelled = false;
const generateLink = async () => {
if (!title.trim() || !description.trim() || !instructions.trim()) {
setDeeplink('');
return;
}
setIsGeneratingDeeplink(true);
try {
const currentConfig = getCurrentConfig();
const link = await generateDeepLink(currentConfig);
if (!isCancelled) {
setDeeplink(link);
}
} catch (error) {
console.error('Failed to generate deeplink:', error);
if (!isCancelled) {
setDeeplink('Error generating deeplink');
}
} finally {
if (!isCancelled) {
setIsGeneratingDeeplink(false);
}
}
};
generateLink();
return () => {
isCancelled = true;
};
}, [
title,
description,
instructions,
prompt,
activities,
parameters,
recipeExtensions,
getCurrentConfig,
]);
const [errors, setErrors] = useState<{
title?: string;
description?: string;
instructions?: string;
}>({});
const requiredFieldsAreFilled = () => {
return title.trim() && description.trim() && instructions.trim();
};
const validateForm = () => {
const newErrors: { title?: string; description?: string; instructions?: string } = {};
if (!title.trim()) {
newErrors.title = 'Title is required';
}
if (!description.trim()) {
newErrors.description = 'Description is required';
}
if (!instructions.trim()) {
newErrors.instructions = 'Instructions are required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleParameterChange = (name: string, value: Partial<Parameter>) => {
setParameters((prev) =>
prev.map((param) => (param.key === name ? { ...param, ...value } : param))
);
};
const handleCopy = () => {
if (!deeplink || isGeneratingDeeplink || deeplink === 'Error generating deeplink') {
return;
}
navigator.clipboard
.writeText(deeplink)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch((err) => {
console.error('Failed to copy the text:', err);
});
};
const handleSaveRecipe = async () => {
if (!saveRecipeName.trim()) {
return;
}
setSaving(true);
try {
const currentRecipe = getCurrentConfig();
if (!currentRecipe.title || !currentRecipe.description || !currentRecipe.instructions) {
throw new Error('Invalid recipe configuration: missing required fields');
}
await saveRecipe(currentRecipe, {
name: saveRecipeName.trim(),
global: saveGlobal,
});
// Reset dialog state
setShowSaveDialog(false);
setSaveRecipeName('');
toastSuccess({
title: saveRecipeName.trim(),
msg: `Recipe saved successfully`,
});
} catch (error) {
console.error('Failed to save recipe:', error);
toastError({
title: 'Save Failed',
msg: `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setSaving(false);
}
};
const handleSaveRecipeClick = () => {
if (!validateForm()) {
return;
}
const currentRecipe = getCurrentConfig();
// Generate a suggested name from the recipe title
const suggestedName = generateRecipeFilename(currentRecipe);
setSaveRecipeName(suggestedName);
setShowSaveDialog(true);
};
const onClickEditTextArea = ({
label,
value,
setValue,
}: {
label: string;
value: string;
setValue: (value: string) => void;
}) => {
setRecipeInfoModalOpen(true);
setRecipeInfoModelProps({
label,
value,
setValue,
});
};
// Reset extensionsLoaded when section changes away from extensions
useEffect(() => {
if (activeSection !== 'extensions') {
setExtensionsLoaded(false);
}
}, [activeSection]);
const page_title = config?.title ? 'View/edit current recipe' : 'Create an agent recipe';
const subtitle = config?.title
? "You can edit the recipe below to change the agent's behavior in a new session."
: 'Your custom agent recipe can be shared with others. Fill in the sections below to create!';
function parseParametersFromInstructions(instructions: string): Parameter[] {
const regex = /\{\{(.*?)\}\}/g;
const matches = [...instructions.matchAll(regex)];
return matches.map((match) => {
return {
key: match[1].trim(),
description: `Enter value for ${match[1].trim()}`,
requirement: 'required',
input_type: 'string', // Default to string; can be changed based on requirements
};
});
}
return (
<div className="flex flex-col w-full h-screen bg-background-default">
{activeSection === 'none' && (
<div className="flex flex-col items-center mb-2 px-6 pt-10">
<div className="w-16 h-16 bg-background-default rounded-full flex items-center justify-center mb-4">
<Geese className="w-12 h-12 text-iconProminent" />
</div>
<h1 className="text-2xl font-medium text-center text-textProminent">{page_title}</h1>
<p className="text-textSubtle text-center mt-2 text-sm">{subtitle}</p>
</div>
)}
<div className="flex-1 overflow-y-auto px-6">
<div className="flex flex-col">
<h2 className="text-lg font-medium mb-2 text-textProminent">Agent Recipe Details</h2>
</div>
<div className="space-y-2 py-2">
<div className="pb-6 border-b-2 border-borderSubtle">
<label htmlFor="title" className="block text-md text-textProminent mb-2 font-bold">
Title <span className="text-red-500">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => {
setTitle(e.target.value);
if (errors.title) {
setErrors({ ...errors, title: undefined });
}
}}
className={`w-full max-w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent overflow-hidden ${
errors.title ? 'border-red-500' : 'border-borderSubtle'
}`}
placeholder="Agent Recipe Title (required)"
/>
{errors.title && <div className="text-red-500 text-sm mt-1">{errors.title}</div>}
</div>
<div className="pt-3 pb-6 border-b-2 border-borderSubtle">
<label
htmlFor="description"
className="block text-md text-textProminent mb-2 font-bold"
>
Description <span className="text-red-500">*</span>
</label>
<input
type="text"
value={description}
onChange={(e) => {
setDescription(e.target.value);
if (errors.description) {
setErrors({ ...errors, description: undefined });
}
}}
className={`w-full max-w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent overflow-hidden ${
errors.description ? 'border-red-500' : 'border-borderSubtle'
}`}
placeholder="Description (required)"
/>
{errors.description && (
<div className="text-red-500 text-sm mt-1">{errors.description}</div>
)}
</div>
<div className="pt-3 pb-6 border-b-2 border-borderSubtle">
<RecipeExpandableInfo
infoLabel="Instructions"
infoValue={instructions}
required={true}
onClickEdit={() =>
onClickEditTextArea({
label: 'Instructions',
value: instructions,
setValue: setInstructions,
})
}
/>
{errors.instructions && (
<div className="text-red-500 text-sm mt-1">{errors.instructions}</div>
)}
</div>
{/* Parameters section */}
<div className="pt-3 pb-6 border-b-2 border-borderSubtle">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-textProminent">Parameters</h3>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
const newKey = `param_${Date.now()}`;
const newParam: Parameter = {
key: newKey,
description: `Enter value for ${newKey}`,
input_type: 'string',
requirement: 'required',
};
setParameters((prev) => [...prev, newParam]);
}}
className="px-3 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors text-sm"
>
Add Parameter
</button>
{parameters.length > 0 && (
<button
type="button"
onClick={() => {
if (parameters.length > 0) {
setParameters((prev) => prev.slice(0, -1));
}
}}
className="px-3 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm"
>
Remove Last
</button>
)}
</div>
</div>
{parameters.map((parameter: Parameter) => (
<ParameterInput
key={parameter.key}
parameter={parameter}
onChange={(name, value) => handleParameterChange(name, value)}
/>
))}
</div>
<div className="pt-3 pb-6 border-b-2 border-borderSubtle">
<RecipeExpandableInfo
infoLabel="Initial Prompt"
infoValue={prompt}
required={false}
onClickEdit={() =>
onClickEditTextArea({ label: 'Initial Prompt', value: prompt, setValue: setPrompt })
}
/>
</div>
<div className="pt-3 pb-6">
<RecipeActivityEditor activities={activities} setActivities={setActivities} />
</div>
{/* Deep Link Display */}
<div className="w-full p-4 bg-bgSubtle rounded-lg overflow-hidden">
{!requiredFieldsAreFilled() ? (
<div className="text-sm text-textSubtle text-xs text-textSubtle">
Fill in required fields to generate link
</div>
) : (
<div className="flex items-center justify-between mb-2 gap-4">
<div className="text-sm text-textSubtle text-xs text-textSubtle flex-shrink-0">
Copy this link to share with friends or paste directly in Chrome to open
</div>
<Button
onClick={() => validateForm() && handleCopy()}
variant="ghost"
size="sm"
disabled={
!deeplink || isGeneratingDeeplink || deeplink === 'Error generating deeplink'
}
className="p-2 hover:bg-background-default rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent flex-shrink-0"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4 text-iconSubtle" />
)}
<span className="ml-1 text-sm text-textSubtle">
{copied ? 'Copied!' : 'Copy'}
</span>
</Button>
</div>
)}
{requiredFieldsAreFilled() && (
<div className="w-full overflow-hidden">
<div
onClick={() => validateForm() && handleCopy()}
className={`text-sm dark:text-white font-mono cursor-pointer hover:bg-background-default p-2 rounded transition-colors overflow-x-auto whitespace-nowrap ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`}
style={{ maxWidth: '500px', width: '100%' }}
>
{isGeneratingDeeplink
? 'Generating deeplink...'
: deeplink || 'Click to generate deeplink'}
</div>
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex flex-col space-y-3 pt-4">
<div className="flex gap-3">
<button
onClick={handleSaveRecipeClick}
disabled={!requiredFieldsAreFilled() || saving}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-3 bg-bgStandard text-textStandard border border-borderStandard rounded-lg hover:bg-bgSubtle transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" />
{saving ? 'Saving...' : 'Save Recipe'}
</button>
<Button
onClick={() => setIsScheduleModalOpen(true)}
disabled={!requiredFieldsAreFilled()}
variant="outline"
size="lg"
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-3 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Calendar className="w-4 h-4" />
Create Schedule
</Button>
</div>
<Button
onClick={() => {
localStorage.removeItem('recipe_editor_extensions');
navigate(-1);
}}
variant="ghost"
className="w-full p-3 text-textSubtle rounded-lg hover:bg-bgSubtle transition-colors"
>
Close
</Button>
</div>
</div>
</div>
<RecipeInfoModal
infoLabel={recipeInfoModelProps?.label}
originalValue={recipeInfoModelProps?.value}
isOpen={isRecipeInfoModalOpen}
onClose={() => setRecipeInfoModalOpen(false)}
onSaveValue={recipeInfoModelProps?.setValue}
/>
<ScheduleFromRecipeModal
isOpen={isScheduleModalOpen}
onClose={() => setIsScheduleModalOpen(false)}
recipe={getCurrentConfig()}
onCreateSchedule={(deepLink) => {
// Navigate to the schedules view with the deep link pre-filled
localStorage.setItem('pendingScheduleDeepLink', deepLink);
navigate('/schedules');
}}
/>
{/* Save Recipe Dialog */}
{showSaveDialog && (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-borderSubtle rounded-lg p-6 w-96 max-w-[90vw]">
<h3 className="text-lg font-medium text-textProminent mb-4">Save Recipe</h3>
<div className="space-y-4">
<div>
<label
htmlFor="recipe-name"
className="block text-sm font-medium text-textStandard mb-2"
>
Recipe Name
</label>
<input
id="recipe-name"
type="text"
value={saveRecipeName}
onChange={(e) => setSaveRecipeName(e.target.value)}
className="w-full p-3 border border-borderSubtle rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
placeholder="Enter recipe name"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-textStandard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={saveGlobal}
onChange={() => setSaveGlobal(true)}
className="mr-2"
/>
<span className="text-sm text-textStandard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={!saveGlobal}
onChange={() => setSaveGlobal(false)}
className="mr-2"
/>
<span className="text-sm text-textStandard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={() => {
setShowSaveDialog(false);
setSaveRecipeName('');
}}
className="px-4 py-2 text-textSubtle hover:text-textStandard transition-colors"
disabled={saving}
>
Cancel
</button>
<button
onClick={handleSaveRecipe}
disabled={!saveRecipeName.trim() || saving}
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Recipe'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+83 -446
View File
@@ -1,18 +1,17 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect } from 'react';
import { listSavedRecipes, convertToLocaleDateString } from '../../recipe/recipeStorage';
import { FileText, Trash2, Bot, Calendar, AlertCircle } from 'lucide-react';
import { FileText, Edit, Trash2, Play, Calendar, AlertCircle, Link } from 'lucide-react';
import { ScrollArea } from '../ui/scroll-area';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Skeleton } from '../ui/skeleton';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
import { Recipe, generateDeepLink } from '../../recipe';
import { toastSuccess, toastError } from '../../toasts';
import { toastSuccess } from '../../toasts';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { deleteRecipe, RecipeManifestResponse } from '../../api';
import CreateRecipeForm, { CreateRecipeButton } from './CreateRecipeForm';
import ImportRecipeForm, { ImportRecipeButton } from './ImportRecipeForm';
import { filterValidUsedParameters } from '../../utils/providerUtils';
import CreateEditRecipeModal from './CreateEditRecipeModal';
import { generateDeepLink, Recipe } from '../../recipe';
export default function RecipesView() {
const [savedRecipes, setSavedRecipes] = useState<RecipeManifestResponse[]>([]);
@@ -20,9 +19,8 @@ export default function RecipesView() {
const [showSkeleton, setShowSkeleton] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedRecipe, setSelectedRecipe] = useState<RecipeManifestResponse | null>(null);
const [showPreview, setShowPreview] = useState(false);
const [showEditor, setShowEditor] = useState(false);
const [showContent, setShowContent] = useState(false);
const [previewDeeplink, setPreviewDeeplink] = useState<string>('');
// Form dialog states
const [showCreateDialog, setShowCreateDialog] = useState(false);
@@ -32,8 +30,8 @@ export default function RecipesView() {
loadSavedRecipes();
}, []);
// Handle Esc key for preview modal
useEscapeKey(showPreview, () => setShowPreview(false));
// Handle Esc key for editor modal
useEscapeKey(showEditor, () => setShowEditor(false));
// Minimum loading time to prevent skeleton flash
useEffect(() => {
@@ -121,34 +119,36 @@ export default function RecipesView() {
}
};
const handlePreviewRecipe = async (recipeManifest: RecipeManifestResponse) => {
const handleEditRecipe = async (recipeManifest: RecipeManifestResponse) => {
setSelectedRecipe(recipeManifest);
setShowPreview(true);
setShowEditor(true);
};
// Generate deeplink for preview
try {
const deeplink = await generateDeepLink(recipeManifest.recipe);
setPreviewDeeplink(deeplink);
} catch (error) {
console.error('Failed to generate deeplink for preview:', error);
setPreviewDeeplink('Error generating deeplink');
const handleEditorClose = (wasSaved?: boolean) => {
setShowEditor(false);
setSelectedRecipe(null);
// Only reload recipes if a recipe was actually saved/updated
if (wasSaved) {
loadSavedRecipes();
}
};
const filteredPreviewParameters = useMemo(() => {
if (!selectedRecipe?.recipe.parameters) {
return [];
const handleCopyDeeplink = async (recipeManifest: RecipeManifestResponse) => {
try {
const deeplink = await generateDeepLink(recipeManifest.recipe);
await navigator.clipboard.writeText(deeplink);
toastSuccess({
title: 'Deeplink copied',
msg: 'Recipe deeplink has been copied to clipboard',
});
} catch (error) {
console.error('Failed to copy deeplink:', error);
toastSuccess({
title: 'Copy failed',
msg: 'Failed to copy deeplink to clipboard',
});
}
return filterValidUsedParameters(selectedRecipe.recipe.parameters, {
instructions: selectedRecipe.recipe.instructions || undefined,
prompt: selectedRecipe.recipe.prompt || undefined,
});
}, [
selectedRecipe?.recipe.parameters,
selectedRecipe?.recipe.instructions,
selectedRecipe?.recipe.prompt,
]);
};
// Render a recipe item
const RecipeItem = ({
@@ -157,7 +157,7 @@ export default function RecipesView() {
}: {
recipeManifestResponse: RecipeManifestResponse;
}) => (
<Card className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted cursor-pointer transition-all duration-150">
<Card className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted transition-all duration-150">
<div className="flex justify-between items-start gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
@@ -177,22 +177,34 @@ export default function RecipesView() {
handleLoadRecipe(recipe);
}}
size="sm"
className="h-8"
className="h-8 w-8 p-0"
title="Use recipe"
>
<Bot className="w-4 h-4 mr-1" />
Use
<Play className="w-4 h-4" />
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
handlePreviewRecipe(recipeManifestResponse);
handleEditRecipe(recipeManifestResponse);
}}
variant="outline"
size="sm"
className="h-8"
className="h-8 w-8 p-0"
title="Edit recipe"
>
<FileText className="w-4 h-4 mr-1" />
Preview
<Edit className="w-4 h-4" />
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
handleCopyDeeplink(recipeManifestResponse);
}}
variant="outline"
size="sm"
className="h-8 w-8 p-0"
title="Copy deeplink"
>
<Link className="w-4 h-4" />
</Button>
<Button
onClick={(e) => {
@@ -201,7 +213,8 @@ export default function RecipesView() {
}}
variant="ghost"
size="sm"
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
className="h-8 w-8 p-0 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
title="Delete recipe"
>
<Trash2 className="w-4 h-4" />
</Button>
@@ -220,8 +233,9 @@ export default function RecipesView() {
<Skeleton className="h-4 w-24" />
</div>
<div className="flex items-center gap-2 shrink-0">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8" />
</div>
</div>
@@ -287,7 +301,15 @@ export default function RecipesView() {
<div className="flex justify-between items-center mb-1">
<h1 className="text-4xl font-light">Recipes</h1>
<div className="flex gap-2">
<CreateRecipeButton onClick={() => setShowCreateDialog(true)} />
<Button
onClick={() => setShowCreateDialog(true)}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<FileText className="w-4 h-4" />
Create Recipe
</Button>
<ImportRecipeButton onClick={() => setShowImportDialog(true)} />
</div>
</div>
@@ -312,416 +334,31 @@ export default function RecipesView() {
</div>
</MainPanelLayout>
{/* Preview Modal */}
{showPreview && selectedRecipe && (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[600px] max-w-[90vw] max-h-[80vh] overflow-y-auto">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-xl font-medium text-text-standard">
{selectedRecipe.recipe.title}
</h3>
</div>
<button
onClick={() => setShowPreview(false)}
className="text-text-muted hover:text-text-standard text-2xl leading-none"
>
×
</button>
</div>
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Deeplink</h4>
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-text-muted">
Copy this link to share with friends or paste directly in Chrome to open
</div>
<Button
onClick={async () => {
try {
const deeplink =
previewDeeplink || (await generateDeepLink(selectedRecipe.recipe));
navigator.clipboard.writeText(deeplink);
toastSuccess({
title: 'Copied!',
msg: 'Recipe deeplink copied to clipboard',
});
} catch (error) {
toastError({
title: 'Copy Failed',
msg: 'Failed to copy deeplink to clipboard',
traceback: error instanceof Error ? error.message : String(error),
});
}
}}
variant="ghost"
size="sm"
className="ml-4 p-2 hover:bg-background-default rounded-lg transition-colors flex items-center"
>
<span className="text-sm text-text-muted">Copy</span>
</Button>
</div>
<div
onClick={async () => {
try {
const deeplink =
previewDeeplink || (await generateDeepLink(selectedRecipe.recipe));
navigator.clipboard.writeText(deeplink);
toastSuccess({
title: 'Copied!',
msg: 'Recipe deeplink copied to clipboard',
});
} catch (error) {
toastError({
title: 'Copy Failed',
msg: 'Failed to copy deeplink to clipboard',
traceback: error instanceof Error ? error.message : String(error),
});
}
}}
className="text-sm truncate font-mono cursor-pointer text-text-standard"
>
{previewDeeplink || 'Generating deeplink...'}
</div>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Description</h4>
<p className="text-text-muted">{selectedRecipe.recipe.description}</p>
</div>
{selectedRecipe.recipe.version && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Version</h4>
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
<span className="text-sm text-text-muted font-mono">
{selectedRecipe.recipe.version}
</span>
</div>
</div>
)}
{selectedRecipe.recipe.instructions && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Instructions</h4>
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
<pre className="text-sm text-text-muted whitespace-pre-wrap font-mono">
{selectedRecipe.recipe.instructions}
</pre>
</div>
</div>
)}
{selectedRecipe.recipe.prompt && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Initial Prompt</h4>
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
<pre className="text-sm text-text-muted whitespace-pre-wrap font-mono">
{selectedRecipe.recipe.prompt}
</pre>
</div>
</div>
)}
{filteredPreviewParameters && filteredPreviewParameters.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Parameters</h4>
<div className="space-y-3">
{filteredPreviewParameters.map((param, index) => (
<div
key={index}
className="bg-background-muted border border-border-subtle p-3 rounded-lg"
>
<div className="flex items-center gap-2 mb-2">
<code className="text-sm font-mono bg-background-default px-2 py-1 rounded text-text-standard">
{param.key}
</code>
<span className="text-xs px-2 py-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{param.input_type}
</span>
<span
className={`text-xs px-2 py-1 rounded ${
param.requirement === 'required'
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
: param.requirement === 'user_prompt'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200'
}`}
>
{param.requirement}
</span>
</div>
<p className="text-sm text-text-muted mb-2">{param.description}</p>
{param.default && (
<div className="text-xs text-text-muted">
<span className="font-medium">Default:</span> {param.default}
</div>
)}
{param.input_type === 'select' &&
param.options &&
param.options.length > 0 && (
<div className="text-xs text-text-muted mt-2">
<span className="font-medium">Options:</span>
<div className="flex flex-wrap gap-1 mt-1">
{param.options.map((option, optIndex) => (
<span
key={optIndex}
className="px-2 py-1 bg-background-default border border-border-subtle rounded text-xs"
>
{option}
</span>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{selectedRecipe.recipe.activities && selectedRecipe.recipe.activities.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Activities</h4>
<div className="flex flex-wrap gap-2">
{selectedRecipe.recipe.activities.map((activity, index) => (
<span
key={index}
className="px-2 py-1 bg-background-muted border border-border-subtle text-text-muted rounded text-sm"
>
{activity}
</span>
))}
</div>
</div>
)}
{selectedRecipe.recipe.extensions && selectedRecipe.recipe.extensions.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Extensions</h4>
<div className="space-y-2">
{selectedRecipe.recipe.extensions.map((extension, index) => {
const extWithDetails = extension as typeof extension & {
version?: string;
type?: string;
bundled?: boolean;
cmd?: string;
args?: string[];
timeout?: number;
};
return (
<div
key={index}
className="bg-background-muted border border-border-subtle p-3 rounded-lg"
>
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-text-standard">{extension.name}</span>
{extWithDetails.version && (
<span className="text-xs px-2 py-1 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 rounded">
v{extWithDetails.version}
</span>
)}
{extWithDetails.type && (
<span className="text-xs px-2 py-1 bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded">
{extWithDetails.type}
</span>
)}
{extWithDetails.bundled && (
<span className="text-xs px-2 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded">
bundled
</span>
)}
</div>
{'description' in extension && extension.description && (
<p className="text-sm text-text-muted mb-2">{extension.description}</p>
)}
{/* Extension command details */}
{extWithDetails.cmd && (
<div className="text-xs text-text-muted mt-2">
<div className="mb-1">
<span className="font-medium">Command:</span>{' '}
<code className="bg-background-default px-1 rounded">
{extWithDetails.cmd}
</code>
</div>
{extWithDetails.args && extWithDetails.args.length > 0 && (
<div className="mb-1">
<span className="font-medium">Args:</span>
<div className="flex flex-wrap gap-1 mt-1">
{extWithDetails.args.map((arg: string, argIndex: number) => (
<code
key={argIndex}
className="px-1 bg-background-default border border-border-subtle rounded text-xs"
>
{arg}
</code>
))}
</div>
</div>
)}
{extWithDetails.timeout && (
<div>
<span className="font-medium">Timeout:</span>{' '}
{extWithDetails.timeout}s
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
)}
{selectedRecipe.recipe.sub_recipes &&
selectedRecipe.recipe.sub_recipes.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Sub Recipes</h4>
<div className="space-y-3">
{selectedRecipe.recipe.sub_recipes.map((subRecipe, index: number) => (
<div
key={index}
className="bg-background-muted border border-border-subtle p-3 rounded-lg"
>
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-text-standard">{subRecipe.name}</span>
<span className="text-xs px-2 py-1 bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 rounded">
sub-recipe
</span>
</div>
<div className="text-sm text-text-muted mb-2">
<span className="font-medium">Path:</span>{' '}
<code className="bg-background-default px-1 rounded font-mono text-xs">
{subRecipe.path}
</code>
</div>
{subRecipe.values && Object.keys(subRecipe.values).length > 0 && (
<div className="text-xs text-text-muted mt-2">
<span className="font-medium">Parameter Values:</span>
<div className="mt-1 space-y-1">
{Object.entries(subRecipe.values).map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<code className="bg-background-default px-1 rounded text-xs">
{key}
</code>
<span>=</span>
<code className="bg-background-default px-1 rounded text-xs">
{String(value)}
</code>
</div>
))}
</div>
</div>
)}
{subRecipe.description && (
<p className="text-sm text-text-muted mt-2">{subRecipe.description}</p>
)}
</div>
))}
</div>
</div>
)}
{selectedRecipe.recipe.response && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Response Schema</h4>
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
<pre className="text-sm text-text-muted whitespace-pre-wrap font-mono">
{
(() => {
const response = selectedRecipe.recipe.response;
try {
if (typeof response === 'string') {
return response;
}
return JSON.stringify(response, null, 2);
} catch {
return String(response);
}
})() as string
}
</pre>
</div>
</div>
)}
{selectedRecipe.recipe.context && selectedRecipe.recipe.context.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Context</h4>
<div className="space-y-2">
{selectedRecipe.recipe.context.map((contextItem, index) => (
<div
key={index}
className="bg-background-muted border border-border-subtle p-2 rounded text-sm text-text-muted font-mono"
>
{contextItem}
</div>
))}
</div>
</div>
)}
{selectedRecipe.recipe.author && (
<div>
<h4 className="text-sm font-medium text-text-standard mb-2">Author</h4>
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
{selectedRecipe.recipe.author.contact && (
<div className="text-sm text-text-muted mb-1">
<span className="font-medium">Contact:</span>{' '}
{selectedRecipe.recipe.author.contact}
</div>
)}
{selectedRecipe.recipe.author.metadata && (
<div className="text-sm text-text-muted">
<span className="font-medium">Metadata:</span>{' '}
{selectedRecipe.recipe.author.metadata}
</div>
)}
</div>
</div>
)}
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-border-subtle">
<Button onClick={() => setShowPreview(false)} variant="ghost">
Close
</Button>
<Button
onClick={() => {
setShowPreview(false);
handleLoadRecipe(selectedRecipe.recipe);
}}
variant="default"
>
Load Recipe
</Button>
</div>
</div>
</div>
{showEditor && selectedRecipe && (
<CreateEditRecipeModal
isOpen={showEditor}
onClose={handleEditorClose}
recipe={selectedRecipe.recipe}
recipeName={selectedRecipe.name}
/>
)}
{/* Use the extracted form components */}
<ImportRecipeForm
isOpen={showImportDialog}
onClose={() => setShowImportDialog(false)}
onSuccess={loadSavedRecipes}
/>
<CreateRecipeForm
isOpen={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
onSuccess={loadSavedRecipes}
/>
{showCreateDialog && (
<CreateEditRecipeModal
isOpen={showCreateDialog}
onClose={() => {
setShowCreateDialog(false);
loadSavedRecipes();
}}
isCreateMode={true}
/>
)}
</>
);
}
@@ -1,679 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { Recipe, generateDeepLink } from '../../recipe';
import { Parameter } from '../../recipe/index';
import { Geese } from '../icons/Geese';
import Copy from '../icons/Copy';
import { Check, Save, Calendar, X } from 'lucide-react';
import { ExtensionConfig, useConfig } from '../ConfigContext';
import { FixedExtensionEntry } from '../ConfigContext';
import RecipeActivityEditor from './RecipeActivityEditor';
import RecipeInfoModal from './RecipeInfoModal';
import RecipeExpandableInfo from './RecipeExpandableInfo';
import { ScheduleFromRecipeModal } from '../schedule/ScheduleFromRecipeModal';
import ParameterInput from '../parameter/ParameterInput';
import { saveRecipe, generateRecipeFilename } from '../../recipe/recipeStorage';
import { toastSuccess, toastError } from '../../toasts';
import { Button } from '../ui/button';
import { filterValidUsedParameters } from '../../utils/providerUtils';
interface ViewRecipeModalProps {
isOpen: boolean;
onClose: () => void;
config: Recipe;
}
export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeModalProps) {
const { getExtensions } = useConfig();
const [recipeConfig] = useState<Recipe | undefined>(config);
const [title, setTitle] = useState(config?.title || '');
const [description, setDescription] = useState(config?.description || '');
const [instructions, setInstructions] = useState(config?.instructions || '');
const [prompt, setPrompt] = useState(config?.prompt || '');
const [activities, setActivities] = useState<string[]>(config?.activities || []);
const [parameters, setParameters] = useState<Parameter[]>(config?.parameters || []);
const [extensionOptions, setExtensionOptions] = useState<FixedExtensionEntry[]>([]);
const [extensionsLoaded, setExtensionsLoaded] = useState(false);
const [copied, setCopied] = useState(false);
const [isRecipeInfoModalOpen, setRecipeInfoModalOpen] = useState(false);
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [saveRecipeName, setSaveRecipeName] = useState('');
const [saveGlobal, setSaveGlobal] = useState(true);
const [saving, setSaving] = useState(false);
const [recipeInfoModelProps, setRecipeInfoModelProps] = useState<{
label: string;
value: string;
setValue: (value: string) => void;
} | null>(null);
// Initialize selected extensions for the recipe from config
const [recipeExtensions] = useState<string[]>(() => {
if (config?.extensions) {
return config.extensions.map((ext) => ext.name);
}
return [];
});
// Reset form when config changes
useEffect(() => {
if (config) {
setTitle(config.title || '');
setDescription(config.description || '');
setInstructions(config.instructions || '');
setPrompt(config.prompt || '');
setActivities(config.activities || []);
setParameters(config.parameters || []);
}
}, [config]);
// Load extensions when modal opens
useEffect(() => {
if (isOpen && !extensionsLoaded) {
const loadExtensions = async () => {
try {
const extensions = await getExtensions(false);
console.log('Loading extensions for recipe modal');
if (extensions && extensions.length > 0) {
const initializedExtensions = extensions.map((ext) => ({
...ext,
enabled: recipeExtensions.includes(ext.name),
}));
setExtensionOptions(initializedExtensions);
setExtensionsLoaded(true);
}
} catch (error) {
console.error('Failed to load extensions:', error);
}
};
loadExtensions();
}
}, [isOpen, getExtensions, recipeExtensions, extensionsLoaded]);
// Auto-detect new parameters from instructions and prompt
// This adds new parameters without overwriting existing ones
useEffect(() => {
const instructionsParams = parseParametersFromInstructions(instructions);
const promptParams = parseParametersFromInstructions(prompt);
// Combine all detected parameters, ensuring no duplicates by key
const detectedParamsMap = new Map<string, Parameter>();
// Add instruction parameters
instructionsParams.forEach((param) => {
detectedParamsMap.set(param.key, param);
});
// Add prompt parameters (won't overwrite existing keys)
promptParams.forEach((param) => {
if (!detectedParamsMap.has(param.key)) {
detectedParamsMap.set(param.key, param);
}
});
const existingParamKeys = new Set(parameters.map((param) => param.key));
// Only add parameters that don't already exist
const newParams = Array.from(detectedParamsMap.values()).filter(
(detectedParam) => !existingParamKeys.has(detectedParam.key)
);
if (newParams.length > 0) {
setParameters((prev) => [...prev, ...newParams]);
}
}, [instructions, prompt, parameters]);
// Filter parameters to only show valid ones that are actually used
const filteredParameters = filterValidUsedParameters(parameters, { instructions, prompt });
const getCurrentConfig = useCallback((): Recipe => {
// Transform the internal parameters state into the desired output format.
const formattedParameters = parameters.map((param) => {
const formattedParam: Parameter = {
key: param.key,
input_type: param.input_type || 'string',
requirement: param.requirement,
description: param.description,
};
// Add the 'default' key ONLY if the parameter is optional and has a default value.
if (param.requirement === 'optional' && param.default) {
formattedParam.default = param.default;
}
// Add options for select input type
if (param.input_type === 'select' && param.options) {
formattedParam.options = param.options.filter((opt) => opt.trim() !== ''); // Filter empty options when saving
}
return formattedParam;
});
const updatedConfig = {
...recipeConfig,
title,
description,
instructions,
activities,
prompt,
parameters: formattedParameters,
extensions: recipeExtensions
.map((name) => {
const extension = extensionOptions.find((e) => e.name === name);
if (!extension) return null;
// Create a clean copy of the extension configuration
const { enabled: _enabled, ...cleanExtension } = extension;
// Remove legacy envs which could potentially include secrets
if ('envs' in cleanExtension) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { envs: _envs, ...finalExtension } = cleanExtension as any;
return finalExtension;
}
return cleanExtension;
})
.filter(Boolean) as ExtensionConfig[],
};
return updatedConfig;
}, [
recipeConfig,
title,
description,
instructions,
activities,
prompt,
parameters,
recipeExtensions,
extensionOptions,
]);
const [errors, setErrors] = useState<{
title?: string;
description?: string;
instructions?: string;
}>({});
const requiredFieldsAreFilled = () => {
return title.trim() && description.trim() && instructions.trim();
};
const validateForm = () => {
const newErrors: { title?: string; description?: string; instructions?: string } = {};
if (!title.trim()) {
newErrors.title = 'Title is required';
}
if (!description.trim()) {
newErrors.description = 'Description is required';
}
if (!instructions.trim()) {
newErrors.instructions = 'Instructions are required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleParameterChange = (name: string, value: Partial<Parameter>) => {
setParameters((prev) =>
prev.map((param) => (param.key === name ? { ...param, ...value } : param))
);
};
const [deeplink, setDeeplink] = useState('');
const [isGeneratingDeeplink, setIsGeneratingDeeplink] = useState(false);
// Generate deeplink whenever recipe configuration changes
useEffect(() => {
let isCancelled = false;
const generateLink = async () => {
if (!title.trim() || !description.trim() || !instructions.trim()) {
setDeeplink('');
return;
}
setIsGeneratingDeeplink(true);
try {
const currentConfig = getCurrentConfig();
const link = await generateDeepLink(currentConfig);
if (!isCancelled) {
setDeeplink(link);
}
} catch (error) {
console.error('Failed to generate deeplink:', error);
if (!isCancelled) {
setDeeplink('Error generating deeplink');
}
} finally {
if (!isCancelled) {
setIsGeneratingDeeplink(false);
}
}
};
generateLink();
return () => {
isCancelled = true;
};
}, [
title,
description,
instructions,
prompt,
activities,
parameters,
recipeExtensions,
getCurrentConfig,
]);
const handleCopy = () => {
if (!deeplink || isGeneratingDeeplink || deeplink === 'Error generating deeplink') {
return;
}
navigator.clipboard
.writeText(deeplink)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch((err) => {
console.error('Failed to copy the text:', err);
});
};
const handleSaveRecipe = async () => {
if (!saveRecipeName.trim()) {
return;
}
setSaving(true);
try {
const currentRecipe = getCurrentConfig();
if (!currentRecipe.title || !currentRecipe.description || !currentRecipe.instructions) {
throw new Error('Invalid recipe configuration: missing required fields');
}
await saveRecipe(currentRecipe, {
name: saveRecipeName.trim(),
global: saveGlobal,
});
// Reset dialog state
setShowSaveDialog(false);
setSaveRecipeName('');
toastSuccess({
title: saveRecipeName.trim(),
msg: `Recipe saved successfully`,
});
} catch (error) {
console.error('Failed to save recipe:', error);
toastError({
title: 'Save Failed',
msg: `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setSaving(false);
}
};
const handleSaveRecipeClick = () => {
if (!validateForm()) {
return;
}
const currentRecipe = getCurrentConfig();
// Generate a suggested name from the recipe title
const suggestedName = generateRecipeFilename(currentRecipe);
setSaveRecipeName(suggestedName);
setShowSaveDialog(true);
};
const onClickEditTextArea = ({
label,
value,
setValue,
}: {
label: string;
value: string;
setValue: (value: string) => void;
}) => {
setRecipeInfoModalOpen(true);
setRecipeInfoModelProps({
label,
value,
setValue,
});
};
function parseParametersFromInstructions(instructions: string): Parameter[] {
const regex = /\{\{(.*?)\}\}/g;
const matches = [...instructions.matchAll(regex)];
return matches.map((match) => {
return {
key: match[1].trim(),
description: `Enter value for ${match[1].trim()}`,
requirement: 'required',
input_type: 'string',
};
});
}
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[400] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-borderSubtle rounded-lg w-[90vw] max-w-4xl h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-borderSubtle">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-background-default rounded-full flex items-center justify-center">
<Geese className="w-6 h-6 text-iconProminent" />
</div>
<div>
<h1 className="text-xl font-medium text-textProminent">View/edit current recipe</h1>
<p className="text-textSubtle text-sm">
You can edit the recipe below to change the agent's behavior in a new session.
</p>
</div>
</div>
<Button
onClick={onClose}
variant="ghost"
size="sm"
className="p-2 hover:bg-bgSubtle rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="space-y-6">
<div className="pb-6 border-b border-borderSubtle">
<label htmlFor="title" className="block text-md text-textProminent mb-2 font-bold">
Title <span className="text-red-500">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => {
setTitle(e.target.value);
if (errors.title) {
setErrors({ ...errors, title: undefined });
}
}}
className={`w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent ${
errors.title ? 'border-red-500' : 'border-borderSubtle'
}`}
placeholder="Agent Recipe Title (required)"
/>
{errors.title && <div className="text-red-500 text-sm mt-1">{errors.title}</div>}
</div>
<div className="pb-6 border-b border-borderSubtle">
<label
htmlFor="description"
className="block text-md text-textProminent mb-2 font-bold"
>
Description <span className="text-red-500">*</span>
</label>
<input
type="text"
value={description}
onChange={(e) => {
setDescription(e.target.value);
if (errors.description) {
setErrors({ ...errors, description: undefined });
}
}}
className={`w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent ${
errors.description ? 'border-red-500' : 'border-borderSubtle'
}`}
placeholder="Description (required)"
/>
{errors.description && (
<div className="text-red-500 text-sm mt-1">{errors.description}</div>
)}
</div>
<div className="pb-6 border-b border-borderSubtle">
<RecipeExpandableInfo
infoLabel="Instructions"
infoValue={instructions}
required={true}
onClickEdit={() =>
onClickEditTextArea({
label: 'Instructions',
value: instructions,
setValue: setInstructions,
})
}
/>
{errors.instructions && (
<div className="text-red-500 text-sm mt-1">{errors.instructions}</div>
)}
</div>
{filteredParameters.map((parameter: Parameter) => (
<ParameterInput
key={parameter.key}
parameter={parameter}
onChange={(name, value) => handleParameterChange(name, value)}
/>
))}
<div className="pb-6 border-b border-borderSubtle">
<RecipeExpandableInfo
infoLabel="Initial Prompt"
infoValue={prompt}
required={false}
onClickEdit={() =>
onClickEditTextArea({
label: 'Initial Prompt',
value: prompt,
setValue: setPrompt,
})
}
/>
</div>
<div className="pb-6 border-b border-borderSubtle">
<RecipeActivityEditor activities={activities} setActivities={setActivities} />
</div>
{/* Deep Link Display */}
<div className="w-full p-4 bg-bgSubtle rounded-lg">
{!requiredFieldsAreFilled() ? (
<div className="text-sm text-textSubtle">
Fill in required fields to generate link
</div>
) : (
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-textSubtle">
Copy this link to share with friends or paste directly in Chrome to open
</div>
<Button
onClick={() => validateForm() && handleCopy()}
variant="ghost"
size="sm"
disabled={
!deeplink || isGeneratingDeeplink || deeplink === 'Error generating deeplink'
}
className="ml-4 p-2 hover:bg-background-default rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4 text-iconSubtle" />
)}
<span className="ml-1 text-sm text-textSubtle">
{copied ? 'Copied!' : 'Copy'}
</span>
</Button>
</div>
)}
{requiredFieldsAreFilled() && (
<div
onClick={() => validateForm() && handleCopy()}
className={`text-sm truncate font-mono cursor-pointer ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`}
>
{isGeneratingDeeplink
? 'Generating deeplink...'
: deeplink || 'Click to generate deeplink'}
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-borderSubtle">
<Button
onClick={onClose}
variant="ghost"
className="px-4 py-2 text-textSubtle rounded-lg hover:bg-bgSubtle transition-colors"
>
Close
</Button>
<div className="flex gap-3">
<button
onClick={handleSaveRecipeClick}
disabled={!requiredFieldsAreFilled() || saving}
className="inline-flex items-center justify-center gap-2 px-4 py-2 bg-bgStandard text-textStandard border border-borderStandard rounded-lg hover:bg-bgSubtle transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" />
{saving ? 'Saving...' : 'Save Recipe'}
</button>
<Button
onClick={() => setIsScheduleModalOpen(true)}
disabled={!requiredFieldsAreFilled()}
variant="outline"
size="default"
className="inline-flex items-center justify-center gap-2 px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Calendar className="w-4 h-4" />
Create Schedule
</Button>
</div>
</div>
</div>
<RecipeInfoModal
infoLabel={recipeInfoModelProps?.label}
originalValue={recipeInfoModelProps?.value}
isOpen={isRecipeInfoModalOpen}
onClose={() => setRecipeInfoModalOpen(false)}
onSaveValue={recipeInfoModelProps?.setValue}
/>
<ScheduleFromRecipeModal
isOpen={isScheduleModalOpen}
onClose={() => setIsScheduleModalOpen(false)}
recipe={getCurrentConfig()}
onCreateSchedule={(deepLink) => {
// Open the schedules view with the deep link pre-filled
window.electron.createChatWindow(
undefined,
undefined,
undefined,
undefined,
undefined,
'schedules'
);
// Store the deep link in localStorage for the schedules view to pick up
localStorage.setItem('pendingScheduleDeepLink', deepLink);
}}
/>
{/* Save Recipe Dialog */}
{showSaveDialog && (
<div className="fixed inset-0 z-[500] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-borderSubtle rounded-lg p-6 w-96 max-w-[90vw]">
<h3 className="text-lg font-medium text-textProminent mb-4">Save Recipe</h3>
<div className="space-y-4">
<div>
<label
htmlFor="recipe-name"
className="block text-sm font-medium text-textStandard mb-2"
>
Recipe Name
</label>
<input
id="recipe-name"
type="text"
value={saveRecipeName}
onChange={(e) => setSaveRecipeName(e.target.value)}
className="w-full p-3 border border-borderSubtle rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
placeholder="Enter recipe name"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-textStandard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={saveGlobal}
onChange={() => setSaveGlobal(true)}
className="mr-2"
/>
<span className="text-sm text-textStandard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={!saveGlobal}
onChange={() => setSaveGlobal(false)}
className="mr-2"
/>
<span className="text-sm text-textStandard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={() => {
setShowSaveDialog(false);
setSaveRecipeName('');
}}
className="px-4 py-2 text-textSubtle hover:text-textStandard transition-colors"
disabled={saving}
>
Cancel
</button>
<button
onClick={handleSaveRecipe}
disabled={!saveRecipeName.trim() || saving}
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Recipe'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,338 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CreateRecipeFromSessionModal from '../CreateRecipeFromSessionModal';
import { createRecipe } from '../../../api/sdk.gen';
import type { CreateRecipeResponse } from '../../../api/types.gen';
vi.mock('../../../api/sdk.gen', () => ({
createRecipe: vi.fn(),
}));
vi.mock('../../../toasts', () => ({
toastError: vi.fn(),
}));
vi.mock('../../../recipe/recipeStorage', () => ({
saveRecipe: vi.fn(),
}));
const mockCreateRecipe = vi.mocked(createRecipe);
describe('CreateRecipeFromSessionModal', () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
sessionId: 'test-session-id',
onRecipeCreated: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
const mockResponse: CreateRecipeResponse = {
recipe: {
title: 'Analyzed Recipe Title',
description: 'Analyzed description',
instructions: 'Analyzed instructions with {{param1}}',
prompt: 'Analyzed prompt',
activities: ['activity1', 'activity2'],
parameters: [
{
key: 'param1',
description: 'Auto-detected parameter',
input_type: 'string',
requirement: 'required',
},
],
response: {
json_schema: { type: 'object' },
},
},
error: undefined,
};
mockCreateRecipe.mockResolvedValue({
data: mockResponse,
error: undefined,
request: new globalThis.Request('http://localhost/test'),
response: new globalThis.Response(),
});
});
describe('Modal Rendering', () => {
it('renders modal when open', () => {
render(<CreateRecipeFromSessionModal {...defaultProps} />);
expect(screen.getByTestId('create-recipe-modal')).toBeInTheDocument();
});
it('does not render when closed', () => {
render(<CreateRecipeFromSessionModal {...defaultProps} isOpen={false} />);
expect(screen.queryByTestId('create-recipe-modal')).not.toBeInTheDocument();
});
it('renders modal header with close button', () => {
render(<CreateRecipeFromSessionModal {...defaultProps} />);
expect(screen.getByTestId('modal-header')).toBeInTheDocument();
expect(screen.getByTestId('close-button')).toBeInTheDocument();
});
it('calls onClose when close button is clicked', async () => {
const user = userEvent.setup();
render(<CreateRecipeFromSessionModal {...defaultProps} />);
await user.click(screen.getByTestId('close-button'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
describe('Analysis Workflow', () => {
it('shows analyzing state initially', () => {
render(<CreateRecipeFromSessionModal {...defaultProps} />);
expect(screen.getByTestId('analyzing-state')).toBeInTheDocument();
expect(screen.getByTestId('analyzing-title')).toBeInTheDocument();
});
it('displays analysis progress indicator', async () => {
render(<CreateRecipeFromSessionModal {...defaultProps} />);
expect(screen.getByTestId('analysis-stage')).toBeInTheDocument();
await waitFor(
() => {
const stageElement = screen.getByTestId('analysis-stage');
expect(stageElement).toBeInTheDocument();
},
{ timeout: 1000 }
);
});
it('shows loading indicator during analysis', () => {
render(<CreateRecipeFromSessionModal {...defaultProps} />);
expect(screen.getByTestId('analysis-spinner')).toBeInTheDocument();
});
it('transitions to form state after analysis completes', async () => {
render(<CreateRecipeFromSessionModal {...defaultProps} />);
await waitFor(
() => {
expect(screen.getByTestId('form-state')).toBeInTheDocument();
},
{ timeout: 3000 }
);
expect(screen.queryByTestId('analyzing-state')).not.toBeInTheDocument();
});
});
describe('Form Pre-filling', () => {
it('pre-fills form with analyzed data', async () => {
render(<CreateRecipeFromSessionModal {...defaultProps} />);
// Wait for analysis to complete and form to be pre-filled
await waitFor(
() => {
expect(screen.getByDisplayValue('Analyzed Recipe Title')).toBeInTheDocument();
},
{ timeout: 2000 }
);
expect(screen.getByDisplayValue('Analyzed description')).toBeInTheDocument();
expect(screen.getByDisplayValue('Analyzed instructions with {{param1}}')).toBeInTheDocument();
const promptInput = screen.getByTestId('prompt-input');
expect(promptInput).toBeInTheDocument();
const recipeNameInput = screen.getByTestId('recipe-name-input');
expect(recipeNameInput).toBeInTheDocument();
});
it('shows recipe form fields after analysis', async () => {
render(<CreateRecipeFromSessionModal {...defaultProps} />);
await waitFor(
() => {
expect(screen.getByTestId('recipe-form')).toBeInTheDocument();
},
{ timeout: 2000 }
);
expect(screen.getByTestId('title-input')).toBeInTheDocument();
expect(screen.getByTestId('description-input')).toBeInTheDocument();
expect(screen.getByTestId('instructions-input')).toBeInTheDocument();
expect(screen.getByTestId('prompt-input')).toBeInTheDocument();
expect(screen.getByTestId('recipe-name-input')).toBeInTheDocument();
});
it('shows save location options', async () => {
render(<CreateRecipeFromSessionModal {...defaultProps} />);
await waitFor(
() => {
expect(screen.getByTestId('save-location-field')).toBeInTheDocument();
},
{ timeout: 2000 }
);
expect(screen.getByTestId('global-radio')).toBeInTheDocument();
expect(screen.getByTestId('directory-radio')).toBeInTheDocument();
});
});
describe('Form Interactions', () => {
it('allows editing form fields', async () => {
const user = userEvent.setup();
render(<CreateRecipeFromSessionModal {...defaultProps} />);
await waitFor(
() => {
expect(screen.getByTestId('title-input')).toBeInTheDocument();
},
{ timeout: 2000 }
);
const titleInput = screen.getByTestId('title-input');
await user.clear(titleInput);
await user.type(titleInput, 'Modified Title');
expect(screen.getByDisplayValue('Modified Title')).toBeInTheDocument();
});
it('allows changing save location', async () => {
const user = userEvent.setup();
render(<CreateRecipeFromSessionModal {...defaultProps} />);
await waitFor(
() => {
expect(screen.getByTestId('directory-radio')).toBeInTheDocument();
},
{ timeout: 2000 }
);
await user.click(screen.getByTestId('directory-radio'));
expect(screen.getByTestId('directory-radio')).toBeChecked();
});
it('validates required fields', async () => {
const user = userEvent.setup();
render(<CreateRecipeFromSessionModal {...defaultProps} />);
await waitFor(
() => {
expect(screen.getByTestId('create-recipe-button')).toBeInTheDocument();
},
{ timeout: 2000 }
);
const titleInput = screen.getByTestId('title-input');
await user.clear(titleInput);
const createButton = screen.getByTestId('create-recipe-button');
expect(createButton).toBeDisabled();
});
});
describe('Recipe Creation', () => {
it('enables create button when form is valid', async () => {
render(<CreateRecipeFromSessionModal {...defaultProps} />);
await waitFor(
() => {
const createButton = screen.getByTestId('create-recipe-button');
expect(createButton).toBeEnabled();
},
{ timeout: 2000 }
);
});
it('creates recipe and closes modal when form is submitted', async () => {
const user = userEvent.setup();
render(<CreateRecipeFromSessionModal {...defaultProps} />);
await waitFor(
() => {
expect(screen.getByTestId('create-recipe-button')).toBeEnabled();
},
{ timeout: 2000 }
);
await user.click(screen.getByTestId('create-recipe-button'));
await waitFor(() => {
expect(defaultProps.onRecipeCreated).toHaveBeenCalled();
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
});
describe('Modal Footer', () => {
it('shows cancel button in all states', async () => {
render(<CreateRecipeFromSessionModal {...defaultProps} />);
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
await waitFor(
() => {
expect(screen.getByTestId('create-recipe-button')).toBeInTheDocument();
},
{ timeout: 2000 }
);
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('calls onClose when cancel button is clicked', async () => {
const user = userEvent.setup();
render(<CreateRecipeFromSessionModal {...defaultProps} />);
await user.click(screen.getByTestId('cancel-button'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('shows different button states based on workflow stage', async () => {
render(<CreateRecipeFromSessionModal {...defaultProps} />);
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
expect(screen.queryByTestId('create-recipe-button')).not.toBeInTheDocument();
await waitFor(
() => {
expect(screen.getByTestId('create-recipe-button')).toBeInTheDocument();
},
{ timeout: 2000 }
);
expect(screen.getByTestId('create-and-run-recipe-button')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('handles analysis errors gracefully', async () => {
render(<CreateRecipeFromSessionModal {...defaultProps} sessionId="" />);
expect(screen.getByTestId('create-recipe-modal')).toBeInTheDocument();
});
it('handles form validation errors', async () => {
const user = userEvent.setup();
render(<CreateRecipeFromSessionModal {...defaultProps} />);
await waitFor(
() => {
expect(screen.getByTestId('title-input')).toBeInTheDocument();
},
{ timeout: 2000 }
);
await user.clear(screen.getByTestId('title-input'));
await user.clear(screen.getByTestId('description-input'));
await user.clear(screen.getByTestId('instructions-input'));
const createButton = screen.getByTestId('create-recipe-button');
expect(createButton).toBeDisabled();
});
});
});
@@ -0,0 +1,131 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { useEscapeKey } from '../../../hooks/useEscapeKey';
interface InstructionsEditorProps {
isOpen: boolean;
onClose: () => void;
value: string;
onChange: (value: string) => void;
error?: string;
}
export default function InstructionsEditor({
isOpen,
onClose,
value,
onChange,
error,
}: InstructionsEditorProps) {
const [localValue, setLocalValue] = useState(value);
useEscapeKey(isOpen, onClose);
React.useEffect(() => {
if (isOpen) {
setLocalValue(value);
}
}, [isOpen, value]);
const handleSave = () => {
onChange(localValue);
onClose();
};
const handleCancel = () => {
setLocalValue(value); // Reset to original value
onClose();
};
const insertExample = () => {
const example = `You are an AI assistant helping with {{task_type}}.
Please follow these steps:
1. Analyze the provided {{input_data}}
2. Apply the specified {{methodology}}
3. Generate a comprehensive report
Requirements:
- Be thorough and accurate
- Use clear, professional language
- Include specific examples where relevant
- Provide actionable recommendations
Format your response with:
- Executive summary
- Detailed analysis
- Key findings
- Next steps
Use {{parameter_name}} syntax for any user-provided values.`;
setLocalValue(example);
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-[400] flex items-center justify-center bg-black/50"
onClick={(e) => {
// Close modal when clicking backdrop
if (e.target === e.currentTarget) {
handleCancel();
}
}}
>
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[900px] max-w-[90vw] max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-text-standard">Instructions Editor</h3>
<button
type="button"
onClick={handleCancel}
className="text-text-muted hover:text-text-standard text-2xl leading-none"
>
×
</button>
</div>
<div className="flex-1 flex flex-col min-h-0">
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-text-standard">Instructions</label>
<Button
type="button"
onClick={insertExample}
variant="ghost"
size="sm"
className="text-xs"
>
Insert Example
</Button>
</div>
<p className="text-xs text-text-muted mb-3">
Use <code className="bg-background-muted px-1 rounded">{`{{parameter_name}}`}</code>{' '}
syntax to define parameters that users can fill in
</p>
</div>
<div className="flex-1 min-h-0">
<textarea
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
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..."
/>
{error && <p className="text-red-500 text-sm mt-2">{error}</p>}
</div>
</div>
<div className="flex justify-end space-x-3 mt-6 pt-4 border-t border-border-subtle">
<Button type="button" onClick={handleCancel} variant="ghost">
Cancel
</Button>
<Button type="button" onClick={handleSave} variant="default">
Save Instructions
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,166 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { useEscapeKey } from '../../../hooks/useEscapeKey';
interface JsonSchemaEditorProps {
isOpen: boolean;
onClose: () => void;
value: string;
onChange: (value: string) => void;
error?: string;
}
export default function JsonSchemaEditor({
isOpen,
onClose,
value,
onChange,
error,
}: JsonSchemaEditorProps) {
const [localValue, setLocalValue] = useState(value);
const [localError, setLocalError] = useState('');
useEscapeKey(isOpen, onClose);
React.useEffect(() => {
if (isOpen) {
setLocalValue(value);
setLocalError('');
}
}, [isOpen, value]);
const handleSave = () => {
if (localValue.trim()) {
try {
JSON.parse(localValue.trim());
setLocalError('');
} catch {
setLocalError('Invalid JSON format');
return;
}
}
onChange(localValue);
onClose();
};
const handleCancel = () => {
setLocalValue(value);
setLocalError('');
onClose();
};
const insertExample = () => {
const example = `{
"type": "object",
"properties": {
"result": {
"type": "string",
"description": "The main result"
},
"status": {
"type": "string",
"enum": ["success", "error"],
"description": "Operation status"
},
"data": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"required": ["result", "status"]
}`;
setLocalValue(example);
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-[400] flex items-center justify-center bg-black/50"
onClick={(e) => {
// Close modal when clicking backdrop
if (e.target === e.currentTarget) {
handleCancel();
}
}}
>
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[800px] max-w-[90vw] max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-text-standard">JSON Schema Editor</h3>
<button
type="button"
onClick={handleCancel}
className="text-text-muted hover:text-text-standard text-2xl leading-none"
>
×
</button>
</div>
<div className="flex-1 flex flex-col min-h-0">
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-text-standard">
Response JSON Schema
</label>
<Button
type="button"
onClick={insertExample}
variant="ghost"
size="sm"
className="text-xs"
>
Insert Example
</Button>
</div>
<p className="text-xs text-text-muted mb-3">
Define the expected structure of the AI's response using JSON Schema format
</p>
</div>
<div className="flex-1 min-h-0">
<textarea
value={localValue}
onChange={(e) => {
setLocalValue(e.target.value);
setLocalError('');
}}
className={`w-full h-full min-h-[400px] 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 ${
localError || error ? 'border-red-500' : 'border-border-subtle'
}`}
placeholder={`{
"type": "object",
"properties": {
"result": {
"type": "string",
"description": "The main result"
}
},
"required": ["result"]
}`}
/>
{(localError || error) && (
<p className="text-red-500 text-sm mt-2">{localError || error}</p>
)}
</div>
</div>
<div className="flex justify-end space-x-3 mt-6 pt-4 border-t border-border-subtle">
<Button type="button" onClick={handleCancel} variant="ghost">
Cancel
</Button>
<Button type="button" onClick={handleSave} variant="default">
Save Schema
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,530 @@
import React, { useState } from 'react';
import { Parameter } from '../../../recipe';
import { RecipeNameField } from './RecipeNameField';
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 { extractTemplateVariables } from '../../../utils/providerUtils';
// Type for field API to avoid linting issues - use any to bypass complex type constraints
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FormFieldApi<_T = any> = any;
interface RecipeFormFieldsProps {
// Form instance from parent
form: RecipeFormApi;
// Event handlers
onTitleChange?: (value: string) => void;
onDescriptionChange?: (value: string) => void;
onInstructionsChange?: (value: string) => void;
onPromptChange?: (value: string) => void;
onJsonSchemaChange?: (value: string) => void;
onRecipeNameChange?: (value: string) => void;
onGlobalChange?: (value: boolean) => void;
}
export function RecipeFormFields({
form,
onTitleChange,
onDescriptionChange,
onInstructionsChange,
onPromptChange,
onJsonSchemaChange,
onRecipeNameChange,
onGlobalChange,
}: RecipeFormFieldsProps) {
const [showJsonSchemaEditor, setShowJsonSchemaEditor] = useState(false);
const [showInstructionsEditor, setShowInstructionsEditor] = useState(false);
const [newParameterName, setNewParameterName] = useState('');
const [expandedParameters, setExpandedParameters] = useState<Set<string>>(new Set());
const parseParametersFromInstructions = React.useCallback(
(instructions: string, prompt?: string, activities?: string[]): Parameter[] => {
const instructionVars = extractTemplateVariables(instructions);
const promptVars = prompt ? extractTemplateVariables(prompt) : [];
const activityVars = activities
? activities.flatMap((activity) => extractTemplateVariables(activity))
: [];
// Combine and deduplicate
const allVars = [...new Set([...instructionVars, ...promptVars, ...activityVars])];
return allVars.map((key: string) => ({
key,
description: `Enter value for ${key}`,
requirement: 'required' as const,
input_type: 'string' as const,
}));
},
[]
);
// Function to update parameters based on current field values
const updateParametersFromFields = React.useCallback(() => {
const currentValues = form.state.values;
const { instructions, prompt, activities, parameters: currentParams } = currentValues;
const newParams = parseParametersFromInstructions(instructions, prompt, activities);
// Separate manually added parameters (those not found in instructions/prompt/activities)
const manualParams = currentParams.filter((param: Parameter) => {
// Only keep manual params that have a valid key and are not found in the parsed params
return (
param.key && param.key.trim() && !newParams.some((newParam) => newParam.key === param.key)
);
});
// Combine parsed parameters with manually added ones, filtering out empty ones
const combinedParams = [
...newParams.map((newParam) => {
const existing = currentParams.find((cp: Parameter) => cp.key === newParam.key);
return existing ? { ...existing } : newParam;
}),
...manualParams,
].filter((param: Parameter) => param.key && param.key.trim()) as Parameter[];
// Only update if parameters actually changed
const currentParamKeys = currentParams.map((p: Parameter) => p.key).sort();
const newParamKeys = combinedParams.map((p) => p.key).sort();
if (JSON.stringify(currentParamKeys) !== JSON.stringify(newParamKeys)) {
form.setFieldValue('parameters', combinedParams);
}
}, [form, parseParametersFromInstructions]);
const isParameterUsed = (
paramKey: string,
instructions: string,
prompt?: string,
activities?: string[]
): boolean => {
const regex = new RegExp(
`\\{\\{\\s*${paramKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\}\\}`,
'g'
);
const usedInInstructions = regex.test(instructions);
const usedInPrompt = prompt ? regex.test(prompt) : false;
const usedInActivities = activities
? activities.some((activity) => {
// For activities, we need to check the full activity string, including message: prefixes
return regex.test(activity);
})
: false;
return usedInInstructions || usedInPrompt || usedInActivities;
};
return (
<div className="space-y-4" data-testid="recipe-form">
{/* Title Field */}
<form.Field name="title">
{(field: FormFieldApi<string>) => (
<div>
<label
htmlFor="recipe-title"
className="block text-sm font-medium text-text-standard mb-2"
>
Title <span className="text-red-500">*</span>
</label>
<input
id="recipe-title"
type="text"
value={field.state.value}
onChange={(e) => {
field.handleChange(e.target.value);
onTitleChange?.(e.target.value);
}}
onBlur={field.handleBlur}
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
}`}
placeholder="Recipe title"
data-testid="title-input"
/>
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm mt-1">{field.state.meta.errors[0]}</p>
)}
</div>
)}
</form.Field>
{/* Description Field */}
<form.Field name="description">
{(field: FormFieldApi<string>) => (
<div>
<label
htmlFor="recipe-description"
className="block text-sm font-medium text-text-standard mb-2"
>
Description <span className="text-red-500">*</span>
</label>
<input
id="recipe-description"
type="text"
value={field.state.value}
onChange={(e) => {
field.handleChange(e.target.value);
onDescriptionChange?.(e.target.value);
}}
onBlur={field.handleBlur}
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
}`}
placeholder="Brief description of what this recipe does"
data-testid="description-input"
/>
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm mt-1">{field.state.meta.errors[0]}</p>
)}
</div>
)}
</form.Field>
{/* Instructions Field */}
<form.Field name="instructions">
{(field: FormFieldApi<string>) => (
<div>
<div className="flex items-center justify-between mb-2">
<label
htmlFor="recipe-instructions"
className="block text-sm font-medium text-text-standard"
>
Instructions <span className="text-red-500">*</span>
</label>
<Button
type="button"
onClick={() => setShowInstructionsEditor(true)}
variant="outline"
size="sm"
className="text-xs"
>
Open Editor
</Button>
</div>
<textarea
id="recipe-instructions"
value={field.state.value}
onChange={(e) => {
field.handleChange(e.target.value);
onInstructionsChange?.(e.target.value);
}}
onBlur={() => {
field.handleBlur();
updateParametersFromFields();
}}
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..."
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
</p>
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm mt-1">{field.state.meta.errors[0]}</p>
)}
{/* Instructions Editor Modal */}
<InstructionsEditor
isOpen={showInstructionsEditor}
onClose={() => setShowInstructionsEditor(false)}
value={field.state.value}
onChange={(value) => {
field.handleChange(value);
onInstructionsChange?.(value);
updateParametersFromFields();
}}
error={field.state.meta.errors.length > 0 ? field.state.meta.errors[0] : undefined}
/>
</div>
)}
</form.Field>
{/* Initial Prompt Field */}
<form.Field name="prompt">
{(field: FormFieldApi<string | undefined>) => (
<div>
<label
htmlFor="recipe-prompt"
className="block text-sm font-medium text-text-standard mb-2"
>
Initial Prompt
</label>
<p className="text-xs text-text-muted mt-2 mb-2">
(Optional - Instructions or Prompt are required)
</p>
<textarea
id="recipe-prompt"
value={field.state.value || ''}
onChange={(e) => {
field.handleChange(e.target.value);
onPromptChange?.(e.target.value);
}}
onBlur={() => {
field.handleBlur();
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..."
rows={3}
data-testid="prompt-input"
/>
</div>
)}
</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>
{/* 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>
)}
</form.Field>
{/* Recipe Name Field */}
<form.Field name="recipeName">
{(field: FormFieldApi<string | undefined>) => (
<div>
<div data-testid="recipe-name-field">
<RecipeNameField
id="recipe-name-field"
value={field.state.value || ''}
onChange={(value) => {
field.handleChange(value);
onRecipeNameChange?.(value);
}}
onBlur={field.handleBlur}
errors={field.state.meta.errors}
/>
</div>
</div>
)}
</form.Field>
{/* Save Location Field */}
<form.Field name="global">
{(field: FormFieldApi<boolean>) => (
<div data-testid="save-location-field">
<label className="block text-sm font-medium text-text-standard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={field.state.value === true}
onChange={() => {
field.handleChange(true);
onGlobalChange?.(true);
}}
className="mr-2"
data-testid="global-radio"
/>
<span className="text-sm text-text-standard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={field.state.value === false}
onChange={() => {
field.handleChange(false);
onGlobalChange?.(false);
}}
className="mr-2"
data-testid="directory-radio"
/>
<span className="text-sm text-text-standard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
)}
</form.Field>
</div>
);
}
@@ -54,6 +54,7 @@ export function RecipeNameField({
errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
placeholder={RECIPE_NAME_PLACEHOLDER}
data-testid="recipe-name-input"
/>
<p className="text-xs text-text-muted mt-1">
Will be automatically formatted (lowercase, dashes for spaces)
@@ -0,0 +1,212 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { Recipe } from '../../../recipe';
import { saveRecipe, generateRecipeFilename } from '../../../recipe/recipeStorage';
import { toastSuccess, toastError } from '../../../toasts';
import { useEscapeKey } from '../../../hooks/useEscapeKey';
import { Play } from 'lucide-react';
interface SaveRecipeDialogProps {
isOpen: boolean;
onClose: (wasSaved?: boolean) => void;
onSuccess?: () => void;
recipe: Recipe;
suggestedName?: string;
showSaveAndRun?: boolean;
onSaveAndRun?: (recipe: Recipe) => void;
}
export default function SaveRecipeDialog({
isOpen,
onClose,
onSuccess,
recipe,
suggestedName,
showSaveAndRun = false,
onSaveAndRun,
}: SaveRecipeDialogProps) {
const [saveRecipeName, setSaveRecipeName] = useState(
suggestedName || generateRecipeFilename(recipe)
);
const [saveGlobal, setSaveGlobal] = useState(true);
const [saving, setSaving] = useState(false);
useEscapeKey(isOpen, onClose);
React.useEffect(() => {
if (isOpen) {
setSaveRecipeName(suggestedName || generateRecipeFilename(recipe));
setSaveGlobal(true);
setSaving(false);
}
}, [isOpen, suggestedName, recipe]);
const handleSaveRecipe = async () => {
if (!saveRecipeName.trim()) {
return;
}
setSaving(true);
try {
if (!recipe.title || !recipe.description || !recipe.instructions) {
throw new Error('Invalid recipe configuration: missing required fields');
}
await saveRecipe(recipe, {
name: saveRecipeName.trim(),
global: saveGlobal,
});
setSaveRecipeName('');
onClose(true);
toastSuccess({
title: saveRecipeName.trim(),
msg: 'Recipe saved successfully',
});
onSuccess?.();
} catch (error) {
console.error('Failed to save recipe:', error);
toastError({
title: 'Save Failed',
msg: `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setSaving(false);
}
};
const handleSaveAndRunRecipe = async () => {
if (!saveRecipeName.trim()) {
return;
}
setSaving(true);
try {
if (!recipe.title || !recipe.description || !recipe.instructions) {
throw new Error('Invalid recipe configuration: missing required fields');
}
await saveRecipe(recipe, {
name: saveRecipeName.trim(),
global: saveGlobal,
});
setSaveRecipeName('');
onClose(true);
toastSuccess({
title: saveRecipeName.trim(),
msg: 'Recipe saved and launched successfully',
});
// Launch the recipe in a new window
onSaveAndRun?.(recipe);
onSuccess?.();
} catch (error) {
console.error('Failed to save and run recipe:', error);
toastError({
title: 'Save and Run Failed',
msg: `Failed to save and run recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setSaving(false);
}
};
const handleClose = () => {
setSaveRecipeName('');
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[500] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-96 max-w-[90vw]">
<h3 className="text-lg font-medium text-text-standard mb-4">Save Recipe</h3>
<div className="space-y-4">
<div>
<label
htmlFor="recipe-name"
className="block text-sm font-medium text-text-standard mb-2"
>
Recipe Name
</label>
<input
id="recipe-name"
type="text"
value={saveRecipeName}
onChange={(e) => setSaveRecipeName(e.target.value)}
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"
placeholder="Enter recipe name"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-text-standard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={saveGlobal}
onChange={() => setSaveGlobal(true)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={!saveGlobal}
onChange={() => setSaveGlobal(false)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<Button type="button" onClick={handleClose} variant="ghost" disabled={saving}>
Cancel
</Button>
<Button
onClick={handleSaveRecipe}
disabled={!saveRecipeName.trim() || saving}
variant="outline"
>
{saving ? 'Saving...' : 'Save Recipe'}
</Button>
{showSaveAndRun && (
<Button
onClick={handleSaveAndRunRecipe}
disabled={!saveRecipeName.trim() || saving}
variant="default"
className="inline-flex items-center justify-center gap-2"
>
<Play className="w-4 h-4" />
{saving ? 'Saving...' : 'Save & Run Recipe'}
</Button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import RecipeActivityEditor from '../../RecipeActivityEditor';
describe('RecipeActivityEditor', () => {
const mockOnChange = vi.fn();
const mockOnBlur = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('Basic Rendering', () => {
it('renders without crashing', () => {
render(<RecipeActivityEditor activities={[]} setActivities={mockOnChange} />);
expect(screen.getByText('Activities')).toBeInTheDocument();
});
it('displays the activities label', () => {
render(<RecipeActivityEditor activities={[]} setActivities={mockOnChange} />);
expect(screen.getByText('Activities')).toBeInTheDocument();
});
it('shows helper text', () => {
render(<RecipeActivityEditor activities={[]} setActivities={mockOnChange} />);
expect(screen.getByText(/top-line prompts and activity buttons/)).toBeInTheDocument();
});
});
describe('Empty State', () => {
it('shows message input when no activities', () => {
render(<RecipeActivityEditor activities={[]} setActivities={mockOnChange} />);
expect(screen.getByText('Message')).toBeInTheDocument();
expect(
screen.getByPlaceholderText(/Enter a user facing introduction message/)
).toBeInTheDocument();
});
});
describe('With Activities', () => {
it('displays existing activities as visual boxes', () => {
const activities = ['message: Hello World', 'button: Click me', 'action: Do something'];
render(<RecipeActivityEditor activities={activities} setActivities={mockOnChange} />);
const messageTextarea = screen.getByPlaceholderText(
/Enter a user facing introduction message/
);
expect(messageTextarea).toHaveValue(' Hello World');
expect(screen.getByText('button: Click me')).toBeInTheDocument();
expect(screen.getByText('action: Do something')).toBeInTheDocument();
const removeButtons = screen.getAllByText('×');
expect(removeButtons).toHaveLength(2);
});
it('truncates long activity text in boxes', () => {
const longActivity = 'button: ' + 'a'.repeat(150);
const activities = [longActivity];
render(<RecipeActivityEditor activities={activities} setActivities={mockOnChange} />);
expect(screen.getByText(/button: a+\.\.\./)).toBeInTheDocument();
const activityBox = screen.getByText(/button: a+\.\.\./).closest('div');
expect(activityBox).toHaveAttribute('title', longActivity);
});
it('handles empty activities array', () => {
render(<RecipeActivityEditor activities={[]} setActivities={mockOnChange} />);
expect(screen.getByText('Activities')).toBeInTheDocument();
expect(screen.queryByText('×')).not.toBeInTheDocument();
});
it('allows removing activities via remove buttons', async () => {
const user = userEvent.setup();
const activities = ['button: Click me', 'action: Do something'];
render(<RecipeActivityEditor activities={activities} setActivities={mockOnChange} />);
const removeButtons = screen.getAllByText('×');
await user.click(removeButtons[0]);
expect(mockOnChange).toHaveBeenCalledWith(['action: Do something']);
});
});
describe('User Interactions', () => {
it('allows typing in message field', async () => {
const user = userEvent.setup();
render(<RecipeActivityEditor activities={[]} setActivities={mockOnChange} />);
const messageInput = screen.getByPlaceholderText(/Enter a user facing introduction message/);
await user.type(messageInput, 'Test message');
expect(messageInput).toHaveValue('Test message');
});
it('calls onBlur when provided', async () => {
const user = userEvent.setup();
render(
<RecipeActivityEditor activities={[]} setActivities={mockOnChange} onBlur={mockOnBlur} />
);
const messageInput = screen.getByPlaceholderText(/Enter a user facing introduction message/);
await user.click(messageInput);
await user.tab();
expect(mockOnBlur).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,709 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useForm } from '@tanstack/react-form';
import { RecipeFormFields } from '../RecipeFormFields';
import { type RecipeFormData } from '../recipeFormSchema';
describe('RecipeFormFields', () => {
const useTestForm = (initialValues?: Partial<RecipeFormData>) => {
const defaultValues: RecipeFormData = {
title: '',
description: '',
instructions: '',
prompt: '',
activities: [],
parameters: [],
jsonSchema: '',
recipeName: '',
global: true,
...initialValues,
};
return useForm({
defaultValues,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
},
});
};
const TestWrapper = ({
initialValues,
...props
}: {
initialValues?: Partial<RecipeFormData>;
} & Omit<Parameters<typeof RecipeFormFields>[0], 'form'>) => {
const form = useTestForm(initialValues);
return <RecipeFormFields form={form} {...props} />;
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Basic Rendering', () => {
it('renders the component without crashing', () => {
render(<TestWrapper />);
expect(screen.getByLabelText(/title/i)).toBeInTheDocument();
});
it('renders required form fields', () => {
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.getAllByText(/activities/i)[0]).toBeInTheDocument();
expect(screen.getAllByText(/parameters/i)[0]).toBeInTheDocument();
expect(screen.getByText(/response json schema/i)).toBeInTheDocument();
});
it('shows form inputs with proper accessibility', () => {
render(<TestWrapper />);
expect(screen.getByRole('textbox', { name: /title/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /description/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /instructions/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /prompt/i })).toBeInTheDocument();
});
});
describe('Form Interactions', () => {
it('allows typing in text fields', async () => {
const user = userEvent.setup();
render(<TestWrapper />);
const titleInput = screen.getByRole('textbox', { name: /title/i });
await user.type(titleInput, 'Test Recipe');
expect(titleInput).toHaveValue('Test Recipe');
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
await user.type(descriptionInput, 'A test recipe');
expect(descriptionInput).toHaveValue('A test recipe');
});
it('allows typing in textarea fields', async () => {
const user = userEvent.setup();
render(<TestWrapper />);
const instructionsInput = screen.getByRole('textbox', { name: /instructions/i });
await user.type(instructionsInput, 'Do something');
expect(instructionsInput).toHaveValue('Do something');
const promptInput = screen.getByRole('textbox', { name: /prompt/i });
await user.type(promptInput, 'Hello world');
expect(promptInput).toHaveValue('Hello world');
});
});
describe('Parameter Management', () => {
it('shows parameter input section', () => {
render(<TestWrapper />);
expect(screen.getByPlaceholderText('Enter parameter name...')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /add parameter/i })).toBeInTheDocument();
});
it('allows adding parameters manually', async () => {
const user = userEvent.setup();
render(<TestWrapper />);
const parameterInput = screen.getByPlaceholderText('Enter parameter name...');
const addButton = screen.getByRole('button', { name: /add parameter/i });
expect(addButton).toBeDisabled();
await user.type(parameterInput, 'test_param');
expect(addButton).toBeEnabled();
await user.click(addButton);
expect(screen.getByText('test_param')).toBeInTheDocument();
expect(parameterInput).toHaveValue('');
expect(addButton).toBeDisabled();
});
});
describe('Always Visible Fields', () => {
it('always shows recipe name field', () => {
render(<TestWrapper />);
expect(screen.getByText('Recipe Name')).toBeInTheDocument();
});
it('always shows save location field', () => {
render(<TestWrapper />);
expect(screen.getByText('Save Location')).toBeInTheDocument();
expect(screen.getByText('Global - Available across all Goose sessions')).toBeInTheDocument();
});
});
describe('Pre-filled Values', () => {
it('displays pre-filled form values', () => {
const initialValues: Partial<RecipeFormData> = {
title: 'Pre-filled Title',
description: 'Pre-filled Description',
instructions: 'Pre-filled Instructions',
prompt: 'Pre-filled Prompt',
};
render(<TestWrapper initialValues={initialValues} />);
expect(screen.getByDisplayValue('Pre-filled Title')).toBeInTheDocument();
expect(screen.getByDisplayValue('Pre-filled Description')).toBeInTheDocument();
expect(screen.getByDisplayValue('Pre-filled Instructions')).toBeInTheDocument();
expect(screen.getByDisplayValue('Pre-filled Prompt')).toBeInTheDocument();
});
});
describe('Editor Buttons', () => {
it('shows editor buttons for instructions and JSON schema', () => {
render(<TestWrapper />);
const editorButtons = screen.getAllByText('Open Editor');
expect(editorButtons.length).toBeGreaterThan(0);
});
});
describe('Parameter Auto-Detection', () => {
it('has parameter detection functionality', async () => {
const user = userEvent.setup();
render(<TestWrapper />);
const instructionsInput = screen.getByPlaceholderText(
'Detailed instructions for the AI, hidden from the user...'
);
// Type instructions with template variables - use paste to avoid curly brace issues
await user.click(instructionsInput);
await user.paste('Hello {{name}}, please {{action}} the {{item}}');
// Blur the field to trigger parameter detection
await user.tab();
// Just verify the component doesn't crash and the text is there
expect(instructionsInput).toHaveValue('Hello {{name}}, please {{action}} the {{item}}');
// Check that the parameter section exists
expect(screen.getByText('Parameters')).toBeInTheDocument();
expect(
screen.getByText(
'Parameters will be automatically detected from {{parameter_name}} syntax in instructions/prompt/activities or you can manually add them below.'
)
).toBeInTheDocument();
});
it('allows manual parameter addition', async () => {
const user = userEvent.setup();
render(<TestWrapper />);
// Add a manual parameter
const parameterInput = screen.getByPlaceholderText('Enter parameter name...');
const addButton = screen.getByText('Add parameter');
await user.type(parameterInput, 'test_param');
await user.click(addButton);
// Verify manual parameter was added
expect(screen.getByText('test_param')).toBeInTheDocument();
// Input should be cleared
expect(parameterInput).toHaveValue('');
});
it('shows parameter management UI', () => {
render(<TestWrapper />);
// Check parameter section exists
expect(screen.getByText('Parameters')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter parameter name...')).toBeInTheDocument();
expect(screen.getByText('Add parameter')).toBeInTheDocument();
// Check help text
expect(screen.getByText(/Parameters will be automatically detected/)).toBeInTheDocument();
});
it('handles activities field for parameter detection', async () => {
const user = userEvent.setup();
render(<TestWrapper />);
// Check that activities section exists
expect(screen.getByText('Activities')).toBeInTheDocument();
expect(screen.getByText('Message')).toBeInTheDocument();
expect(screen.getByText('Activity Buttons')).toBeInTheDocument();
// Check that activity input exists
const messageInput = screen.getByPlaceholderText(
'Enter a user facing introduction message for your recipe (supports **bold**, *italic*, `code`, etc.)'
);
expect(messageInput).toBeInTheDocument();
// Use paste to avoid curly brace issues
await user.click(messageInput);
await user.paste('Welcome to {{recipe_name}}!');
expect(messageInput).toHaveValue('Welcome to {{recipe_name}}!');
});
it('actually detects and creates parameters from template variables', async () => {
const user = userEvent.setup();
// Use a form with initial empty values to test parameter detection
const TestComponent = () => {
const form = useForm({
defaultValues: {
title: '',
description: '',
instructions: '',
prompt: '',
activities: [],
parameters: [],
jsonSchema: '',
recipeName: '',
global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
},
});
return <RecipeFormFields form={form} />;
};
render(<TestComponent />);
const instructionsInput = screen.getByPlaceholderText(
'Detailed instructions for the AI, hidden from the user...'
);
// Add instructions with template variables
await user.click(instructionsInput);
await user.paste('Process {{name}} and {{type}} for {{user}}');
// Blur to trigger parameter detection
await user.tab();
// Wait a moment for the parameter detection to process
await new Promise((resolve) => setTimeout(resolve, 100));
// Check if parameters were detected and added
// The parameters should appear as text in the parameter section
const parameterSection = screen.getByText('Parameters').closest('div');
expect(parameterSection).toBeInTheDocument();
// Look for the parameter names in the DOM
// They should appear as text content in the parameter components
const nameParam = screen.queryByText('name');
const typeParam = screen.queryByText('type');
const userParam = screen.queryByText('user');
// At least verify that the parameter detection mechanism is in place
// Even if the parameters don't show up immediately, the functionality should exist
expect(instructionsInput).toHaveValue('Process {{name}} and {{type}} for {{user}}');
expect(
screen.getByText(
'Parameters will be automatically detected from {{parameter_name}} syntax in instructions/prompt/activities or you can manually add them below.'
)
).toBeInTheDocument();
// If parameters are detected, they should be visible
if (nameParam) {
expect(nameParam).toBeInTheDocument();
}
if (typeParam) {
expect(typeParam).toBeInTheDocument();
}
if (userParam) {
expect(userParam).toBeInTheDocument();
}
});
it('renders actual parameter form fields for detected parameters', async () => {
const user = userEvent.setup();
// Start with a form that has some parameters already using the correct Parameter type
const TestComponent = () => {
const form = useForm({
defaultValues: {
title: '',
description: '',
instructions: '',
prompt: '',
activities: [],
parameters: [
{
key: 'username',
description: 'User identifier',
input_type: 'string',
requirement: 'required',
},
{
key: 'count',
description: 'Number of items',
input_type: 'number',
requirement: 'optional',
default: '10',
},
{
key: 'enabled',
description: 'Enable feature',
input_type: 'boolean',
requirement: 'required',
},
],
jsonSchema: '',
recipeName: '',
global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
},
});
return <RecipeFormFields form={form} />;
};
render(<TestComponent />);
// Check that parameter names are displayed in code blocks with more specific selectors
const usernameCode = screen.getByText('username').closest('code');
const countCode = screen.getByText('count').closest('code');
const enabledCode = screen.getByText('enabled').closest('code');
expect(usernameCode).toBeInTheDocument();
expect(countCode).toBeInTheDocument();
expect(enabledCode).toBeInTheDocument();
// Find parameter containers by looking for the parameter-input class
const parameterContainers = document.querySelectorAll('.parameter-input');
expect(parameterContainers).toHaveLength(3);
// Find the first parameter's expand button using more specific selector
const firstParameterContainer = parameterContainers[0];
const expandButton =
firstParameterContainer.querySelector('button[title*="chevron"]') ||
firstParameterContainer
.querySelector('button svg[data-lucide="chevron-right"]')
?.closest('button') ||
firstParameterContainer
.querySelector('button svg[data-lucide="chevron-down"]')
?.closest('button');
if (expandButton) {
await user.click(expandButton as HTMLElement);
// Now check for specific parameter form fields within this parameter container
const descriptionInput = firstParameterContainer.querySelector(
'input[placeholder*="Enter the name"]'
);
expect(descriptionInput).toBeInTheDocument();
expect(descriptionInput).toHaveValue('User identifier');
// Check for input type select
const inputTypeSelect = firstParameterContainer.querySelector('select');
expect(inputTypeSelect).toBeInTheDocument();
expect(inputTypeSelect).toHaveValue('string');
// Check for requirement select
const requirementSelects = firstParameterContainer.querySelectorAll('select');
expect(requirementSelects.length).toBeGreaterThanOrEqual(2);
// Test interaction with description field
if (descriptionInput) {
await user.clear(descriptionInput);
await user.type(descriptionInput, 'Updated user identifier');
expect(descriptionInput).toHaveValue('Updated user identifier');
}
} else {
// Fallback: just verify the parameter containers exist
expect(parameterContainers.length).toBeGreaterThan(0);
}
});
it('renders parameter form fields when manually adding parameters', async () => {
const user = userEvent.setup();
render(<TestWrapper />);
// Add a manual parameter
const parameterInput = screen.getByPlaceholderText('Enter parameter name...');
const addButton = screen.getByText('Add parameter');
await user.type(parameterInput, 'test_param');
await user.click(addButton);
// Verify the parameter name appears in a code block
const parameterCode = screen.getByText('test_param').closest('code');
expect(parameterCode).toBeInTheDocument();
// Find the parameter container using more specific selector
const parameterContainer = document.querySelector('.parameter-input');
expect(parameterContainer).toBeInTheDocument();
// The parameter should be expanded by default when first added, so we should see form fields
// Check for parameter description input within the parameter container
const descriptionInput = parameterContainer?.querySelector(
'input[placeholder*="Enter the name"]'
);
expect(descriptionInput).toBeInTheDocument();
// Check for parameter type select within the parameter container
const selects = parameterContainer?.querySelectorAll('select');
expect(selects?.length).toBeGreaterThanOrEqual(2);
const inputTypeSelect = selects
? Array.from(selects).find((select) =>
Array.from(select.options).some((option) => option.text === 'String')
)
: null;
expect(inputTypeSelect).toBeInTheDocument();
expect(inputTypeSelect?.value).toBe('string');
// Check for requirement select
const requirementSelect = selects
? Array.from(selects).find((select) =>
Array.from(select.options).some((option) => option.text === 'Required')
)
: null;
expect(requirementSelect).toBeInTheDocument();
expect(requirementSelect?.value).toBe('required');
// Verify we can interact with the parameter form fields
// First clear the existing value, then type the new one
if (descriptionInput) {
await user.clear(descriptionInput);
await user.type(descriptionInput, 'Test parameter description');
expect(descriptionInput).toHaveValue('Test parameter description');
}
// Test changing the requirement
if (requirementSelect) {
await user.selectOptions(requirementSelect, 'optional');
expect(requirementSelect.value).toBe('optional');
// After changing to optional, a default value field should appear
const defaultValueInput = parameterContainer?.querySelector(
'input[placeholder="Enter default value"]'
);
expect(defaultValueInput).toBeInTheDocument();
// Test the default value input
if (defaultValueInput) {
await user.type(defaultValueInput, 'default_test_value');
expect(defaultValueInput).toHaveValue('default_test_value');
}
}
});
it('shows unused parameter indicator', async () => {
// Create a form with parameters that are NOT used in instructions/prompt/activities
const TestComponent = () => {
const form = useForm({
defaultValues: {
title: 'Test Recipe',
description: 'Test Description',
instructions: 'Do something simple without parameters',
prompt: 'Start the task',
activities: [],
parameters: [
{
key: 'unused_param',
description: 'This parameter is not used',
input_type: 'string',
requirement: 'required',
},
{
key: 'another_unused',
description: 'Another unused parameter',
input_type: 'number',
requirement: 'optional',
},
],
jsonSchema: '',
recipeName: '',
global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
},
});
return <RecipeFormFields form={form} />;
};
render(<TestComponent />);
// Check that unused indicators are shown
const unusedTexts = screen.getAllByText('Unused');
expect(unusedTexts.length).toBe(2); // Should have 2 unused parameters
// Check for warning icons - try different selectors since lucide icons may render differently in tests
const warningIcons =
document.querySelectorAll('svg') ||
document.querySelectorAll('[class*="lucide"]') ||
document.querySelectorAll('[title*="unused"]');
// At minimum, we should have some SVG elements for the icons
expect(warningIcons.length).toBeGreaterThan(0);
// Verify the unused parameters are marked with orange styling
const parameterContainers = document.querySelectorAll('.parameter-input');
expect(parameterContainers.length).toBe(2);
// Check that each parameter container has an unused indicator with orange text
let unusedIndicatorsFound = 0;
parameterContainers.forEach((container) => {
const unusedIndicator = container.querySelector('.text-orange-500');
if (unusedIndicator) {
unusedIndicatorsFound++;
}
});
expect(unusedIndicatorsFound).toBe(2); // Both parameters should be marked as unused
// Verify the unused text appears with the warning styling
unusedTexts.forEach((unusedText) => {
expect(unusedText).toHaveClass('text-orange-500');
});
});
it('does not show unused indicator for parameters used in instructions', async () => {
const TestComponent = () => {
const form = useForm({
defaultValues: {
title: 'Test Recipe',
description: 'Test Description',
instructions: 'Process the {{username}} and set count to {{count}}',
prompt: 'Start with {{username}}',
activities: [],
parameters: [
{
key: 'username',
description: 'User identifier',
input_type: 'string',
requirement: 'required',
},
{
key: 'count',
description: 'Number of items',
input_type: 'number',
requirement: 'required',
},
{
key: 'unused_param',
description: 'This is not used',
input_type: 'string',
requirement: 'required',
},
],
jsonSchema: '',
recipeName: '',
global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
},
});
return <RecipeFormFields form={form} />;
};
render(<TestComponent />);
// Should have 3 parameters total
const parameterContainers = document.querySelectorAll('.parameter-input');
expect(parameterContainers.length).toBe(3);
// Only one should have the unused indicator (unused_param)
const unusedTexts = screen.getAllByText('Unused');
expect(unusedTexts.length).toBe(1);
// Check that username and count parameters do NOT have unused indicators
const usernameContainer = Array.from(parameterContainers).find((container) =>
container.textContent?.includes('username')
);
const countContainer = Array.from(parameterContainers).find((container) =>
container.textContent?.includes('count')
);
expect(usernameContainer?.querySelector('.text-orange-500')).not.toBeInTheDocument();
expect(countContainer?.querySelector('.text-orange-500')).not.toBeInTheDocument();
// But unused_param should have the unused indicator
const unusedContainer = Array.from(parameterContainers).find((container) =>
container.textContent?.includes('unused_param')
);
expect(unusedContainer?.querySelector('.text-orange-500')).toBeInTheDocument();
});
it('shows delete button for parameters', async () => {
const user = userEvent.setup();
render(<TestWrapper />);
// Add a manual parameter
const parameterInput = screen.getByPlaceholderText('Enter parameter name...');
const addButton = screen.getByText('Add parameter');
await user.type(parameterInput, 'deletable_param');
await user.click(addButton);
// Find the parameter container
const parameterContainer = document.querySelector('.parameter-input');
expect(parameterContainer).toBeInTheDocument();
// Check for delete button (trash icon)
const deleteButton =
parameterContainer?.querySelector('button[title*="Delete parameter"]') ||
parameterContainer?.querySelector('button svg[data-lucide="trash-2"]')?.closest('button');
expect(deleteButton).toBeInTheDocument();
// Test deleting the parameter
if (deleteButton) {
await user.click(deleteButton as HTMLElement);
// Parameter should be removed
expect(screen.queryByText('deletable_param')).not.toBeInTheDocument();
expect(document.querySelector('.parameter-input')).not.toBeInTheDocument();
}
});
it('supports different parameter input types', async () => {
const user = userEvent.setup();
render(<TestWrapper />);
// Add a parameter and test changing its type
const parameterInput = screen.getByPlaceholderText('Enter parameter name...');
const addButton = screen.getByText('Add parameter');
await user.type(parameterInput, 'typed_param');
await user.click(addButton);
const parameterContainer = document.querySelector('.parameter-input');
const selects = parameterContainer?.querySelectorAll('select');
const inputTypeSelect = selects
? Array.from(selects).find((select) =>
Array.from(select.options).some((option) => option.text === 'String')
)
: null;
if (inputTypeSelect) {
// Test changing to different input types
await user.selectOptions(inputTypeSelect, 'number');
expect(inputTypeSelect.value).toBe('number');
await user.selectOptions(inputTypeSelect, 'boolean');
expect(inputTypeSelect.value).toBe('boolean');
await user.selectOptions(inputTypeSelect, 'select');
expect(inputTypeSelect.value).toBe('select');
// When type is 'select', options field should appear
const optionsTextarea = parameterContainer?.querySelector(
'textarea[placeholder*="Option 1"]'
);
expect(optionsTextarea).toBeInTheDocument();
}
});
});
});
@@ -0,0 +1,430 @@
import { describe, it, expect } from 'vitest';
import { RecipeFormData, recipeFormSchema } from '../recipeFormSchema';
describe('recipeFormSchema', () => {
const validFormData: RecipeFormData = {
title: 'Test Recipe Title',
description: 'Test Description that is long enough to pass validation',
instructions: 'Test instructions that are long enough to pass the minimum length validation',
prompt: 'Test prompt',
activities: ['activity1', 'activity2'],
parameters: [
{
key: 'param1',
description: 'Test parameter',
input_type: 'string' as const,
requirement: 'required' as const,
},
],
jsonSchema: '{"type": "object"}',
recipeName: 'test_recipe',
global: true,
};
describe('Zod Schema Validation', () => {
describe('Basic Validation', () => {
it('validates a complete valid form', () => {
const result = recipeFormSchema.safeParse(validFormData);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(validFormData);
}
});
it('returns validation result with correct structure', () => {
const result = recipeFormSchema.safeParse(validFormData);
expect(result).toHaveProperty('success');
expect(typeof result.success).toBe('boolean');
if (result.success) {
expect(result).toHaveProperty('data');
} else {
expect(result).toHaveProperty('error');
}
});
});
describe('Required Field Validation', () => {
it('requires title with minimum length', () => {
const invalidData = { ...validFormData, title: '' };
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.includes('title'))).toBe(true);
}
});
it('requires description with minimum length', () => {
const invalidData = { ...validFormData, description: '' };
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.includes('description'))).toBe(
true
);
}
});
it('requires instructions with minimum length', () => {
const invalidData = { ...validFormData, instructions: '' };
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.includes('instructions'))).toBe(
true
);
}
});
it('allows empty prompt', () => {
const validData = { ...validFormData, prompt: '' };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('allows undefined prompt', () => {
const validData = { ...validFormData, prompt: undefined };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
});
describe('String Field Validation', () => {
it('validates minimum title length', () => {
const invalidData = { ...validFormData, title: 'AB' }; // Less than 3 chars
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
const titleError = result.error.issues.find((issue) => issue.path.includes('title'));
expect(titleError?.message).toContain('at least 3 characters');
}
});
it('validates minimum description length', () => {
const invalidData = { ...validFormData, description: 'Short' }; // Less than 10 chars
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
const descError = result.error.issues.find((issue) => issue.path.includes('description'));
expect(descError?.message).toContain('at least 10 characters');
}
});
it('validates minimum instructions length', () => {
const invalidData = { ...validFormData, instructions: 'Short' }; // Less than 20 chars
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
const instError = result.error.issues.find((issue) =>
issue.path.includes('instructions')
);
expect(instError?.message).toContain('at least 20 characters');
}
});
it('validates maximum title length', () => {
const longTitle = 'a'.repeat(101); // More than 100 chars
const invalidData = { ...validFormData, title: longTitle };
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
const titleError = result.error.issues.find((issue) => issue.path.includes('title'));
expect(titleError?.message).toContain('100 characters or less');
}
});
it('validates maximum description length', () => {
const longDescription = 'a'.repeat(501); // More than 500 chars
const invalidData = { ...validFormData, description: longDescription };
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
const descError = result.error.issues.find((issue) => issue.path.includes('description'));
expect(descError?.message).toContain('500 characters or less');
}
});
});
describe('JSON Schema Validation', () => {
it('validates valid JSON schema', () => {
const validData = {
...validFormData,
jsonSchema: '{"type": "object", "properties": {"name": {"type": "string"}}}',
};
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('rejects invalid JSON schema', () => {
const invalidData = { ...validFormData, jsonSchema: 'invalid json' };
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
const jsonError = result.error.issues.find((issue) => issue.path.includes('jsonSchema'));
expect(jsonError?.message).toBe('Invalid JSON schema format');
}
});
it('allows empty JSON schema', () => {
const validData = { ...validFormData, jsonSchema: '' };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('allows undefined JSON schema', () => {
const validData = { ...validFormData, jsonSchema: undefined };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
});
describe('Recipe Name Validation', () => {
it('allows empty recipe name', () => {
const validData = { ...validFormData, recipeName: '' };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('allows undefined recipe name', () => {
const validData = { ...validFormData, recipeName: undefined };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('rejects invalid recipe name characters', () => {
// The regex /^[^<>:"/\\|?*]+$/ rejects these specific characters
const invalidData = { ...validFormData, recipeName: 'invalid<name' };
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
const nameError = result.error.issues.find((issue) => issue.path.includes('recipeName'));
expect(nameError?.message).toContain('invalid characters');
}
});
});
describe('Parameter Validation', () => {
it('validates parameters with all required fields', () => {
const validData = {
...validFormData,
parameters: [
{
key: 'param1',
description: 'Test parameter',
input_type: 'string' as const,
requirement: 'required' as const,
},
],
};
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('rejects parameters with empty keys', () => {
const invalidData = {
...validFormData,
parameters: [
{
key: '',
description: 'Empty key',
input_type: 'string' as const,
requirement: 'required' as const,
},
],
};
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some(
(issue) => issue.path.includes('parameters') && issue.path.includes('key')
)
).toBe(true);
}
});
it('rejects parameters with empty descriptions', () => {
const invalidData = {
...validFormData,
parameters: [
{
key: 'param1',
description: '',
input_type: 'string' as const,
requirement: 'required' as const,
},
],
};
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some(
(issue) => issue.path.includes('parameters') && issue.path.includes('description')
)
).toBe(true);
}
});
it('allows empty parameters array', () => {
const validData = { ...validFormData, parameters: [] };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
});
describe('Activities Validation', () => {
it('allows empty activities array', () => {
const validData = { ...validFormData, activities: [] };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('allows activities with string content', () => {
const validData = {
...validFormData,
activities: [
'Simple activity',
'Activity with {{parameter}}',
'Activity with special chars !@#$%',
],
};
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('rejects non-string activities', () => {
const invalidData = {
...validFormData,
activities: [123 as unknown as string, 'valid activity'],
};
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.includes('activities'))).toBe(true);
}
});
});
describe('Global Field Validation', () => {
it('validates global field as boolean true', () => {
const validData = { ...validFormData, global: true };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('validates global field as boolean false', () => {
const validData = { ...validFormData, global: false };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
});
describe('Multiple Validation Errors', () => {
it('handles multiple validation errors', () => {
const invalidData = {
...validFormData,
title: 'AB', // Too short
description: 'Short', // Too short
instructions: 'Short', // Too short
jsonSchema: 'invalid json',
};
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.length).toBeGreaterThan(0);
expect(result.error.issues.some((issue) => issue.path.includes('title'))).toBe(true);
expect(result.error.issues.some((issue) => issue.path.includes('description'))).toBe(
true
);
expect(result.error.issues.some((issue) => issue.path.includes('instructions'))).toBe(
true
);
expect(result.error.issues.some((issue) => issue.path.includes('jsonSchema'))).toBe(true);
}
});
});
describe('Edge Cases', () => {
it('handles null values gracefully', () => {
const dataWithNulls = {
...validFormData,
title: null as unknown as string,
description: null as unknown as string,
instructions: null as unknown as string,
};
const result = recipeFormSchema.safeParse(dataWithNulls);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.includes('title'))).toBe(true);
expect(result.error.issues.some((issue) => issue.path.includes('description'))).toBe(
true
);
expect(result.error.issues.some((issue) => issue.path.includes('instructions'))).toBe(
true
);
}
});
it('handles undefined values gracefully', () => {
const dataWithUndefined = {
...validFormData,
title: undefined as unknown as string,
description: undefined as unknown as string,
instructions: undefined as unknown as string,
};
const result = recipeFormSchema.safeParse(dataWithUndefined);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.includes('title'))).toBe(true);
expect(result.error.issues.some((issue) => issue.path.includes('description'))).toBe(
true
);
expect(result.error.issues.some((issue) => issue.path.includes('instructions'))).toBe(
true
);
}
});
it('handles completely empty form data', () => {
const emptyData = {
title: '',
description: '',
instructions: '',
prompt: '',
activities: [],
parameters: [],
jsonSchema: '',
recipeName: '',
global: true,
};
const result = recipeFormSchema.safeParse(emptyData);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue) => issue.path.includes('title'))).toBe(true);
expect(result.error.issues.some((issue) => issue.path.includes('description'))).toBe(
true
);
expect(result.error.issues.some((issue) => issue.path.includes('instructions'))).toBe(
true
);
}
});
it('handles minimal valid form data', () => {
const minimalData = {
title: 'Valid Title',
description: 'Valid description that meets minimum length',
instructions: 'Valid instructions that meet the minimum length requirement',
prompt: '',
activities: [],
parameters: [],
jsonSchema: '',
recipeName: '',
global: true,
};
const result = recipeFormSchema.safeParse(minimalData);
expect(result.success).toBe(true);
});
});
});
});
@@ -0,0 +1,71 @@
import { z } from 'zod';
import { validateJsonSchema } from '../../../recipe/validation';
// Zod schema for Parameter - matching API RecipeParameter type
const parameterSchema = z.object({
key: z.string().min(1, 'Parameter key is required'),
input_type: z.enum(['string', 'number', 'boolean', 'date', 'file', 'select']),
requirement: z.enum(['required', 'optional', 'user_prompt']),
description: z.string().min(1, 'Parameter description is required'),
default: z.string().nullable().optional(),
options: z.array(z.string()).nullable().optional(),
});
// Export the parameter type for use in components
export type RecipeParameter = z.infer<typeof parameterSchema>;
// Main recipe form schema
export const recipeFormSchema = z.object({
title: z
.string()
.min(1, 'Title is required')
.min(3, 'Title must be at least 3 characters')
.max(100, 'Title must be 100 characters or less'),
description: z
.string()
.min(1, 'Description is required')
.min(10, 'Description must be at least 10 characters')
.max(500, 'Description must be 500 characters or less'),
instructions: z
.string()
.min(1, 'Instructions are required')
.min(20, 'Instructions must be at least 20 characters'),
prompt: z.string().optional(),
activities: z.array(z.string()).default([]),
parameters: z.array(parameterSchema).default([]),
jsonSchema: z
.string()
.optional()
.refine((value) => {
if (!value || !value.trim()) return true;
try {
const parsed = JSON.parse(value.trim());
const validationResult = validateJsonSchema(parsed);
return validationResult.success;
} catch {
return false;
}
}, 'Invalid JSON schema format'),
recipeName: z
.string()
.optional()
.refine((name) => {
if (!name || !name.trim()) return true;
return /^[^<>:"/\\|?*]+$/.test(name.trim());
}, 'Recipe name contains invalid characters (< > : " / \\ | ? *)'),
global: z.boolean().default(true),
});
export type RecipeFormData = z.infer<typeof recipeFormSchema>;
// Type for the form API - using any to avoid complex generic constraints
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type RecipeFormApi = any;
@@ -11,7 +11,6 @@ import {
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import { View, ViewOptions } from '../../utils/navigationUtils';
import { formatMessageTimestamp } from '../../utils/timeUtils';
import { SearchView } from '../conversation/SearchView';
import { SearchHighlighter } from '../../utils/searchHighlighter';
@@ -165,7 +164,6 @@ interface SearchContainerElement extends HTMLDivElement {
}
interface SessionListViewProps {
setView: (view: View, viewOptions?: ViewOptions) => void;
onSelectSession: (sessionId: string) => void;
selectedSessionId?: string | null;
}
@@ -1,15 +1,10 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, ViewOptions } from '../../utils/navigationUtils';
import SessionListView from './SessionListView';
import SessionHistoryView from './SessionHistoryView';
import { useLocation } from 'react-router-dom';
import { getSession, Session } from '../../api';
interface SessionsViewProps {
setView: (view: View, viewOptions?: ViewOptions) => void;
}
const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
const SessionsView: React.FC = () => {
const [selectedSession, setSelectedSession] = useState<Session | null>(null);
const [showSessionHistory, setShowSessionHistory] = useState(false);
const [isLoadingSession, setIsLoadingSession] = useState(false);
@@ -91,7 +86,6 @@ const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
/>
) : (
<SessionListView
setView={setView}
onSelectSession={handleSelectSession}
selectedSessionId={selectedSession?.id ?? null}
/>
@@ -17,16 +17,16 @@ import { getProviderMetadata } from '../modelInterface';
import { Alert } from '../../../alerts';
import BottomMenuAlertPopover from '../../../bottom_menu/BottomMenuAlertPopover';
import { Recipe } from '../../../../recipe';
import { saveRecipe, generateRecipeFilename } from '../../../../recipe/recipeStorage';
import { toastSuccess, toastError } from '../../../../toasts';
import ViewRecipeModal from '../../../recipes/ViewRecipeModal';
import { generateRecipeFilename } from '../../../../recipe/recipeStorage';
import CreateEditRecipeModal from '../../../recipes/CreateEditRecipeModal';
import SaveRecipeDialog from '../../../recipes/shared/SaveRecipeDialog';
interface ModelsBottomBarProps {
sessionId: string | null;
dropdownRef: React.RefObject<HTMLDivElement>;
setView: (view: View) => void;
alerts: Alert[];
recipeConfig?: Recipe | null;
recipe?: Recipe | null;
hasMessages?: boolean; // Add prop to know if there are messages to create a recipe from
}
@@ -35,7 +35,7 @@ export default function ModelsBottomBar({
dropdownRef,
setView,
alerts,
recipeConfig,
recipe,
hasMessages = false,
}: ModelsBottomBarProps) {
const {
@@ -54,11 +54,8 @@ export default function ModelsBottomBar({
const [isLeadWorkerActive, setIsLeadWorkerActive] = useState(false);
const [providerDefaultModel, setProviderDefaultModel] = useState<string | null>(null);
// Save recipe dialog state (like in RecipeEditor.tsx)
// Save recipe dialog state
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [saveRecipeName, setSaveRecipeName] = useState('');
const [saveGlobal, setSaveGlobal] = useState(true);
const [saving, setSaving] = useState(false);
// View recipe modal state
const [showViewRecipeModal, setShowViewRecipeModal] = useState(false);
@@ -172,58 +169,18 @@ export default function ModelsBottomBar({
// Handle view recipe - open modal instead of navigating
const handleViewRecipe = () => {
if (recipeConfig) {
if (recipe) {
setShowViewRecipeModal(true);
}
};
// Handle save recipe - show save dialog (like in RecipeEditor.tsx)
// Handle save recipe - show save dialog
const handleSaveRecipeClick = () => {
if (recipeConfig) {
const suggestedName = generateRecipeFilename(recipeConfig);
setSaveRecipeName(suggestedName);
if (recipe) {
setShowSaveDialog(true);
}
};
// Handle save recipe (like in RecipeEditor.tsx)
const handleSaveRecipe = async () => {
if (!saveRecipeName.trim() || !recipeConfig) {
return;
}
setSaving(true);
try {
if (!recipeConfig.title || !recipeConfig.description || !recipeConfig.instructions) {
throw new Error('Invalid recipe configuration: missing required fields');
}
await saveRecipe(recipeConfig, {
name: saveRecipeName.trim(),
global: saveGlobal,
});
// Reset dialog state
setShowSaveDialog(false);
setSaveRecipeName('');
toastSuccess({
title: saveRecipeName.trim(),
msg: `Recipe saved successfully`,
});
} catch (error) {
console.error('Failed to save recipe:', error);
toastError({
title: 'Save Failed',
msg: `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setSaving(false);
}
};
return (
<div className="relative flex items-center" ref={dropdownRef}>
<BottomMenuAlertPopover alerts={alerts} />
@@ -255,7 +212,7 @@ export default function ModelsBottomBar({
</DropdownMenuItem>
{/* Recipe-specific menu items - only show when actively using a recipe */}
{recipeConfig && (
{recipe && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleViewRecipe}>
@@ -269,18 +226,20 @@ export default function ModelsBottomBar({
</>
)}
<DropdownMenuSeparator />
{/* Only show "Create a recipe from this session" when there are messages to create from */}
{hasMessages && (
<DropdownMenuItem
onClick={() => {
// Signal to create an agent from the current chat
window.dispatchEvent(new CustomEvent('make-agent-from-chat'));
}}
>
<span>Create a recipe from this session</span>
<ChefHat className="ml-auto h-4 w-4" />
</DropdownMenuItem>
{/* Only show "Create a recipe from this session" when there are messages AND no recipe is currently active */}
{hasMessages && !recipe && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
// Signal to create an agent from the current chat
window.dispatchEvent(new CustomEvent('make-agent-from-chat'));
}}
>
<span>Create a recipe from this session</span>
<ChefHat className="ml-auto h-4 w-4" />
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
@@ -297,93 +256,23 @@ export default function ModelsBottomBar({
<LeadWorkerSettings isOpen={isLeadWorkerModalOpen} onClose={handleLeadWorkerModalClose} />
) : null}
{/* Save Recipe Dialog - copied from RecipeEditor.tsx */}
{showSaveDialog && (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-borderSubtle rounded-lg p-6 w-96 max-w-[90vw]">
<h3 className="text-lg font-medium text-textProminent mb-4">Save Recipe</h3>
<div className="space-y-4">
<div>
<label
htmlFor="recipe-name"
className="block text-sm font-medium text-textStandard mb-2"
>
Recipe Name
</label>
<input
id="recipe-name"
type="text"
value={saveRecipeName}
onChange={(e) => setSaveRecipeName(e.target.value)}
className="w-full p-3 border border-borderSubtle rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
placeholder="Enter recipe name"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-textStandard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={saveGlobal}
onChange={() => setSaveGlobal(true)}
className="mr-2"
/>
<span className="text-sm text-textStandard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={!saveGlobal}
onChange={() => setSaveGlobal(false)}
className="mr-2"
/>
<span className="text-sm text-textStandard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={() => {
setShowSaveDialog(false);
setSaveRecipeName('');
}}
className="px-4 py-2 text-textSubtle hover:text-textStandard transition-colors"
disabled={saving}
>
Cancel
</button>
<button
onClick={handleSaveRecipe}
disabled={!saveRecipeName.trim() || saving}
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Recipe'}
</button>
</div>
</div>
</div>
{/* Save Recipe Dialog */}
{showSaveDialog && recipe && (
<SaveRecipeDialog
isOpen={showSaveDialog}
onClose={() => setShowSaveDialog(false)}
recipe={recipe}
/>
)}
{/* View Recipe Modal */}
{recipeConfig && (
<ViewRecipeModal
{/* todo: we don't have the actual recipe name when in chat only in recipes list view so we generate it for now */}
{recipe && (
<CreateEditRecipeModal
isOpen={showViewRecipeModal}
onClose={() => setShowViewRecipeModal(false)}
config={recipeConfig}
recipe={recipe}
recipeName={generateRecipeFilename(recipe)}
/>
)}
</div>
+10 -9
View File
@@ -11,8 +11,8 @@ interface ChatContextType {
setChat: (chat: ChatType) => void;
resetChat: () => void;
hasActiveSession: boolean;
setRecipeConfig: (recipe: Recipe | null) => void;
clearRecipeConfig: () => void;
setRecipe: (recipe: Recipe | null) => void;
clearRecipe: () => void;
// Draft functionality
draft: string;
setDraft: (draft: string) => void;
@@ -58,23 +58,24 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
title: DEFAULT_CHAT_TITLE,
messages: [],
messageHistoryIndex: 0,
recipeConfig: null,
recipe: null,
recipeParameters: null,
});
clearDraft();
};
const setRecipeConfig = (recipe: Recipe | null) => {
const setRecipe = (recipe: Recipe | null) => {
setChat({
...chat,
recipeConfig: recipe,
recipe: recipe,
recipeParameters: null,
});
};
const clearRecipeConfig = () => {
const clearRecipe = () => {
setChat({
...chat,
recipeConfig: null,
recipe: null,
});
};
@@ -85,8 +86,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
setChat,
resetChat,
hasActiveSession,
setRecipeConfig,
clearRecipeConfig,
setRecipe,
clearRecipe,
draft,
setDraft,
clearDraft,
+22 -8
View File
@@ -26,7 +26,7 @@ export enum AgentState {
}
export interface InitializationContext {
recipeConfig?: Recipe;
recipe?: Recipe;
resumeSessionId?: string;
setAgentWaitingMessage: (msg: string | null) => void;
setIsExtensionsLoading?: (isLoading: boolean) => void;
@@ -81,7 +81,8 @@ export function useAgent(): UseAgentReturn {
messages: messages?.map((message: ApiMessage) =>
convertApiMessageToFrontendMessage(message)
),
recipeConfig: agentSession.recipe,
recipe: agentSession.recipe,
recipeParameters: agentSession.user_recipe_values || null,
};
}
@@ -114,7 +115,7 @@ export function useAgent(): UseAgentReturn {
: await startAgent({
body: {
working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string,
recipe: recipeFromAppConfig ?? initContext.recipeConfig,
recipe: recipeFromAppConfig ?? initContext.recipe,
},
throwOnError: true,
});
@@ -137,10 +138,14 @@ export function useAgent(): UseAgentReturn {
}
agentWaitingMessage('Extensions are loading');
const recipeForInit = initContext.recipe || agentSession.recipe || undefined;
await initializeSystem(agentSession.id, provider as string, model as string, {
getExtensions,
addExtension,
setIsExtensionsLoading: initContext.setIsExtensionsLoading,
recipeParameters: agentSession.user_recipe_values,
recipe: recipeForInit,
});
if (COST_TRACKING_ENABLED) {
@@ -151,15 +156,24 @@ export function useAgent(): UseAgentReturn {
}
}
const messages = agentSession.conversation || [];
const recipe = initContext.recipe || agentSession.recipe;
const conversation = agentSession.conversation || [];
// If we're loading a recipe from initContext (new recipe load), start with empty messages
// Otherwise, use the messages from the session
const messages =
initContext.recipe && !initContext.resumeSessionId
? []
: conversation.map((message: ApiMessage) =>
convertApiMessageToFrontendMessage(message)
);
let initChat: ChatType = {
sessionId: agentSession.id,
title: agentSession.recipe?.title || agentSession.description,
messageHistoryIndex: 0,
messages: messages.map((message: ApiMessage) =>
convertApiMessageToFrontendMessage(message)
),
recipeConfig: agentSession.recipe,
messages: messages,
recipe: recipe,
recipeParameters: agentSession.user_recipe_values || null,
};
setAgentState(AgentState.INITIALIZED);
+5 -5
View File
@@ -94,10 +94,10 @@ export const useChatEngine = ({
body: {
session_id: chat.sessionId,
session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR'),
...(chat.recipeConfig?.title
...(chat.recipe?.title
? {
recipe_name: chat.recipeConfig.title,
recipe_version: chat.recipeConfig?.version ?? 'unknown',
recipe_name: chat.recipe.title,
recipe_version: chat.recipe?.version ?? 'unknown',
}
: {}),
},
@@ -220,9 +220,9 @@ export const useChatEngine = ({
}
}, [chat.sessionId, messages, chatState]);
// Update token counts when sessionMetadata changes from the message stream
// Update token counts when session changes from the message stream
useEffect(() => {
console.log('Session metadata received:', session);
console.log('Session received:', session);
if (session) {
setSessionTokenCount(session.total_tokens || 0);
setSessionInputTokens(session.accumulated_input_tokens || 0);
+1 -1
View File
@@ -626,7 +626,7 @@ export function useMessageStream({
updateMessageStreamBody,
notifications,
currentModelInfo,
session: session,
session,
setError,
};
}
+147 -104
View File
@@ -1,24 +1,25 @@
import { useEffect, useMemo, useState, useRef } from 'react';
import { createRecipe, Recipe, scanRecipe } from '../recipe';
import { Recipe, scanRecipe } from '../recipe';
import { Message, createUserMessage } from '../types/message';
import {
updateSystemPromptWithParameters,
substituteParameters,
filterValidUsedParameters,
} from '../utils/providerUtils';
import { updateSessionUserRecipeValues } from '../api';
import { useChatContext } from '../contexts/ChatContext';
import { ChatType } from '../types/chat';
import { toastSuccess } from '../toasts';
export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) => {
const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false);
export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => {
const [isParameterModalOpen, setIsParameterModalOpen] = useState(false);
const [readyForAutoUserPrompt, setReadyForAutoUserPrompt] = useState(false);
const [recipeError, setRecipeError] = useState<string | null>(null);
const [isRecipeWarningModalOpen, setIsRecipeWarningModalOpen] = useState(false);
const [recipeAccepted, setRecipeAccepted] = useState(false);
const [isCreateRecipeModalOpen, setIsCreateRecipeModalOpen] = useState(false);
const [hasSecurityWarnings, setHasSecurityWarnings] = useState(false);
const [recipeParameters, setRecipeParameters] = useState<Record<string, string> | null>(null);
const [readyForAutoUserPrompt, setReadyForAutoUserPrompt] = useState(false);
const [recipeError, setRecipeError] = useState<string | null>(null);
const recipeParameters = chat.recipeParameters;
const chatContext = useChatContext();
const messages = chat.messages;
@@ -30,33 +31,66 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
messagesRef.current = messages;
}, [messages]);
const finalRecipeConfig = chat.recipeConfig;
const finalRecipe = chat.recipe;
useEffect(() => {
if (!chatContext) return;
// If we have a recipe from navigation state, persist it
if (recipeConfig && !chatContext.chat.recipeConfig) {
chatContext.setRecipeConfig(recipeConfig);
// If we have a recipe from navigation state, always set it and reset acceptance state
// This ensures that when loading a new recipe, we start fresh
if (recipe) {
// Check if this is actually a different recipe (by comparing title and content)
const currentRecipe = chatContext.chat.recipe;
const isNewRecipe =
!currentRecipe ||
currentRecipe.title !== recipe.title ||
currentRecipe.instructions !== recipe.instructions ||
currentRecipe.prompt !== recipe.prompt ||
JSON.stringify(currentRecipe.activities) !== JSON.stringify(recipe.activities);
if (isNewRecipe) {
console.log('Setting new recipe config:', recipe.title);
// Reset recipe acceptance state when loading a new recipe
setRecipeAccepted(false);
setIsParameterModalOpen(false);
setIsRecipeWarningModalOpen(false);
chatContext.setChat({
...chatContext.chat,
recipe: recipe,
recipeParameters: null,
messages: [],
});
}
return;
}
// If we have a recipe from app config (deeplink), persist it
// But only if the chat context doesn't explicitly have null (which indicates it was cleared)
const appRecipeConfig = window.appConfig.get('recipe') as Recipe | null;
if (appRecipeConfig && chatContext.chat.recipeConfig === undefined) {
chatContext.setRecipeConfig(appRecipeConfig);
const appRecipe = window.appConfig.get('recipe') as Recipe | null;
if (appRecipe && chatContext.chat.recipe === undefined) {
chatContext.setRecipe(appRecipe);
}
}, [chatContext, recipeConfig]);
}, [chatContext, recipe]);
useEffect(() => {
const checkRecipeAcceptance = async () => {
if (finalRecipeConfig) {
if (finalRecipe) {
// If the recipe comes from session metadata (not from navigation state),
// it means it was already accepted in a previous session, so auto-accept it
const isFromSessionMetadata = !recipe && finalRecipe;
if (isFromSessionMetadata) {
// Recipe loaded from session metadata should be automatically accepted
setRecipeAccepted(true);
return;
}
try {
const hasAccepted = await window.electron.hasAcceptedRecipeBefore(finalRecipeConfig);
const hasAccepted = await window.electron.hasAcceptedRecipeBefore(finalRecipe);
if (!hasAccepted) {
const securityScanResult = await scanRecipe(finalRecipeConfig);
const securityScanResult = await scanRecipe(finalRecipe);
setHasSecurityWarnings(securityScanResult.has_security_warnings);
setIsRecipeWarningModalOpen(true);
@@ -67,63 +101,116 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
setHasSecurityWarnings(false);
setIsRecipeWarningModalOpen(true);
}
} else {
setRecipeAccepted(false);
setIsRecipeWarningModalOpen(false);
}
};
checkRecipeAcceptance();
}, [finalRecipeConfig]);
}, [finalRecipe, recipe]);
// Filter parameters to only show valid ones that are actually used in the recipe
const filteredParameters = useMemo(() => {
if (!finalRecipeConfig?.parameters) {
if (!finalRecipe?.parameters) {
return [];
}
return filterValidUsedParameters(finalRecipeConfig.parameters, {
prompt: finalRecipeConfig.prompt || undefined,
instructions: finalRecipeConfig.instructions || undefined,
return filterValidUsedParameters(finalRecipe.parameters, {
prompt: finalRecipe.prompt || undefined,
instructions: finalRecipe.instructions || undefined,
activities: finalRecipe.activities || undefined,
});
}, [finalRecipeConfig]);
}, [finalRecipe]);
// Check if template variables are actually used in the recipe content
const requiresParameters = useMemo(() => {
return filteredParameters.length > 0;
}, [filteredParameters]);
const hasParameters = !!recipeParameters;
// Check if all required parameters have been filled in
const hasAllRequiredParameters = useMemo(() => {
if (!requiresParameters) {
return true; // No parameters required, so all are "filled"
}
if (!recipeParameters) {
return false; // Parameters required but none provided
}
// Check if all filtered parameters have values
return filteredParameters.every((param) => {
const value = recipeParameters[param.key];
return value !== undefined && value !== null && value.trim() !== '';
});
}, [filteredParameters, recipeParameters, requiresParameters]);
const hasMessages = messages.length > 0;
useEffect(() => {
if (requiresParameters && recipeAccepted) {
if (!hasParameters && !hasMessages) {
setIsParameterModalOpen(true);
}
// Only show parameter modal if:
// 1. Recipe requires parameters
// 2. Recipe has been accepted
// 3. Not all required parameters have been filled in yet
// 4. Parameter modal is not already open (prevent multiple opens)
// 5. No messages in chat yet (don't show after conversation has started)
if (
requiresParameters &&
recipeAccepted &&
!hasAllRequiredParameters &&
!isParameterModalOpen &&
!hasMessages
) {
setIsParameterModalOpen(true);
}
}, [requiresParameters, hasParameters, recipeAccepted, hasMessages]);
}, [
requiresParameters,
hasAllRequiredParameters,
recipeAccepted,
filteredParameters,
isParameterModalOpen,
hasMessages,
chat.sessionId,
finalRecipe?.title,
]);
useEffect(() => {
setReadyForAutoUserPrompt(true);
}, []);
const initialPrompt = useMemo(() => {
if (!finalRecipeConfig?.prompt || !recipeAccepted || finalRecipeConfig?.isScheduledExecution) {
if (!finalRecipe?.prompt || !recipeAccepted || finalRecipe?.isScheduledExecution) {
return '';
}
if (requiresParameters && recipeParameters) {
return substituteParameters(finalRecipeConfig.prompt, recipeParameters);
return substituteParameters(finalRecipe.prompt, recipeParameters);
}
return finalRecipeConfig.prompt;
}, [finalRecipeConfig, recipeParameters, recipeAccepted, requiresParameters]);
return finalRecipe.prompt;
}, [finalRecipe, recipeParameters, recipeAccepted, requiresParameters]);
const handleParameterSubmit = async (inputValues: Record<string, string>) => {
setRecipeParameters(inputValues);
// Update chat state with parameters
if (chatContext) {
chatContext.setChat({
...chatContext.chat,
recipeParameters: inputValues,
});
}
setIsParameterModalOpen(false);
try {
await updateSystemPromptWithParameters(
chat.sessionId,
inputValues,
finalRecipeConfig || undefined
);
await updateSystemPromptWithParameters(chat.sessionId, inputValues, finalRecipe || undefined);
// Save recipe parameters to session metadata
await updateSessionUserRecipeValues({
path: {
session_id: chat.sessionId,
},
body: {
userRecipeValues: inputValues,
},
throwOnError: true,
});
} catch (error) {
console.error('Failed to update system prompt with parameters:', error);
}
@@ -131,8 +218,8 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
const handleRecipeAccept = async () => {
try {
if (finalRecipeConfig) {
await window.electron.recordRecipeHash(finalRecipeConfig);
if (finalRecipe) {
await window.electron.recordRecipeHash(finalRecipe);
setRecipeAccepted(true);
setIsRecipeWarningModalOpen(false);
}
@@ -154,8 +241,8 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
onAutoExecute?: () => void
) => {
if (
finalRecipeConfig?.isScheduledExecution &&
finalRecipeConfig?.prompt &&
finalRecipe?.isScheduledExecution &&
finalRecipe?.prompt &&
(!requiresParameters || recipeParameters) &&
messages.length === 0 &&
!isLoading &&
@@ -163,10 +250,8 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
recipeAccepted
) {
const finalPrompt = recipeParameters
? substituteParameters(finalRecipeConfig.prompt, recipeParameters)
: finalRecipeConfig.prompt;
console.log('Auto-sending substituted prompt for scheduled execution:', finalPrompt);
? substituteParameters(finalRecipe.prompt, recipeParameters)
: finalRecipe.prompt;
const userMessage = createUserMessage(finalPrompt);
append(userMessage);
@@ -184,59 +269,7 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
return;
}
window.electron.logInfo('Making recipe from chat...');
isCreatingRecipeRef.current = true;
window.isCreatingRecipe = true;
setIsGeneratingRecipe(true);
try {
const createRecipeRequest = {
messages: messagesRef.current,
title: '',
description: '',
session_id: chat.sessionId,
};
const response = await createRecipe(createRecipeRequest);
if (response.error) {
throw new Error(`Failed to create recipe: ${response.error}`);
}
window.electron.logInfo('Created recipe successfully');
if (!response.recipe) {
throw new Error('No recipe data received');
}
window.sessionStorage.setItem('ignoreRecipeConfigChanges', 'true');
window.electron.createChatWindow(
undefined,
undefined,
undefined,
undefined,
response.recipe,
'recipeEditor'
);
window.electron.logInfo('Opening recipe editor window');
setTimeout(() => {
window.sessionStorage.removeItem('ignoreRecipeConfigChanges');
}, 1000);
} catch (error) {
window.electron.logInfo('Failed to create recipe:');
const errorMessage = error instanceof Error ? error.message : String(error);
window.electron.logInfo(errorMessage);
setRecipeError(errorMessage);
} finally {
isCreatingRecipeRef.current = false;
window.isCreatingRecipe = false;
setIsGeneratingRecipe(false);
}
setIsCreateRecipeModalOpen(true);
};
window.addEventListener('make-agent-from-chat', handleMakeAgent);
@@ -246,14 +279,21 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
};
}, [chat.sessionId]);
const handleRecipeCreated = (recipe: Recipe) => {
toastSuccess({
title: 'Recipe created successfully!',
msg: `"${recipe.title}" has been saved and is ready to use.`,
});
};
return {
recipeConfig: finalRecipeConfig,
recipe: finalRecipe,
recipeParameters,
filteredParameters,
initialPrompt,
isGeneratingRecipe,
isParameterModalOpen,
setIsParameterModalOpen,
recipeParameters,
readyForAutoUserPrompt,
handleParameterSubmit,
handleAutoExecution,
recipeError,
@@ -264,5 +304,8 @@ export const useRecipeManager = (chat: ChatType, recipeConfig?: Recipe | null) =
handleRecipeAccept,
handleRecipeCancel,
hasSecurityWarnings,
isCreateRecipeModalOpen,
setIsCreateRecipeModalOpen,
handleRecipeCreated,
};
};
+1 -24
View File
@@ -489,7 +489,6 @@ let appConfig = {
};
const windowMap = new Map<number, BrowserWindow>();
const goosedClients = new Map<number, Client>();
// Track power save blockers per window
@@ -511,28 +510,7 @@ const createChat = async (
let workingDir = '';
let goosedProcess: import('child_process').ChildProcess | null = null;
if (viewType === 'recipeEditor') {
// For recipeEditor, get the port from existing windows' config
const existingWindows = BrowserWindow.getAllWindows();
if (existingWindows.length > 0) {
// Get the config from localStorage through an existing window
try {
const config = await existingWindows[0].webContents.executeJavaScript(
`window.electron.getConfig()`
);
if (config) {
port = config.GOOSE_PORT;
workingDir = config.GOOSE_WORKING_DIR;
}
} catch (e) {
console.error('Failed to get config from localStorage:', e);
}
}
if (port === 0) {
console.error('No existing Goose process found for recipeEditor');
throw new Error('Cannot create recipeEditor window: No existing Goose process found');
}
} else {
{
// Apply current environment settings before creating chat
updateEnvironmentVariables(envToggles);
@@ -688,7 +666,6 @@ const createChat = async (
permission: '/permission',
ConfigureProviders: '/configure-providers',
sharedSession: '/shared-session',
recipeEditor: '/recipe-editor',
welcome: '/welcome',
};
+5 -6
View File
@@ -111,8 +111,8 @@ type ElectronAPI = {
getUpdateState: () => Promise<{ updateAvailable: boolean; latestVersion?: string } | null>;
// Recipe warning functions
closeWindow: () => void;
hasAcceptedRecipeBefore: (recipeConfig: Recipe) => Promise<boolean>;
recordRecipeHash: (recipeConfig: Recipe) => Promise<boolean>;
hasAcceptedRecipeBefore: (recipe: Recipe) => Promise<boolean>;
recordRecipeHash: (recipe: Recipe) => Promise<boolean>;
openDirectoryInExplorer: (directoryPath: string) => Promise<boolean>;
};
@@ -232,10 +232,9 @@ const electronAPI: ElectronAPI = {
return ipcRenderer.invoke('get-update-state');
},
closeWindow: () => ipcRenderer.send('close-window'),
hasAcceptedRecipeBefore: (recipeConfig: Recipe) =>
ipcRenderer.invoke('has-accepted-recipe-before', recipeConfig),
recordRecipeHash: (recipeConfig: Recipe) =>
ipcRenderer.invoke('record-recipe-hash', recipeConfig),
hasAcceptedRecipeBefore: (recipe: Recipe) =>
ipcRenderer.invoke('has-accepted-recipe-before', recipe),
recordRecipeHash: (recipe: Recipe) => ipcRenderer.invoke('record-recipe-hash', recipe),
openDirectoryInExplorer: (directoryPath: string) =>
ipcRenderer.invoke('open-directory-in-explorer', directoryPath),
};
+1 -77
View File
@@ -1,18 +1,9 @@
import {
createRecipe as apiCreateRecipe,
encodeRecipe as apiEncodeRecipe,
decodeRecipe as apiDecodeRecipe,
scanRecipe as apiScanRecipe,
} from '../api';
import type {
CreateRecipeRequest as ApiCreateRecipeRequest,
CreateRecipeResponse as ApiCreateRecipeResponse,
RecipeParameter,
Message as ApiMessage,
Role,
MessageContent,
} from '../api';
import type { Message as FrontendMessage } from '../types/message';
import type { RecipeParameter } from '../api';
// Re-export OpenAPI types with frontend-specific additions
export type Parameter = RecipeParameter;
@@ -28,73 +19,6 @@ export type Recipe = import('../api').Recipe & {
mcps?: number;
};
// Create frontend-compatible type that accepts frontend Message until we can refactor.
export interface CreateRecipeRequest {
// TODO: Fix this type to match Message OpenAPI spec
messages: FrontendMessage[];
title: string;
description: string;
activities?: string[];
author?: {
contact?: string;
metadata?: string;
};
session_id: string;
}
export type CreateRecipeResponse = ApiCreateRecipeResponse;
function convertFrontendMessageToApiMessage(frontendMessage: FrontendMessage): ApiMessage {
// TODO: Fix this type to match Message OpenAPI spec
return {
id: frontendMessage.id,
role: frontendMessage.role as Role,
content: frontendMessage.content.map((content) => ({
...content,
// Convert toolCall to match API expectations
...(content.type === 'toolRequest' && 'toolCall' in content
? {
toolCall: content.toolCall as unknown as { [key: string]: unknown },
}
: {}),
})) as MessageContent[],
created: frontendMessage.created,
};
}
export async function createRecipe(request: CreateRecipeRequest): Promise<CreateRecipeResponse> {
console.log('Creating recipe with request:', JSON.stringify(request, null, 2));
try {
const apiRequest: ApiCreateRecipeRequest = {
messages: request.messages.map(convertFrontendMessageToApiMessage),
title: request.title,
description: request.description,
session_id: request.session_id,
activities: request.activities || undefined,
author: request.author
? {
contact: request.author.contact || undefined,
metadata: request.author.metadata || undefined,
}
: undefined,
};
const response = await apiCreateRecipe({
body: apiRequest,
});
if (!response.data) {
throw new Error('No data returned from API');
}
return response.data;
} catch (error) {
console.error('Failed to create recipe:', error);
throw error;
}
}
export async function encodeRecipe(recipe: Recipe): Promise<string> {
try {
const response = await apiEncodeRecipe({
-9
View File
@@ -8,7 +8,6 @@ import {
import type { Recipe } from '../api/types.gen';
describe('Recipe Validation', () => {
// Valid recipe examples based on project recipes
const validRecipe: Recipe = {
version: '1.0.0',
title: 'Test Recipe',
@@ -111,7 +110,6 @@ describe('Recipe Validation', () => {
if (!result.success) {
console.log('Author validation errors:', result.errors);
}
// This test may fail due to strict validation - adjust expectations
expect(typeof result.success).toBe('boolean');
expect(Array.isArray(result.errors)).toBe(true);
});
@@ -201,14 +199,12 @@ describe('Recipe Validation', () => {
...validRecipe,
parameters: [
{
// Only key provided, other fields missing
key: 'test',
},
],
};
const result = validateRecipe(recipeWithIncompleteParam);
// The OpenAPI schema may be more permissive than expected
expect(typeof result.success).toBe('boolean');
expect(Array.isArray(result.errors)).toBe(true);
});
@@ -252,7 +248,6 @@ describe('Recipe Validation', () => {
};
const result = validateRecipe(recipeWithExtra);
// Should either succeed (if passthrough) or fail gracefully
expect(typeof result.success).toBe('boolean');
expect(Array.isArray(result.errors)).toBe(true);
});
@@ -267,7 +262,6 @@ describe('Recipe Validation', () => {
};
const result = validateRecipe(recipeWithLongStrings);
// Should handle gracefully regardless of outcome
expect(typeof result.success).toBe('boolean');
});
});
@@ -356,7 +350,6 @@ describe('Recipe Validation', () => {
it('validates array input as valid JSON schema', () => {
const result = validateJsonSchema(['not', 'an', 'object']);
// Arrays are objects in JavaScript, so this might be valid
expect(typeof result.success).toBe('boolean');
expect(Array.isArray(result.errors)).toBe(true);
});
@@ -487,7 +480,6 @@ describe('Recipe Validation', () => {
if (!result.success) {
console.log('ReadmeBot validation errors:', result.errors);
}
// This test may fail due to strict validation - adjust expectations
expect(typeof result.success).toBe('boolean');
expect(Array.isArray(result.errors)).toBe(true);
});
@@ -534,7 +526,6 @@ describe('Recipe Validation', () => {
if (!result.success) {
console.log('LintRecipe validation errors:', result.errors);
}
// This test may fail due to strict validation - adjust expectations
expect(typeof result.success).toBe('boolean');
expect(Array.isArray(result.errors)).toBe(true);
});
+1 -1
View File
@@ -6,6 +6,6 @@ export interface ChatType {
title: string;
messageHistoryIndex: number;
messages: Message[];
recipeConfig?: Recipe | null; // Add recipe configuration to chat state
recipe?: Recipe | null; // Add recipe configuration to chat state
recipeParameters?: Record<string, string> | null; // Add recipe parameters to chat state
}
@@ -247,16 +247,6 @@ describe('providerUtils', () => {
expect(result).toEqual([]);
});
it('should handle non-array parameters', () => {
const parameters = {} as unknown as RecipeParameter[]; // Invalid type
const recipeContent = {
prompt: 'Use {{some_param}}',
};
const result = filterValidUsedParameters(parameters, recipeContent);
expect(result).toEqual([]);
});
it('should handle empty recipe content', () => {
const parameters = [createParameter('param1'), createParameter('param2')];
const recipeContent = {};
+4 -5
View File
@@ -1,4 +1,5 @@
import { NavigateFunction } from 'react-router-dom';
import { Recipe } from '../api/types.gen';
export type View =
| 'welcome'
@@ -15,7 +16,6 @@ export type View =
| 'schedules'
| 'sharedSession'
| 'loading'
| 'recipeEditor'
| 'recipes'
| 'permission';
@@ -27,13 +27,14 @@ export type ViewOptions = {
sessionDetails?: unknown;
error?: string;
baseUrl?: string;
config?: unknown;
recipe?: Recipe;
parentView?: View;
parentViewOptions?: ViewOptions;
disableAnimation?: boolean;
initialMessage?: string;
resetChat?: boolean;
shareToken?: string;
resumeSessionId?: string;
};
export const createNavigationHandler = (navigate: NavigateFunction) => {
@@ -66,9 +67,7 @@ export const createNavigationHandler = (navigate: NavigateFunction) => {
case 'sharedSession':
navigate('/shared-session', { state: options });
break;
case 'recipeEditor':
navigate('/recipe-editor', { state: options });
break;
case 'welcome':
navigate('/welcome', { state: options });
break;
+52 -17
View File
@@ -7,6 +7,7 @@ import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigC
import { addSubRecipesToAgent } from '../recipe/add_sub_recipe_on_agent';
import {
extendPrompt,
Recipe,
RecipeParameter,
SubRecipe,
updateAgentProvider,
@@ -78,9 +79,9 @@ const isValidParameterName = (variable: string): boolean => {
// Helper function to filter recipe parameters to only show valid ones that are actually used
export const filterValidUsedParameters = (
parameters: RecipeParameter[] | undefined,
recipeContent: { prompt?: string; instructions?: string }
recipeContent: { prompt?: string; instructions?: string; activities?: string[] }
): RecipeParameter[] => {
if (!parameters || !Array.isArray(parameters)) {
if (!parameters) {
return [];
}
@@ -91,7 +92,13 @@ export const filterValidUsedParameters = (
const instructionVariables = recipeContent.instructions
? extractTemplateVariables(recipeContent.instructions)
: [];
const allUsedVariables = [...new Set([...promptVariables, ...instructionVariables])];
// Extract variables from activities using flatMap
const activityVariables = recipeContent.activities?.flatMap(extractTemplateVariables) ?? [];
const allUsedVariables = [
...new Set([...promptVariables, ...instructionVariables, ...activityVariables]),
];
// Filter parameters to only include:
// 1. Parameters with valid names (no spaces, dots, pipes, etc.)
@@ -142,15 +149,15 @@ export const substituteParameters = (text: string, params: Record<string, string
export const updateSystemPromptWithParameters = async (
sessionId: string,
recipeParameters: Record<string, string>,
recipeConfig?: {
recipe?: {
instructions?: string | null;
sub_recipes?: SubRecipe[] | null;
parameters?: RecipeParameter[] | null;
}
): Promise<void> => {
const subRecipes = recipeConfig?.sub_recipes;
const subRecipes = recipe?.sub_recipes;
try {
const originalInstructions = recipeConfig?.instructions;
const originalInstructions = recipe?.instructions;
if (!originalInstructions) {
return;
@@ -191,6 +198,8 @@ export const initializeSystem = async (
getExtensions?: (b: boolean) => Promise<FixedExtensionEntry[]>;
addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>;
setIsExtensionsLoading?: (loading: boolean) => void;
recipeParameters?: Record<string, string> | null;
recipe?: Recipe;
}
) => {
try {
@@ -215,18 +224,26 @@ export const initializeSystem = async (
console.log('This will not end well');
}
// Get recipeConfig directly here
const recipeConfig = window.appConfig?.get?.('recipe');
const recipe_instructions = (recipeConfig as { instructions?: string })?.instructions;
const responseConfig = (recipeConfig as { response?: { json_schema?: unknown } })?.response;
const subRecipes = (recipeConfig as { sub_recipes?: SubRecipe[] })?.sub_recipes;
const parameters = (recipeConfig as { parameters?: RecipeParameter[] })?.parameters;
const hasParameters = parameters && parameters?.length > 0;
// Get recipe - prefer from options (session metadata) over app config
const recipe = options?.recipe || window.appConfig?.get?.('recipe');
const recipe_instructions = (recipe as { instructions?: string })?.instructions;
const responseConfig = (recipe as { response?: { json_schema?: unknown } })?.response;
const subRecipes = (recipe as { sub_recipes?: SubRecipe[] })?.sub_recipes;
const hasSubRecipes = subRecipes && subRecipes?.length > 0;
const recipeParameters = options?.recipeParameters;
// Determine the system prompt
let prompt = desktopPrompt;
if (!hasParameters && recipe_instructions) {
prompt = `${desktopPromptBot}\nIMPORTANT instructions for you to operate as agent:\n${recipe_instructions}`;
// If we have recipe instructions, add them to the system prompt with parameter substitution
if (recipe_instructions) {
const substitutedInstructions = recipeParameters
? substituteParameters(recipe_instructions, recipeParameters)
: recipe_instructions;
prompt = `${desktopPromptBot}\nIMPORTANT instructions for you to operate as agent:\n${substitutedInstructions}`;
}
// Extend the system prompt with desktop-specific information
await extendPrompt({
body: {
@@ -235,9 +252,27 @@ export const initializeSystem = async (
},
});
if (!hasParameters && hasSubRecipes) {
await addSubRecipesToAgent(sessionId, subRecipes);
if (hasSubRecipes) {
let finalSubRecipes = subRecipes;
// If we have parameters, substitute them in sub-recipe values
if (recipeParameters) {
finalSubRecipes = subRecipes.map((subRecipe) => ({
...subRecipe,
values: subRecipe.values
? Object.fromEntries(
Object.entries(subRecipe.values).map(([key, value]) => [
key,
substituteParameters(value, recipeParameters),
])
)
: subRecipe.values,
}));
}
await addSubRecipesToAgent(sessionId, finalSubRecipes);
}
// Configure session with response config if present
if (responseConfig?.json_schema) {
const sessionConfigResponse = await updateSessionConfig({
+6 -6
View File
@@ -3,9 +3,9 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import crypto from 'crypto';
function calculateRecipeHash(recipeConfig: unknown): string {
function calculateRecipeHash(recipe: unknown): string {
const hash = crypto.createHash('sha256');
hash.update(JSON.stringify(recipeConfig));
hash.update(JSON.stringify(recipe));
return hash.digest('hex');
}
@@ -16,8 +16,8 @@ async function getRecipeHashesDir(): Promise<string> {
return hashesDir;
}
ipcMain.handle('has-accepted-recipe-before', async (_event, recipeConfig) => {
const hash = calculateRecipeHash(recipeConfig);
ipcMain.handle('has-accepted-recipe-before', async (_event, recipe) => {
const hash = calculateRecipeHash(recipe);
const hashFile = path.join(await getRecipeHashesDir(), `${hash}.hash`);
try {
await fs.access(hashFile);
@@ -30,8 +30,8 @@ ipcMain.handle('has-accepted-recipe-before', async (_event, recipeConfig) => {
}
});
ipcMain.handle('record-recipe-hash', async (_event, recipeConfig) => {
const hash = calculateRecipeHash(recipeConfig);
ipcMain.handle('record-recipe-hash', async (_event, recipe) => {
const hash = calculateRecipeHash(recipe);
const filePath = path.join(await getRecipeHashesDir(), `${hash}.hash`);
const timestamp = new Date().toISOString();
await fs.writeFile(filePath, timestamp);