diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index 61ad44fded53..f5dde78bcecc 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -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 = if session_config.no_session { None } else if session_config.resume { diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 823959013bd3..d5dd848db5f8 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -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, diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index ce829e1ff4a6..851bb55c95e7 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -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, - // Required metadata - title: String, - description: String, + session_id: String, // Optional fields #[serde(default)] - activities: Option>, - #[serde(default)] author: Option, - session_id: String, } #[derive(Debug, Deserialize, ToSchema)] @@ -127,25 +121,38 @@ async fn create_recipe( Json(request): Json, ) -> Result, 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>, ) -> Result, 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() diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index c7cee665fcc5..bcd3e30e4dc7 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -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, +} + 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, + Json(request): Json, +) -> Result { + 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) -> 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) } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index a5c35c7a6582..c8651e6a9433 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1609,9 +1609,31 @@ impl Agent { extension_configs.len() ); + let (title, description) = + if let Ok(json_content) = serde_json::from_str::(&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) diff --git a/crates/goose/src/context_mgmt/auto_compact.rs b/crates/goose/src/context_mgmt/auto_compact.rs index 8977a3fd5897..aebd1096e25a 100644 --- a/crates/goose/src/context_mgmt/auto_compact.rs +++ b/crates/goose/src/context_mgmt/auto_compact.rs @@ -330,6 +330,7 @@ mod tests { extension_data: extension_data::ExtensionData::new(), conversation: Some(conversation), message_count, + user_recipe_values: None, } } diff --git a/crates/goose/src/prompts/recipe.md b/crates/goose/src/prompts/recipe.md index c5bb9ca768bc..9f9d4aa082da 100644 --- a/crates/goose/src/prompts/recipe.md +++ b/crates/goose/src/prompts/recipe.md @@ -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", diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 98da04b8c7c1..6b8f362c2423 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -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> = OnceCell::const_new(); @@ -39,6 +40,7 @@ pub struct Session { pub accumulated_output_tokens: Option, pub schedule_id: Option, pub recipe: Option, + pub user_recipe_values: Option>, pub conversation: Option, pub message_count: usize, } @@ -56,6 +58,7 @@ pub struct SessionUpdateBuilder { accumulated_output_tokens: Option>, schedule_id: Option>, recipe: Option>, + user_recipe_values: Option>>, } #[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>, + ) -> 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 = row.try_get("recipe_json")?; let recipe = recipe_json.and_then(|json| serde_json::from_str(&json).ok()); + let user_recipe_values_json: Option = 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::("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 diff --git a/crates/goose/tests/test_support.rs b/crates/goose/tests/test_support.rs index c84ecd3e8eb1..d156b8459f29 100644 --- a/crates/goose/tests/test_support.rs +++ b/crates/goose/tests/test_support.rs @@ -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, } } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 7dd85aa7fdf6..2265633433e1 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -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": [ diff --git a/ui/desktop/src/App.test.tsx b/ui/desktop/src/App.test.tsx index ecc30beed8e8..6889f4155883 100644 --- a/ui/desktop/src/App.test.tsx +++ b/ui/desktop/src/App.test.tsx @@ -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(), diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 2a0dbd4a6d03..b6c2475f02f2 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -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 ; + return ; }; const SchedulesRoute = () => { @@ -136,30 +133,6 @@ const RecipesRoute = () => { return ; }; -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 ; -}; - 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() { } /> } /> } /> - } /> = ClientOptions & { @@ -489,6 +489,17 @@ export const updateSessionDescription = (o }); }; +export const updateSessionUserRecipeValues = (options: Options) => { + return (options.client ?? _heyApiClient).put({ + url: '/sessions/{session_id}/user_recipe_values', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + export const status = (options?: Options) => { return (options?.client ?? _heyApiClient).get({ url: '/status', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index cd9bb840fc9d..3ec84d9ed92c 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -120,12 +120,8 @@ export type CreateCustomProviderRequest = { }; export type CreateRecipeRequest = { - activities?: Array | null; author?: AuthorRequest | null; - description: string; - messages: Array; 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; diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index caf710fbd4c8..07231583ab38 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -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 && } - {/* 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 && (
{ 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 && (
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 || {}} />
@@ -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({
- ) : !recipeConfig && showPopularTopics ? ( + ) : !recipe && showPopularTopics ? ( /* Show PopularChatTopics when no messages, no recipe, and showPopularTopics is true (Pair view) */ 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 && ( -
-
-

Recipe Creation Failed

-

{recipeError}

-
- -
-
-
- )} - - {/* No modals needed for the new simplified context manager */} + {/* Create Recipe from Session Modal */} + setIsCreateRecipeModalOpen(false)} + sessionId={chat.sessionId} + onRecipeCreated={handleRecipeCreated} + />
); } diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 2ed48b1d09e5..9d2d34722a39 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -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} />
diff --git a/ui/desktop/src/components/LayingEggLoader.tsx b/ui/desktop/src/components/LayingEggLoader.tsx deleted file mode 100644 index 7be31c98eee7..000000000000 --- a/ui/desktop/src/components/LayingEggLoader.tsx +++ /dev/null @@ -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 ( -
-
-
- -
-

- Laying an egg{dots} -

-

- Please wait while we process your request -

-
-
- ); -} diff --git a/ui/desktop/src/components/Layout/AppLayout.tsx b/ui/desktop/src/components/Layout/AppLayout.tsx index 47c23c5178ff..8b23c57f6093 100644 --- a/ui/desktop/src/components/Layout/AppLayout.tsx +++ b/ui/desktop/src/components/Layout/AppLayout.tsx @@ -57,9 +57,6 @@ const AppLayoutContent: React.FC = ({ setIsGoosehintsModalOpen } case 'sharedSession': navigate('/shared-session', { state: viewOptions }); break; - case 'recipeEditor': - navigate('/recipe-editor', { state: viewOptions }); - break; case 'welcome': navigate('/welcome'); break; diff --git a/ui/desktop/src/components/pair.tsx b/ui/desktop/src/components/pair.tsx index d95486fe7cb0..44ee659141fe 100644 --- a/ui/desktop/src/components/pair.tsx +++ b/ui/desktop/src/components/pair.tsx @@ -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: diff --git a/ui/desktop/src/components/parameter/ParameterInput.tsx b/ui/desktop/src/components/parameter/ParameterInput.tsx index 7ed1decfe6ff..3caba6d41fc3 100644 --- a/ui/desktop/src/components/parameter/ParameterInput.tsx +++ b/ui/desktop/src/components/parameter/ParameterInput.tsx @@ -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) => void; + onDelete?: (parameterKey: string) => void; + isUnused?: boolean; + isExpanded?: boolean; + onToggleExpanded?: (parameterKey: string) => void; } -const ParameterInput: React.FC = ({ parameter, onChange }) => { - // All values are derived directly from props, maintaining the controlled component pattern +const ParameterInput: React.FC = ({ + parameter, + onChange, + onDelete, + isUnused = false, + isExpanded = true, + onToggleExpanded, +}) => { const { key, description, requirement } = parameter; const defaultValue = parameter.default || ''; - return ( -
-

- Parameter:{' '} - {parameter.key} -

+ 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); + } + }; -
- - 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"`} - /> -

This is the message the end-user will see.

-
+ return ( +
+ {/* Collapsed header - always visible */} +
+
+ {onToggleExpanded && ( + + )} - {/* Controls for requirement, input type, and default value */} -
-
- - +
+ + {parameter.key} + + {isUnused && ( +
+ + Unused +
+ )} +
-
- - -
- - {/* The default value input is only shown for optional parameters */} - {requirement === 'optional' && ( -
- - onChange(key, { default: e.target.value })} - className="w-full p-3 border rounded-lg bg-background-default text-textStandard" - placeholder="Enter default value" - /> -
+ + )}
- {/* Options field for select input type */} - {parameter.input_type === 'select' && ( -
- -