Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 25 additions & 17 deletions ui/desktop/src/components/ConfigContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,23 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
[reloadConfig]
);

const refreshExtensions = useCallback(async () => {
const result = await apiGetExtensions();

if (result.response.status === 422) {
throw new MalformedConfigError();
}

if (result.error && !result.data) {
console.log(result.error);
return extensionsList;
}

const extensionResponse: ExtensionResponse = result.data!;
setExtensionsList(extensionResponse.extensions);
return extensionResponse.extensions;
}, [extensionsList]);

const addExtension = useCallback(
async (name: string, config: ExtensionConfig, enabled: boolean) => {
// remove shims if present
Expand All @@ -122,39 +139,30 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
body: query,
});
await reloadConfig();
// Refresh extensions list after successful addition
await refreshExtensions();
},
[reloadConfig]
[reloadConfig, refreshExtensions]
);

const removeExtension = useCallback(
async (name: string) => {
await apiRemoveExtension({ path: { name: name } });
await reloadConfig();
// Refresh extensions list after successful removal
await refreshExtensions();
},
[reloadConfig]
[reloadConfig, refreshExtensions]
);

const getExtensions = useCallback(
async (forceRefresh = false): Promise<FixedExtensionEntry[]> => {
if (forceRefresh || extensionsList.length === 0) {
const result = await apiGetExtensions();

if (result.response.status === 422) {
throw new MalformedConfigError();
}

if (result.error && !result.data) {
console.log(result.error);
return extensionsList;
}

const extensionResponse: ExtensionResponse = result.data!;
setExtensionsList(extensionResponse.extensions);
return extensionResponse.extensions;
return await refreshExtensions();
}
return extensionsList;
},
[extensionsList]
[extensionsList, refreshExtensions]
);

