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:
@@ -5211,9 +5211,6 @@
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
|
||||
@@ -465,7 +465,7 @@ export type ExtensionData = {
|
||||
};
|
||||
|
||||
export type ExtensionEntry = ExtensionConfig & {
|
||||
enabled: boolean;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type ExtensionLoadResult = {
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -38,6 +38,7 @@ export type AppView =
|
||||
| "home"
|
||||
| "chat"
|
||||
| "skills"
|
||||
| "extensions"
|
||||
| "agents"
|
||||
| "projects"
|
||||
| "session-history";
|
||||
@@ -51,7 +52,6 @@ const SETTINGS_SECTIONS = new Set<SectionId>([
|
||||
"appearance",
|
||||
"providers",
|
||||
"compaction",
|
||||
"extensions",
|
||||
"voice",
|
||||
"general",
|
||||
"projects",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HomeScreen } from "@/features/home/ui/HomeScreen";
|
||||
import { ChatView } from "@/features/chat/ui/ChatView";
|
||||
import { SkillsView } from "@/features/skills/ui/SkillsView";
|
||||
import { ExtensionsView } from "@/features/extensions/ui/ExtensionsView";
|
||||
import { AgentsView } from "@/features/agents/ui/AgentsView";
|
||||
import { ProjectsView } from "@/features/projects/ui/ProjectsView";
|
||||
import { SessionHistoryView } from "@/features/sessions/ui/SessionHistoryView";
|
||||
@@ -48,6 +49,8 @@ export function AppShellContent({
|
||||
switch (activeView) {
|
||||
case "skills":
|
||||
return <SkillsView onStartChatWithSkill={onStartChatWithSkill} />;
|
||||
case "extensions":
|
||||
return <ExtensionsView />;
|
||||
case "agents":
|
||||
return <AgentsView />;
|
||||
case "projects":
|
||||
|
||||
@@ -317,6 +317,44 @@ describe("artifactPathPolicy", () => {
|
||||
expect(ranking?.primaryCandidate?.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("uses semantic tool names instead of display titles for write detection", () => {
|
||||
const result = buildArtifactsIndexForMessages(
|
||||
[
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
created: Date.now(),
|
||||
metadata: { userVisible: true, agentVisible: true },
|
||||
content: [
|
||||
{
|
||||
type: "toolRequest",
|
||||
id: "tool-1",
|
||||
name: "Writing project summary",
|
||||
toolName: "write_file",
|
||||
arguments: { path: "/Users/test/project-a/summary.md" },
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
type: "toolResponse",
|
||||
id: "tool-1",
|
||||
name: "Writing project summary",
|
||||
result: "Created /Users/test/project-a/summary.md",
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
roots,
|
||||
);
|
||||
|
||||
const ranking = result.byMessageId.get("assistant-1");
|
||||
expect(ranking?.primaryToolCallId).toBe("tool-1");
|
||||
expect(ranking?.primaryCandidate?.toolName).toBe("write_file");
|
||||
expect(ranking?.primaryCandidate?.resolvedPath).toBe(
|
||||
"/Users/test/project-a/summary.md",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers an allowed candidate as primary when top-ranked candidate is blocked", () => {
|
||||
const ranking = rankMessageToolArtifacts(
|
||||
[
|
||||
|
||||
@@ -190,11 +190,12 @@ function extractToolCallsFromMessage(
|
||||
|
||||
for (const block of message.content) {
|
||||
if (block.type === "toolRequest") {
|
||||
const toolName = block.toolName ?? block.name;
|
||||
if (!byId.has(block.id)) {
|
||||
orderedIds.push(block.id);
|
||||
byId.set(block.id, {
|
||||
toolCallId: block.id,
|
||||
toolName: block.name,
|
||||
toolName,
|
||||
args: toSafeRecord(block.arguments),
|
||||
toolCallIndex,
|
||||
});
|
||||
@@ -202,7 +203,7 @@ function extractToolCallsFromMessage(
|
||||
} else {
|
||||
const existing = byId.get(block.id);
|
||||
if (existing) {
|
||||
existing.toolName = block.name || existing.toolName;
|
||||
existing.toolName = toolName || existing.toolName;
|
||||
existing.args = toSafeRecord(block.arguments);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IconStack2 } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SkillIcon } from "@/features/skills/ui/SkillIcon";
|
||||
import type { Persona } from "@/shared/types/agents";
|
||||
import type { ChatSkillDraft } from "../types";
|
||||
import { ComposerChip } from "./ComposerChip";
|
||||
@@ -40,7 +40,7 @@ export function ChatInputSelectionChips({
|
||||
key={skill.id}
|
||||
tone="skill"
|
||||
label={skill.name}
|
||||
leading={<IconStack2 className="size-3.5" />}
|
||||
leading={<SkillIcon className="size-3.5" />}
|
||||
onRemove={() => onRemoveSkill(skill.id)}
|
||||
removeLabel={t("skill.clearSelected", {
|
||||
skill: skill.name,
|
||||
|
||||
@@ -19,7 +19,6 @@ import type { ActiveWorkspace } from "../stores/chatSessionStore";
|
||||
import { WorkspaceWidget } from "./widgets/WorkspaceWidget";
|
||||
import { ChangesWidget } from "./widgets/ChangesWidget";
|
||||
import { ArtifactsWidget } from "./widgets/ArtifactsWidget";
|
||||
import { ExtensionsWidget } from "./widgets/ExtensionsWidget";
|
||||
import { openPath } from "@tauri-apps/plugin-opener";
|
||||
|
||||
interface ContextPanelProps {
|
||||
@@ -204,7 +203,6 @@ export function ContextPanel({
|
||||
onOpenFile={handleOpenChangedFile}
|
||||
/>
|
||||
<ArtifactsWidget />
|
||||
<ExtensionsWidget />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sparkles, User, Zap } from "lucide-react";
|
||||
import { Sparkles, User } from "lucide-react";
|
||||
import { IconFile, IconFolder } from "@tabler/icons-react";
|
||||
import { SkillIcon } from "@/features/skills/ui/SkillIcon";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
|
||||
import { PopoverContent } from "@/shared/ui/popover";
|
||||
@@ -165,7 +166,7 @@ export function MentionAutocomplete({
|
||||
onMouseEnter={() => setInternalIndex(globalIndex)}
|
||||
>
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand/10 text-brand">
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
<SkillIcon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-sm font-medium">{skill.name}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconStack2 } from "@tabler/icons-react";
|
||||
import { SkillIcon } from "@/features/skills/ui/SkillIcon";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import type { MessageChip } from "@/shared/types/messages";
|
||||
|
||||
@@ -12,7 +12,7 @@ const messageChipClasses: Record<MessageChip["type"], string> = {
|
||||
};
|
||||
|
||||
export function MessageMetadataChip({ chip }: { chip: MessageChip }) {
|
||||
const Icon = chip.type === "skill" ? IconStack2 : null;
|
||||
const Icon = chip.type === "skill" ? SkillIcon : null;
|
||||
|
||||
return (
|
||||
<span
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconPuzzle, IconSearch } from "@tabler/icons-react";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Widget } from "./Widget";
|
||||
import { listExtensions } from "@/features/extensions/api/extensions";
|
||||
import {
|
||||
getDisplayName,
|
||||
type ExtensionEntry,
|
||||
} from "@/features/extensions/types";
|
||||
|
||||
export function ExtensionsWidget() {
|
||||
const { t } = useTranslation("chat");
|
||||
const [extensions, setExtensions] = useState<ExtensionEntry[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const fetchEnabled = useCallback(() => {
|
||||
listExtensions()
|
||||
.then((all) => setExtensions(all.filter((e) => e.enabled)))
|
||||
.catch(() => setExtensions([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEnabled();
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === "visible") fetchEnabled();
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
window.addEventListener("focus", fetchEnabled);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
window.removeEventListener("focus", fetchEnabled);
|
||||
};
|
||||
}, [fetchEnabled]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchTerm) return extensions;
|
||||
const q = searchTerm.toLowerCase();
|
||||
return extensions.filter((ext) => {
|
||||
const name = getDisplayName(ext).toLowerCase();
|
||||
return (
|
||||
name.includes(q) || (ext.description ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [extensions, searchTerm]);
|
||||
|
||||
return (
|
||||
<Widget
|
||||
title={t("contextPanel.widgets.extensions")}
|
||||
icon={<IconPuzzle className="size-3.5" />}
|
||||
flush
|
||||
>
|
||||
{extensions.length === 0 ? (
|
||||
<p className="px-3 py-2.5 text-xs text-foreground-subtle">
|
||||
{t("contextPanel.empty.noExtensions")}
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<div className="border-b border-border px-3 py-1.5">
|
||||
<div className="flex items-center gap-1.5 text-foreground-subtle">
|
||||
<IconSearch className="size-3" />
|
||||
<Input
|
||||
variant="ghost"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={t("contextPanel.widgets.searchExtensions")}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-40 overflow-y-auto px-3 py-2">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="py-1 text-xs text-foreground-subtle">
|
||||
{t("contextPanel.empty.noMatchingExtensions")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((ext) => (
|
||||
<div key={ext.config_key} className="flex items-center gap-2">
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-green-500" />
|
||||
<span className="truncate text-xs">
|
||||
{getDisplayName(ext)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,6 @@
|
||||
import { getClient } from "@/shared/api/acpConnection";
|
||||
import type { ExtensionConfig, ExtensionEntry } from "../types";
|
||||
|
||||
export function nameToKey(name: string): string {
|
||||
return name
|
||||
.replace(/\s/g, "")
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export async function listExtensions(): Promise<ExtensionEntry[]> {
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseConfigExtensions({});
|
||||
@@ -17,7 +10,7 @@ export async function listExtensions(): Promise<ExtensionEntry[]> {
|
||||
export async function addExtension(
|
||||
name: string,
|
||||
extensionConfig: ExtensionConfig,
|
||||
enabled: boolean,
|
||||
enabled = false,
|
||||
): Promise<void> {
|
||||
const client = await getClient();
|
||||
await client.goose.GooseConfigExtensionsAdd({
|
||||
@@ -31,11 +24,3 @@ export async function removeExtension(configKey: string): Promise<void> {
|
||||
const client = await getClient();
|
||||
await client.goose.GooseConfigExtensionsRemove({ configKey });
|
||||
}
|
||||
|
||||
export async function toggleExtension(
|
||||
configKey: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
const client = await getClient();
|
||||
await client.goose.GooseConfigExtensionsToggle({ configKey, enabled });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ExtensionEntry } from "../../types";
|
||||
import { useExtensionModalForm } from "../useExtensionModalForm";
|
||||
|
||||
describe("useExtensionModalForm", () => {
|
||||
it("builds trimmed stdio configs with args, env vars, and timeout", () => {
|
||||
const { result } = renderHook(() => useExtensionModalForm());
|
||||
|
||||
act(() => {
|
||||
result.current.setName(" GitHub MCP ");
|
||||
result.current.setDescription("Issue tools");
|
||||
result.current.setCmd(" npx ");
|
||||
result.current.setArgs(" -y \n @modelcontextprotocol/server-github \n\n");
|
||||
result.current.setTimeout("45");
|
||||
result.current.updateEnvVar(0, "key", " GITHUB_TOKEN ");
|
||||
result.current.updateEnvVar(0, "value", "secret");
|
||||
});
|
||||
act(() => {
|
||||
result.current.addEnvVar();
|
||||
});
|
||||
act(() => {
|
||||
result.current.updateEnvVar(1, "key", " ");
|
||||
result.current.updateEnvVar(1, "value", "ignored");
|
||||
});
|
||||
|
||||
expect(result.current.buildSubmitPayload()).toEqual({
|
||||
name: "GitHub MCP",
|
||||
config: {
|
||||
type: "stdio",
|
||||
name: "GitHub MCP",
|
||||
description: "Issue tools",
|
||||
cmd: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-github"],
|
||||
envs: { GITHUB_TOKEN: "secret" },
|
||||
timeout: 45,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds streamable HTTP configs and falls back to the default timeout", () => {
|
||||
const { result } = renderHook(() => useExtensionModalForm());
|
||||
|
||||
act(() => {
|
||||
result.current.setType("streamable_http");
|
||||
result.current.setName(" Context7 ");
|
||||
result.current.setDescription("Docs");
|
||||
result.current.setUri(" https://mcp.context7.com/mcp ");
|
||||
result.current.setTimeout("");
|
||||
});
|
||||
|
||||
expect(result.current.buildSubmitPayload()).toEqual({
|
||||
name: "Context7",
|
||||
config: {
|
||||
type: "streamable_http",
|
||||
name: "Context7",
|
||||
description: "Docs",
|
||||
uri: "https://mcp.context7.com/mcp",
|
||||
timeout: 300,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves editable config fields without submitting entry fields", () => {
|
||||
const extension: ExtensionEntry = {
|
||||
type: "streamable_http",
|
||||
name: "context7",
|
||||
description: "Docs",
|
||||
uri: "https://old.example/mcp",
|
||||
env_keys: ["API_KEY"],
|
||||
headers: { Authorization: "Bearer token" },
|
||||
socket: "/tmp/mcp.sock",
|
||||
config_key: "context7",
|
||||
enabled: true,
|
||||
timeout: 60,
|
||||
};
|
||||
const { result } = renderHook(() => useExtensionModalForm(extension));
|
||||
|
||||
act(() => {
|
||||
result.current.setUri("https://new.example/mcp");
|
||||
});
|
||||
|
||||
expect(result.current.buildSubmitPayload()?.config).toMatchObject({
|
||||
type: "streamable_http",
|
||||
name: "context7",
|
||||
uri: "https://new.example/mcp",
|
||||
env_keys: ["API_KEY"],
|
||||
headers: { Authorization: "Bearer token" },
|
||||
socket: "/tmp/mcp.sock",
|
||||
timeout: 60,
|
||||
});
|
||||
expect(result.current.buildSubmitPayload()?.config).not.toHaveProperty(
|
||||
"config_key",
|
||||
);
|
||||
expect(result.current.buildSubmitPayload()?.config).not.toHaveProperty(
|
||||
"enabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps secret env keys visible and preserves them when unchanged", () => {
|
||||
const extension: ExtensionEntry = {
|
||||
type: "stdio",
|
||||
name: "github",
|
||||
description: "Issue tools",
|
||||
cmd: "npx",
|
||||
args: [],
|
||||
envs: { LEGACY_TOKEN: "plain" },
|
||||
env_keys: ["GITHUB_TOKEN"],
|
||||
config_key: "github",
|
||||
enabled: false,
|
||||
};
|
||||
const { result } = renderHook(() => useExtensionModalForm(extension));
|
||||
|
||||
expect(result.current.envVars).toMatchObject([
|
||||
{ key: "LEGACY_TOKEN", value: "plain" },
|
||||
{ key: "GITHUB_TOKEN", value: "" },
|
||||
]);
|
||||
expect(result.current.buildSubmitPayload()?.config).toMatchObject({
|
||||
envs: { LEGACY_TOKEN: "plain" },
|
||||
env_keys: ["GITHUB_TOKEN"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-HTTP streamable HTTP URIs", () => {
|
||||
const { result } = renderHook(() => useExtensionModalForm());
|
||||
|
||||
act(() => {
|
||||
result.current.setType("streamable_http");
|
||||
result.current.setName("Local file");
|
||||
result.current.setUri("file:///tmp/mcp");
|
||||
});
|
||||
|
||||
expect(result.current.canSubmit).toBe(false);
|
||||
expect(result.current.buildSubmitPayload()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not coerce unsupported SSE extensions into HTTP configs", () => {
|
||||
const extension: ExtensionEntry = {
|
||||
type: "sse",
|
||||
name: "legacy-sse",
|
||||
description: "Legacy SSE endpoint",
|
||||
uri: "https://old.example/sse",
|
||||
config_key: "legacy-sse",
|
||||
enabled: true,
|
||||
};
|
||||
const { result } = renderHook(() => useExtensionModalForm(extension));
|
||||
|
||||
expect(result.current.type).toBe("unsupported");
|
||||
expect(result.current.canSubmit).toBe(false);
|
||||
expect(result.current.buildSubmitPayload()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when required fields are missing", () => {
|
||||
const { result } = renderHook(() => useExtensionModalForm());
|
||||
|
||||
act(() => {
|
||||
result.current.setName("No command");
|
||||
});
|
||||
|
||||
expect(result.current.canSubmit).toBe(false);
|
||||
expect(result.current.buildSubmitPayload()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ExtensionEntry } from "../../types";
|
||||
import { useExtensionsSettings } from "../useExtensionsSettings";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
addExtension: vi.fn(),
|
||||
listExtensions: vi.fn(),
|
||||
removeExtension: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../api/extensions", () => ({
|
||||
addExtension: mocks.addExtension,
|
||||
listExtensions: mocks.listExtensions,
|
||||
removeExtension: mocks.removeExtension,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: mocks.toastError },
|
||||
}));
|
||||
|
||||
const enabledExtension: ExtensionEntry = {
|
||||
type: "stdio",
|
||||
name: "github",
|
||||
description: "Issue tracker",
|
||||
cmd: "npx",
|
||||
args: [],
|
||||
config_key: "github",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
describe("useExtensionsSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.listExtensions.mockResolvedValue([enabledExtension]);
|
||||
mocks.addExtension.mockResolvedValue(undefined);
|
||||
mocks.removeExtension.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("preserves an edited extension's enabled flag", async () => {
|
||||
const { result } = renderHook(() => useExtensionsSettings());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleConfigure(enabledExtension);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.handleSubmit("github", enabledExtension);
|
||||
});
|
||||
|
||||
expect(mocks.addExtension).toHaveBeenCalledWith(
|
||||
"github",
|
||||
enabledExtension,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("saves new extensions as disabled catalog entries", async () => {
|
||||
const { result } = renderHook(() => useExtensionsSettings());
|
||||
const newExtension: ExtensionEntry = {
|
||||
...enabledExtension,
|
||||
name: "linear",
|
||||
config_key: "linear",
|
||||
};
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSubmit("linear", newExtension);
|
||||
});
|
||||
|
||||
expect(mocks.addExtension).toHaveBeenCalledWith(
|
||||
"linear",
|
||||
newExtension,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not delete the new extension when renamed old-key removal fails", async () => {
|
||||
mocks.removeExtension.mockRejectedValueOnce(new Error("remove failed"));
|
||||
const { result } = renderHook(() => useExtensionsSettings());
|
||||
const renamedExtension: ExtensionEntry = {
|
||||
...enabledExtension,
|
||||
name: "linear",
|
||||
config_key: "linear",
|
||||
};
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleConfigure(enabledExtension);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.handleSubmit("linear", renamedExtension);
|
||||
});
|
||||
|
||||
expect(mocks.addExtension).toHaveBeenCalledWith(
|
||||
"linear",
|
||||
renamedExtension,
|
||||
true,
|
||||
);
|
||||
expect(mocks.removeExtension).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.removeExtension).toHaveBeenCalledWith("github");
|
||||
expect(mocks.removeExtension).not.toHaveBeenCalledWith("linear");
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(
|
||||
"extensions.errors.saveFailed",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
buildExtensionSubmitPayload,
|
||||
canSubmitExtensionConfig,
|
||||
parseExtensionEnvRows,
|
||||
type ExtensionEnvRow,
|
||||
type ExtensionModalType,
|
||||
} from "../lib/extensionFormConfig";
|
||||
import type { ExtensionConfig, ExtensionEntry } from "../types";
|
||||
|
||||
export type { ExtensionModalType };
|
||||
|
||||
export interface EnvVar extends ExtensionEnvRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
let nextEnvId = 0;
|
||||
|
||||
function newEmptyEnvVar(): EnvVar {
|
||||
return { id: nextEnvId++, key: "", value: "" };
|
||||
}
|
||||
|
||||
function withEnvIds(rows: ExtensionEnvRow[]): EnvVar[] {
|
||||
return rows.length > 0
|
||||
? rows.map((row) => ({ id: nextEnvId++, ...row }))
|
||||
: [newEmptyEnvVar()];
|
||||
}
|
||||
|
||||
function initialType(extension?: ExtensionEntry): ExtensionModalType {
|
||||
if (!extension) return "stdio";
|
||||
if (extension.type === "stdio" || extension.type === "streamable_http") {
|
||||
return extension.type;
|
||||
}
|
||||
return "unsupported";
|
||||
}
|
||||
|
||||
function initialEnvVars(extension?: ExtensionEntry): EnvVar[] {
|
||||
if (extension?.type === "stdio")
|
||||
return withEnvIds(
|
||||
parseExtensionEnvRows(extension.envs, extension.env_keys),
|
||||
);
|
||||
if (extension?.type === "streamable_http")
|
||||
return withEnvIds(
|
||||
parseExtensionEnvRows(extension.envs, extension.env_keys),
|
||||
);
|
||||
return [newEmptyEnvVar()];
|
||||
}
|
||||
|
||||
export function useExtensionModalForm(extension?: ExtensionEntry) {
|
||||
const [name, setName] = useState(extension?.name ?? "");
|
||||
const [type, setType] = useState<ExtensionModalType>(() =>
|
||||
initialType(extension),
|
||||
);
|
||||
const [description, setDescription] = useState(extension?.description ?? "");
|
||||
const [cmd, setCmd] = useState(
|
||||
extension?.type === "stdio" ? extension.cmd : "",
|
||||
);
|
||||
const [args, setArgs] = useState(
|
||||
extension?.type === "stdio" ? extension.args.join("\n") : "",
|
||||
);
|
||||
const [uri, setUri] = useState(
|
||||
extension?.type === "streamable_http" ? extension.uri : "",
|
||||
);
|
||||
const [timeout, setTimeout] = useState(
|
||||
String(
|
||||
extension?.type === "stdio" || extension?.type === "streamable_http"
|
||||
? (extension.timeout ?? 300)
|
||||
: 300,
|
||||
),
|
||||
);
|
||||
const [envVars, setEnvVars] = useState<EnvVar[]>(() =>
|
||||
initialEnvVars(extension),
|
||||
);
|
||||
|
||||
const canSubmit = canSubmitExtensionConfig({ type, name, cmd, uri });
|
||||
|
||||
const updateEnvVar = (
|
||||
index: number,
|
||||
field: "key" | "value",
|
||||
value: string,
|
||||
) => {
|
||||
setEnvVars((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], [field]: value };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addEnvVar = () => {
|
||||
setEnvVars((prev) => [...prev, newEmptyEnvVar()]);
|
||||
};
|
||||
|
||||
const removeEnvVar = (id: number) => {
|
||||
setEnvVars((prev) => {
|
||||
if (prev.length <= 1) return [newEmptyEnvVar()];
|
||||
return prev.filter((v) => v.id !== id);
|
||||
});
|
||||
};
|
||||
|
||||
const buildSubmitPayload = (): {
|
||||
name: string;
|
||||
config: ExtensionConfig;
|
||||
} | null => {
|
||||
return buildExtensionSubmitPayload({
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
cmd,
|
||||
args,
|
||||
uri,
|
||||
timeout,
|
||||
envVars,
|
||||
extension,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
name,
|
||||
setName,
|
||||
type,
|
||||
setType,
|
||||
description,
|
||||
setDescription,
|
||||
cmd,
|
||||
setCmd,
|
||||
args,
|
||||
setArgs,
|
||||
uri,
|
||||
setUri,
|
||||
timeout,
|
||||
setTimeout,
|
||||
envVars,
|
||||
canSubmit,
|
||||
updateEnvVar,
|
||||
addEnvVar,
|
||||
removeEnvVar,
|
||||
buildSubmitPayload,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
addExtension,
|
||||
listExtensions,
|
||||
removeExtension,
|
||||
} from "../api/extensions";
|
||||
import { nameToKey } from "../lib/extensionKeys";
|
||||
import type { ExtensionConfig, ExtensionEntry } from "../types";
|
||||
|
||||
type ExtensionModalMode = "add" | "edit" | null;
|
||||
|
||||
export function useExtensionsSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [extensions, setExtensions] = useState<ExtensionEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [modalMode, setModalMode] = useState<ExtensionModalMode>(null);
|
||||
const [editingExtension, setEditingExtension] =
|
||||
useState<ExtensionEntry | null>(null);
|
||||
|
||||
const fetchExtensions = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await listExtensions();
|
||||
setExtensions(result);
|
||||
} catch {
|
||||
setExtensions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchExtensions();
|
||||
}, [fetchExtensions]);
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
setEditingExtension(null);
|
||||
setModalMode("add");
|
||||
}, []);
|
||||
|
||||
const handleConfigure = useCallback((extension: ExtensionEntry) => {
|
||||
setEditingExtension(extension);
|
||||
setModalMode("edit");
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (name: string, config: ExtensionConfig) => {
|
||||
try {
|
||||
const newKey = nameToKey(name);
|
||||
const isEdit = !!editingExtension;
|
||||
const isAdd = !editingExtension;
|
||||
const keyChanged = isEdit && editingExtension.config_key !== newKey;
|
||||
|
||||
if (
|
||||
(isAdd || keyChanged) &&
|
||||
extensions.some((extension) => extension.config_key === newKey)
|
||||
) {
|
||||
toast.error(t("extensions.errors.nameConflict", { name }));
|
||||
return;
|
||||
}
|
||||
|
||||
await addExtension(name, config, editingExtension?.enabled ?? false);
|
||||
if (keyChanged) {
|
||||
await removeExtension(editingExtension.config_key);
|
||||
}
|
||||
setModalMode(null);
|
||||
setEditingExtension(null);
|
||||
await fetchExtensions();
|
||||
} catch {
|
||||
await fetchExtensions();
|
||||
toast.error(t("extensions.errors.saveFailed"));
|
||||
}
|
||||
},
|
||||
[editingExtension, extensions, fetchExtensions, t],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (configKey: string) => {
|
||||
try {
|
||||
await removeExtension(configKey);
|
||||
setModalMode(null);
|
||||
setEditingExtension(null);
|
||||
await fetchExtensions();
|
||||
} catch (error) {
|
||||
toast.error(t("extensions.errors.deleteFailed"));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[fetchExtensions, t],
|
||||
);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
setModalMode(null);
|
||||
setEditingExtension(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
extensions,
|
||||
isLoading,
|
||||
modalMode,
|
||||
editingExtension,
|
||||
handleAdd,
|
||||
handleConfigure,
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
handleModalClose,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ExtensionEntry } from "../../types";
|
||||
import {
|
||||
classifyExtension,
|
||||
filterExtensions,
|
||||
getExtensionCategoryCounts,
|
||||
splitExtensionsByCategory,
|
||||
} from "../extensionCategories";
|
||||
|
||||
function extension(
|
||||
name: string,
|
||||
type: ExtensionEntry["type"],
|
||||
description = "",
|
||||
): ExtensionEntry {
|
||||
return {
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
config_key: name,
|
||||
enabled: true,
|
||||
...(type === "stdio" ? { cmd: "npx", args: [] } : {}),
|
||||
...(type === "streamable_http" ? { uri: "http://localhost:3000/mcp" } : {}),
|
||||
} as ExtensionEntry;
|
||||
}
|
||||
|
||||
const labelForCategory = (category: string) =>
|
||||
category === "gooseCapabilities" ? "Goose capabilities" : "Apps & services";
|
||||
|
||||
describe("extension categories", () => {
|
||||
it("classifies built-in and platform extensions as Goose capabilities", () => {
|
||||
expect(classifyExtension(extension("developer", "builtin"))).toBe(
|
||||
"gooseCapabilities",
|
||||
);
|
||||
expect(classifyExtension(extension("computer", "platform"))).toBe(
|
||||
"gooseCapabilities",
|
||||
);
|
||||
expect(classifyExtension(extension("github", "stdio"))).toBe(
|
||||
"appsServices",
|
||||
);
|
||||
});
|
||||
|
||||
it("filters by search text across name, description, and category label", () => {
|
||||
const extensions = [
|
||||
extension("github", "stdio", "Issue tracker"),
|
||||
extension("developer", "builtin", "Code tools"),
|
||||
];
|
||||
|
||||
expect(
|
||||
filterExtensions({
|
||||
extensions,
|
||||
searchTerm: "issue",
|
||||
activeFilter: "all",
|
||||
getCategoryLabel: labelForCategory,
|
||||
}).map((item) => item.name),
|
||||
).toEqual(["github"]);
|
||||
|
||||
expect(
|
||||
filterExtensions({
|
||||
extensions,
|
||||
searchTerm: "goose",
|
||||
activeFilter: "all",
|
||||
getCategoryLabel: labelForCategory,
|
||||
}).map((item) => item.name),
|
||||
).toEqual(["developer"]);
|
||||
});
|
||||
|
||||
it("counts and splits extensions by category", () => {
|
||||
const extensions = [
|
||||
extension("developer", "builtin"),
|
||||
extension("computer", "platform"),
|
||||
extension("github", "stdio"),
|
||||
];
|
||||
|
||||
expect(getExtensionCategoryCounts(extensions)).toEqual({
|
||||
appsServices: 1,
|
||||
gooseCapabilities: 2,
|
||||
});
|
||||
expect(splitExtensionsByCategory(extensions)).toMatchObject({
|
||||
primaryExtensions: [{ name: "github" }],
|
||||
gooseCapabilities: [{ name: "developer" }, { name: "computer" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ExtensionEntry } from "../../types";
|
||||
import {
|
||||
buildExtensionEnvConfig,
|
||||
buildExtensionSubmitPayload,
|
||||
canSubmitExtensionConfig,
|
||||
parseExtensionEnvRows,
|
||||
} from "../extensionFormConfig";
|
||||
|
||||
describe("extensionFormConfig", () => {
|
||||
it("combines legacy envs and secret env keys without duplicates", () => {
|
||||
expect(
|
||||
parseExtensionEnvRows(
|
||||
{ LEGACY_TOKEN: "plain", SHARED_TOKEN: "plain-shared" },
|
||||
["GITHUB_TOKEN", "SHARED_TOKEN"],
|
||||
),
|
||||
).toEqual([
|
||||
{ key: "LEGACY_TOKEN", value: "plain" },
|
||||
{ key: "SHARED_TOKEN", value: "plain-shared" },
|
||||
{ key: "GITHUB_TOKEN", value: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds envs for populated values and env_keys for blank values", () => {
|
||||
expect(
|
||||
buildExtensionEnvConfig([
|
||||
{ key: " GITHUB_TOKEN ", value: "secret" },
|
||||
{ key: " API_KEY ", value: "" },
|
||||
{ key: " ", value: "ignored" },
|
||||
]),
|
||||
).toEqual({
|
||||
envs: { GITHUB_TOKEN: "secret" },
|
||||
env_keys: ["API_KEY"],
|
||||
});
|
||||
});
|
||||
|
||||
it("validates streamable HTTP URLs by scheme", () => {
|
||||
expect(
|
||||
canSubmitExtensionConfig({
|
||||
type: "streamable_http",
|
||||
name: "Context7",
|
||||
cmd: "",
|
||||
uri: "https://mcp.context7.com/mcp",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canSubmitExtensionConfig({
|
||||
type: "streamable_http",
|
||||
name: "Local file",
|
||||
cmd: "",
|
||||
uri: "file:///tmp/mcp",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("builds clean submit payloads while preserving streamable-only fields", () => {
|
||||
const extension: ExtensionEntry = {
|
||||
type: "streamable_http",
|
||||
name: "context7",
|
||||
description: "Docs",
|
||||
uri: "https://old.example/mcp",
|
||||
env_keys: ["API_KEY"],
|
||||
headers: { Authorization: "Bearer token" },
|
||||
socket: "/tmp/mcp.sock",
|
||||
config_key: "context7",
|
||||
enabled: true,
|
||||
timeout: 60,
|
||||
};
|
||||
|
||||
const payload = buildExtensionSubmitPayload({
|
||||
type: "streamable_http",
|
||||
name: " context7 ",
|
||||
description: "Docs",
|
||||
cmd: "",
|
||||
args: "",
|
||||
uri: " https://new.example/mcp ",
|
||||
timeout: "90",
|
||||
envVars: [{ key: "API_KEY", value: "" }],
|
||||
extension,
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
name: "context7",
|
||||
config: {
|
||||
type: "streamable_http",
|
||||
name: "context7",
|
||||
description: "Docs",
|
||||
uri: "https://new.example/mcp",
|
||||
env_keys: ["API_KEY"],
|
||||
headers: { Authorization: "Bearer token" },
|
||||
socket: "/tmp/mcp.sock",
|
||||
timeout: 90,
|
||||
},
|
||||
});
|
||||
expect(payload?.config).not.toHaveProperty("config_key");
|
||||
expect(payload?.config).not.toHaveProperty("enabled");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ExtensionEntry } from "../types";
|
||||
import { getDisplayName } from "../types";
|
||||
|
||||
export type ExtensionCategory = "appsServices" | "gooseCapabilities";
|
||||
|
||||
export type ExtensionFilter = "all" | ExtensionCategory;
|
||||
|
||||
export const EXTENSION_CATEGORIES: readonly ExtensionCategory[] = [
|
||||
"appsServices",
|
||||
"gooseCapabilities",
|
||||
];
|
||||
|
||||
const GOOSE_CAPABILITY_TYPES = new Set(["builtin", "platform"]);
|
||||
export function classifyExtension(
|
||||
extension: ExtensionEntry,
|
||||
): ExtensionCategory {
|
||||
if (GOOSE_CAPABILITY_TYPES.has(extension.type)) {
|
||||
return "gooseCapabilities";
|
||||
}
|
||||
return "appsServices";
|
||||
}
|
||||
|
||||
export function compareExtensionsByName(
|
||||
a: ExtensionEntry,
|
||||
b: ExtensionEntry,
|
||||
): number {
|
||||
return getDisplayName(a).localeCompare(getDisplayName(b));
|
||||
}
|
||||
|
||||
export function getExtensionCategoryCounts(
|
||||
extensions: ExtensionEntry[],
|
||||
): Record<ExtensionCategory, number> {
|
||||
const counts: Record<ExtensionCategory, number> = {
|
||||
appsServices: 0,
|
||||
gooseCapabilities: 0,
|
||||
};
|
||||
for (const extension of extensions) {
|
||||
counts[classifyExtension(extension)] += 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function filterExtensions(options: {
|
||||
extensions: ExtensionEntry[];
|
||||
searchTerm: string;
|
||||
activeFilter: ExtensionFilter;
|
||||
getCategoryLabel: (category: ExtensionCategory) => string;
|
||||
}): ExtensionEntry[] {
|
||||
const { extensions, searchTerm, activeFilter, getCategoryLabel } = options;
|
||||
const query = searchTerm.toLowerCase();
|
||||
|
||||
return extensions
|
||||
.filter((extension) => {
|
||||
const category = classifyExtension(extension);
|
||||
const matchesSearch =
|
||||
!query ||
|
||||
getDisplayName(extension).toLowerCase().includes(query) ||
|
||||
extension.name.toLowerCase().includes(query) ||
|
||||
(extension.description ?? "").toLowerCase().includes(query) ||
|
||||
getCategoryLabel(category).toLowerCase().includes(query);
|
||||
|
||||
return (
|
||||
matchesSearch && (activeFilter === "all" || category === activeFilter)
|
||||
);
|
||||
})
|
||||
.sort(compareExtensionsByName);
|
||||
}
|
||||
|
||||
export function splitExtensionsByCategory(extensions: ExtensionEntry[]): {
|
||||
primaryExtensions: ExtensionEntry[];
|
||||
gooseCapabilities: ExtensionEntry[];
|
||||
} {
|
||||
const primaryExtensions: ExtensionEntry[] = [];
|
||||
const gooseCapabilities: ExtensionEntry[] = [];
|
||||
|
||||
for (const extension of extensions) {
|
||||
if (classifyExtension(extension) === "gooseCapabilities") {
|
||||
gooseCapabilities.push(extension);
|
||||
} else {
|
||||
primaryExtensions.push(extension);
|
||||
}
|
||||
}
|
||||
|
||||
return { primaryExtensions, gooseCapabilities };
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type {
|
||||
ExtensionConfig,
|
||||
ExtensionEntry,
|
||||
StdioExtensionConfig,
|
||||
StreamableHttpExtensionConfig,
|
||||
} from "../types";
|
||||
|
||||
export type ExtensionModalType = "stdio" | "streamable_http" | "unsupported";
|
||||
|
||||
export interface ExtensionEnvRow {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ExtensionSubmitConfigInput {
|
||||
type: ExtensionModalType;
|
||||
name: string;
|
||||
description: string;
|
||||
cmd: string;
|
||||
args: string;
|
||||
uri: string;
|
||||
timeout: string;
|
||||
envVars: ExtensionEnvRow[];
|
||||
extension?: ExtensionEntry;
|
||||
}
|
||||
|
||||
type PreservedCommonFields = {
|
||||
available_tools?: string[];
|
||||
bundled?: boolean;
|
||||
};
|
||||
|
||||
export function parseExtensionEnvRows(
|
||||
envs?: Record<string, string>,
|
||||
envKeys?: string[],
|
||||
): ExtensionEnvRow[] {
|
||||
const rows: ExtensionEnvRow[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
for (const [key, value] of Object.entries(envs ?? {})) {
|
||||
rows.push({ key, value });
|
||||
seenKeys.add(key);
|
||||
}
|
||||
|
||||
for (const key of envKeys ?? []) {
|
||||
if (seenKeys.has(key)) continue;
|
||||
rows.push({ key, value: "" });
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function buildExtensionEnvConfig(
|
||||
vars: ExtensionEnvRow[],
|
||||
): Pick<
|
||||
StdioExtensionConfig | StreamableHttpExtensionConfig,
|
||||
"envs" | "env_keys"
|
||||
> {
|
||||
const envs: Record<string, string> = {};
|
||||
const envKeys: string[] = [];
|
||||
|
||||
for (const v of vars) {
|
||||
const key = v.key.trim();
|
||||
if (!key) continue;
|
||||
|
||||
if (v.value.trim().length > 0) envs[key] = v.value;
|
||||
else envKeys.push(key);
|
||||
}
|
||||
|
||||
return {
|
||||
...(Object.keys(envs).length > 0 ? { envs } : {}),
|
||||
...(envKeys.length > 0 ? { env_keys: envKeys } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidStreamableHttpUri(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function canSubmitExtensionConfig(input: {
|
||||
type: ExtensionModalType;
|
||||
name: string;
|
||||
cmd: string;
|
||||
uri: string;
|
||||
}): boolean {
|
||||
return (
|
||||
input.type !== "unsupported" &&
|
||||
input.name.trim().length > 0 &&
|
||||
(input.type === "stdio"
|
||||
? input.cmd.trim().length > 0
|
||||
: isValidStreamableHttpUri(input.uri))
|
||||
);
|
||||
}
|
||||
|
||||
function preservedCommonFields(
|
||||
extension?: ExtensionEntry,
|
||||
): PreservedCommonFields {
|
||||
return {
|
||||
...(extension &&
|
||||
"available_tools" in extension &&
|
||||
extension.available_tools?.length
|
||||
? { available_tools: extension.available_tools }
|
||||
: {}),
|
||||
...(extension && "bundled" in extension && extension.bundled !== undefined
|
||||
? { bundled: extension.bundled }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildExtensionSubmitPayload({
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
cmd,
|
||||
args,
|
||||
uri,
|
||||
timeout,
|
||||
envVars,
|
||||
extension,
|
||||
}: ExtensionSubmitConfigInput): {
|
||||
name: string;
|
||||
config: ExtensionConfig;
|
||||
} | null {
|
||||
if (!canSubmitExtensionConfig({ type, name, cmd, uri })) return null;
|
||||
|
||||
const trimmedName = name.trim();
|
||||
const envConfig = buildExtensionEnvConfig(envVars);
|
||||
const timeoutNum = Number.parseInt(timeout, 10) || 300;
|
||||
const commonFields = preservedCommonFields(extension);
|
||||
|
||||
if (type === "stdio") {
|
||||
return {
|
||||
name: trimmedName,
|
||||
config: {
|
||||
type: "stdio",
|
||||
name: trimmedName,
|
||||
description,
|
||||
cmd: cmd.trim(),
|
||||
args: args
|
||||
.split("\n")
|
||||
.map((arg) => arg.trim())
|
||||
.filter(Boolean),
|
||||
...envConfig,
|
||||
timeout: timeoutNum,
|
||||
...commonFields,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: trimmedName,
|
||||
config: {
|
||||
type: "streamable_http",
|
||||
name: trimmedName,
|
||||
description,
|
||||
uri: uri.trim(),
|
||||
...envConfig,
|
||||
...(extension?.type === "streamable_http" && extension.headers
|
||||
? { headers: extension.headers }
|
||||
: {}),
|
||||
...(extension?.type === "streamable_http" && extension.socket
|
||||
? { socket: extension.socket }
|
||||
: {}),
|
||||
timeout: timeoutNum,
|
||||
...commonFields,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function nameToKey(name: string): string {
|
||||
return name
|
||||
.replace(/\s/g, "")
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export const normalizeExtensionKey = nameToKey;
|
||||
@@ -21,6 +21,15 @@ export interface BuiltinExtensionConfig {
|
||||
available_tools?: string[];
|
||||
}
|
||||
|
||||
export interface PlatformExtensionConfig {
|
||||
type: "platform";
|
||||
name: string;
|
||||
description: string;
|
||||
display_name?: string;
|
||||
bundled?: boolean;
|
||||
available_tools?: string[];
|
||||
}
|
||||
|
||||
export interface StreamableHttpExtensionConfig {
|
||||
type: "streamable_http";
|
||||
name: string;
|
||||
@@ -30,6 +39,7 @@ export interface StreamableHttpExtensionConfig {
|
||||
env_keys?: string[];
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
socket?: string;
|
||||
bundled?: boolean;
|
||||
available_tools?: string[];
|
||||
}
|
||||
@@ -42,19 +52,47 @@ export interface SseExtensionConfig {
|
||||
bundled?: boolean;
|
||||
}
|
||||
|
||||
export interface FrontendExtensionConfig {
|
||||
type: "frontend";
|
||||
name: string;
|
||||
description: string;
|
||||
tools: unknown[];
|
||||
frontend_tools?: unknown[];
|
||||
instructions?: string;
|
||||
bundled?: boolean;
|
||||
available_tools?: string[];
|
||||
}
|
||||
|
||||
export interface InlinePythonExtensionConfig {
|
||||
type: "inline_python";
|
||||
name: string;
|
||||
description: string;
|
||||
code: string;
|
||||
timeout?: number;
|
||||
dependencies?: string[];
|
||||
available_tools?: string[];
|
||||
}
|
||||
|
||||
export type ExtensionConfig =
|
||||
| StdioExtensionConfig
|
||||
| BuiltinExtensionConfig
|
||||
| PlatformExtensionConfig
|
||||
| StreamableHttpExtensionConfig
|
||||
| SseExtensionConfig;
|
||||
| SseExtensionConfig
|
||||
| FrontendExtensionConfig
|
||||
| InlinePythonExtensionConfig;
|
||||
|
||||
export type ExtensionEntry = ExtensionConfig & {
|
||||
config_key: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export function getDisplayName(ext: ExtensionEntry): string {
|
||||
if (ext.type === "builtin" && ext.display_name) {
|
||||
export function getDisplayName(ext: {
|
||||
type: ExtensionConfig["type"];
|
||||
name: string;
|
||||
display_name?: string | null;
|
||||
}): string {
|
||||
if ((ext.type === "builtin" || ext.type === "platform") && ext.display_name) {
|
||||
return ext.display_name;
|
||||
}
|
||||
return ext.name;
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconSettings } from "@tabler/icons-react";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import { getDisplayName, type ExtensionEntry } from "../types";
|
||||
|
||||
interface ExtensionItemProps {
|
||||
extension: ExtensionEntry;
|
||||
onToggle: (extension: ExtensionEntry) => Promise<void>;
|
||||
onConfigure?: (extension: ExtensionEntry) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getSubtitle(ext: ExtensionEntry): string {
|
||||
@@ -18,48 +17,35 @@ function getSubtitle(ext: ExtensionEntry): string {
|
||||
return ext.type;
|
||||
}
|
||||
|
||||
const EDITABLE_TYPES = new Set(["stdio", "streamable_http"]);
|
||||
function isUserManagedExtension(ext: ExtensionEntry): boolean {
|
||||
return (
|
||||
(ext.type === "stdio" || ext.type === "streamable_http") && !ext.bundled
|
||||
);
|
||||
}
|
||||
|
||||
function isEditable(ext: ExtensionEntry): boolean {
|
||||
return EDITABLE_TYPES.has(ext.type) && !ext.bundled;
|
||||
return isUserManagedExtension(ext);
|
||||
}
|
||||
|
||||
export function ExtensionItem({
|
||||
extension,
|
||||
onToggle,
|
||||
onConfigure,
|
||||
className,
|
||||
}: ExtensionItemProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [isToggling, setIsToggling] = useState(false);
|
||||
const [visualEnabled, setVisualEnabled] = useState(extension.enabled);
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (isToggling) return;
|
||||
setIsToggling(true);
|
||||
setVisualEnabled(!extension.enabled);
|
||||
try {
|
||||
await onToggle(extension);
|
||||
} catch {
|
||||
setVisualEnabled(extension.enabled);
|
||||
} finally {
|
||||
setIsToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const editable = isEditable(extension);
|
||||
const checked = isToggling ? visualEnabled : extension.enabled;
|
||||
const displayName = getDisplayName(extension);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-20 items-center justify-between gap-3 border-b border-border-soft-divider py-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{displayName}</span>
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{t(`extensions.types.${extension.type}`, {
|
||||
defaultValue: extension.type,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{getSubtitle(extension)}
|
||||
@@ -78,14 +64,6 @@ export function ExtensionItem({
|
||||
<IconSettings className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isToggling}
|
||||
aria-label={t("extensions.toggle", {
|
||||
name: displayName,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
@@ -19,49 +20,19 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import {
|
||||
useExtensionModalForm,
|
||||
type ExtensionModalType,
|
||||
} from "../hooks/useExtensionModalForm";
|
||||
import type { ExtensionConfig, ExtensionEntry } from "../types";
|
||||
|
||||
type ExtensionType = "stdio" | "streamable_http";
|
||||
|
||||
interface ExtensionModalProps {
|
||||
extension?: ExtensionEntry;
|
||||
onSubmit: (
|
||||
name: string,
|
||||
config: ExtensionConfig,
|
||||
enabled: boolean,
|
||||
) => Promise<void>;
|
||||
onSubmit: (name: string, config: ExtensionConfig) => Promise<void>;
|
||||
onDelete?: (configKey: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface EnvVar {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
let nextEnvId = 0;
|
||||
|
||||
function parseEnvVars(envs?: Record<string, string>): EnvVar[] {
|
||||
if (!envs || Object.keys(envs).length === 0)
|
||||
return [{ id: nextEnvId++, key: "", value: "" }];
|
||||
return Object.entries(envs).map(([key, value]) => ({
|
||||
id: nextEnvId++,
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildEnvVars(vars: EnvVar[]): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const v of vars) {
|
||||
if (v.key.trim()) {
|
||||
result[v.key.trim()] = v.value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function ExtensionModal({
|
||||
extension,
|
||||
onSubmit,
|
||||
@@ -71,293 +42,238 @@ export function ExtensionModal({
|
||||
const { t } = useTranslation("settings");
|
||||
const isEdit = !!extension;
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [name, setName] = useState(extension?.name ?? "");
|
||||
const [type, setType] = useState<ExtensionType>(
|
||||
extension?.type === "streamable_http" || extension?.type === "sse"
|
||||
? "streamable_http"
|
||||
: "stdio",
|
||||
);
|
||||
const [description, setDescription] = useState(extension?.description ?? "");
|
||||
const [cmd, setCmd] = useState(
|
||||
extension?.type === "stdio" ? extension.cmd : "",
|
||||
);
|
||||
const [args, setArgs] = useState(
|
||||
extension?.type === "stdio" ? extension.args.join("\n") : "",
|
||||
);
|
||||
const [uri, setUri] = useState(
|
||||
extension?.type === "streamable_http"
|
||||
? extension.uri
|
||||
: extension?.type === "sse"
|
||||
? (extension.uri ?? "")
|
||||
: "",
|
||||
);
|
||||
const [timeout, setTimeout] = useState(
|
||||
String(
|
||||
extension?.type === "stdio" || extension?.type === "streamable_http"
|
||||
? (extension.timeout ?? 300)
|
||||
: 300,
|
||||
),
|
||||
);
|
||||
const [envVars, setEnvVars] = useState<EnvVar[]>(() => {
|
||||
if (extension?.type === "stdio") return parseEnvVars(extension.envs);
|
||||
if (extension?.type === "streamable_http")
|
||||
return parseEnvVars(extension.envs);
|
||||
return [{ id: nextEnvId++, key: "", value: "" }];
|
||||
});
|
||||
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
(type === "stdio" ? cmd.trim().length > 0 : uri.trim().length > 0);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const form = useExtensionModalForm(extension);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit || isSaving) return;
|
||||
if (!form.canSubmit || isSaving) return;
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim();
|
||||
const envs = buildEnvVars(envVars);
|
||||
const timeoutNum = Number.parseInt(timeout, 10) || 300;
|
||||
|
||||
let config: ExtensionConfig;
|
||||
|
||||
if (type === "stdio") {
|
||||
config = {
|
||||
...(extension?.type === "stdio" ? extension : {}),
|
||||
type: "stdio",
|
||||
name: trimmedName,
|
||||
description,
|
||||
cmd: cmd.trim(),
|
||||
args: args
|
||||
.split("\n")
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean),
|
||||
envs,
|
||||
timeout: timeoutNum,
|
||||
};
|
||||
} else {
|
||||
if (!uri.trim()) return;
|
||||
config = {
|
||||
...(extension?.type === "streamable_http" ? extension : {}),
|
||||
type: "streamable_http",
|
||||
name: trimmedName,
|
||||
description,
|
||||
uri: uri.trim(),
|
||||
envs,
|
||||
timeout: timeoutNum,
|
||||
};
|
||||
}
|
||||
|
||||
await onSubmit(trimmedName, config, extension?.enabled ?? true);
|
||||
const payload = form.buildSubmitPayload();
|
||||
if (!payload) return;
|
||||
await onSubmit(payload.name, payload.config);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateEnvVar = (index: number, field: "key" | "value", val: string) => {
|
||||
setEnvVars((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], [field]: val };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!extension || !onDelete || isDeleting) return;
|
||||
|
||||
const addEnvVar = () => {
|
||||
setEnvVars((prev) => [...prev, { id: nextEnvId++, key: "", value: "" }]);
|
||||
};
|
||||
|
||||
const removeEnvVar = (id: number) => {
|
||||
setEnvVars((prev) => {
|
||||
if (prev.length <= 1) return [{ id: nextEnvId++, key: "", value: "" }];
|
||||
return prev.filter((v) => v.id !== id);
|
||||
});
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete(extension.config_key);
|
||||
setIsDeleteDialogOpen(false);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit
|
||||
? t("extensions.editExtension")
|
||||
: t("extensions.addExtension")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit
|
||||
? t("extensions.editExtension")
|
||||
: t("extensions.addExtension")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-name">{t("extensions.fields.name")}</Label>
|
||||
<Input
|
||||
id="ext-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("extensions.fields.namePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-type">{t("extensions.fields.type")}</Label>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(v) => setType(v as ExtensionType)}
|
||||
>
|
||||
<SelectTrigger id="ext-type" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">
|
||||
{t("extensions.types.stdio")}
|
||||
</SelectItem>
|
||||
<SelectItem value="streamable_http">
|
||||
{t("extensions.types.streamable_http")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-desc">
|
||||
{t("extensions.fields.description")}
|
||||
</Label>
|
||||
<Input
|
||||
id="ext-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("extensions.fields.descriptionPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{type === "stdio" && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-cmd">
|
||||
{t("extensions.fields.command")}
|
||||
</Label>
|
||||
<Input
|
||||
id="ext-cmd"
|
||||
value={cmd}
|
||||
onChange={(e) => setCmd(e.target.value)}
|
||||
placeholder={t("extensions.fields.commandPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-args">
|
||||
{t("extensions.fields.arguments")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="ext-args"
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
placeholder={t("extensions.fields.argumentsPlaceholder")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "streamable_http" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-uri">{t("extensions.fields.url")}</Label>
|
||||
<Label htmlFor="ext-name">{t("extensions.fields.name")}</Label>
|
||||
<Input
|
||||
id="ext-uri"
|
||||
value={uri}
|
||||
onChange={(e) => setUri(e.target.value)}
|
||||
placeholder={t("extensions.fields.urlPlaceholder")}
|
||||
id="ext-name"
|
||||
value={form.name}
|
||||
onChange={(e) => form.setName(e.target.value)}
|
||||
placeholder={t("extensions.fields.namePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-timeout">
|
||||
{t("extensions.fields.timeout")}
|
||||
</Label>
|
||||
<Input
|
||||
id="ext-timeout"
|
||||
type="number"
|
||||
value={timeout}
|
||||
onChange={(e) => setTimeout(e.target.value)}
|
||||
min={1}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-type">{t("extensions.fields.type")}</Label>
|
||||
<Select
|
||||
value={form.type}
|
||||
onValueChange={(value) =>
|
||||
form.setType(value as ExtensionModalType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="ext-type" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">
|
||||
{t("extensions.types.stdio")}
|
||||
</SelectItem>
|
||||
<SelectItem value="streamable_http">
|
||||
{t("extensions.types.streamable_http")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-desc">
|
||||
{t("extensions.fields.description")}
|
||||
</Label>
|
||||
<Input
|
||||
id="ext-desc"
|
||||
value={form.description}
|
||||
onChange={(e) => form.setDescription(e.target.value)}
|
||||
placeholder={t("extensions.fields.descriptionPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.type === "stdio" && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-cmd">
|
||||
{t("extensions.fields.command")}
|
||||
</Label>
|
||||
<Input
|
||||
id="ext-cmd"
|
||||
value={form.cmd}
|
||||
onChange={(e) => form.setCmd(e.target.value)}
|
||||
placeholder={t("extensions.fields.commandPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-args">
|
||||
{t("extensions.fields.arguments")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="ext-args"
|
||||
value={form.args}
|
||||
onChange={(e) => form.setArgs(e.target.value)}
|
||||
placeholder={t("extensions.fields.argumentsPlaceholder")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form.type === "streamable_http" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-uri">{t("extensions.fields.url")}</Label>
|
||||
<Input
|
||||
id="ext-uri"
|
||||
value={form.uri}
|
||||
onChange={(e) => form.setUri(e.target.value)}
|
||||
placeholder={t("extensions.fields.urlPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ext-timeout">
|
||||
{t("extensions.fields.timeout")}
|
||||
</Label>
|
||||
<Input
|
||||
id="ext-timeout"
|
||||
type="number"
|
||||
value={form.timeout}
|
||||
onChange={(e) => form.setTimeout(e.target.value)}
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("extensions.fields.envVars")}</Label>
|
||||
<div className="space-y-2">
|
||||
{form.envVars.map((env, i) => (
|
||||
<div key={env.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={env.key}
|
||||
onChange={(e) =>
|
||||
form.updateEnvVar(i, "key", e.target.value)
|
||||
}
|
||||
placeholder={t("extensions.fields.envKeyPlaceholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={env.value}
|
||||
onChange={(e) =>
|
||||
form.updateEnvVar(i, "value", e.target.value)
|
||||
}
|
||||
placeholder={t("extensions.fields.envValuePlaceholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => form.removeEnvVar(env.id)}
|
||||
className="shrink-0 hover:text-destructive"
|
||||
aria-label={t("extensions.fields.removeEnvVar")}
|
||||
>
|
||||
<IconTrash className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={form.addEnvVar}
|
||||
>
|
||||
<IconPlus className="size-3.5" />
|
||||
{t("extensions.fields.addEnvVar")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("extensions.fields.envVars")}</Label>
|
||||
<div className="space-y-2">
|
||||
{envVars.map((env, i) => (
|
||||
<div key={env.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={env.key}
|
||||
onChange={(e) => updateEnvVar(i, "key", e.target.value)}
|
||||
placeholder={t("extensions.fields.envKeyPlaceholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={env.value}
|
||||
onChange={(e) => updateEnvVar(i, "value", e.target.value)}
|
||||
placeholder={t("extensions.fields.envValuePlaceholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => removeEnvVar(env.id)}
|
||||
className="shrink-0 hover:text-destructive"
|
||||
aria-label={t("extensions.fields.removeEnvVar")}
|
||||
>
|
||||
<IconTrash className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<DialogFooter>
|
||||
{isEdit && onDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addEnvVar}
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
disabled={isSaving || isDeleting}
|
||||
className="mr-auto text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<IconPlus className="size-3.5" />
|
||||
{t("extensions.fields.addEnvVar")}
|
||||
<IconTrash className="size-4" />
|
||||
{t("extensions.deleteExtension")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{isEdit && onDelete && (
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onDelete(extension.config_key);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className="mr-auto text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<IconTrash className="size-4" />
|
||||
{t("extensions.deleteExtension")}
|
||||
{t("extensions.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t("extensions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || isSaving}
|
||||
>
|
||||
{t("extensions.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!form.canSubmit || isSaving}
|
||||
>
|
||||
{t("extensions.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isEdit && onDelete && (
|
||||
<ConfirmDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
title={t("extensions.deleteConfirmation.title", { name: form.name })}
|
||||
description={t("extensions.deleteConfirmation.description")}
|
||||
cancelLabel={t("extensions.cancel")}
|
||||
confirmLabel={t("extensions.deleteConfirmation.confirm")}
|
||||
loadingLabel={t("extensions.deleteConfirmation.deleting")}
|
||||
isLoading={isDeleting}
|
||||
overlayClassName="z-[70]"
|
||||
positionerClassName="z-[71]"
|
||||
onConfirm={handleConfirmDelete}
|
||||
onConfirmError={() => undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,143 +1,115 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { IconChevronDown, IconPlus } from "@tabler/icons-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { SearchBar } from "@/shared/ui/SearchBar";
|
||||
import { FilterRow } from "@/shared/ui/page-shell";
|
||||
import { SettingsPage } from "@/shared/ui/SettingsPage";
|
||||
import { useExtensionsSettings } from "../hooks/useExtensionsSettings";
|
||||
import {
|
||||
listExtensions,
|
||||
addExtension,
|
||||
removeExtension,
|
||||
toggleExtension,
|
||||
nameToKey,
|
||||
} from "../api/extensions";
|
||||
import {
|
||||
getDisplayName,
|
||||
type ExtensionConfig,
|
||||
type ExtensionEntry,
|
||||
} from "../types";
|
||||
EXTENSION_CATEGORIES,
|
||||
filterExtensions,
|
||||
getExtensionCategoryCounts,
|
||||
splitExtensionsByCategory,
|
||||
type ExtensionFilter,
|
||||
} from "../lib/extensionCategories";
|
||||
import type { ExtensionEntry } from "../types";
|
||||
import { ExtensionItem } from "./ExtensionItem";
|
||||
import { ExtensionModal } from "./ExtensionModal";
|
||||
import { SettingsPage } from "@/shared/ui/SettingsPage";
|
||||
|
||||
function FilterButton({
|
||||
active,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={active ? "default" : "outline-flat"}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExtensionsSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [extensions, setExtensions] = useState<ExtensionEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [modalMode, setModalMode] = useState<"add" | "edit" | null>(null);
|
||||
const [editingExtension, setEditingExtension] =
|
||||
useState<ExtensionEntry | null>(null);
|
||||
const {
|
||||
extensions,
|
||||
isLoading,
|
||||
modalMode,
|
||||
editingExtension,
|
||||
handleAdd,
|
||||
handleConfigure,
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
handleModalClose,
|
||||
} = useExtensionsSettings();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeFilter, setActiveFilter] = useState<ExtensionFilter>("all");
|
||||
const [showGooseCapabilities, setShowGooseCapabilities] = useState(false);
|
||||
|
||||
const fetchExtensions = useCallback(async () => {
|
||||
try {
|
||||
const result = await listExtensions();
|
||||
setExtensions(result);
|
||||
} catch {
|
||||
setExtensions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchExtensions();
|
||||
}, [fetchExtensions]);
|
||||
|
||||
const matchesSearch = useCallback(
|
||||
(ext: ExtensionEntry) => {
|
||||
if (!searchTerm) return true;
|
||||
const q = searchTerm.toLowerCase();
|
||||
return (
|
||||
getDisplayName(ext).toLowerCase().includes(q) ||
|
||||
ext.name.toLowerCase().includes(q) ||
|
||||
(ext.description ?? "").toLowerCase().includes(q) ||
|
||||
ext.type.toLowerCase().includes(q)
|
||||
);
|
||||
},
|
||||
[searchTerm],
|
||||
const filteredExtensions = useMemo(
|
||||
() =>
|
||||
filterExtensions({
|
||||
extensions,
|
||||
searchTerm,
|
||||
activeFilter,
|
||||
getCategoryLabel: (category) => t(`extensions.categories.${category}`),
|
||||
}),
|
||||
[activeFilter, extensions, searchTerm, t],
|
||||
);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...extensions].sort((a, b) => {
|
||||
if (a.type === "builtin" && b.type !== "builtin") return -1;
|
||||
if (a.type !== "builtin" && b.type === "builtin") return 1;
|
||||
const aBundled = a.bundled === true;
|
||||
const bBundled = b.bundled === true;
|
||||
if (aBundled && !bBundled) return -1;
|
||||
if (!aBundled && bBundled) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [extensions]);
|
||||
|
||||
const enabled = useMemo(
|
||||
() => sorted.filter((e) => e.enabled && matchesSearch(e)),
|
||||
[sorted, matchesSearch],
|
||||
);
|
||||
const available = useMemo(
|
||||
() => sorted.filter((e) => !e.enabled && matchesSearch(e)),
|
||||
[sorted, matchesSearch],
|
||||
const { primaryExtensions, gooseCapabilities } = useMemo(
|
||||
() => splitExtensionsByCategory(filteredExtensions),
|
||||
[filteredExtensions],
|
||||
);
|
||||
|
||||
const handleToggle = async (ext: ExtensionEntry) => {
|
||||
try {
|
||||
await toggleExtension(ext.config_key, !ext.enabled);
|
||||
await fetchExtensions();
|
||||
} catch {
|
||||
toast.error(t("extensions.errors.toggleFailed"));
|
||||
}
|
||||
};
|
||||
const visibleExtensions =
|
||||
activeFilter === "gooseCapabilities"
|
||||
? gooseCapabilities
|
||||
: [...primaryExtensions, ...gooseCapabilities];
|
||||
const hasSearch = searchTerm.trim().length > 0;
|
||||
const shouldShowGooseCapabilities =
|
||||
activeFilter === "gooseCapabilities" || showGooseCapabilities || hasSearch;
|
||||
const showGooseCapabilitiesToggle =
|
||||
activeFilter !== "gooseCapabilities" &&
|
||||
!hasSearch &&
|
||||
gooseCapabilities.length > 0;
|
||||
|
||||
const handleConfigure = (ext: ExtensionEntry) => {
|
||||
setEditingExtension(ext);
|
||||
setModalMode("edit");
|
||||
};
|
||||
const categoryCounts = useMemo(
|
||||
() => getExtensionCategoryCounts(extensions),
|
||||
[extensions],
|
||||
);
|
||||
|
||||
const handleSubmit = async (
|
||||
name: string,
|
||||
config: ExtensionConfig,
|
||||
extensionEnabled: boolean,
|
||||
const renderSection = (
|
||||
title: string,
|
||||
sectionExtensions: ExtensionEntry[],
|
||||
showTitle = true,
|
||||
) => {
|
||||
try {
|
||||
const newKey = nameToKey(name);
|
||||
const isEdit = !!editingExtension;
|
||||
const isAdd = !editingExtension;
|
||||
const keyChanged = isEdit && editingExtension.config_key !== newKey;
|
||||
|
||||
if (
|
||||
(isAdd || keyChanged) &&
|
||||
extensions.some((e) => e.config_key === newKey)
|
||||
) {
|
||||
toast.error(t("extensions.errors.nameConflict", { name }));
|
||||
return;
|
||||
}
|
||||
|
||||
await addExtension(name, config, extensionEnabled);
|
||||
if (keyChanged) {
|
||||
await removeExtension(editingExtension.config_key);
|
||||
}
|
||||
setModalMode(null);
|
||||
setEditingExtension(null);
|
||||
await fetchExtensions();
|
||||
} catch {
|
||||
toast.error(t("extensions.errors.saveFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (configKey: string) => {
|
||||
try {
|
||||
await removeExtension(configKey);
|
||||
setModalMode(null);
|
||||
setEditingExtension(null);
|
||||
await fetchExtensions();
|
||||
} catch {
|
||||
toast.error(t("extensions.errors.deleteFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setModalMode(null);
|
||||
setEditingExtension(null);
|
||||
if (sectionExtensions.length === 0) return null;
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
{showTitle ? (
|
||||
<h4 className="text-sm font-normal text-foreground">{title}</h4>
|
||||
) : null}
|
||||
<div className="grid gap-x-12 sm:grid-cols-2">
|
||||
{sectionExtensions.map((ext) => (
|
||||
<ExtensionItem
|
||||
key={ext.config_key}
|
||||
extension={ext}
|
||||
onConfigure={handleConfigure}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -146,76 +118,98 @@ export function ExtensionsSettings() {
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xxs"
|
||||
onClick={() => {
|
||||
setEditingExtension(null);
|
||||
setModalMode("add");
|
||||
}}
|
||||
variant="outline-flat"
|
||||
size="xs"
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<IconPlus className="size-4" />
|
||||
<IconPlus className="size-3.5" />
|
||||
{t("extensions.addExtension")}
|
||||
</Button>
|
||||
}
|
||||
controls={
|
||||
<SearchBar
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder={t("extensions.search")}
|
||||
aria-label={t("extensions.search")}
|
||||
size="compact"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<SearchBar
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder={t("extensions.search")}
|
||||
aria-label={t("extensions.search")}
|
||||
size="compact"
|
||||
/>
|
||||
<FilterRow>
|
||||
<FilterButton
|
||||
active={activeFilter === "all"}
|
||||
onClick={() => setActiveFilter("all")}
|
||||
>
|
||||
{t("extensions.filters.all")}
|
||||
</FilterButton>
|
||||
{EXTENSION_CATEGORIES.map((category) =>
|
||||
categoryCounts[category] > 0 ? (
|
||||
<FilterButton
|
||||
key={category}
|
||||
active={activeFilter === category}
|
||||
onClick={() => setActiveFilter(category)}
|
||||
>
|
||||
{t(`extensions.categories.${category}`)}
|
||||
</FilterButton>
|
||||
) : null,
|
||||
)}
|
||||
</FilterRow>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="divide-y divide-border">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-14 animate-pulse bg-muted/30" />
|
||||
<div className="grid gap-x-12 sm:grid-cols-2">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-20 animate-pulse border-b border-border-soft-divider py-4"
|
||||
>
|
||||
<div className="h-4 w-2/5 rounded bg-muted/50" />
|
||||
<div className="mt-2 h-3 w-3/5 rounded bg-muted/40" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : extensions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("extensions.empty")}</p>
|
||||
) : enabled.length === 0 && available.length === 0 && searchTerm ? (
|
||||
) : visibleExtensions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("extensions.noResults")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{enabled.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("extensions.enabledCount", { count: enabled.length })}
|
||||
</h4>
|
||||
<div className="divide-y divide-border">
|
||||
{enabled.map((ext) => (
|
||||
<ExtensionItem
|
||||
key={ext.config_key}
|
||||
extension={ext}
|
||||
onToggle={handleToggle}
|
||||
onConfigure={handleConfigure}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-8">
|
||||
{activeFilter !== "gooseCapabilities"
|
||||
? renderSection(
|
||||
t("extensions.sections.extensions"),
|
||||
primaryExtensions,
|
||||
false,
|
||||
)
|
||||
: null}
|
||||
|
||||
{available.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{t("extensions.availableCount", { count: available.length })}
|
||||
</h4>
|
||||
<div className="divide-y divide-border">
|
||||
{available.map((ext) => (
|
||||
<ExtensionItem
|
||||
key={ext.config_key}
|
||||
extension={ext}
|
||||
onToggle={handleToggle}
|
||||
onConfigure={handleConfigure}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{shouldShowGooseCapabilities
|
||||
? renderSection(
|
||||
t("extensions.sections.gooseCapabilities"),
|
||||
gooseCapabilities,
|
||||
)
|
||||
: null}
|
||||
|
||||
{showGooseCapabilitiesToggle ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowGooseCapabilities((current) => !current)}
|
||||
className="w-full text-muted-foreground"
|
||||
>
|
||||
{showGooseCapabilities
|
||||
? t("extensions.hideGooseCapabilities")
|
||||
: t("extensions.showGooseCapabilities", {
|
||||
count: gooseCapabilities.length,
|
||||
})}
|
||||
{!showGooseCapabilities ? (
|
||||
<IconChevronDown className="size-3" />
|
||||
) : null}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PageShell } from "@/shared/ui/page-shell";
|
||||
import { ExtensionsSettings } from "./ExtensionsSettings";
|
||||
|
||||
export function ExtensionsView() {
|
||||
return (
|
||||
<PageShell>
|
||||
<ExtensionsSettings />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ExtensionEntry } from "../../types";
|
||||
import { ExtensionModal } from "../ExtensionModal";
|
||||
|
||||
const extension: ExtensionEntry = {
|
||||
type: "stdio",
|
||||
name: "github",
|
||||
description: "Issue tracker",
|
||||
cmd: "npx",
|
||||
args: [],
|
||||
config_key: "github",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
describe("ExtensionModal", () => {
|
||||
it("confirms before deleting an extension", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleDelete = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<ExtensionModal
|
||||
extension={extension}
|
||||
onSubmit={vi.fn()}
|
||||
onDelete={handleDelete}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Delete Extension" }));
|
||||
|
||||
expect(handleDelete).not.toHaveBeenCalled();
|
||||
const confirmation = screen.getByRole("dialog", {
|
||||
name: 'Delete "github" permanently?',
|
||||
});
|
||||
expect(
|
||||
within(confirmation).getByText('Delete "github" permanently?'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
within(confirmation).getByRole("button", { name: "Delete Extension" }),
|
||||
);
|
||||
|
||||
expect(handleDelete).toHaveBeenCalledWith("github");
|
||||
});
|
||||
|
||||
it("dismisses the delete confirmation when clicking outside it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleDelete = vi.fn().mockResolvedValue(undefined);
|
||||
const handleClose = vi.fn();
|
||||
|
||||
render(
|
||||
<ExtensionModal
|
||||
extension={extension}
|
||||
onSubmit={vi.fn()}
|
||||
onDelete={handleDelete}
|
||||
onClose={handleClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Delete Extension" }));
|
||||
expect(
|
||||
screen.getByRole("dialog", {
|
||||
name: 'Delete "github" permanently?',
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const overlays = document.querySelectorAll('[data-slot$="dialog-overlay"]');
|
||||
expect(overlays).toHaveLength(2);
|
||||
expect(overlays[overlays.length - 1]).toHaveClass("z-[70]");
|
||||
await user.click(overlays[overlays.length - 1] as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("dialog", {
|
||||
name: 'Delete "github" permanently?',
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(handleDelete).not.toHaveBeenCalled();
|
||||
expect(handleClose).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "Edit Extension" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ExtensionEntry } from "../../types";
|
||||
import { ExtensionsSettings } from "../ExtensionsSettings";
|
||||
|
||||
const mockUseExtensionsSettings = vi.fn();
|
||||
|
||||
vi.mock("@/features/extensions/hooks/useExtensionsSettings", () => ({
|
||||
useExtensionsSettings: () => mockUseExtensionsSettings(),
|
||||
}));
|
||||
|
||||
const extensions: ExtensionEntry[] = [
|
||||
{
|
||||
type: "stdio",
|
||||
name: "github",
|
||||
description: "Issue tracker",
|
||||
cmd: "npx",
|
||||
args: [],
|
||||
config_key: "github",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
type: "builtin",
|
||||
name: "developer",
|
||||
display_name: "Developer",
|
||||
description: "Code tools",
|
||||
config_key: "developer",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
type: "platform",
|
||||
name: "summarize",
|
||||
display_name: "Summarize",
|
||||
description: "Summarize files",
|
||||
config_key: "summarize",
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
describe("ExtensionsSettings", () => {
|
||||
beforeEach(() => {
|
||||
mockUseExtensionsSettings.mockReturnValue({
|
||||
extensions,
|
||||
isLoading: false,
|
||||
modalMode: null,
|
||||
editingExtension: null,
|
||||
handleAdd: vi.fn(),
|
||||
handleConfigure: vi.fn(),
|
||||
handleSubmit: vi.fn(),
|
||||
handleDelete: vi.fn(),
|
||||
handleModalClose: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("reveals matching Goose capabilities while searching", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ExtensionsSettings />);
|
||||
|
||||
expect(screen.queryByText("Developer")).not.toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByRole("searchbox"), "developer");
|
||||
|
||||
expect(screen.getByText("Developer")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: /show .*built-in goose capabilities/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show global enable toggles", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ExtensionsSettings />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("switch", { name: /disable github/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByRole("searchbox"), "summarize");
|
||||
|
||||
expect(
|
||||
screen.queryByRole("switch", { name: /enable summarize/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("switch", { name: /enable developer/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -13,11 +13,10 @@ import {
|
||||
Stethoscope,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { IconPlug, IconPuzzle } from "@tabler/icons-react";
|
||||
import { IconPlug } from "@tabler/icons-react";
|
||||
import { AppearanceSettings } from "./AppearanceSettings";
|
||||
import { DoctorSettings } from "./DoctorSettings";
|
||||
import { ProvidersSettings } from "./ProvidersSettings";
|
||||
import { ExtensionsSettings } from "@/features/extensions/ui/ExtensionsSettings";
|
||||
import { VoiceInputSettings } from "./VoiceInputSettings";
|
||||
import { GeneralSettings } from "./GeneralSettings";
|
||||
import { CompactionSettings } from "./CompactionSettings";
|
||||
@@ -29,7 +28,6 @@ const NAV_ITEMS = [
|
||||
{ id: "appearance", labelKey: "nav.appearance", icon: Palette },
|
||||
{ id: "providers", labelKey: "nav.providers", icon: IconPlug },
|
||||
{ id: "compaction", labelKey: "nav.compaction", icon: Minimize2 },
|
||||
{ id: "extensions", labelKey: "nav.extensions", icon: IconPuzzle },
|
||||
{ id: "voice", labelKey: "nav.voice", icon: Mic },
|
||||
{ id: "general", labelKey: "nav.general", icon: Settings2 },
|
||||
{ id: "projects", labelKey: "nav.projects", icon: FolderKanban },
|
||||
@@ -170,7 +168,6 @@ export function SettingsModal({
|
||||
{activeSection === "appearance" && <AppearanceSettings />}
|
||||
{activeSection === "providers" && <ProvidersSettings />}
|
||||
{activeSection === "compaction" && <CompactionSettings />}
|
||||
{activeSection === "extensions" && <ExtensionsSettings />}
|
||||
{activeSection === "voice" && <VoiceInputSettings />}
|
||||
{activeSection === "doctor" && <DoctorSettings />}
|
||||
{activeSection === "general" && <GeneralSettings />}
|
||||
|
||||
@@ -5,11 +5,12 @@ import {
|
||||
IconHome,
|
||||
IconLayoutSidebar,
|
||||
IconLayoutSidebarFilled,
|
||||
IconApps,
|
||||
IconRobotFace,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconStack,
|
||||
} from "@tabler/icons-react";
|
||||
import { SkillIcon } from "@/features/skills/ui/SkillIcon";
|
||||
import { getDisplaySessionTitle } from "@/features/chat/lib/sessionTitle";
|
||||
import { GooseIcon } from "@/shared/ui/icons/GooseIcon";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
@@ -131,7 +132,12 @@ export function Sidebar({
|
||||
icon: typeof IconRobotFace;
|
||||
}[] = [
|
||||
{ id: "agents", label: t("navigation.agents"), icon: IconRobotFace },
|
||||
{ id: "skills", label: t("navigation.skills"), icon: IconStack },
|
||||
{ id: "skills", label: t("navigation.skills"), icon: SkillIcon },
|
||||
{
|
||||
id: "extensions",
|
||||
label: t("navigation.extensions"),
|
||||
icon: IconApps,
|
||||
},
|
||||
{
|
||||
id: "session-history",
|
||||
label: t("navigation.sessionHistory"),
|
||||
@@ -461,7 +467,7 @@ export function Sidebar({
|
||||
size={collapsed ? "icon-sm" : "default"}
|
||||
onClick={onSettingsClick}
|
||||
className={cn(
|
||||
"h-10 w-full rounded-md bg-transparent text-foreground hover:bg-transparent hover:text-foreground active:bg-transparent",
|
||||
"h-10 w-full rounded-md bg-transparent text-muted-foreground/85 hover:bg-transparent hover:text-foreground active:bg-transparent",
|
||||
collapsed
|
||||
? "justify-center p-3"
|
||||
: "justify-start gap-2.5 px-3 py-2.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconMessage } from "@tabler/icons-react";
|
||||
import { IconEdit, IconMessage } from "@tabler/icons-react";
|
||||
import { getDisplaySessionTitle } from "@/features/chat/lib/sessionTitle";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -96,16 +96,16 @@ export function SidebarRecentsSection({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
size="icon-xs"
|
||||
onClick={onNewChat}
|
||||
aria-label={t("actions.newChat")}
|
||||
title={t("actions.newChat")}
|
||||
className={cn(
|
||||
"mr-1 h-6 flex-shrink-0 rounded-full bg-muted px-2 text-[11px] text-foreground opacity-0 transition-opacity duration-150 ease-out hover:bg-muted/80 hover:text-foreground",
|
||||
"pointer-events-none group-hover/chats-header:pointer-events-auto group-hover/chats-header:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100",
|
||||
"mr-1 size-6 flex-shrink-0 rounded-md",
|
||||
"opacity-0 pointer-events-none group-hover/chats-header:opacity-100 group-hover/chats-header:pointer-events-auto group-focus-within/chats-header:opacity-100 group-focus-within/chats-header:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto",
|
||||
)}
|
||||
>
|
||||
{t("actions.newChat")}
|
||||
<IconEdit className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { IconBook } from "@tabler/icons-react";
|
||||
|
||||
export const SkillIcon = IconBook;
|
||||
@@ -125,6 +125,8 @@ describe("acpNotificationHandler", () => {
|
||||
type: "toolRequest",
|
||||
id: "tool-1",
|
||||
name: "mcp_app_bench__inspect_host_info",
|
||||
toolName: "mcp_app_bench__inspect_host_info",
|
||||
extensionName: "mcp_app_bench",
|
||||
status: "completed",
|
||||
});
|
||||
expect(message.content[1]).toMatchObject({
|
||||
@@ -234,6 +236,11 @@ describe("acpNotificationHandler", () => {
|
||||
"mcpApp",
|
||||
"text",
|
||||
]);
|
||||
expect(buffer?.[1]?.content[0]).toMatchObject({
|
||||
type: "toolRequest",
|
||||
toolName: "mcp_app_bench__inspect_host_info",
|
||||
extensionName: "mcp_app_bench",
|
||||
});
|
||||
expect(buffer?.[1]?.content[2]).toMatchObject({
|
||||
type: "mcpApp",
|
||||
id: "tool-1",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearReplayBuffer,
|
||||
getReplayBuffer,
|
||||
} from "@/features/chat/hooks/replayBuffer";
|
||||
import { useChatStore } from "@/features/chat/stores/chatStore";
|
||||
import {
|
||||
clearMessageTracking,
|
||||
handleSessionNotification,
|
||||
setActiveMessageId,
|
||||
} from "../acpNotificationHandler";
|
||||
import { registerSession } from "../acpSessionTracker";
|
||||
|
||||
describe("ACP tool call status handling", () => {
|
||||
beforeEach(() => {
|
||||
clearMessageTracking();
|
||||
clearReplayBuffer("replay-failed-tool-session");
|
||||
clearReplayBuffer("goose-session");
|
||||
useChatStore.setState({
|
||||
messagesBySession: {},
|
||||
sessionStateById: {},
|
||||
queuedMessageBySession: {},
|
||||
draftsBySession: {},
|
||||
activeSessionId: null,
|
||||
isConnected: false,
|
||||
loadingSessionIds: new Set<string>(),
|
||||
scrollTargetMessageBySession: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("marks failed replay tool updates as errors", async () => {
|
||||
const replaySessionId = "replay-failed-tool-session";
|
||||
useChatStore.setState({
|
||||
loadingSessionIds: new Set<string>([replaySessionId]),
|
||||
});
|
||||
|
||||
await handleSessionNotification({
|
||||
sessionId: replaySessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
title: "shell",
|
||||
},
|
||||
} as never);
|
||||
|
||||
await handleSessionNotification({
|
||||
sessionId: replaySessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "tool-1",
|
||||
status: "failed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "Command failed.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
|
||||
const assistant = getReplayBuffer(replaySessionId)?.[0];
|
||||
expect(assistant?.content[0]).toMatchObject({
|
||||
type: "toolRequest",
|
||||
id: "tool-1",
|
||||
status: "error",
|
||||
});
|
||||
expect(assistant?.content[1]).toMatchObject({
|
||||
type: "toolResponse",
|
||||
id: "tool-1",
|
||||
isError: true,
|
||||
result: "Command failed.",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks failed live tool updates as errors", async () => {
|
||||
registerSession(
|
||||
"local-session",
|
||||
"goose-session",
|
||||
"goose",
|
||||
"/Users/aharvard/.goose/artifacts",
|
||||
);
|
||||
setActiveMessageId("goose-session", "assistant-1");
|
||||
|
||||
await handleSessionNotification({
|
||||
sessionId: "goose-session",
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
title: "shell",
|
||||
},
|
||||
} as never);
|
||||
|
||||
await handleSessionNotification({
|
||||
sessionId: "goose-session",
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "tool-1",
|
||||
status: "failed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "Command failed.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
|
||||
const [message] =
|
||||
useChatStore.getState().messagesBySession["local-session"];
|
||||
expect(message.content[0]).toMatchObject({
|
||||
type: "toolRequest",
|
||||
id: "tool-1",
|
||||
status: "error",
|
||||
});
|
||||
expect(message.content[1]).toMatchObject({
|
||||
type: "toolResponse",
|
||||
id: "tool-1",
|
||||
isError: true,
|
||||
result: "Command failed.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
findLatestUnpairedToolRequest,
|
||||
} from "@/features/chat/hooks/replayBuffer";
|
||||
import type {
|
||||
ToolCallStatus,
|
||||
ToolRequestContent,
|
||||
ToolResponseContent,
|
||||
} from "@/shared/types/messages";
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
getLocalSessionId,
|
||||
subscribeToSessionRegistration,
|
||||
} from "./acpSessionTracker";
|
||||
import { getToolCallIdentity } from "./acpToolCallIdentity";
|
||||
import { perfLog } from "@/shared/lib/perfLog";
|
||||
|
||||
// Pre-set message ID for the next live stream per goose session
|
||||
@@ -53,6 +55,9 @@ const pendingUsageUpdates = new Map<
|
||||
{ accumulatedTotal: number; contextLimit: number }
|
||||
>();
|
||||
|
||||
const toolCallStatusFromUpdate = (status: string): ToolCallStatus =>
|
||||
status === "failed" ? "error" : "completed";
|
||||
|
||||
subscribeToSessionRegistration((localSessionId, gooseSessionId) => {
|
||||
const pendingUsage = pendingUsageUpdates.get(gooseSessionId);
|
||||
if (!pendingUsage) {
|
||||
@@ -180,6 +185,7 @@ function handleReplay(
|
||||
|
||||
case "tool_call": {
|
||||
const created = getReplayCreated(update);
|
||||
const identity = getToolCallIdentity(update);
|
||||
const msg = ensureReplayAssistantMessage(
|
||||
sessionId,
|
||||
getReplayMessageId(update),
|
||||
@@ -189,6 +195,7 @@ function handleReplay(
|
||||
type: "toolRequest",
|
||||
id: update.toolCallId,
|
||||
name: update.title,
|
||||
...identity,
|
||||
arguments: {},
|
||||
status: "executing",
|
||||
startedAt: created ?? Date.now(),
|
||||
@@ -199,6 +206,7 @@ function handleReplay(
|
||||
case "tool_call_update": {
|
||||
const created = getReplayCreated(update);
|
||||
const replayMessageId = getReplayMessageId(update);
|
||||
const identity = getToolCallIdentity(update);
|
||||
const trackedMessageId = getTrackedReplayAssistantMessageId(sessionId);
|
||||
const replayMsg = replayMessageId
|
||||
? getBufferedMessage(sessionId, replayMessageId)
|
||||
@@ -216,15 +224,19 @@ function handleReplay(
|
||||
if (created !== undefined && !existingMsg && msg === replayMsg) {
|
||||
msg.created = created;
|
||||
}
|
||||
if (update.title) {
|
||||
if (update.title || Object.keys(identity).length > 0) {
|
||||
const tc = msg.content.find(
|
||||
(c) => c.type === "toolRequest" && c.id === update.toolCallId,
|
||||
);
|
||||
if (tc && tc.type === "toolRequest") {
|
||||
(tc as ToolRequestContent).name = update.title;
|
||||
Object.assign(tc as ToolRequestContent, {
|
||||
...(update.title ? { name: update.title } : {}),
|
||||
...identity,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (update.status === "completed" || update.status === "failed") {
|
||||
const toolCallStatus = toolCallStatusFromUpdate(update.status);
|
||||
const tc = msg.content.find(
|
||||
(c) => c.type === "toolRequest" && c.id === update.toolCallId,
|
||||
);
|
||||
@@ -233,7 +245,8 @@ function handleReplay(
|
||||
if (idx >= 0) {
|
||||
msg.content[idx] = {
|
||||
...tc,
|
||||
status: "completed",
|
||||
...identity,
|
||||
status: toolCallStatus,
|
||||
} as ToolRequestContent;
|
||||
}
|
||||
}
|
||||
@@ -299,11 +312,13 @@ function handleLive(
|
||||
|
||||
case "tool_call": {
|
||||
const messageId = ensureLiveAssistantMessage(sessionId, gooseSessionId);
|
||||
const identity = getToolCallIdentity(update);
|
||||
|
||||
const toolRequest: ToolRequestContent = {
|
||||
type: "toolRequest",
|
||||
id: update.toolCallId,
|
||||
name: update.title,
|
||||
...identity,
|
||||
arguments: {},
|
||||
status: "executing",
|
||||
startedAt: Date.now(),
|
||||
@@ -315,19 +330,25 @@ function handleLive(
|
||||
|
||||
case "tool_call_update": {
|
||||
const messageId = ensureLiveAssistantMessage(sessionId, gooseSessionId);
|
||||
const identity = getToolCallIdentity(update);
|
||||
|
||||
if (update.title) {
|
||||
if (update.title || Object.keys(identity).length > 0) {
|
||||
store.updateMessage(sessionId, messageId, (msg) => ({
|
||||
...msg,
|
||||
content: msg.content.map((c) =>
|
||||
c.type === "toolRequest" && c.id === update.toolCallId
|
||||
? { ...c, name: update.title ?? "" }
|
||||
? {
|
||||
...c,
|
||||
...(update.title ? { name: update.title } : {}),
|
||||
...identity,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
if (update.status === "completed" || update.status === "failed") {
|
||||
const toolCallStatus = toolCallStatusFromUpdate(update.status);
|
||||
const streamingMessage = store.messagesBySession[sessionId]?.find(
|
||||
(m) => m.id === messageId,
|
||||
);
|
||||
@@ -339,7 +360,11 @@ function handleLive(
|
||||
...msg,
|
||||
content: msg.content.map((block) =>
|
||||
block.type === "toolRequest" && block.id === update.toolCallId
|
||||
? { ...block, status: "completed" }
|
||||
? {
|
||||
...block,
|
||||
...identity,
|
||||
status: toolCallStatus,
|
||||
}
|
||||
: block,
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { SessionUpdate } from "@agentclientprotocol/sdk";
|
||||
|
||||
export interface ToolCallIdentity {
|
||||
toolName?: string;
|
||||
extensionName?: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export function getToolCallIdentity(update: SessionUpdate): ToolCallIdentity {
|
||||
if (!isRecord(update._meta)) {
|
||||
return {};
|
||||
}
|
||||
const goose = update._meta.goose;
|
||||
if (!isRecord(goose)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const toolCall = isRecord(goose.mcpApp)
|
||||
? goose.mcpApp
|
||||
: isRecord(goose.toolCall)
|
||||
? goose.toolCall
|
||||
: null;
|
||||
if (!toolCall) return {};
|
||||
|
||||
return {
|
||||
...(typeof toolCall.toolName === "string"
|
||||
? { toolName: toolCall.toolName }
|
||||
: {}),
|
||||
...(typeof toolCall.extensionName === "string"
|
||||
? { extensionName: toolCall.extensionName }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -45,9 +45,7 @@
|
||||
"empty": {
|
||||
"folderNotSet": "Folder not set",
|
||||
"noChanges": "No uncommitted changes",
|
||||
"noProjectAssigned": "No project assigned",
|
||||
"noExtensions": "No extensions enabled",
|
||||
"noMatchingExtensions": "No matching extensions"
|
||||
"noProjectAssigned": "No project assigned"
|
||||
},
|
||||
"errors": {
|
||||
"gitRead": "Couldn't read git status."
|
||||
@@ -91,8 +89,6 @@
|
||||
"artifacts": "Artifacts",
|
||||
"changes": "Changes",
|
||||
"changesOnBranch": "on",
|
||||
"extensions": "Extensions",
|
||||
"searchExtensions": "Search extensions...",
|
||||
"workspace": "Workspace"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -83,16 +83,17 @@
|
||||
"extensions": {
|
||||
"title": "Extensions",
|
||||
"search": "Search extensions...",
|
||||
"enabled": "Enabled",
|
||||
"enabledCount": "Enabled ({{count}})",
|
||||
"available": "Available",
|
||||
"availableCount": "Available ({{count}})",
|
||||
"empty": "No extensions configured.",
|
||||
"noResults": "No extensions match your search.",
|
||||
"addExtension": "Add extension",
|
||||
"editExtension": "Edit Extension",
|
||||
"deleteExtension": "Delete Extension",
|
||||
"toggle": "Toggle {{name}}",
|
||||
"deleteConfirmation": {
|
||||
"title": "Delete \"{{name}}\" permanently?",
|
||||
"description": "This will permanently remove this extension and all of its settings.",
|
||||
"confirm": "Delete Extension",
|
||||
"deleting": "Deleting..."
|
||||
},
|
||||
"configure": "Configure {{name}}",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
@@ -120,8 +121,20 @@
|
||||
"streamable_http": "HTTP",
|
||||
"builtin": "Built-in"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"categories": {
|
||||
"appsServices": "Apps & services",
|
||||
"gooseCapabilities": "Goose capabilities"
|
||||
},
|
||||
"sections": {
|
||||
"extensions": "Extensions",
|
||||
"gooseCapabilities": "Built-in Goose capabilities"
|
||||
},
|
||||
"showGooseCapabilities": "Show {{count}} built-in Goose capabilities",
|
||||
"hideGooseCapabilities": "Hide built-in Goose capabilities",
|
||||
"errors": {
|
||||
"toggleFailed": "Failed to toggle extension. Please try again.",
|
||||
"saveFailed": "Failed to save extension. Please try again.",
|
||||
"deleteFailed": "Failed to delete extension. Please try again.",
|
||||
"nameConflict": "An extension named \"{{name}}\" already exists."
|
||||
@@ -198,7 +211,6 @@
|
||||
"doctor": "Doctor",
|
||||
"general": "General",
|
||||
"projects": "Projects",
|
||||
"extensions": "Extensions",
|
||||
"providers": "Providers",
|
||||
"voice": "Voice"
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"navigation": {
|
||||
"agents": "Agents",
|
||||
"extensions": "Extensions",
|
||||
"home": "Home",
|
||||
"sessionHistory": "Session history",
|
||||
"skills": "Skills"
|
||||
|
||||
@@ -45,9 +45,7 @@
|
||||
"empty": {
|
||||
"folderNotSet": "Carpeta no configurada",
|
||||
"noChanges": "No hay cambios sin confirmar",
|
||||
"noProjectAssigned": "No hay proyecto asignado",
|
||||
"noExtensions": "No hay extensiones habilitadas",
|
||||
"noMatchingExtensions": "No hay extensiones que coincidan"
|
||||
"noProjectAssigned": "No hay proyecto asignado"
|
||||
},
|
||||
"errors": {
|
||||
"gitRead": "No se pudo leer el estado de git."
|
||||
@@ -91,8 +89,6 @@
|
||||
"artifacts": "Artefactos",
|
||||
"changes": "Cambios",
|
||||
"changesOnBranch": "en",
|
||||
"extensions": "Extensiones",
|
||||
"searchExtensions": "Buscar extensiones...",
|
||||
"workspace": "Espacio de trabajo"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -83,16 +83,17 @@
|
||||
"extensions": {
|
||||
"title": "Extensiones",
|
||||
"search": "Buscar extensiones...",
|
||||
"enabled": "Habilitadas",
|
||||
"enabledCount": "Habilitadas ({{count}})",
|
||||
"available": "Disponibles",
|
||||
"availableCount": "Disponibles ({{count}})",
|
||||
"empty": "No hay extensiones configuradas.",
|
||||
"noResults": "Ninguna extensión coincide con tu búsqueda.",
|
||||
"addExtension": "Agregar extensión",
|
||||
"editExtension": "Editar extensión",
|
||||
"deleteExtension": "Eliminar extensión",
|
||||
"toggle": "Activar/desactivar {{name}}",
|
||||
"deleteConfirmation": {
|
||||
"title": "¿Eliminar \"{{name}}\" de forma permanente?",
|
||||
"description": "Esto eliminará de forma permanente esta extensión y toda su configuración.",
|
||||
"confirm": "Eliminar extensión",
|
||||
"deleting": "Eliminando..."
|
||||
},
|
||||
"configure": "Configurar {{name}}",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
@@ -120,8 +121,20 @@
|
||||
"streamable_http": "HTTP",
|
||||
"builtin": "Integrada"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Todas"
|
||||
},
|
||||
"categories": {
|
||||
"appsServices": "Apps y servicios",
|
||||
"gooseCapabilities": "Capacidades de Goose"
|
||||
},
|
||||
"sections": {
|
||||
"extensions": "Extensiones",
|
||||
"gooseCapabilities": "Capacidades integradas de Goose"
|
||||
},
|
||||
"showGooseCapabilities": "Mostrar {{count}} capacidades integradas de Goose",
|
||||
"hideGooseCapabilities": "Ocultar capacidades integradas de Goose",
|
||||
"errors": {
|
||||
"toggleFailed": "Error al cambiar la extensión. Inténtalo de nuevo.",
|
||||
"saveFailed": "Error al guardar la extensión. Inténtalo de nuevo.",
|
||||
"deleteFailed": "Error al eliminar la extensión. Inténtalo de nuevo.",
|
||||
"nameConflict": "Ya existe una extensión llamada \"{{name}}\"."
|
||||
@@ -198,7 +211,6 @@
|
||||
"doctor": "Diagnóstico",
|
||||
"general": "General",
|
||||
"projects": "Proyectos",
|
||||
"extensions": "Extensiones",
|
||||
"providers": "Proveedores",
|
||||
"voice": "Voz"
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"navigation": {
|
||||
"agents": "Agentes",
|
||||
"extensions": "Extensiones",
|
||||
"home": "Inicio",
|
||||
"sessionHistory": "Historial de sesiones",
|
||||
"skills": "Habilidades"
|
||||
|
||||
@@ -78,6 +78,8 @@ export interface ToolRequestContent {
|
||||
type: "toolRequest";
|
||||
id: string;
|
||||
name: string;
|
||||
toolName?: string;
|
||||
extensionName?: string;
|
||||
arguments: Record<string, unknown>;
|
||||
status: ToolCallStatus;
|
||||
/** Epoch ms when the tool call started executing (set on event receipt). */
|
||||
|
||||
@@ -21,7 +21,7 @@ export function SessionActivityIndicator({
|
||||
role="status"
|
||||
aria-label="Chat active"
|
||||
className={cn(
|
||||
"absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full border border-background bg-background shadow-sm transition-opacity duration-200 ease-out animate-in fade-in-0",
|
||||
"absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center transition-opacity duration-200 ease-out animate-in fade-in-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ConfirmDialog } from "./confirm-dialog";
|
||||
|
||||
describe("ConfirmDialog", () => {
|
||||
it("routes rejected confirm actions to onConfirmError", async () => {
|
||||
const user = userEvent.setup();
|
||||
const error = new Error("Delete failed");
|
||||
const onConfirm = vi.fn().mockRejectedValue(error);
|
||||
const onConfirmError = vi.fn();
|
||||
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
title="Delete item?"
|
||||
description="This cannot be undone."
|
||||
cancelLabel="Cancel"
|
||||
confirmLabel="Delete"
|
||||
onConfirm={onConfirm}
|
||||
onConfirmError={onConfirmError}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
await waitFor(() => {
|
||||
expect(onConfirmError).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type * as React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Button, type ButtonProps } from "@/shared/ui/button";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
cancelLabel: React.ReactNode;
|
||||
confirmLabel: React.ReactNode;
|
||||
loadingLabel?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
confirmVariant?: ButtonProps["variant"];
|
||||
contentClassName?: string;
|
||||
overlayClassName?: string;
|
||||
positionerClassName?: string;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onConfirmError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
loadingLabel,
|
||||
isLoading = false,
|
||||
confirmVariant = "destructive",
|
||||
contentClassName = "max-w-sm",
|
||||
overlayClassName,
|
||||
positionerClassName,
|
||||
onConfirm,
|
||||
onConfirmError,
|
||||
}: ConfirmDialogProps) {
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
await onConfirm();
|
||||
} catch (error) {
|
||||
onConfirmError?.(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!isLoading) {
|
||||
onOpenChange(nextOpen);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={contentClassName}
|
||||
overlayClassName={overlayClassName}
|
||||
positionerClassName={positionerClassName}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirmVariant}
|
||||
disabled={isLoading}
|
||||
onClick={() => void handleConfirm()}
|
||||
>
|
||||
{isLoading && loadingLabel ? loadingLabel : confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -47,17 +47,24 @@ function DialogOverlay({
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
overlayClassName,
|
||||
positionerClassName,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
overlayClassName?: string;
|
||||
positionerClassName?: string;
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
<div
|
||||
data-slot="dialog-positioner"
|
||||
className="pointer-events-none fixed inset-0 z-[61] grid place-items-center p-4"
|
||||
className={cn(
|
||||
"pointer-events-none fixed inset-0 z-[61] grid place-items-center p-4",
|
||||
positionerClassName,
|
||||
)}
|
||||
>
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
|
||||
Reference in New Issue
Block a user