fix: revert the change for unexpected change from ui/goose2 work (#9098)

This commit is contained in:
Lifei Zhou
2026-05-08 19:19:15 +10:00
committed by GitHub
parent c822d1ed82
commit 2db5e58ec7
21 changed files with 207 additions and 201 deletions
+7
View File
@@ -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
}
@@ -36,7 +36,7 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
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<HashMap<&'static str, PlatformExtensionDef>
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<HashMap<&'static str, PlatformExtensionDef>
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<HashMap<&'static str, PlatformExtensionDef>
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()),
+2 -4
View File
@@ -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
-23
View File
@@ -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);
}
}
+1 -108
View File
@@ -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]
+5 -5
View File
@@ -244,7 +244,7 @@ pub async fn run_config_mcp<C: Connection>() {
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<C: Connection>() {
pub async fn run_fs_read_text_file_true<C: Connection>() {
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<C: Connection>() {
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<C: Connection>(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<C: Connection>() {
.await;
let config = TestConnectionConfig {
builtins: vec!["summon".to_string(), "skills".to_string()],
builtins: vec!["summon".to_string()],
cwd: Some(cwd),
..Default::default()
};
@@ -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(
+3
View File
@@ -5211,6 +5211,9 @@
},
{
"type": "object",
"required": [
"enabled"
],
"properties": {
"enabled": {
"type": "boolean"
+1 -1
View File
@@ -465,7 +465,7 @@ export type ExtensionData = {
};
export type ExtensionEntry = ExtensionConfig & {
enabled?: boolean;
enabled: boolean;
};
export type ExtensionLoadResult = {
+4 -12
View File
@@ -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<ConfigProviderProps> = ({ 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<ConfigProviderProps> = ({ 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<ConfigProviderProps> = ({ 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 || []);
@@ -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',
@@ -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<boolean | void>;
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({
<div className="">
<ExtensionList
extensions={extensions}
onToggle={handleExtensionToggle}
onConfigure={handleConfigureClick}
disableConfiguration={disableConfiguration}
searchTerm={searchTerm}
@@ -238,7 +267,7 @@ export default function ExtensionsSection({
title={intl.formatMessage(i18n.addCustomExtension)}
initialData={extensionToFormData({
...deepLinkConfig,
enabled: false,
enabled: true,
} as FixedExtensionEntry)}
onClose={handleModalClose}
onSubmit={handleAddExtension}
@@ -9,7 +9,7 @@ vi.mock('./bundled-extensions.json', () => ({
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
);
});
});
@@ -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
@@ -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<void>;
}
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<void>;
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);
@@ -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';
@@ -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<boolean | void> | 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
<div className="flex items-center justify-end gap-2">
{editable && (
<button
type="button"
className="text-text-secondary hover:text-text-primary"
aria-label={intl.formatMessage(i18n.configureExtension, {
name: getFriendlyTitle(extension),
@@ -61,6 +105,15 @@ export default function ExtensionItem({ extension, onConfigure, isStatic }: Exte
<Gear className="w-4 h-4" />
</button>
)}
<Switch
checked={(isToggling && visuallyEnabled) || extension.enabled}
onCheckedChange={() => handleToggle(extension)}
disabled={isToggling}
variant="mono"
aria-label={intl.formatMessage(i18n.toggleExtension, {
name: getFriendlyTitle(extension),
})}
/>
</div>
</CardAction>
</CardHeader>
@@ -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<boolean | void> | 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 (
<div className="space-y-8">
@@ -83,7 +82,8 @@ export default function ExtensionList({
<ExtensionItem
key={extension.name}
extension={extension}
onConfigure={configureHandler}
onToggle={onToggle}
onConfigure={onConfigure}
isStatic={isStatic}
/>
))}
@@ -104,7 +104,8 @@ export default function ExtensionList({
<ExtensionItem
key={extension.name}
extension={extension}
onConfigure={configureHandler}
onToggle={onToggle}
onConfigure={onConfigure}
isStatic={isStatic}
/>
))}
@@ -112,7 +113,7 @@ export default function ExtensionList({
</div>
)}
{!hasVisibleExtensions && (
{extensions.length === 0 && (
<div className="text-center text-text-secondary py-8">
{intl.formatMessage(i18n.noExtensions)}
</div>
@@ -39,7 +39,7 @@ describe('Extension Utils', () => {
type: 'stdio',
cmd: '',
endpoint: '',
enabled: false,
enabled: true,
timeout: 300,
envVars: [],
headers: [],
@@ -47,7 +47,7 @@ export function getDefaultFormData(): ExtensionFormData {
type: 'stdio',
cmd: '',
endpoint: '',
enabled: false,
enabled: true,
timeout: 300,
envVars: [],
headers: [],
+6 -3
View File
@@ -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."