const toggleExtension = useCallback(
Expand Down
11 changes: 9 additions & 2 deletions ui/desktop/src/components/extensions/ExtensionsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { MainPanelLayout } from '../Layout/MainPanelLayout';
import { Button } from '../ui/button';
import { Plus } from 'lucide-react';
import { GPSIcon } from '../ui/icons';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import ExtensionModal from '../settings/extensions/modal/ExtensionModal';
import {
getDefaultFormData,
Expand All @@ -31,6 +31,13 @@ export default function ExtensionsView({
const [refreshKey, setRefreshKey] = useState(0);
const { addExtension } = useConfig();

// Trigger refresh when deep link config changes (i.e., when a deep link is processed)
useEffect(() => {
if (viewOptions.deepLinkConfig) {
setRefreshKey((prevKey) => prevKey + 1);
}
}, [viewOptions.deepLinkConfig, viewOptions.showEnvVars]);

const handleModalClose = () => {
setIsAddModalOpen(false);
};
Expand All @@ -46,7 +53,7 @@ export default function ExtensionsView({
setRefreshKey((prevKey) => prevKey + 1);
} catch (error) {
console.error('Failed to activate extension:', error);
// Even if activation fails, we don't reopen the modal
setRefreshKey((prevKey) => prevKey + 1);
}
};

Expand Down
44 changes: 32 additions & 12 deletions ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ export default function ExtensionsSection({
showEnvVars
);

// Update deep link state when props change
useEffect(() => {
setDeepLinkConfigStateVar(deepLinkConfig);
setShowEnvVarsStateVar(showEnvVars);
}, [deepLinkConfig, showEnvVars]);

// Reset deep link state when component is re-mounted (via key prop changes)
useEffect(() => {
return () => {
// Cleanup function to reset state when component unmounts
setDeepLinkConfigStateVar(null);
setShowEnvVarsStateVar(null);
};
}, []);

const fetchExtensions = useCallback(async () => {
const extensionsList = await getExtensions(true); // Force refresh
// Sort extensions by name to maintain consistent order
Expand All @@ -69,12 +84,6 @@ export default function ExtensionsSection({
enabled: disableConfiguration ? selectedExtensions.includes(ext.name) : ext.enabled,
}));

console.log(
'Setting extensions with selectedExtensions:',
selectedExtensions,
'Extensions:',
sortedExtensions
);
setExtensions(sortedExtensions);
}, [getExtensions, disableConfiguration, selectedExtensions]);

Expand Down Expand Up @@ -129,27 +138,33 @@ export default function ExtensionsSection({
const extensionConfig = createExtensionConfig(formData);
try {
await activateExtension({ addToConfig: addExtension, extensionConfig: extensionConfig });
// Immediately refresh the extensions list after successful activation
await fetchExtensions();
} catch (error) {
console.error('Failed to activate extension:', error);
// Even if activation fails, we don't reopen the modal
} finally {
// Add a small delay to ensure backend has updated, then refresh the extensions list
setTimeout(async () => {
await fetchExtensions();
}, 500);
await fetchExtensions();
}
};

const handleUpdateExtension = async (formData: ExtensionFormData) => {
if (!selectedExtension) {
console.error('No selected extension for update');
return;
}

// Close the modal immediately
handleModalClose();

const extensionConfig = createExtensionConfig(formData);
const originalName = selectedExtension.name;

try {
await updateExtension({
enabled: formData.enabled,
extensionConfig: extensionConfig,
addToConfig: addExtension,
removeFromConfig: removeExtension,
originalName: originalName,
});
} catch (error) {
console.error('Failed to update extension:', error);
Expand Down Expand Up @@ -182,6 +197,11 @@ export default function ExtensionsSection({
setIsModalOpen(false);
setIsAddModalOpen(false);
setSelectedExtension(null);

// Clear any navigation state that might be cached
if (window.history.state?.deepLinkConfig) {
window.history.replaceState({}, '', window.location.hash);
}
};

return (
Expand Down
2 changes: 1 addition & 1 deletion ui/desktop/src/components/settings/extensions/agent-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,6 @@ export async function removeFromAgent(
}
}

function sanitizeName(name: string) {
export function sanitizeName(name: string) {
return name.toLowerCase().replace(/-/g, '').replace(/_/g, '').replace(/\s/g, '');
}
109 changes: 92 additions & 17 deletions ui/desktop/src/components/settings/extensions/extension-manager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ExtensionConfig } from '../../../api/types.gen';
import { toastService, ToastServiceOptions } from '../../../toasts';
import { addToAgent, removeFromAgent } from './agent-api';
import { addToAgent, removeFromAgent, sanitizeName } from './agent-api';

interface ActivateExtensionProps {
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
Expand Down Expand Up @@ -129,46 +129,121 @@ export async function addToAgentOnStartup({
interface UpdateExtensionProps {
enabled: boolean;
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
removeFromConfig: (name: string) => Promise<void>;
extensionConfig: ExtensionConfig;
originalName?: string;
}

/**
* Updates an extension configuration without changing its enabled state
* Updates an extension configuration, handling name changes
*/
export async function updateExtension({
enabled,
addToConfig,
removeFromConfig,
extensionConfig,
originalName,
}: UpdateExtensionProps) {
if (enabled) {
// Sanitize the new name to match the behavior when adding extensions
const sanitizedNewName = sanitizeName(extensionConfig.name);
const sanitizedOriginalName = originalName ? sanitizeName(originalName) : undefined;

// Check if the sanitized name has changed
const nameChanged = sanitizedOriginalName && sanitizedOriginalName !== sanitizedNewName;

if (nameChanged) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handling this looks good as I know it was something missed before, but some new tests for it would be good! Either in this or a followup

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good will follow up with tests now that we have vitest!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Handle name change: remove old extension and add new one

// First remove the old extension from agent (using original name)
try {
// AddToAgent
await addToAgent(extensionConfig);
await removeFromAgent(originalName!, { silent: true }); // Suppress removal toast since we'll show update toast
} catch (error) {
console.error('[updateExtension]: Failed to add extension to agent during update:', error);
// Failed to add to agent -- show that error to user and do not update the config file
throw error;
console.error('Failed to remove old extension from agent during rename:', error);
// Continue with the process even if agent removal fails
}

// Then add to config
// Remove old extension from config (using original name)
try {
await addToConfig(extensionConfig.name, extensionConfig, enabled);
await removeFromConfig(originalName!); // We know originalName is not undefined here because nameChanged is true
} catch (error) {
console.error('[updateExtension]: Failed to update extension in config:', error);
throw error;
console.error('Failed to remove old extension from config during rename:', error);
throw error; // This is more critical, so we throw
}
} else {

// Create a copy of the extension config with the sanitized name
const sanitizedExtensionConfig = {
...extensionConfig,
name: sanitizedNewName,
};

// Add new extension with sanitized name
if (enabled) {
try {
// AddToAgent with silent option to avoid duplicate toasts
await addToAgent(sanitizedExtensionConfig, { silent: true });
} catch (error) {
console.error('[updateExtension]: Failed to add renamed extension to agent:', error);
throw error;
}
}

// Add to config with sanitized name
try {
await addToConfig(extensionConfig.name, extensionConfig, enabled);
await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled);
} catch (error) {
console.error('[updateExtension]: Failed to update disabled extension in config:', error);
console.error('[updateExtension]: Failed to add renamed extension to config:', error);
throw error;
}
// show a toast that it was successfully updated

toastService.configure({ silent: false });
toastService.success({
title: `Update extension`,
msg: `Successfully updated ${extensionConfig.name} extension`,
msg: `Successfully updated ${sanitizedNewName} extension`,
});
} else {
// Create a copy of the extension config with the sanitized name
const sanitizedExtensionConfig = {
...extensionConfig,
name: sanitizedNewName,
};

if (enabled) {
try {
// AddToAgent with silent option to avoid duplicate toasts
await addToAgent(sanitizedExtensionConfig, { silent: true });
} catch (error) {
console.error('[updateExtension]: Failed to add extension to agent during update:', error);
// Failed to add to agent -- show that error to user and do not update the config file
throw error;
}

// Then add to config
try {
await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled);
} catch (error) {
console.error('[updateExtension]: Failed to update extension in config:', error);
throw error;
}

// show a toast that it was successfully updated
toastService.success({
title: `Update extension`,
msg: `Successfully updated ${sanitizedNewName} extension`,
});
} else {
try {
await addToConfig(sanitizedNewName, sanitizedExtensionConfig, enabled);
} catch (error) {
console.error('[updateExtension]: Failed to update disabled extension in config:', error);
throw error;
}

// show a toast that it was successfully updated
toastService.success({
title: `Update extension`,
msg: `Successfully updated ${sanitizedNewName} extension`,
});
}
}
}

Expand Down
Loading