diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 50c20c14..5a8d97da 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -811,6 +811,13 @@ impl Agent { let results = futures::future::join_all(extension_futures).await; + // Persist once after all extensions are loaded + if results.iter().any(|r| r.success) { + if let Err(e) = self.persist_extension_state(&session_id).await { + warn!("Failed to persist extension state after bulk load: {}", e); + } + } + results } diff --git a/crates/goose/src/agents/platform_extensions/mod.rs b/crates/goose/src/agents/platform_extensions/mod.rs index f4f5dbc9..c68d19ee 100644 --- a/crates/goose/src/agents/platform_extensions/mod.rs +++ b/crates/goose/src/agents/platform_extensions/mod.rs @@ -36,7 +36,7 @@ pub static PLATFORM_EXTENSIONS: Lazy display_name: "Analyze", description: "Analyze code structure with tree-sitter: directory overviews, file details, symbol call graphs", - default_enabled: false, + default_enabled: true, unprefixed_tools: true, hidden: false, client_factory: |ctx| Box::new(analyze::AnalyzeClient::new(ctx).unwrap()), @@ -64,7 +64,7 @@ pub static PLATFORM_EXTENSIONS: Lazy display_name: "Apps", description: "Create and manage custom Goose apps through chat. Apps are HTML/CSS/JavaScript and run in sandboxed windows.", - default_enabled: false, + default_enabled: true, unprefixed_tools: false, hidden: false, client_factory: |ctx| Box::new(apps::AppsManagerClient::new(ctx).unwrap()), @@ -105,7 +105,7 @@ pub static PLATFORM_EXTENSIONS: Lazy name: summon::EXTENSION_NAME, display_name: "Summon", description: "Load knowledge and delegate tasks to subagents", - default_enabled: false, + default_enabled: true, unprefixed_tools: true, hidden: false, client_factory: |ctx| Box::new(summon::SummonClient::new(ctx).unwrap()), @@ -182,7 +182,7 @@ pub static PLATFORM_EXTENSIONS: Lazy display_name: "Top Of Mind", description: "Inject custom context into every turn via GOOSE_MOIM_MESSAGE_TEXT and GOOSE_MOIM_MESSAGE_FILE environment variables", - default_enabled: false, + default_enabled: true, unprefixed_tools: false, hidden: false, client_factory: |ctx| Box::new(tom::TomClient::new(ctx).unwrap()), diff --git a/crates/goose/src/config/base.rs b/crates/goose/src/config/base.rs index e9f608ab..1c55fa2f 100644 --- a/crates/goose/src/config/base.rs +++ b/crates/goose/src/config/base.rs @@ -1994,12 +1994,10 @@ extensions: ) .unwrap(); - // User config (higher priority / write target) has already migrated, then disables - // developer and adds a new extension. + // User config (higher priority / write target) disables developer and adds a new extension std::fs::write( local_file.path(), r#" -extensions_on_demand_migration: true extensions: developer: enabled: false @@ -2024,7 +2022,7 @@ extensions: let values = config.load()?; let extensions = values.get("extensions").unwrap().as_mapping().unwrap(); - // developer should be disabled (user config overrides system after migration) + // developer should be disabled (user config overrides system) let dev = extensions.get("developer").unwrap().as_mapping().unwrap(); assert!(!dev.get("enabled").unwrap().as_bool().unwrap()); // Fields from the system config should be preserved via merge diff --git a/crates/goose/src/config/extensions.rs b/crates/goose/src/config/extensions.rs index 3b47f285..460a6e93 100644 --- a/crates/goose/src/config/extensions.rs +++ b/crates/goose/src/config/extensions.rs @@ -13,13 +13,8 @@ pub const DEFAULT_EXTENSION_DESCRIPTION: &str = ""; pub const DEFAULT_DISPLAY_NAME: &str = "Developer"; const EXTENSIONS_CONFIG_KEY: &str = "extensions"; -fn default_extension_enabled() -> bool { - true -} - #[derive(Debug, Deserialize, Serialize, Clone, ToSchema)] pub struct ExtensionEntry { - #[serde(default = "default_extension_enabled")] pub enabled: bool, #[serde(flatten)] pub config: ExtensionConfig, @@ -215,22 +210,4 @@ mod tests { assert!(!is_extension_available(&unknown_platform)); assert!(is_extension_available(&builtin)); } - - #[test] - fn test_extension_entry_defaults_missing_enabled_to_true() { - let value = serde_yaml::from_str( - r#" -type: stdio -name: github -description: GitHub tools -cmd: npx -args: [] -"#, - ) - .unwrap(); - - let entry: ExtensionEntry = serde_yaml::from_value(value).unwrap(); - - assert!(entry.enabled); - } } diff --git a/crates/goose/src/config/migrations.rs b/crates/goose/src/config/migrations.rs index 1db4cb94..d6f20e91 100644 --- a/crates/goose/src/config/migrations.rs +++ b/crates/goose/src/config/migrations.rs @@ -114,18 +114,10 @@ mod tests { assert!(changed); let extensions_key = serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string()); assert!(config.contains_key(&extensions_key)); - - let extensions = config.get(&extensions_key).unwrap().as_mapping().unwrap(); - for (key, value) in extensions { - let key = key.as_str().unwrap(); - let def = PLATFORM_EXTENSIONS.get(key).unwrap(); - let entry: ExtensionEntry = serde_yaml::from_value(value.clone()).unwrap(); - assert_eq!(entry.enabled, def.default_enabled); - } } #[test] - fn test_migrate_platform_extensions_refreshes_metadata_without_changing_enabled() { + fn test_migrate_platform_extensions_preserves_enabled_state() { let mut config = Mapping::new(); let mut extensions = Mapping::new(); let todo_entry = ExtensionEntry { @@ -157,105 +149,6 @@ mod tests { let todo_entry: ExtensionEntry = serde_yaml::from_value(todo_value.clone()).unwrap(); assert!(!todo_entry.enabled); - match todo_entry.config { - ExtensionConfig::Platform { - description, - display_name, - .. - } => { - assert_ne!(description, "old description"); - assert_ne!(display_name.as_deref(), Some("Old Name")); - } - other => panic!("expected platform extension, got {other:?}"), - } - } - - #[test] - fn test_migrate_platform_extensions_preserves_existing_enabled_values() { - let mut config = Mapping::new(); - let mut extensions = Mapping::new(); - - let analyze_entry = ExtensionEntry { - config: ExtensionConfig::Platform { - name: "analyze".to_string(), - description: "Analyze code structure".to_string(), - display_name: Some("Analyze".to_string()), - bundled: Some(true), - available_tools: Vec::new(), - }, - enabled: true, - }; - let developer_entry = ExtensionEntry { - config: ExtensionConfig::Platform { - name: "developer".to_string(), - description: "Write and edit files, and execute shell commands".to_string(), - display_name: Some("Developer".to_string()), - bundled: Some(true), - available_tools: Vec::new(), - }, - enabled: false, - }; - let custom_developer_entry = ExtensionEntry { - config: ExtensionConfig::Stdio { - name: "developer".to_string(), - description: "Custom user developer tools".to_string(), - cmd: "custom-developer".to_string(), - args: Vec::new(), - envs: Default::default(), - env_keys: Vec::new(), - timeout: Some(300), - bundled: None, - available_tools: Vec::new(), - }, - enabled: true, - }; - - extensions.insert( - serde_yaml::Value::String("analyze".to_string()), - serde_yaml::to_value(&analyze_entry).unwrap(), - ); - extensions.insert( - serde_yaml::Value::String("developer".to_string()), - serde_yaml::to_value(&developer_entry).unwrap(), - ); - extensions.insert( - serde_yaml::Value::String("custom-developer".to_string()), - serde_yaml::to_value(&custom_developer_entry).unwrap(), - ); - config.insert( - serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string()), - serde_yaml::Value::Mapping(extensions), - ); - - assert!(run_migrations(&mut config)); - - let extensions_key = serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string()); - let extensions = config.get(&extensions_key).unwrap().as_mapping().unwrap(); - let analyze: ExtensionEntry = serde_yaml::from_value( - extensions - .get(serde_yaml::Value::String("analyze".to_string())) - .unwrap() - .clone(), - ) - .unwrap(); - let developer: ExtensionEntry = serde_yaml::from_value( - extensions - .get(serde_yaml::Value::String("developer".to_string())) - .unwrap() - .clone(), - ) - .unwrap(); - let custom_developer: ExtensionEntry = serde_yaml::from_value( - extensions - .get(serde_yaml::Value::String("custom-developer".to_string())) - .unwrap() - .clone(), - ) - .unwrap(); - - assert!(analyze.enabled); - assert!(!developer.enabled); - assert!(custom_developer.enabled); } #[test] diff --git a/crates/goose/tests/acp_common_tests/mod.rs b/crates/goose/tests/acp_common_tests/mod.rs index 88a5c904..203dac46 100644 --- a/crates/goose/tests/acp_common_tests/mod.rs +++ b/crates/goose/tests/acp_common_tests/mod.rs @@ -244,7 +244,7 @@ pub async fn run_config_mcp() { let mcp = McpFixture::new(expected_session_id.clone()).await; let config_yaml = format!( - "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions_on_demand_migration: true\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n", + "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n", mcp.url ); fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap(); @@ -294,7 +294,7 @@ pub async fn run_config_mcp() { pub async fn run_fs_read_text_file_true() { let temp_dir = tempfile::tempdir().unwrap(); let config_yaml = format!( - "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions_on_demand_migration: true\nextensions:\n developer:\n enabled: true\n type: platform\n name: developer\n description: Developer\n display_name: Developer\n bundled: true\n available_tools: []\n" + "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n developer:\n enabled: true\n type: platform\n name: developer\n description: Developer\n display_name: Developer\n bundled: true\n available_tools: []\n" ); fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap(); @@ -463,7 +463,7 @@ pub async fn run_load_mode() { let mcp = McpFixture::new(expected_session_id.clone()).await; let config_yaml = format!( - "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions_on_demand_migration: true\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n", + "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n", mcp.url ); fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap(); @@ -703,7 +703,7 @@ async fn run_mode_set_impl(via: SetModeVia) { let mcp = McpFixture::new(expected_session_id.clone()).await; let config_yaml = format!( - "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions_on_demand_migration: true\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n", + "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n", mcp.url ); fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap(); @@ -1305,7 +1305,7 @@ pub async fn run_prompt_skill() { .await; let config = TestConnectionConfig { - builtins: vec!["summon".to_string(), "skills".to_string()], + builtins: vec!["summon".to_string()], cwd: Some(cwd), ..Default::default() }; diff --git a/crates/goose/tests/acp_custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs index 4478545f..de0117a2 100644 --- a/crates/goose/tests/acp_custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -523,7 +523,6 @@ fn test_developer_fs_requests_use_acp_session_id() { // gpt-5-nano routes to the Responses API; use a Chat Completions // model so the canned SSE fixtures are parsed correctly. current_model: "gpt-4.1".to_string(), - builtins: vec!["developer".to_string()], read_text_file: Some(Arc::new(move |req| { *seen_session_id_clone.lock().unwrap() = Some(req.session_id.0.to_string()); Ok(agent_client_protocol::schema::ReadTextFileResponse::new( diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 558f48f6..18a3e5d8 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -5211,6 +5211,9 @@ }, { "type": "object", + "required": [ + "enabled" + ], "properties": { "enabled": { "type": "boolean" diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 666a7088..3ac35db8 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -465,7 +465,7 @@ export type ExtensionData = { }; export type ExtensionEntry = ExtensionConfig & { - enabled?: boolean; + enabled: boolean; }; export type ExtensionLoadResult = { diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index 134e3faa..434cad8c 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -18,7 +18,6 @@ import type { ProviderDetails, ExtensionQuery, ExtensionConfig, - ExtensionEntry, } from '../api'; export type { ExtensionConfig } from '../api/types.gen'; @@ -28,12 +27,6 @@ export type FixedExtensionEntry = ExtensionConfig & { enabled: boolean; }; -const normalizeExtensions = (extensions: ExtensionEntry[]): FixedExtensionEntry[] => - extensions.map((extension) => ({ - ...extension, - enabled: extension.enabled ?? true, - })); - interface ConfigContextType { config: ConfigResponse['config']; providersList: ProviderDetails[]; @@ -133,10 +126,9 @@ export const ConfigProvider: React.FC = ({ children }) => { } const extensionResponse: ExtensionResponse = result.data!; - const extensions = normalizeExtensions(extensionResponse.extensions); - setExtensionsList(extensions); + setExtensionsList(extensionResponse.extensions); setExtensionWarnings(extensionResponse.warnings || []); - return extensions; + return extensionResponse.extensions; }, [extensionsList]); const addExtension = useCallback( @@ -221,7 +213,7 @@ export const ConfigProvider: React.FC = ({ children }) => { // Load extensions try { const extensionsResponse = await apiGetExtensions(); - let extensions = normalizeExtensions(extensionsResponse.data?.extensions || []); + let extensions = extensionsResponse.data?.extensions || []; // Always sync bundled extensions from bundled-extensions.json // This ensures: @@ -244,7 +236,7 @@ export const ConfigProvider: React.FC = ({ children }) => { await syncBundledExtensions(extensions, addExtensionForSync); // Reload extensions after sync const refreshedResponse = await apiGetExtensions(); - extensions = normalizeExtensions(refreshedResponse.data?.extensions || []); + extensions = refreshedResponse.data?.extensions || []; setExtensionsList(extensions); setExtensionWarnings(extensionsResponse.data?.warnings || []); diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx index 3f34dfbb..b1606a5c 100644 --- a/ui/desktop/src/components/extensions/ExtensionsView.tsx +++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx @@ -32,7 +32,7 @@ const i18n = defineMessages({ defaultNote: { id: 'extensionsView.defaultNote', defaultMessage: - 'Extensions stay available here, and Goose can load them on demand during a chat.', + 'Extensions enabled here are used as the default for new chats. You can also toggle active extensions during chat.', }, addCustomExtension: { id: 'extensionsView.addCustomExtension', diff --git a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx index 981cb0ed..6c5606f0 100644 --- a/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx @@ -13,7 +13,7 @@ import { getDefaultFormData, } from './utils'; -import { activateExtensionDefault, deleteExtension } from './index'; +import { activateExtensionDefault, deleteExtension, toggleExtensionDefault } from './index'; import { ExtensionConfig } from '../../../api/types.gen'; const i18n = defineMessages({ @@ -44,6 +44,8 @@ interface ExtensionSectionProps { showEnvVars?: boolean; hideButtons?: boolean; disableConfiguration?: boolean; + customToggle?: (extension: FixedExtensionEntry) => Promise; + selectedExtensions?: string[]; // Add controlled state onModalClose?: (extensionName: string) => void; searchTerm?: string; } @@ -53,6 +55,8 @@ export default function ExtensionsSection({ showEnvVars, hideButtons, disableConfiguration, + customToggle, + selectedExtensions = [], onModalClose, searchTerm = '', }: ExtensionSectionProps) { @@ -76,26 +80,50 @@ export default function ExtensionsSection({ const extensions = useMemo(() => { if (extensionsList.length === 0) return []; - return [...extensionsList].sort((a, b) => { - // First sort by builtin - if (a.type === 'builtin' && b.type !== 'builtin') return -1; - if (a.type !== 'builtin' && b.type === 'builtin') return 1; + return [...extensionsList] + .sort((a, b) => { + // First sort by builtin + if (a.type === 'builtin' && b.type !== 'builtin') return -1; + if (a.type !== 'builtin' && b.type === 'builtin') return 1; - // Then sort by bundled (handle null/undefined cases) - const aBundled = 'bundled' in a && a.bundled === true; - const bBundled = 'bundled' in b && b.bundled === true; - if (aBundled && !bBundled) return -1; - if (!aBundled && bBundled) return 1; + // Then sort by bundled (handle null/undefined cases) + const aBundled = 'bundled' in a && a.bundled === true; + const bBundled = 'bundled' in b && b.bundled === true; + if (aBundled && !bBundled) return -1; + if (!aBundled && bBundled) return 1; - // Finally sort alphabetically within each group - return a.name.localeCompare(b.name); - }); - }, [extensionsList]); + // Finally sort alphabetically within each group + return a.name.localeCompare(b.name); + }) + .map((ext) => ({ + ...ext, + // Use selectedExtensions to determine enabled state in recipe editor + enabled: disableConfiguration ? selectedExtensions.includes(ext.name) : ext.enabled, + })); + }, [extensionsList, disableConfiguration, selectedExtensions]); const fetchExtensions = useCallback(async () => { await getExtensions(true); // Force refresh - this will update the context }, [getExtensions]); + const handleExtensionToggle = async (extensionConfig: FixedExtensionEntry) => { + if (customToggle) { + await customToggle(extensionConfig); + return true; + } + + const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn'; + + await toggleExtensionDefault({ + toggle: toggleDirection, + extensionConfig: extensionConfig, + addToConfig: addExtension, + }); + + await fetchExtensions(); + return true; + }; + const handleConfigureClick = (extension: FixedExtensionEntry) => { setSelectedExtension(extension); setIsModalOpen(true); @@ -181,6 +209,7 @@ export default function ExtensionsSection({
({ name: 'developer', display_name: 'Developer', description: 'General development tools.', - enabled: false, + enabled: true, type: 'builtin', timeout: 300, }, @@ -18,7 +18,7 @@ vi.mock('./bundled-extensions.json', () => ({ name: 'googledrive', display_name: 'Google Drive', description: 'Google Drive integration.', - enabled: false, + enabled: true, type: 'stdio', cmd: 'googledrive-mcp', args: [], @@ -130,7 +130,7 @@ describe('pruneDeprecatedBundledExtensions', () => { name: 'googledrive', bundled: true, }), - false + true ); }); }); diff --git a/ui/desktop/src/components/settings/extensions/deeplink.ts b/ui/desktop/src/components/settings/extensions/deeplink.ts index dd1d8702..f8d59b40 100644 --- a/ui/desktop/src/components/settings/extensions/deeplink.ts +++ b/ui/desktop/src/components/settings/extensions/deeplink.ts @@ -178,14 +178,15 @@ export async function addExtensionFromDeepLink( return; } - // Note: deeplink activation doesn't have access to sessionId, so the extension - // is saved for on-demand use instead of being globally enabled. - await addExtensionFn(config.name, config, false); + // Note: deeplink activation doesn't have access to sessionId + // The extension will be added to config but not activated in the current session + // It will be activated when the next session starts + await addExtensionFn(config.name, config, true); // Show success toast and navigate to extensions page toastService.success({ title: 'Extension Installed', - msg: `${config.name} extension has been installed successfully and is available on demand.`, + msg: `${config.name} extension has been installed successfully. Start a new chat session to use it.`, }); // Navigate to extensions page to show the newly installed extension diff --git a/ui/desktop/src/components/settings/extensions/extension-manager.ts b/ui/desktop/src/components/settings/extensions/extension-manager.ts index f25d6f0c..bf5c501e 100644 --- a/ui/desktop/src/components/settings/extensions/extension-manager.ts +++ b/ui/desktop/src/components/settings/extensions/extension-manager.ts @@ -1,6 +1,12 @@ import type { ExtensionConfig } from '../../../api/types.gen'; import { toastService } from '../../../toasts'; -import { trackExtensionAdded, trackExtensionDeleted, getErrorType } from '../../../utils/analytics'; +import { + trackExtensionAdded, + trackExtensionEnabled, + trackExtensionDisabled, + trackExtensionDeleted, + getErrorType, +} from '../../../utils/analytics'; function isBuiltinExtension(config: ExtensionConfig): boolean { return config.type === 'builtin'; @@ -32,6 +38,46 @@ export async function deleteExtension({ } } +interface ToggleExtensionDefaultProps { + toggle: 'toggleOn' | 'toggleOff'; + extensionConfig: ExtensionConfig; + addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; +} + +export async function toggleExtensionDefault({ + toggle, + extensionConfig, + addToConfig, +}: ToggleExtensionDefaultProps) { + const isBuiltin = isBuiltinExtension(extensionConfig); + const enabled = toggle === 'toggleOn'; + + try { + await addToConfig(extensionConfig.name, extensionConfig, enabled); + if (enabled) { + trackExtensionEnabled(extensionConfig.name, true, undefined, isBuiltin); + } else { + trackExtensionDisabled(extensionConfig.name, true, undefined, isBuiltin); + } + toastService.success({ + title: extensionConfig.name, + msg: enabled ? 'Extension enabled in defaults' : 'Extension removed from defaults', + }); + } catch (error) { + console.error('Failed to update extension default in config:', error); + if (enabled) { + trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin); + } else { + trackExtensionDisabled(extensionConfig.name, false, getErrorType(error), isBuiltin); + } + toastService.error({ + title: extensionConfig.name, + msg: 'Failed to update extension default', + }); + throw error; + } +} + interface ActivateExtensionDefaultProps { addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise; extensionConfig: ExtensionConfig; @@ -44,11 +90,11 @@ export async function activateExtensionDefault({ const isBuiltin = isBuiltinExtension(extensionConfig); try { - await addToConfig(extensionConfig.name, extensionConfig, false); + await addToConfig(extensionConfig.name, extensionConfig, true); trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin); toastService.success({ title: extensionConfig.name, - msg: 'Extension added', + msg: 'Extension added as default', }); } catch (error) { console.error('Failed to add extension to config:', error); diff --git a/ui/desktop/src/components/settings/extensions/index.ts b/ui/desktop/src/components/settings/extensions/index.ts index 8a18dd4a..12f806ef 100644 --- a/ui/desktop/src/components/settings/extensions/index.ts +++ b/ui/desktop/src/components/settings/extensions/index.ts @@ -1,6 +1,10 @@ export { DEFAULT_EXTENSION_TIMEOUT, nameToKey } from './utils'; -export { activateExtensionDefault, deleteExtension } from './extension-manager'; +export { + activateExtensionDefault, + toggleExtensionDefault, + deleteExtension, +} from './extension-manager'; export { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './bundled-extensions'; diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx index 192d6260..8c436083 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx @@ -1,4 +1,6 @@ +import { useState, useEffect } from 'react'; import kebabCase from 'lodash/kebabCase'; +import { Switch } from '../../../ui/switch'; import { Gear } from '../../../icons'; import { FixedExtensionEntry } from '../../../ConfigContext'; import { getSubtitle, getFriendlyTitle } from './ExtensionList'; @@ -10,16 +12,59 @@ const i18n = defineMessages({ id: 'extensionItem.configureExtension', defaultMessage: 'Configure {name} Extension', }, + toggleExtension: { + id: 'extensionItem.toggleExtension', + defaultMessage: 'Toggle {name} extension On or Off', + }, }); interface ExtensionItemProps { extension: FixedExtensionEntry; + onToggle: (extension: FixedExtensionEntry) => Promise | void; onConfigure?: (extension: FixedExtensionEntry) => void; isStatic?: boolean; // to not allow users to edit configuration } -export default function ExtensionItem({ extension, onConfigure, isStatic }: ExtensionItemProps) { +export default function ExtensionItem({ + extension, + onToggle, + onConfigure, + isStatic, +}: ExtensionItemProps) { const intl = useIntl(); + // Add local state to track the visual toggle state + const [visuallyEnabled, setVisuallyEnabled] = useState(extension.enabled); + // Track if we're in the process of toggling + const [isToggling, setIsToggling] = useState(false); + + const handleToggle = async (ext: FixedExtensionEntry) => { + // Prevent multiple toggles while one is in progress + if (isToggling) return; + + setIsToggling(true); + + // Immediately update visual state + const newState = !ext.enabled; + setVisuallyEnabled(newState); + + try { + // Call the actual toggle function that performs the async operation + await onToggle(ext); + // Success case is handled by the useEffect below when extension.enabled changes + } catch { + // If there was an error, revert the visual state + setVisuallyEnabled(!newState); + } finally { + setIsToggling(false); + } + }; + + // Update visual state when the actual extension state changes + useEffect(() => { + if (!isToggling) { + setVisuallyEnabled(extension.enabled); + } + }, [extension.enabled, isToggling]); const renderSubtitle = () => { const { description, command } = getSubtitle(extension); @@ -51,7 +96,6 @@ export default function ExtensionItem({ extension, onConfigure, isStatic }: Exte
{editable && ( )} + handleToggle(extension)} + disabled={isToggling} + variant="mono" + aria-label={intl.formatMessage(i18n.toggleExtension, { + name: getFriendlyTitle(extension), + })} + />
diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx index 800e5f5b..9cb15f25 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx @@ -8,11 +8,11 @@ import { defineMessages, useIntl } from '../../../../i18n'; const i18n = defineMessages({ defaultExtensions: { id: 'extensionList.defaultExtensions', - defaultMessage: 'Active by Default ({count})', + defaultMessage: 'Default Extensions ({count})', }, availableExtensions: { id: 'extensionList.availableExtensions', - defaultMessage: 'Available On Demand ({count})', + defaultMessage: 'Available Extensions ({count})', }, noExtensions: { id: 'extensionList.noExtensions', @@ -26,6 +26,7 @@ const i18n = defineMessages({ interface ExtensionListProps { extensions: FixedExtensionEntry[]; + onToggle: (extension: FixedExtensionEntry) => Promise | void; onConfigure?: (extension: FixedExtensionEntry) => void; isStatic?: boolean; disableConfiguration?: boolean; @@ -34,9 +35,10 @@ interface ExtensionListProps { export default function ExtensionList({ extensions, + onToggle, onConfigure, isStatic, - disableConfiguration, + disableConfiguration: _disableConfiguration, searchTerm = '', }: ExtensionListProps) { const matchesSearch = (extension: FixedExtensionEntry): boolean => { @@ -66,9 +68,6 @@ export default function ExtensionList({ const sortedDisabledExtensions = [...disabledExtensions].sort((a, b) => getFriendlyTitle(a).localeCompare(getFriendlyTitle(b)) ); - const configureHandler = disableConfiguration ? undefined : onConfigure; - const hasVisibleExtensions = - sortedEnabledExtensions.length > 0 || sortedDisabledExtensions.length > 0; return (
@@ -83,7 +82,8 @@ export default function ExtensionList({ ))} @@ -104,7 +104,8 @@ export default function ExtensionList({ ))} @@ -112,7 +113,7 @@ export default function ExtensionList({
)} - {!hasVisibleExtensions && ( + {extensions.length === 0 && (
{intl.formatMessage(i18n.noExtensions)}
diff --git a/ui/desktop/src/components/settings/extensions/utils.test.ts b/ui/desktop/src/components/settings/extensions/utils.test.ts index 8b271948..ab886aa1 100644 --- a/ui/desktop/src/components/settings/extensions/utils.test.ts +++ b/ui/desktop/src/components/settings/extensions/utils.test.ts @@ -39,7 +39,7 @@ describe('Extension Utils', () => { type: 'stdio', cmd: '', endpoint: '', - enabled: false, + enabled: true, timeout: 300, envVars: [], headers: [], diff --git a/ui/desktop/src/components/settings/extensions/utils.ts b/ui/desktop/src/components/settings/extensions/utils.ts index 77b3c79c..00e29c1f 100644 --- a/ui/desktop/src/components/settings/extensions/utils.ts +++ b/ui/desktop/src/components/settings/extensions/utils.ts @@ -47,7 +47,7 @@ export function getDefaultFormData(): ExtensionFormData { type: 'stdio', cmd: '', endpoint: '', - enabled: false, + enabled: true, timeout: 300, envVars: [], headers: [], diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index bc4378d8..fb8ae3b0 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -1061,14 +1061,17 @@ "extensionItem.configureExtension": { "defaultMessage": "Configure {name} Extension" }, + "extensionItem.toggleExtension": { + "defaultMessage": "Toggle {name} extension On or Off" + }, "extensionList.availableExtensions": { - "defaultMessage": "Available On Demand ({count})" + "defaultMessage": "Available Extensions ({count})" }, "extensionList.builtInExtension": { "defaultMessage": "Built-in extension" }, "extensionList.defaultExtensions": { - "defaultMessage": "Active by Default ({count})" + "defaultMessage": "Default Extensions ({count})" }, "extensionList.noExtensions": { "defaultMessage": "No extensions available" @@ -1128,7 +1131,7 @@ "defaultMessage": "Browse extensions" }, "extensionsView.defaultNote": { - "defaultMessage": "Extensions stay available here, and Goose can load them on demand during a chat." + "defaultMessage": "Extensions enabled here are used as the default for new chats. You can also toggle active extensions during chat." }, "extensionsView.description": { "defaultMessage": "These extensions use the Model Context Protocol (MCP). They can expand Goose's capabilities using three main components: Prompts, Resources, and Tools. {searchShortcut} to search."