Files
LangBot/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx
T
advancer-young 096ec1a8ce feat(mcp): support mcp resources (#2215)
* feat(mcp): support mcp resources

* feat(web): split MCP resources into tab

* docs: add MCP resources PR review

* feat(mcp): productionize resource support

* feat(mcp): scope local agent tools and resources

* fix(web): gate space embedding models behind login

* fix(web): prevent clipped space model CTA

* test: update preproc resource tool expectations

* fix(web): expose skill authoring tools in selector

---------

Co-authored-by: yang.xiang <yang.xiang@advancegroup.com>
Co-authored-by: Hyu <chenhyu@proton.me>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-06-30 19:16:30 +08:00

884 lines
32 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { backendClient } from '@/app/infra/http';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { Skeleton } from '@/components/ui/skeleton';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { CircleHelp, Plus, X, Server, Wrench, Sparkles } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Plugin } from '@/app/infra/entities/plugin';
import { MCPServer, Skill } from '@/app/infra/entities/api';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
function InfoTooltip({ label }: { label: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex size-4 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
aria-label={label}
>
<CircleHelp className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[280px]">
{label}
</TooltipContent>
</Tooltip>
);
}
export default function PipelineExtension({
pipelineId,
}: {
pipelineId: string;
}) {
const { t } = useTranslation();
const {
available: boxAvailable,
hint: boxHint,
reason: boxReason,
} = useBoxStatus();
const [loading, setLoading] = useState(true);
const [enableAllPlugins, setEnableAllPlugins] = useState(true);
const [enableAllMCPServers, setEnableAllMCPServers] = useState(true);
const [enableAllSkills, setEnableAllSkills] = useState(true);
const [selectedPlugins, setSelectedPlugins] = useState<Plugin[]>([]);
const [allPlugins, setAllPlugins] = useState<Plugin[]>([]);
const [selectedMCPServers, setSelectedMCPServers] = useState<MCPServer[]>([]);
const [allMCPServers, setAllMCPServers] = useState<MCPServer[]>([]);
const [selectedSkills, setSelectedSkills] = useState<Skill[]>([]);
const [allSkills, setAllSkills] = useState<Skill[]>([]);
const [pluginDialogOpen, setPluginDialogOpen] = useState(false);
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
const [skillDialogOpen, setSkillDialogOpen] = useState(false);
const [tempSelectedPluginIds, setTempSelectedPluginIds] = useState<string[]>(
[],
);
const [tempSelectedMCPIds, setTempSelectedMCPIds] = useState<string[]>([]);
const [tempSelectedSkillIds, setTempSelectedSkillIds] = useState<string[]>(
[],
);
useEffect(() => {
loadExtensions();
}, [pipelineId]);
const getPluginId = (plugin: Plugin): string => {
const author = plugin.manifest.manifest.metadata.author;
const name = plugin.manifest.manifest.metadata.name;
return `${author}/${name}`;
};
const loadExtensions = async () => {
try {
setLoading(true);
const data = await backendClient.getPipelineExtensions(pipelineId);
setEnableAllPlugins(data.enable_all_plugins ?? true);
setEnableAllMCPServers(data.enable_all_mcp_servers ?? true);
setEnableAllSkills(data.enable_all_skills ?? true);
const boundPluginIds = new Set(
data.bound_plugins.map((p) => `${p.author}/${p.name}`),
);
const selected = data.available_plugins.filter((plugin) =>
boundPluginIds.has(getPluginId(plugin)),
);
setSelectedPlugins(selected);
setAllPlugins(data.available_plugins);
// Load MCP servers
const boundMCPServerIds = new Set(data.bound_mcp_servers || []);
const selectedMCP = data.available_mcp_servers.filter((server) =>
boundMCPServerIds.has(server.uuid || ''),
);
setSelectedMCPServers(selectedMCP);
setAllMCPServers(data.available_mcp_servers);
// Load Skills
const boundSkillNames = new Set(data.bound_skills || []);
const selectedSkill = (data.available_skills || []).filter((skill) =>
boundSkillNames.has(skill.name),
);
setSelectedSkills(selectedSkill);
setAllSkills(data.available_skills || []);
} catch (error) {
console.error('Failed to load extensions:', error);
toast.error(t('pipelines.extensions.loadError'));
} finally {
setLoading(false);
}
};
const saveToBackend = async (
plugins: Plugin[],
mcpServers: MCPServer[],
skills: Skill[],
newEnableAllPlugins?: boolean,
newEnableAllMCPServers?: boolean,
newEnableAllSkills?: boolean,
) => {
try {
const boundPluginsArray = plugins.map((plugin) => {
const metadata = plugin.manifest.manifest.metadata;
return {
author: metadata.author || '',
name: metadata.name,
};
});
const boundMCPServerIds = mcpServers.map((server) => server.uuid || '');
const boundSkillIds = skills.map((skill) => skill.name);
await backendClient.updatePipelineExtensions(
pipelineId,
boundPluginsArray,
boundMCPServerIds,
newEnableAllPlugins ?? enableAllPlugins,
newEnableAllMCPServers ?? enableAllMCPServers,
boundSkillIds,
newEnableAllSkills ?? enableAllSkills,
);
toast.success(t('pipelines.extensions.saveSuccess'));
} catch (error) {
console.error('Failed to save extensions:', error);
toast.error(t('pipelines.extensions.saveError'));
// Reload on error to restore correct state
loadExtensions();
}
};
const handleRemovePlugin = async (pluginId: string) => {
const newPlugins = selectedPlugins.filter(
(p) => getPluginId(p) !== pluginId,
);
setSelectedPlugins(newPlugins);
await saveToBackend(newPlugins, selectedMCPServers, selectedSkills);
};
const handleRemoveMCPServer = async (serverUuid: string) => {
const newServers = selectedMCPServers.filter((s) => s.uuid !== serverUuid);
setSelectedMCPServers(newServers);
await saveToBackend(selectedPlugins, newServers, selectedSkills);
};
const handleRemoveSkill = async (skillName: string) => {
const newSkills = selectedSkills.filter((s) => s.name !== skillName);
setSelectedSkills(newSkills);
await saveToBackend(selectedPlugins, selectedMCPServers, newSkills);
};
const handleOpenPluginDialog = () => {
setTempSelectedPluginIds(selectedPlugins.map((p) => getPluginId(p)));
setPluginDialogOpen(true);
};
const handleOpenMCPDialog = () => {
setTempSelectedMCPIds(selectedMCPServers.map((s) => s.uuid || ''));
setMcpDialogOpen(true);
};
const handleOpenSkillDialog = () => {
setTempSelectedSkillIds(selectedSkills.map((s) => s.name));
setSkillDialogOpen(true);
};
const handleTogglePlugin = (pluginId: string) => {
setTempSelectedPluginIds((prev) =>
prev.includes(pluginId)
? prev.filter((id) => id !== pluginId)
: [...prev, pluginId],
);
};
const handleToggleMCPServer = (serverUuid: string) => {
setTempSelectedMCPIds((prev) =>
prev.includes(serverUuid)
? prev.filter((id) => id !== serverUuid)
: [...prev, serverUuid],
);
};
const handleToggleSkill = (skillName: string) => {
setTempSelectedSkillIds((prev) =>
prev.includes(skillName)
? prev.filter((id) => id !== skillName)
: [...prev, skillName],
);
};
const handleToggleAllPlugins = () => {
if (tempSelectedPluginIds.length === allPlugins.length) {
setTempSelectedPluginIds([]);
} else {
setTempSelectedPluginIds(allPlugins.map((p) => getPluginId(p)));
}
};
const handleToggleAllMCPServers = () => {
if (tempSelectedMCPIds.length === allMCPServers.length) {
setTempSelectedMCPIds([]);
} else {
setTempSelectedMCPIds(allMCPServers.map((s) => s.uuid || ''));
}
};
const handleToggleAllSkills = () => {
if (tempSelectedSkillIds.length === allSkills.length) {
setTempSelectedSkillIds([]);
} else {
setTempSelectedSkillIds(allSkills.map((s) => s.name));
}
};
const handleConfirmPluginSelection = async () => {
const newSelected = allPlugins.filter((p) =>
tempSelectedPluginIds.includes(getPluginId(p)),
);
setSelectedPlugins(newSelected);
setPluginDialogOpen(false);
await saveToBackend(newSelected, selectedMCPServers, selectedSkills);
};
const handleConfirmMCPSelection = async () => {
const newSelected = allMCPServers.filter((s) =>
tempSelectedMCPIds.includes(s.uuid || ''),
);
setSelectedMCPServers(newSelected);
setMcpDialogOpen(false);
await saveToBackend(selectedPlugins, newSelected, selectedSkills);
};
const handleConfirmSkillSelection = async () => {
const newSelected = allSkills.filter((s) =>
tempSelectedSkillIds.includes(s.name),
);
setSelectedSkills(newSelected);
setSkillDialogOpen(false);
await saveToBackend(selectedPlugins, selectedMCPServers, newSelected);
};
const handleToggleEnableAllPlugins = async (checked: boolean) => {
setEnableAllPlugins(checked);
await saveToBackend(
selectedPlugins,
selectedMCPServers,
selectedSkills,
checked,
undefined,
undefined,
);
};
const handleToggleEnableAllMCPServers = async (checked: boolean) => {
setEnableAllMCPServers(checked);
await saveToBackend(
selectedPlugins,
selectedMCPServers,
selectedSkills,
undefined,
checked,
undefined,
);
};
const handleToggleEnableAllSkills = async (checked: boolean) => {
setEnableAllSkills(checked);
await saveToBackend(
selectedPlugins,
selectedMCPServers,
selectedSkills,
undefined,
undefined,
checked,
);
};
if (loading) {
return (
<div className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
);
}
return (
<div className="space-y-6">
{/* Plugins Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">
{t('pipelines.extensions.pluginsTitle')}
</h3>
<div className="flex items-center gap-2">
<Label
htmlFor="enable-all-plugins"
className="text-sm font-normal cursor-pointer"
>
{t('pipelines.extensions.enableAllPlugins')}
</Label>
<Switch
id="enable-all-plugins"
checked={enableAllPlugins}
onCheckedChange={handleToggleEnableAllPlugins}
/>
</div>
</div>
<div className="space-y-2">
{enableAllPlugins ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.allPluginsEnabled')}
</p>
</div>
) : selectedPlugins.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noPluginsSelected')}
</p>
</div>
) : (
<div className="space-y-2">
{selectedPlugins.map((plugin) => {
const pluginId = getPluginId(plugin);
const metadata = plugin.manifest.manifest.metadata;
return (
<div
key={pluginId}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex-1 flex items-center gap-3">
<img
src={backendClient.getPluginIconURL(
metadata.author || '',
metadata.name,
)}
alt={metadata.name}
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
/>
<div className="flex-1">
<div className="font-medium">{metadata.name}</div>
<div className="text-sm text-muted-foreground">
{metadata.author} v{metadata.version}
</div>
<div className="flex gap-1 mt-1">
<PluginComponentList
components={(() => {
const componentKindCount: Record<string, number> =
{};
for (const component of plugin.components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
return componentKindCount;
})()}
showComponentName={true}
showTitle={false}
useBadge={true}
t={t}
/>
</div>
</div>
{!plugin.enabled && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemovePlugin(pluginId)}
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
)}
</div>
<Button
onClick={handleOpenPluginDialog}
variant="outline"
className="w-full"
disabled={enableAllPlugins}
>
<Plus className="mr-2 h-4 w-4" />
{t('pipelines.extensions.addPlugin')}
</Button>
</div>
{/* MCP Servers Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<h3 className="text-sm font-semibold text-foreground">
{t('pipelines.extensions.mcpServersTitle')}
</h3>
<InfoTooltip
label={t('pipelines.extensions.mcpServersScopeTooltip')}
/>
</div>
<div className="flex items-center gap-2">
<Label
htmlFor="enable-all-mcp-servers"
className="text-sm font-normal cursor-pointer"
>
{t('pipelines.extensions.enableAllMCPServers')}
</Label>
<InfoTooltip
label={t('pipelines.extensions.enableAllMCPServersTooltip')}
/>
<Switch
id="enable-all-mcp-servers"
checked={enableAllMCPServers}
onCheckedChange={handleToggleEnableAllMCPServers}
/>
</div>
</div>
<div className="space-y-2">
{enableAllMCPServers ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.allMCPServersEnabled')}
</p>
</div>
) : selectedMCPServers.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noMCPServersSelected')}
</p>
</div>
) : (
<div className="space-y-2">
{selectedMCPServers.map((server) => (
<div
key={server.uuid}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex-1 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
<Server className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="font-medium">{server.name}</div>
<div className="text-sm text-muted-foreground">
{server.mode}
</div>
{server.runtime_info &&
server.runtime_info.status === 'connected' && (
<Badge
variant="outline"
className="flex items-center gap-1 mt-1"
>
<Wrench className="h-3 w-3 text-black dark:text-white" />
<span className="text-xs text-black dark:text-white">
{t('pipelines.extensions.toolCount', {
count: server.runtime_info.tool_count || 0,
})}
</span>
</Badge>
)}
</div>
{!server.enable && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveMCPServer(server.uuid || '')}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
<Button
onClick={handleOpenMCPDialog}
variant="outline"
className="w-full"
disabled={enableAllMCPServers}
>
<Plus className="mr-2 h-4 w-4" />
{t('pipelines.extensions.addMCPServer')}
</Button>
</div>
{/* Skills Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">
{t('pipelines.extensions.skillsTitle')}
</h3>
<div className="flex items-center gap-2">
<Label
htmlFor="enable-all-skills"
className="text-sm font-normal cursor-pointer"
>
{t('pipelines.extensions.enableAllSkills')}
</Label>
<Switch
id="enable-all-skills"
checked={enableAllSkills}
onCheckedChange={handleToggleEnableAllSkills}
disabled={!boxAvailable}
/>
</div>
</div>
{!boxAvailable && (
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
)}
<div className="space-y-2">
{enableAllSkills ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.allSkillsEnabled')}
</p>
</div>
) : selectedSkills.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noSkillsSelected')}
</p>
</div>
) : (
<div className="space-y-2">
{selectedSkills.map((skill) => (
<div
key={skill.name}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex-1 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
<Sparkles className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="font-medium">
{skill.display_name || skill.name}
</div>
<div className="text-sm text-muted-foreground">
{skill.description}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveSkill(skill.name)}
disabled={!boxAvailable}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
<Button
onClick={handleOpenSkillDialog}
variant="outline"
className="w-full"
disabled={enableAllSkills || !boxAvailable}
>
<Plus className="mr-2 h-4 w-4" />
{t('pipelines.extensions.addSkill')}
</Button>
</div>
{/* Plugin Selection Dialog */}
<Dialog open={pluginDialogOpen} onOpenChange={setPluginDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('pipelines.extensions.selectPlugins')}</DialogTitle>
</DialogHeader>
{allPlugins.length > 0 && (
<div
className="flex items-center gap-3 px-1 py-2 border-b cursor-pointer"
onClick={handleToggleAllPlugins}
>
<Checkbox
checked={
tempSelectedPluginIds.length === allPlugins.length &&
allPlugins.length > 0
}
onCheckedChange={handleToggleAllPlugins}
/>
<span className="text-sm font-medium">
{t('pipelines.extensions.selectAll')}
</span>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{allPlugins.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noPluginsInstalled')}
</p>
</div>
) : (
allPlugins.map((plugin) => {
const pluginId = getPluginId(plugin);
const metadata = plugin.manifest.manifest.metadata;
const isSelected = tempSelectedPluginIds.includes(pluginId);
return (
<div
key={pluginId}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleTogglePlugin(pluginId)}
>
<Checkbox checked={isSelected} />
<img
src={backendClient.getPluginIconURL(
metadata.author || '',
metadata.name,
)}
alt={metadata.name}
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
/>
<div className="flex-1">
<div className="font-medium">{metadata.name}</div>
<div className="text-sm text-muted-foreground">
{metadata.author} v{metadata.version}
</div>
<div className="flex gap-1 mt-1">
<PluginComponentList
components={(() => {
const componentKindCount: Record<string, number> =
{};
for (const component of plugin.components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
return componentKindCount;
})()}
showComponentName={true}
showTitle={false}
useBadge={true}
t={t}
/>
</div>
</div>
{!plugin.enabled && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
)}
</div>
);
})
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setPluginDialogOpen(false)}
>
{t('common.cancel')}
</Button>
<Button onClick={handleConfirmPluginSelection}>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* MCP Server Selection Dialog */}
<Dialog open={mcpDialogOpen} onOpenChange={setMcpDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>
{t('pipelines.extensions.selectMCPServers')}
</DialogTitle>
</DialogHeader>
{allMCPServers.length > 0 && (
<div
className="flex items-center gap-3 px-1 py-2 border-b cursor-pointer"
onClick={handleToggleAllMCPServers}
>
<Checkbox
checked={
tempSelectedMCPIds.length === allMCPServers.length &&
allMCPServers.length > 0
}
onCheckedChange={handleToggleAllMCPServers}
/>
<span className="text-sm font-medium">
{t('pipelines.extensions.selectAll')}
</span>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{allMCPServers.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noMCPServersConfigured')}
</p>
</div>
) : (
allMCPServers.map((server) => {
const isSelected = tempSelectedMCPIds.includes(
server.uuid || '',
);
return (
<div
key={server.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleToggleMCPServer(server.uuid || '')}
>
<Checkbox checked={isSelected} />
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
<Server className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="font-medium">{server.name}</div>
<div className="text-sm text-muted-foreground">
{server.mode}
</div>
{server.runtime_info &&
server.runtime_info.status === 'connected' && (
<Badge
variant="outline"
className="flex items-center gap-1 mt-1"
>
<Wrench className="h-3 w-3 text-black dark:text-white" />
<span className="text-xs text-black dark:text-white">
{t('pipelines.extensions.toolCount', {
count: server.runtime_info.tool_count || 0,
})}
</span>
</Badge>
)}
</div>
{!server.enable && (
<Badge variant="secondary">
{t('pipelines.extensions.disabled')}
</Badge>
)}
</div>
);
})
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setMcpDialogOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleConfirmMCPSelection}>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Skill Selection Dialog */}
<Dialog open={skillDialogOpen} onOpenChange={setSkillDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('pipelines.extensions.selectSkills')}</DialogTitle>
</DialogHeader>
{allSkills.length > 0 && (
<div
className="flex items-center gap-3 px-1 py-2 border-b cursor-pointer"
onClick={handleToggleAllSkills}
>
<Checkbox
checked={
tempSelectedSkillIds.length === allSkills.length &&
allSkills.length > 0
}
onCheckedChange={handleToggleAllSkills}
/>
<span className="text-sm font-medium">
{t('pipelines.extensions.selectAll')}
</span>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{allSkills.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noSkillsAvailable')}
</p>
</div>
) : (
allSkills.map((skill) => {
const isSelected = tempSelectedSkillIds.includes(skill.name);
return (
<div
key={skill.name}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleToggleSkill(skill.name)}
>
<Checkbox checked={isSelected} />
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
<Sparkles className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="font-medium">
{skill.display_name || skill.name}
</div>
<div className="text-sm text-muted-foreground">
{skill.description}
</div>
</div>
</div>
);
})
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSkillDialogOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleConfirmSkillSelection}>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}