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
2 changes: 1 addition & 1 deletion ui/desktop/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"license": {
"name": "Apache-2.0"
},
"version": "1.0.13"
"version": "1.0.14"
},
"paths": {
"/config": {
Expand Down
264 changes: 136 additions & 128 deletions ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,65 +4,29 @@ import { Switch } from '../../ui/switch';
import { Plus, X } from 'lucide-react';
import { Gear } from '../../icons/Gear';
import { GPSIcon } from '../../ui/icons';
import { useConfig } from '../../ConfigContext';
import { useConfig, FixedExtensionEntry } from '../../ConfigContext';
import Modal from '../../Modal';
import { Input } from '../../ui/input';
import Select from 'react-select';
import { createDarkSelectStyles, darkSelectTheme } from '../../ui/select-styles';

interface ExtensionConfig {
args?: string[];
cmd?: string;
enabled: boolean;
envs?: Record<string, string>;
name: string;
type: 'stdio' | 'sse' | 'builtin';
}

interface ExtensionItem {
id: string;
title: string;
subtitle: string;
enabled: boolean;
canConfigure: boolean;
config: ExtensionConfig;
}

interface EnvVar {
key: string;
value: string;
}

// Helper function to get a friendly title from extension name
const getFriendlyTitle = (name: string): string => {
return name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};

// Helper function to get a subtitle based on extension type and configuration
const getSubtitle = (config: ExtensionConfig): string => {
if (config.type === 'builtin') {
return 'Built-in extension';
}
return `${config.type.toUpperCase()} extension${config.cmd ? ` (${config.cmd})` : ''}`;
};
import { ExtensionConfig } from '../../../api/types.gen';

export default function ExtensionsSection() {
const { config, read, updateExtension, addExtension } = useConfig();
const [extensions, setExtensions] = useState<ExtensionItem[]>([]);
const [selectedExtension, setSelectedExtension] = useState<ExtensionItem | null>(null);
const { toggleExtension, getExtensions, addExtension } = useConfig();
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [extensions, setExtensions] = useState<FixedExtensionEntry[]>([]);
const [selectedExtension, setSelectedExtension] = useState<FixedExtensionEntry | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [formData, setFormData] = useState<{
name: string;
type: 'stdio' | 'sse';
type: 'stdio' | 'sse' | 'builtin';
cmd?: string;
args?: string[];
endpoint?: string;
enabled: boolean;
envVars: EnvVar[];
envVars: { key: string; value: string }[];
}>({
name: '',
type: 'stdio',
Expand All @@ -73,63 +37,82 @@ export default function ExtensionsSection() {
envVars: [],
});

useEffect(() => {
const extensions = read('extensions', false);
if (extensions) {
const extensionItems: ExtensionItem[] = Object.entries(extensions).map(([name, ext]) => {
const extensionConfig = ext as ExtensionConfig;
return {
id: name,
title: getFriendlyTitle(name),
subtitle: getSubtitle(extensionConfig),
enabled: extensionConfig.enabled,
canConfigure: extensionConfig.type === 'stdio' && !!extensionConfig.envs,
config: extensionConfig,
};
});
setExtensions(extensionItems);
// Helper function to get a friendly title from extension name
const getFriendlyTitle = (name: string): string => {
return name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};

// Helper function to get a subtitle based on extension type and configuration
const getSubtitle = (config: ExtensionConfig): string => {
if (config.type === 'builtin') {
return 'Built-in extension';
}
if (config.type === 'stdio') {
return `STDIO extension${config.cmd ? ` (${config.cmd})` : ''}`;
}
}, [read]);
if (config.type === 'sse') {
return `SSE extension${config.uri ? ` (${config.uri})` : ''}`;
}
return `Unknown type of extension`;
};
const fetchExtensions = async () => {
setLoading(true);
try {
const extensionsList = await getExtensions(true); // Force refresh
// Sort extensions by name to maintain consistent order
const sortedExtensions = [...extensionsList].sort((a, b) => a.name.localeCompare(b.name));
setExtensions(sortedExtensions);
setError(null);
} catch (err) {
setError('Failed to load extensions');
console.error('Error loading extensions:', err);
} finally {
setLoading(false);
}
};

useEffect(() => {
fetchExtensions();
}, []);

useEffect(() => {
if (selectedExtension) {
const envVars = selectedExtension.config.envs
? Object.entries(selectedExtension.config.envs).map(([key, value]) => ({
key,
value: value as string,
}))
: [];
// Type guard: Check if 'envs' property exists for this variant
const hasEnvs = selectedExtension.type === 'sse' || selectedExtension.type === 'stdio';

const envVars =
hasEnvs && selectedExtension.envs
? Object.entries(selectedExtension.envs).map(([key, value]) => ({
key,
value: value as string,
}))
: [];

setFormData({
name: selectedExtension.config.name,
type: selectedExtension.config.type as 'stdio' | 'sse',
cmd: selectedExtension.config.type === 'stdio' ? selectedExtension.config.cmd : undefined,
args: selectedExtension.config.args || [],
endpoint:
selectedExtension.config.type === 'sse' ? selectedExtension.config.cmd : undefined,
enabled: selectedExtension.config.enabled,
name: selectedExtension.name,
type: selectedExtension.type,
cmd: selectedExtension.type === 'stdio' ? selectedExtension.cmd : undefined,
args: selectedExtension.type === 'stdio' ? selectedExtension.args : [],
endpoint: selectedExtension.type === 'sse' ? selectedExtension.uri : undefined,
enabled: selectedExtension.enabled,
envVars,
});
}
}, [selectedExtension]);

const handleExtensionToggle = async (id: string) => {
const extension = extensions.find((ext) => ext.id === id);
if (extension) {
const updatedConfig = {
...extension.config,
enabled: !extension.config.enabled,
};

try {
await updateExtension(id, updatedConfig);
} catch (error) {
console.error('Failed to update extension:', error);
}
const handleExtensionToggle = async (name: string) => {
try {
await toggleExtension(name);
fetchExtensions(); // Refresh the list after toggling
} catch (error) {
console.error('Failed to toggle extension:', error);
}
};

const handleConfigureClick = (extension: ExtensionItem) => {
const handleConfigureClick = (extension: FixedExtensionEntry) => {
setSelectedExtension(extension);
setIsModalOpen(true);
};
Expand All @@ -145,24 +128,35 @@ export default function ExtensionsSection() {
{} as Record<string, string>
);

const extensionConfig = {
name: formData.name,
type: formData.type,
enabled: formData.enabled,
envs,
...(formData.type === 'stdio'
? {
cmd: formData.cmd,
args: formData.args,
}
: {
cmd: formData.endpoint,
}),
};
let extensionConfig: ExtensionConfig;

if (formData.type === 'stdio') {
extensionConfig = {
type: 'stdio',
name: formData.name,
cmd: formData.cmd,
args: formData.args,
...(Object.keys(envs).length > 0 ? { envs } : {}),
};
} else if (formData.type === 'sse') {
extensionConfig = {
type: 'sse',
name: formData.name,
uri: formData.endpoint, // Assuming endpoint maps to uri for SSE type
...(Object.keys(envs).length > 0 ? { envs } : {}),
};
} else {
// For other types
extensionConfig = {
type: formData.type,
name: formData.name,
};
}
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: looks like it is reused, make it as a share function?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yeah a refactor is coming soon -- once the functionality is mostly there :)


try {
await addExtension(formData.name, extensionConfig);
await addExtension(formData.name, extensionConfig, formData.enabled);
handleModalClose();
fetchExtensions(); // Refresh the list after adding
} catch (error) {
console.error('Failed to add extension:', error);
}
Expand Down Expand Up @@ -207,7 +201,6 @@ export default function ExtensionsSection() {
envVars: newEnvVars,
});
};

const handleSaveConfig = async () => {
if (!selectedExtension) return;

Expand All @@ -221,24 +214,36 @@ export default function ExtensionsSection() {
{} as Record<string, string>
);

const updatedConfig = {
name: formData.name,
type: formData.type,
enabled: formData.enabled,
envs,
...(formData.type === 'stdio'
? {
cmd: formData.cmd,
args: formData.args,
}
: {
cmd: formData.endpoint,
}),
};
let extensionConfig: ExtensionConfig;

if (formData.type === 'stdio') {
extensionConfig = {
type: 'stdio',
name: formData.name,
cmd: formData.cmd,
args: formData.args,
...(Object.keys(envs).length > 0 ? { envs } : {}),
};
} else if (formData.type === 'sse') {
extensionConfig = {
type: 'sse',
name: formData.name,
uri: formData.endpoint, // Assuming endpoint maps to uri for SSE type
...(Object.keys(envs).length > 0 ? { envs } : {}),
};
} else {
// For other types
extensionConfig = {
type: formData.type,
name: formData.name,
};
}

try {
await updateExtension(selectedExtension.id, updatedConfig);
// CHANGE: Use addExtension instead of updateExtension
await addExtension(formData.name, extensionConfig, formData.enabled);
handleModalClose();
fetchExtensions(); // Refresh the list after updating
} catch (error) {
console.error('Failed to update extension configuration:', error);
}
Expand All @@ -256,14 +261,17 @@ export default function ExtensionsSection() {
</p>
<div className="space-y-2">
{extensions.map((extension, index) => (
<React.Fragment key={extension.id}>
<React.Fragment key={extension.name}>
<div className="flex items-center justify-between py-3">
<div className="space-y-1">
<h3 className="font-medium text-textStandard">{extension.title}</h3>
<p className="text-sm text-textSubtle">{extension.subtitle}</p>
<h3 className="font-medium text-textStandard">
{getFriendlyTitle(extension.name)}
</h3>
<p className="text-sm text-textSubtle">{getSubtitle(extension)}</p>
</div>
<div className="flex items-center gap-4">
{extension.canConfigure && (
{/* Only show config button for non-builtin extensions */}
{extension.type !== 'builtin' && (
<button
className="text-textSubtle hover:text-textStandard"
onClick={() => handleConfigureClick(extension)}
Expand All @@ -273,8 +281,8 @@ export default function ExtensionsSection() {
)}
<Switch
checked={extension.enabled}
onCheckedChange={() => handleExtensionToggle(extension.id)}
className="bg-[#393838] [&_span[data-state]]:bg-white"
onCheckedChange={() => handleExtensionToggle(extension.name)}
variant="mono"
/>
</div>
</div>
Expand Down Expand Up @@ -329,10 +337,10 @@ export default function ExtensionsSection() {
<label className="text-sm font-medium mb-2 block">Type</label>
<Select
value={{ value: formData.type, label: formData.type.toUpperCase() }}
onChange={(option) =>
onChange={(option: { value: string; label: string } | null) =>
setFormData({
...formData,
type: (option?.value as 'stdio' | 'sse') || 'stdio',
type: (option?.value as 'stdio' | 'sse' | 'builtin') || 'stdio',
})
}
options={[
Expand Down Expand Up @@ -464,7 +472,7 @@ export default function ExtensionsSection() {
<label className="text-sm font-medium mb-2 block">Type</label>
<Select
value={{ value: formData.type, label: formData.type.toUpperCase() }}
onChange={(option) =>
onChange={(option: { value: string; label: string } | null) =>
setFormData({
...formData,
type: (option?.value as 'stdio' | 'sse') || 'stdio',
Expand Down
Loading