Redesign Extensions page. Remove enable toggle in UI. Treat Extension Manager as core MCP enabler per session. (#8940)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-05-01 19:16:55 -07:00
committed by GitHub
parent c365e7b950
commit 45d8bf81d0
67 changed files with 2415 additions and 980 deletions
-3
View File
@@ -5211,9 +5211,6 @@
},
{
"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 = {
+12 -4
View File
@@ -18,6 +18,7 @@ import type {
ProviderDetails,
ExtensionQuery,
ExtensionConfig,
ExtensionEntry,
} from '../api';
export type { ExtensionConfig } from '../api/types.gen';
@@ -27,6 +28,12 @@ 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[];
@@ -126,9 +133,10 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
}
const extensionResponse: ExtensionResponse = result.data!;
setExtensionsList(extensionResponse.extensions);
const extensions = normalizeExtensions(extensionResponse.extensions);
setExtensionsList(extensions);
setExtensionWarnings(extensionResponse.warnings || []);
return extensionResponse.extensions;
return extensions;
}, [extensionsList]);
const addExtension = useCallback(
@@ -213,7 +221,7 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
// Load extensions
try {
const extensionsResponse = await apiGetExtensions();
let extensions = extensionsResponse.data?.extensions || [];
let extensions = normalizeExtensions(extensionsResponse.data?.extensions || []);
// Always sync bundled extensions from bundled-extensions.json
// This ensures:
@@ -236,7 +244,7 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
await syncBundledExtensions(extensions, addExtensionForSync);
// Reload extensions after sync
const refreshedResponse = await apiGetExtensions();
extensions = refreshedResponse.data?.extensions || [];
extensions = normalizeExtensions(refreshedResponse.data?.extensions || []);
setExtensionsList(extensions);
setExtensionWarnings(extensionsResponse.data?.warnings || []);
@@ -27,12 +27,12 @@ const i18n = defineMessages({
description: {
id: '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.',
"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.",
},
defaultNote: {
id: 'extensionsView.defaultNote',
defaultMessage:
'Extensions enabled here are used as the default for new chats. You can also toggle active extensions during chat.',
'Extensions stay available here, and Goose can load them on demand during a chat.',
},
addCustomExtension: {
id: 'extensionsView.addCustomExtension',
@@ -155,9 +155,7 @@ export default function ExtensionsView({
<Button
className="flex items-center gap-2 justify-center"
variant="secondary"
onClick={() =>
window.open('https://goose-docs.ai/v1/extensions/', '_blank')
}
onClick={() => window.open('https://goose-docs.ai/v1/extensions/', '_blank')}
>
<GPSIcon size={12} />
{intl.formatMessage(i18n.browseExtensions)}
@@ -167,7 +165,10 @@ export default function ExtensionsView({
</div>
<div className="px-8 pb-16">
<SearchView onSearch={(term) => setSearchTerm(term)} placeholder={intl.formatMessage(i18n.searchPlaceholder)}>
<SearchView
onSearch={(term) => setSearchTerm(term)}
placeholder={intl.formatMessage(i18n.searchPlaceholder)}
>
<ExtensionsSection
key={refreshKey}
deepLinkConfig={viewOptions.deepLinkConfig}
@@ -13,7 +13,7 @@ import {
getDefaultFormData,
} from './utils';
import { activateExtensionDefault, deleteExtension, toggleExtensionDefault } from './index';
import { activateExtensionDefault, deleteExtension } from './index';
import { ExtensionConfig } from '../../../api/types.gen';
const i18n = defineMessages({
@@ -44,8 +44,6 @@ 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;
}
@@ -55,8 +53,6 @@ export default function ExtensionsSection({
showEnvVars,
hideButtons,
disableConfiguration,
customToggle,
selectedExtensions = [],
onModalClose,
searchTerm = '',
}: ExtensionSectionProps) {
@@ -80,50 +76,26 @@ 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);
})
.map((ext) => ({
...ext,
// Use selectedExtensions to determine enabled state in recipe editor
enabled: disableConfiguration ? selectedExtensions.includes(ext.name) : ext.enabled,
}));
}, [extensionsList, disableConfiguration, selectedExtensions]);
// Finally sort alphabetically within each group
return a.name.localeCompare(b.name);
});
}, [extensionsList]);
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);
@@ -209,7 +181,6 @@ export default function ExtensionsSection({
<div className="">
<ExtensionList
extensions={extensions}
onToggle={handleExtensionToggle}
onConfigure={handleConfigureClick}
disableConfiguration={disableConfiguration}
searchTerm={searchTerm}
@@ -228,9 +199,7 @@ export default function ExtensionsSection({
<Button
className="flex items-center gap-2 justify-center"
variant="secondary"
onClick={() =>
window.open('https://goose-docs.ai/v1/extensions/', '_blank')
}
onClick={() => window.open('https://goose-docs.ai/v1/extensions/', '_blank')}
>
<GPSIcon size={12} />
{intl.formatMessage(i18n.browseExtensions)}
@@ -269,7 +238,7 @@ export default function ExtensionsSection({
title={intl.formatMessage(i18n.addCustomExtension)}
initialData={extensionToFormData({
...deepLinkConfig,
enabled: true,
enabled: false,
} 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: true,
enabled: false,
type: 'builtin',
timeout: 300,
},
@@ -18,7 +18,7 @@ vi.mock('./bundled-extensions.json', () => ({
name: 'googledrive',
display_name: 'Google Drive',
description: 'Google Drive integration.',
enabled: true,
enabled: false,
type: 'stdio',
cmd: 'googledrive-mcp',
args: [],
@@ -130,7 +130,7 @@ describe('pruneDeprecatedBundledExtensions', () => {
name: 'googledrive',
bundled: true,
}),
true
false
);
});
});
@@ -178,15 +178,14 @@ export async function addExtensionFromDeepLink(
return;
}
// 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);
// 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);
// Show success toast and navigate to extensions page
toastService.success({
title: 'Extension Installed',
msg: `${config.name} extension has been installed successfully. Start a new chat session to use it.`,
msg: `${config.name} extension has been installed successfully and is available on demand.`,
});
// Navigate to extensions page to show the newly installed extension
@@ -1,12 +1,6 @@
import type { ExtensionConfig } from '../../../api/types.gen';
import { toastService } from '../../../toasts';
import {
trackExtensionAdded,
trackExtensionEnabled,
trackExtensionDisabled,
trackExtensionDeleted,
getErrorType,
} from '../../../utils/analytics';
import { trackExtensionAdded, trackExtensionDeleted, getErrorType } from '../../../utils/analytics';
function isBuiltinExtension(config: ExtensionConfig): boolean {
return config.type === 'builtin';
@@ -38,46 +32,6 @@ 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;
@@ -90,11 +44,11 @@ export async function activateExtensionDefault({
const isBuiltin = isBuiltinExtension(extensionConfig);
try {
await addToConfig(extensionConfig.name, extensionConfig, true);
await addToConfig(extensionConfig.name, extensionConfig, false);
trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin);
toastService.success({
title: extensionConfig.name,
msg: 'Extension added as default',
msg: 'Extension added',
});
} catch (error) {
console.error('Failed to add extension to config:', error);
@@ -1,10 +1,6 @@
export { DEFAULT_EXTENSION_TIMEOUT, nameToKey } from './utils';
export {
activateExtensionDefault,
toggleExtensionDefault,
deleteExtension,
} from './extension-manager';
export { activateExtensionDefault, deleteExtension } from './extension-manager';
export { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './bundled-extensions';
@@ -1,6 +1,4 @@
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';
@@ -12,59 +10,16 @@ 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,
onToggle,
onConfigure,
isStatic,
}: ExtensionItemProps) {
export default function ExtensionItem({ extension, 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);
@@ -96,20 +51,16 @@ export default function ExtensionItem({
<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) })}
aria-label={intl.formatMessage(i18n.configureExtension, {
name: getFriendlyTitle(extension),
})}
onClick={() => onConfigure?.(extension)}
>
<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: 'Default Extensions ({count})',
defaultMessage: 'Active by Default ({count})',
},
availableExtensions: {
id: 'extensionList.availableExtensions',
defaultMessage: 'Available Extensions ({count})',
defaultMessage: 'Available On Demand ({count})',
},
noExtensions: {
id: 'extensionList.noExtensions',
@@ -26,7 +26,6 @@ const i18n = defineMessages({
interface ExtensionListProps {
extensions: FixedExtensionEntry[];
onToggle: (extension: FixedExtensionEntry) => Promise<boolean | void> | void;
onConfigure?: (extension: FixedExtensionEntry) => void;
isStatic?: boolean;
disableConfiguration?: boolean;
@@ -35,10 +34,9 @@ interface ExtensionListProps {
export default function ExtensionList({
extensions,
onToggle,
onConfigure,
isStatic,
disableConfiguration: _disableConfiguration,
disableConfiguration,
searchTerm = '',
}: ExtensionListProps) {
const matchesSearch = (extension: FixedExtensionEntry): boolean => {
@@ -68,6 +66,9 @@ 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">
@@ -82,8 +83,7 @@ export default function ExtensionList({
<ExtensionItem
key={extension.name}
extension={extension}
onToggle={onToggle}
onConfigure={onConfigure}
onConfigure={configureHandler}
isStatic={isStatic}
/>
))}
@@ -95,15 +95,16 @@ export default function ExtensionList({
<div>
<h2 className="text-lg font-medium text-text-secondary mb-4 flex items-center gap-2">
<span className="w-2 h-2 bg-gray-400 rounded-full"></span>
{intl.formatMessage(i18n.availableExtensions, { count: sortedDisabledExtensions.length })}
{intl.formatMessage(i18n.availableExtensions, {
count: sortedDisabledExtensions.length,
})}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-2">
{sortedDisabledExtensions.map((extension) => (
<ExtensionItem
key={extension.name}
extension={extension}
onToggle={onToggle}
onConfigure={onConfigure}
onConfigure={configureHandler}
isStatic={isStatic}
/>
))}
@@ -111,8 +112,10 @@ export default function ExtensionList({
</div>
)}
{extensions.length === 0 && (
<div className="text-center text-text-secondary py-8">{intl.formatMessage(i18n.noExtensions)}</div>
{!hasVisibleExtensions && (
<div className="text-center text-text-secondary py-8">
{intl.formatMessage(i18n.noExtensions)}
</div>
)}
</div>
);
@@ -39,7 +39,7 @@ describe('Extension Utils', () => {
type: 'stdio',
cmd: '',
endpoint: '',
enabled: true,
enabled: false,
timeout: 300,
envVars: [],
headers: [],
@@ -47,7 +47,7 @@ export function getDefaultFormData(): ExtensionFormData {
type: 'stdio',
cmd: '',
endpoint: '',
enabled: true,
enabled: false,
timeout: 300,
envVars: [],
headers: [],
+3 -6
View File
@@ -1058,17 +1058,14 @@
"extensionItem.configureExtension": {
"defaultMessage": "Configure {name} Extension"
},
"extensionItem.toggleExtension": {
"defaultMessage": "Toggle {name} extension On or Off"
},
"extensionList.availableExtensions": {
"defaultMessage": "Available Extensions ({count})"
"defaultMessage": "Available On Demand ({count})"
},
"extensionList.builtInExtension": {
"defaultMessage": "Built-in extension"
},
"extensionList.defaultExtensions": {
"defaultMessage": "Default Extensions ({count})"
"defaultMessage": "Active by Default ({count})"
},
"extensionList.noExtensions": {
"defaultMessage": "No extensions available"
@@ -1128,7 +1125,7 @@
"defaultMessage": "Browse extensions"
},
"extensionsView.defaultNote": {
"defaultMessage": "Extensions enabled here are used as the default for new chats. You can also toggle active extensions during chat."
"defaultMessage": "Extensions stay available here, and Goose can load them on demand during a 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."