diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 0fa5cc475e26..5c8d6c6068b7 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -80,6 +80,7 @@ pub async fn handle_configure() -> Result<(), Box> { display_name: Some(goose::config::DEFAULT_DISPLAY_NAME.to_string()), timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), bundled: Some(true), + description: None, }, })?; } @@ -558,6 +559,7 @@ pub fn configure_extensions_dialog() -> Result<(), Box> { display_name: Some(display_name), timeout: Some(timeout), bundled: Some(true), + description: None, }, })?; diff --git a/crates/goose-cli/src/recipes/extract_from_cli.rs b/crates/goose-cli/src/recipes/extract_from_cli.rs index 3ac4ff522129..dae452142ea1 100644 --- a/crates/goose-cli/src/recipes/extract_from_cli.rs +++ b/crates/goose-cli/src/recipes/extract_from_cli.rs @@ -33,6 +33,7 @@ pub fn extract_recipe_info_from_cli( name, values: None, sequential_when_repeated: true, + description: None, }; all_sub_recipes.push(additional_sub_recipe); } diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 1635a65d919e..b2cf9b932810 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -306,6 +306,7 @@ impl Session { // TODO: should set a timeout timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), bundled: None, + description: None, }; self.agent .add_extension(config) diff --git a/crates/goose-server/src/bin/generate_schema.rs b/crates/goose-server/src/bin/generate_schema.rs index 8d2588c00659..18de38e07695 100644 --- a/crates/goose-server/src/bin/generate_schema.rs +++ b/crates/goose-server/src/bin/generate_schema.rs @@ -19,9 +19,12 @@ fn main() { fs::create_dir_all(parent).unwrap(); } - fs::write(&output_path, schema).unwrap(); - println!( + fs::write(&output_path, &schema).unwrap(); + eprintln!( "Successfully generated OpenAPI schema at {}", output_path.display() ); + + // Output the schema to stdout for piping + println!("{}", schema); } diff --git a/crates/goose-server/src/routes/extension.rs b/crates/goose-server/src/routes/extension.rs index 64a941c167c2..d0ddb7ccf3db 100644 --- a/crates/goose-server/src/routes/extension.rs +++ b/crates/goose-server/src/routes/extension.rs @@ -251,6 +251,7 @@ async fn add_extension( display_name, timeout, bundled: None, + description: None, }, ExtensionConfigRequest::Frontend { name, diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index 545c4c222743..93d9ff99fefe 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -163,6 +163,7 @@ pub enum ExtensionConfig { /// The name used to identify this extension name: String, display_name: Option, // needed for the UI + description: Option, timeout: Option, /// Whether this extension is bundled with Goose #[serde(default)] @@ -208,6 +209,7 @@ impl Default for ExtensionConfig { Self::Builtin { name: config::DEFAULT_EXTENSION.to_string(), display_name: Some(config::DEFAULT_DISPLAY_NAME.to_string()), + description: None, timeout: Some(config::DEFAULT_EXTENSION_TIMEOUT), bundled: Some(true), } diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 6adac4009bb4..8976a92489ea 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -242,6 +242,7 @@ impl ExtensionManager { ExtensionConfig::Builtin { name, display_name: _, + description: _, timeout, bundled: _, } => { diff --git a/crates/goose/src/config/extensions.rs b/crates/goose/src/config/extensions.rs index 9bf964acccfe..7a075714c5e4 100644 --- a/crates/goose/src/config/extensions.rs +++ b/crates/goose/src/config/extensions.rs @@ -46,6 +46,7 @@ impl ExtensionConfigManager { display_name: Some(DEFAULT_DISPLAY_NAME.to_string()), timeout: Some(DEFAULT_EXTENSION_TIMEOUT), bundled: Some(true), + description: Some(DEFAULT_EXTENSION_DESCRIPTION.to_string()), }, }, )]); diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 80b0cf903d6b..63cd4120064c 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -151,6 +151,8 @@ pub struct SubRecipe { pub values: Option>, #[serde(default)] pub sequential_when_repeated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, } fn deserialize_value_map_as_string<'de, D>( @@ -204,6 +206,7 @@ pub enum RecipeParameterInputType { Boolean, Date, File, + Select, } impl fmt::Display for RecipeParameterInputType { @@ -224,6 +227,8 @@ pub struct RecipeParameter { pub description: String, #[serde(skip_serializing_if = "Option::is_none")] pub default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option>, } /// Builder for creating Recipe instances diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 971f05dbb766..890722390495 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1527,6 +1527,10 @@ "description": "Whether this extension is bundled with Goose", "nullable": true }, + "description": { + "type": "string", + "nullable": true + }, "display_name": { "type": "string", "nullable": true @@ -2297,6 +2301,13 @@ "key": { "type": "string" }, + "options": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, "requirement": { "$ref": "#/components/schemas/RecipeParameterRequirement" } @@ -2309,7 +2320,8 @@ "number", "boolean", "date", - "file" + "file", + "select" ] }, "RecipeParameterRequirement": { @@ -2717,6 +2729,10 @@ "path" ], "properties": { + "description": { + "type": "string", + "nullable": true + }, "name": { "type": "string" }, diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index e4b2881a9806..0784cb457f63 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -15,7 +15,8 @@ import AnnouncementModal from './components/AnnouncementModal'; import { generateSessionId } from './sessions'; import ProviderGuard from './components/ProviderGuard'; -import Hub, { type ChatType } from './components/hub'; +import { ChatType } from './types/chat'; +import Hub from './components/hub'; import Pair from './components/pair'; import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView'; import SessionsView from './components/sessions/SessionsView'; @@ -182,6 +183,12 @@ const PairRouteWrapper = ({ // Check if we have a resumed session or recipe config from navigation state useEffect(() => { + // Only process if we actually have navigation state + if (!location.state) { + console.log('No navigation state, preserving existing chat state'); + return; + } + const resumedSession = location.state?.resumedSession as SessionDetails | undefined; const recipeConfig = location.state?.recipeConfig as Recipe | undefined; const resetChat = location.state?.resetChat as boolean | undefined; @@ -205,22 +212,32 @@ const PairRouteWrapper = ({ // Clear the navigation state to prevent reloading on navigation window.history.replaceState({}, document.title); - } else if (recipeConfig) { - console.log('Loading recipe config in pair view:', recipeConfig.title); + } else if (recipeConfig && resetChat) { + console.log('Loading new recipe config in pair view:', recipeConfig.title); - // Load recipe config and optionally reset chat - // Use the ref to get the current chat state without adding it as a dependency - const currentChat = chatRef.current; const updatedChat: ChatType = { - ...currentChat, - recipeConfig: recipeConfig, + id: chatRef.current.id, // Keep the same ID title: recipeConfig.title || 'Recipe Chat', + messages: [], // Clear messages to start fresh + messageHistoryIndex: 0, + recipeConfig: recipeConfig, + recipeParameters: null, // Clear parameters for new recipe }; - if (resetChat) { - updatedChat.messages = []; - updatedChat.messageHistoryIndex = 0; - } + // Update both the local chat state and the app-level pairChat state + setChat(updatedChat); + setPairChat(updatedChat); + + // Clear the navigation state to prevent reloading on navigation + window.history.replaceState({}, document.title); + } else if (recipeConfig && !chatRef.current.recipeConfig) { + // Only set recipe config if we don't already have one (e.g., from deeplinks) + + const updatedChat: ChatType = { + ...chatRef.current, + recipeConfig: recipeConfig, + title: recipeConfig.title || chatRef.current.title, + }; // Update both the local chat state and the app-level pairChat state setChat(updatedChat); @@ -228,7 +245,14 @@ const PairRouteWrapper = ({ // Clear the navigation state to prevent reloading on navigation window.history.replaceState({}, document.title); + } else if (location.state) { + // We have navigation state but it doesn't match our conditions + // Clear it to prevent future processing, but don't modify chat state + console.log('Clearing unprocessed navigation state'); + window.history.replaceState({}, document.title); } + // If we have a recipe config but resetChat is false and we already have a recipe, + // do nothing - just continue with the existing chat state }, [location.state, setChat, setPairChat]); return ( diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 53230d066b87..908d9264f350 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -165,6 +165,7 @@ export type ExtensionConfig = { * Whether this extension is bundled with Goose */ bundled?: boolean | null; + description?: string | null; display_name?: string | null; /** * The name used to identify this extension @@ -451,10 +452,11 @@ export type RecipeParameter = { description: string; input_type: RecipeParameterInputType; key: string; + options?: Array | null; requirement: RecipeParameterRequirement; }; -export type RecipeParameterInputType = 'string' | 'number' | 'boolean' | 'date' | 'file'; +export type RecipeParameterInputType = 'string' | 'number' | 'boolean' | 'date' | 'file' | 'select'; export type RecipeParameterRequirement = 'required' | 'optional' | 'user_prompt'; @@ -622,6 +624,7 @@ export type Settings = { }; export type SubRecipe = { + description?: string | null; name: string; path: string; sequential_when_repeated?: boolean; diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index c20c29de3884..eb4d093b8b74 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -67,19 +67,12 @@ import { useSessionContinuation } from '../hooks/useSessionContinuation'; import { useFileDrop } from '../hooks/useFileDrop'; import { useCostTracking } from '../hooks/useCostTracking'; import { Message } from '../types/message'; -import { Recipe } from '../recipe'; // Context for sharing current model info const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null); export const useCurrentModelInfo = () => useContext(CurrentModelContext); -export interface ChatType { - id: string; - title: string; - messageHistoryIndex: number; - messages: Message[]; - recipeConfig?: Recipe | null; // Add recipe configuration to chat state -} +import { ChatType } from '../types/chat'; interface BaseChatProps { chat: ChatType; @@ -204,17 +197,29 @@ function BaseChatContent({ // Reset recipe usage tracking when recipe changes useEffect(() => { - if (recipeConfig?.title !== currentRecipeTitle) { - setCurrentRecipeTitle(recipeConfig?.title || null); - setHasStartedUsingRecipe(false); + const previousTitle = currentRecipeTitle; + const newTitle = recipeConfig?.title || null; + const hasRecipeChanged = newTitle !== currentRecipeTitle; + + if (hasRecipeChanged) { + setCurrentRecipeTitle(newTitle); + + const isSwitchingBetweenRecipes = previousTitle && newTitle; + const isInitialRecipeLoad = !previousTitle && newTitle && messages.length === 0; + const hasExistingConversation = newTitle && messages.length > 0; - // Clear existing messages when a new recipe is loaded - if (recipeConfig?.title && recipeConfig.title !== currentRecipeTitle) { + if (isSwitchingBetweenRecipes) { + console.log('Switching from recipe:', previousTitle, 'to:', newTitle); + setHasStartedUsingRecipe(false); setMessages([]); setAncestorMessages([]); + } else if (isInitialRecipeLoad) { + setHasStartedUsingRecipe(false); + } else if (hasExistingConversation) { + setHasStartedUsingRecipe(true); } } - }, [recipeConfig?.title, currentRecipeTitle, setMessages, setAncestorMessages]); + }, [recipeConfig?.title, currentRecipeTitle, messages.length, setMessages, setAncestorMessages]); // Handle recipe auto-execution useEffect(() => { @@ -427,29 +432,6 @@ function BaseChatContent({ {error.message || 'Honk! Goose experienced an error while responding'} - {/* Expandable Error Details */} -
- - Error details - -
-
- Error Type: {error.name || 'Unknown'} -
-
- Message: {error.message || 'No message'} -
- {error.stack && ( -
- Stack Trace: -
-                                    {error.stack}
-                                  
-
- )} -
-
- {/* Regular retry button for non-token-limit errors */}
-
- {/* Input row with inline action buttons */} -
-
-