fix(web): disable quota-reached create actions (#2389)

* fix(web): disable quota-reached create actions

* fix(web): close quota review gaps

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
Hyu
2026-08-03 19:08:47 +08:00
committed by GitHub
parent 3101c9be6a
commit f0b2c103c1
21 changed files with 1037 additions and 293 deletions
+104 -42
View File
@@ -49,6 +49,8 @@ import type {
} from '@/app/home/mcp/components/mcp-form/MCPForm';
import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel';
import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel';
import { useWorkspaceQuotaStatus } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
type PopoverView = 'menu' | 'mcp' | 'github';
@@ -154,6 +156,12 @@ function AddExtensionContent() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
const { extensions: extensionQuota, extensionsReached } =
useWorkspaceQuotaStatus();
const extensionQuotaTooltip = t('limitation.createDisabledTooltip', {
resource: t('sidebar.extensions'),
max: extensionQuota.max,
});
// Localized label for an extension type, used in the install dialog.
const extensionTypeLabel = (type: string) =>
@@ -344,23 +352,28 @@ function AddExtensionContent() {
t,
]);
const handleInstallPlugin = useCallback(async (plugin: PluginV4) => {
setInstallInfo({
plugin_author: plugin.author,
plugin_name: plugin.name,
plugin_version: plugin.latest_version,
plugin_label: extractI18nObject(plugin.label) || plugin.name,
plugin_description: extractI18nObject(plugin.description) || '',
plugin_icon: plugin.icon || '',
});
setInstallExtensionType(plugin.type || 'plugin');
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
setInstallError(null);
setInstallIconFailed(false);
setModalOpen(true);
}, []);
const handleInstallPlugin = useCallback(
async (plugin: PluginV4) => {
if (extensionsReached) return;
setInstallInfo({
plugin_author: plugin.author,
plugin_name: plugin.name,
plugin_version: plugin.latest_version,
plugin_label: extractI18nObject(plugin.label) || plugin.name,
plugin_description: extractI18nObject(plugin.description) || '',
plugin_icon: plugin.icon || '',
});
setInstallExtensionType(plugin.type || 'plugin');
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
setInstallError(null);
setInstallIconFailed(false);
setModalOpen(true);
},
[extensionsReached],
);
function handleModalConfirm() {
if (extensionsReached) return;
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
httpClient
@@ -402,6 +415,7 @@ function AddExtensionContent() {
const uploadFile = useCallback(
async (file: File) => {
if (extensionsReached) return;
if (!validateFileType(file)) {
toast.error(t('addExtension.unsupportedFileType'));
return;
@@ -421,14 +435,15 @@ function AddExtensionContent() {
setSkillUploadPreviewOpen(true);
}
},
[t, setSelectedTaskId],
[extensionsReached, t, setSelectedTaskId],
);
const handleFileSelect = useCallback(() => {
if (extensionsReached) return;
if (fileInputRef.current) {
fileInputRef.current.click();
}
}, []);
}, [extensionsReached]);
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
@@ -455,12 +470,13 @@ function AddExtensionContent() {
(event: React.DragEvent) => {
event.preventDefault();
setIsDragOver(false);
if (extensionsReached) return;
const files = Array.from(event.dataTransfer.files);
if (files.length > 0) {
uploadFile(files[0]);
}
},
[uploadFile],
[extensionsReached, uploadFile],
);
function handleMCPCreated(_serverName: string) {
@@ -490,7 +506,8 @@ function AddExtensionContent() {
return false;
}
} catch {
// If we can't check, let backend handle it
toast.error(t('limitation.quotaCheckFailed'));
return false;
}
return true;
}
@@ -630,9 +647,11 @@ function AddExtensionContent() {
async function handleGithubConfirm() {
if (!selectedAsset || !selectedRelease) return;
if (!(await checkExtensionsLimit())) return;
setGithubInstallStatus(GithubInstallStatus.INSTALLING);
if (!(await checkExtensionsLimit())) {
setGithubInstallStatus(GithubInstallStatus.ASK_CONFIRM);
return;
}
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
httpClient
.installPluginFromGithub(
@@ -664,9 +683,11 @@ function AddExtensionContent() {
async function handleGithubSkillConfirm() {
if (!githubSkillInfo) return;
if (!(await checkExtensionsLimit())) return;
setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING);
if (!(await checkExtensionsLimit())) {
setGithubInstallStatus(GithubInstallStatus.SKILL_PREVIEW);
return;
}
try {
await httpClient.installSkillFromGithub(
githubURL.trim(),
@@ -726,17 +747,24 @@ function AddExtensionContent() {
setPopoverOpen(open);
}}
>
<PopoverTrigger asChild>
<Button
variant="default"
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0"
>
<PlusIcon className="w-4 h-4" />
<span className="whitespace-nowrap">
{t('addExtension.manualAdd')}
</span>
</Button>
</PopoverTrigger>
<WorkspaceQuotaTooltip
quota={extensionQuota}
resource={t('sidebar.extensions')}
>
<PopoverTrigger asChild>
<Button
variant="default"
disabled={extensionsReached}
aria-disabled={extensionsReached}
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100"
>
<PlusIcon className="w-4 h-4" />
<span className="whitespace-nowrap">
{t('addExtension.manualAdd')}
</span>
</Button>
</PopoverTrigger>
</WorkspaceQuotaTooltip>
<PopoverContent
forceMount
className={`${getPopoverWidth()} max-h-[min(720px,80vh)] overflow-hidden p-0`}
@@ -745,9 +773,19 @@ function AddExtensionContent() {
{/* ===== Menu View ===== */}
{popoverView === 'menu' && (
<div className="space-y-4 p-4">
{extensionsReached && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
{extensionQuotaTooltip}
</div>
)}
{/* File upload area */}
<div
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
aria-disabled={extensionsReached}
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
extensionsReached
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer'
} ${
isDragOver
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50'
@@ -777,7 +815,8 @@ function AddExtensionContent() {
<div className="space-y-2">
<button
type="button"
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
disabled={extensionsReached}
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => setPopoverView('mcp')}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
@@ -796,7 +835,8 @@ function AddExtensionContent() {
<button
type="button"
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
disabled={extensionsReached}
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => setPopoverView('github')}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
@@ -815,7 +855,8 @@ function AddExtensionContent() {
<button
type="button"
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
disabled={extensionsReached}
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
onClick={async () => {
if (!(await checkExtensionsLimit())) return;
setPopoverOpen(false);
@@ -882,6 +923,7 @@ function AddExtensionContent() {
type="submit"
form="mcp-form"
size="sm"
disabled={extensionsReached}
onClick={async (e) => {
if (!(await checkExtensionsLimit())) {
e.preventDefault();
@@ -946,6 +988,7 @@ function AddExtensionContent() {
className="w-full"
onClick={handleGithubAddressSubmit}
disabled={
extensionsReached ||
!githubURL.trim() ||
fetchingReleases ||
fetchingSkillPreview
@@ -1102,7 +1145,11 @@ function AddExtensionContent() {
</div>
</div>
)}
<Button className="w-full" onClick={handleGithubConfirm}>
<Button
className="w-full"
onClick={handleGithubConfirm}
disabled={extensionsReached}
>
{t('common.confirm')}
</Button>
</div>
@@ -1184,6 +1231,7 @@ function AddExtensionContent() {
<Button
className="w-full"
onClick={handleGithubSkillConfirm}
disabled={extensionsReached}
>
{t('common.confirm')}
</Button>
@@ -1240,6 +1288,8 @@ function AddExtensionContent() {
<MarketPage
installPlugin={handleInstallPlugin}
headerActions={extensionActions}
installDisabled={extensionsReached}
installDisabledTooltip={extensionQuotaTooltip}
/>
</div>
</div>
@@ -1325,9 +1375,17 @@ function AddExtensionContent() {
<Button variant="outline" onClick={() => setModalOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleModalConfirm}>
{t('common.confirm')}
</Button>
<WorkspaceQuotaTooltip
quota={extensionQuota}
resource={t('sidebar.extensions')}
>
<Button
onClick={handleModalConfirm}
disabled={extensionsReached}
>
{t('common.confirm')}
</Button>
</WorkspaceQuotaTooltip>
</>
)}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
@@ -1359,6 +1417,8 @@ function AddExtensionContent() {
{pluginUploadPreviewFile && (
<PluginLocalPreviewPanel
file={pluginUploadPreviewFile}
quota={extensionQuota}
quotaResource={t('sidebar.extensions')}
onCancel={() => {
setPluginUploadPreviewOpen(false);
setPluginUploadPreviewFile(null);
@@ -1392,6 +1452,8 @@ function AddExtensionContent() {
{skillUploadPreviewFile && (
<SkillZipPreviewPanel
file={skillUploadPreviewFile}
quota={extensionQuota}
quotaResource={t('sidebar.extensions')}
onCancel={() => {
setSkillUploadPreviewOpen(false);
setSkillUploadPreviewFile(null);
@@ -109,6 +109,11 @@ import {
import { cn } from '@/lib/utils';
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
import { FeedbackPopoverContent } from './FeedbackPopover';
import {
type WorkspaceQuotaItem,
useWorkspaceQuotaStatus,
} from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
// Compare two version strings, returns true if v1 > v2
function compareVersions(v1: string, v2: string): boolean {
@@ -279,6 +284,14 @@ function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const UNLIMITED_QUOTA: WorkspaceQuotaItem = {
count: 0,
max: -1,
reached: false,
loading: false,
disabled: false,
};
async function waitForMCPRefreshTask(taskId: number) {
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
@@ -386,6 +399,7 @@ function NavItems({
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const sidebarData = useSidebarData();
const quotaStatus = useWorkspaceQuotaStatus();
const { state: sidebarState, isMobile } = useSidebar();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
@@ -529,19 +543,33 @@ function NavItems({
if (config.id === 'add-extension' && !canManageResources) {
return null;
}
// Non-entity entries (e.g. monitoring, market, mcp) render as plain links
const quota =
config.id === 'add-extension'
? quotaStatus.extensions
: UNLIMITED_QUOTA;
// Non-entity entries (e.g. monitoring and the extension market) render as plain links.
return (
<SidebarMenuItem key={config.id}>
<SidebarMenuButton
isActive={selectedChild?.id === config.id}
onClick={() => onChildClick(config)}
tooltip={config.name}
<WorkspaceQuotaTooltip
quota={quota}
resource={config.name}
side="right"
>
{config.icon}
<span className="cursor-pointer select-none">
{config.name}
</span>
</SidebarMenuButton>
<SidebarMenuButton
isActive={selectedChild?.id === config.id}
onClick={() => {
if (!quota.disabled) onChildClick(config);
}}
disabled={quota.disabled}
aria-disabled={quota.disabled}
tooltip={quota.disabled ? undefined : config.name}
>
{config.icon}
<span className="cursor-pointer select-none">
{config.name}
</span>
</SidebarMenuButton>
</WorkspaceQuotaTooltip>
</SidebarMenuItem>
);
}
@@ -575,6 +603,18 @@ function NavItems({
const isSkill = categoryId === 'skills';
const isBot = categoryId === 'bots';
const isMCP = categoryId === 'mcp';
const quota =
categoryId === 'bots'
? quotaStatus.bots
: categoryId === 'pipelines'
? quotaStatus.pipelines
: categoryId === 'knowledge'
? quotaStatus.knowledgeBases
: categoryId === 'plugins' ||
categoryId === 'mcp' ||
categoryId === 'skills'
? quotaStatus.extensions
: UNLIMITED_QUOTA;
const resolveItemRoute = (item: SidebarEntityItem): string => {
if (item.extensionType === 'mcp') {
@@ -907,128 +947,144 @@ function NavItems({
>
<div className="flex items-center justify-between mb-1 px-2">
<span className="text-sm font-medium">{config.name}</span>
{canCreate &&
(isPlugin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
{canCreate && (
<WorkspaceQuotaTooltip
quota={quota}
resource={config.name}
side="right"
>
{isPlugin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Store className="size-4" />
{t('plugins.goToMarketplace')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension');
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Store className="size-4" />
{t('plugins.goToMarketplace')}
<Upload className="size-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Upload className="size-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : isSkill ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/skills?action=create');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<FilePlus2 className="size-4" />
{t('skills.createManually')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Upload className="size-4" />
{t('skills.uploadZip')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Github className="size-4" />
{t('skills.importFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={() => {
navigate(`${routePrefix}?id=new`);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Plus className="size-3.5" />
</button>
))}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : isSkill ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/skills?action=create');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<FilePlus2 className="size-4" />
{t('skills.createManually')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Upload className="size-4" />
{t('skills.uploadZip')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Github className="size-4" />
{t('skills.importFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
onClick={() => {
navigate(`${routePrefix}?id=new`);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Plus className="size-3.5" />
</button>
)}
</WorkspaceQuotaTooltip>
)}
</div>
<div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto">
{renderEntityList(true)}
@@ -1096,103 +1152,119 @@ function NavItems({
/>
</button>
)}
{canCreate &&
(isPlugin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
{canCreate && (
<WorkspaceQuotaTooltip
quota={quota}
resource={config.name}
side="right"
>
{isPlugin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension');
}}
>
<Store className="size-4" />
{t('plugins.goToMarketplace')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension');
navigate('/home/add-extension?manual=1');
}}
>
<Store className="size-4" />
{t('plugins.goToMarketplace')}
<Upload className="size-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Upload className="size-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : isSkill ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/skills?action=create');
}}
>
<FilePlus2 className="size-4" />
{t('skills.createManually')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Upload className="size-4" />
{t('skills.uploadZip')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Github className="size-4" />
{t('skills.importFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => {
e.stopPropagation();
navigate(`${routePrefix}?id=new`);
}}
>
<Plus className="size-3.5" />
</button>
))}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : isSkill ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/skills?action=create');
}}
>
<FilePlus2 className="size-4" />
{t('skills.createManually')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Upload className="size-4" />
{t('skills.uploadZip')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Github className="size-4" />
{t('skills.importFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
onClick={(e) => {
e.stopPropagation();
navigate(`${routePrefix}?id=new`);
}}
>
<Plus className="size-3.5" />
</button>
)}
</WorkspaceQuotaTooltip>
)}
<CollapsibleTrigger asChild>
<button
type="button"
@@ -4,6 +4,7 @@ import React, {
useState,
useEffect,
useCallback,
useRef,
} from 'react';
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider';
@@ -48,9 +49,11 @@ export interface SidebarDataContextValue {
pipelines: SidebarEntityItem[];
knowledgeBases: SidebarEntityItem[];
plugins: SidebarEntityItem[];
pluginCount: number;
mcpServers: SidebarEntityItem[];
skills: SidebarEntityItem[];
pluginPages: PluginPageItem[];
quotaDataLoaded: boolean;
refreshBots: () => Promise<void>;
refreshPipelines: () => Promise<void>;
refreshKnowledgeBases: () => Promise<void>;
@@ -77,9 +80,36 @@ export function SidebarDataProvider({
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
const [pluginCount, setPluginCount] = useState(0);
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
const [quotaDataLoaded, setQuotaDataLoaded] = useState(false);
const refreshRequestIds = useRef({
bots: 0,
pipelines: 0,
knowledgeBases: 0,
plugins: 0,
mcpServers: 0,
skills: 0,
});
const quotaResourceLoaded = useRef({
bots: false,
pipelines: false,
knowledgeBases: false,
plugins: false,
mcpServers: false,
skills: false,
});
const setQuotaResourceLoaded = useCallback(
(resource: keyof typeof quotaResourceLoaded.current, loaded: boolean) => {
quotaResourceLoaded.current[resource] = loaded;
setQuotaDataLoaded(
Object.values(quotaResourceLoaded.current).every(Boolean),
);
},
[],
);
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
useState<boolean>(() => {
@@ -96,8 +126,11 @@ export function SidebarDataProvider({
}, []);
const refreshBots = useCallback(async () => {
const requestId = ++refreshRequestIds.current.bots;
try {
const resp = await httpClient.getBots();
if (requestId !== refreshRequestIds.current.bots) return;
setQuotaResourceLoaded('bots', true);
setBots(
resp.bots.map((bot) => ({
id: bot.uuid || '',
@@ -109,13 +142,18 @@ export function SidebarDataProvider({
})),
);
} catch (error) {
if (requestId !== refreshRequestIds.current.bots) return;
setQuotaResourceLoaded('bots', false);
console.error('Failed to fetch bots for sidebar:', error);
}
}, []);
}, [setQuotaResourceLoaded]);
const refreshPipelines = useCallback(async () => {
const requestId = ++refreshRequestIds.current.pipelines;
try {
const resp = await httpClient.getPipelines();
if (requestId !== refreshRequestIds.current.pipelines) return;
setQuotaResourceLoaded('pipelines', true);
setPipelines(
resp.pipelines.map((p) => ({
id: p.uuid || '',
@@ -126,13 +164,18 @@ export function SidebarDataProvider({
})),
);
} catch (error) {
if (requestId !== refreshRequestIds.current.pipelines) return;
setQuotaResourceLoaded('pipelines', false);
console.error('Failed to fetch pipelines for sidebar:', error);
}
}, []);
}, [setQuotaResourceLoaded]);
const refreshKnowledgeBases = useCallback(async () => {
const requestId = ++refreshRequestIds.current.knowledgeBases;
try {
const resp = await httpClient.getKnowledgeBases();
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
setQuotaResourceLoaded('knowledgeBases', true);
setKnowledgeBases(
resp.bases.map((kb) => ({
id: kb.uuid || '',
@@ -143,11 +186,14 @@ export function SidebarDataProvider({
})),
);
} catch (error) {
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
setQuotaResourceLoaded('knowledgeBases', false);
console.error('Failed to fetch knowledge bases for sidebar:', error);
}
}, []);
}, [setQuotaResourceLoaded]);
const refreshPlugins = useCallback(async () => {
const requestId = ++refreshRequestIds.current.plugins;
try {
const [pluginsResp, marketplaceResp] = await Promise.all([
httpClient.getPlugins(),
@@ -155,6 +201,9 @@ export function SidebarDataProvider({
.getMarketplacePlugins(1, 100)
.catch(() => ({ plugins: [] })),
]);
if (requestId !== refreshRequestIds.current.plugins) return;
setQuotaResourceLoaded('plugins', true);
setPluginCount(pluginsResp.plugins?.length ?? 0);
// Build marketplace version lookup: "author/name" -> latest_version
const marketplaceVersions = new Map<string, string>();
@@ -241,13 +290,18 @@ export function SidebarDataProvider({
}
setPluginPages(pages);
} catch (error) {
if (requestId !== refreshRequestIds.current.plugins) return;
setQuotaResourceLoaded('plugins', false);
console.error('Failed to fetch plugins for sidebar:', error);
}
}, []);
}, [setQuotaResourceLoaded]);
const refreshMCPServers = useCallback(async () => {
const requestId = ++refreshRequestIds.current.mcpServers;
try {
const resp = await httpClient.getMCPServers();
if (requestId !== refreshRequestIds.current.mcpServers) return;
setQuotaResourceLoaded('mcpServers', true);
setMCPServers(
resp.servers.map((server) => ({
id: server.name, // Keep __ for API calls
@@ -257,13 +311,18 @@ export function SidebarDataProvider({
})),
);
} catch (error) {
if (requestId !== refreshRequestIds.current.mcpServers) return;
setQuotaResourceLoaded('mcpServers', false);
console.error('Failed to fetch MCP servers for sidebar:', error);
}
}, []);
}, [setQuotaResourceLoaded]);
const refreshSkills = useCallback(async () => {
const requestId = ++refreshRequestIds.current.skills;
try {
const resp = await httpClient.getSkills();
if (requestId !== refreshRequestIds.current.skills) return;
setQuotaResourceLoaded('skills', true);
setSkills(
resp.skills.map((skill) => ({
id: skill.name,
@@ -273,11 +332,22 @@ export function SidebarDataProvider({
})),
);
} catch (error) {
if (requestId !== refreshRequestIds.current.skills) return;
setQuotaResourceLoaded('skills', false);
console.error('Failed to fetch skills for sidebar:', error);
}
}, []);
}, [setQuotaResourceLoaded]);
const refreshAll = useCallback(async () => {
quotaResourceLoaded.current = {
bots: false,
pipelines: false,
knowledgeBases: false,
plugins: false,
mcpServers: false,
skills: false,
};
setQuotaDataLoaded(false);
await Promise.all([
refreshBots(),
refreshPipelines(),
@@ -307,9 +377,11 @@ export function SidebarDataProvider({
pipelines,
knowledgeBases,
plugins,
pluginCount,
mcpServers,
skills,
pluginPages,
quotaDataLoaded,
refreshBots,
refreshPipelines,
refreshKnowledgeBases,
@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { WorkspaceQuotaItem } from './useWorkspaceQuotaStatus';
export function WorkspaceQuotaTooltip({
quota,
resource,
children,
side = 'top',
}: {
quota: WorkspaceQuotaItem;
resource: string;
children: ReactNode;
side?: 'top' | 'right' | 'bottom' | 'left';
}) {
const { t } = useTranslation();
if (!quota.disabled) return children;
const message = quota.loading
? t('limitation.quotaLoadingTooltip')
: t('limitation.createDisabledTooltip', {
resource,
max: quota.max,
});
return (
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
aria-disabled="true"
aria-label={message}
className="inline-flex cursor-not-allowed rounded-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{children}
</span>
</TooltipTrigger>
<TooltipContent side={side} className="max-w-72 text-center">
{message}
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,82 @@
import { systemInfo } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
export interface WorkspaceQuotaItem {
count: number;
max: number;
reached: boolean;
loading: boolean;
disabled: boolean;
}
export interface WorkspaceQuotaStatus {
bots: WorkspaceQuotaItem;
pipelines: WorkspaceQuotaItem;
knowledgeBases: WorkspaceQuotaItem;
extensions: WorkspaceQuotaItem;
botsReached: boolean;
pipelinesReached: boolean;
knowledgeBasesReached: boolean;
extensionsReached: boolean;
}
function quotaItem(
count: number,
max: number | undefined,
loaded: boolean,
): WorkspaceQuotaItem {
const normalizedMax = typeof max === 'number' ? max : -1;
const reached = loaded && normalizedMax >= 0 && count >= normalizedMax;
return {
count,
max: normalizedMax,
reached,
loading: !loaded,
disabled: !loaded || reached,
};
}
export function useWorkspaceQuotaStatus(): WorkspaceQuotaStatus {
const {
bots,
pipelines,
knowledgeBases,
pluginCount,
mcpServers,
skills,
quotaDataLoaded,
} = useSidebarData();
const limitation = systemInfo.limitation;
const botQuota = quotaItem(
bots.length,
limitation?.max_bots,
quotaDataLoaded,
);
const pipelineQuota = quotaItem(
pipelines.length,
limitation?.max_pipelines,
quotaDataLoaded,
);
const knowledgeBaseQuota = quotaItem(
knowledgeBases.length,
limitation?.max_knowledge_bases,
quotaDataLoaded,
);
const extensionQuota = quotaItem(
pluginCount + mcpServers.length + skills.length,
limitation?.max_extensions,
quotaDataLoaded,
);
return {
bots: botQuota,
pipelines: pipelineQuota,
knowledgeBases: knowledgeBaseQuota,
extensions: extensionQuota,
botsReached: botQuota.disabled,
pipelinesReached: pipelineQuota.disabled,
knowledgeBasesReached: knowledgeBaseQuota.disabled,
extensionsReached: extensionQuota.disabled,
};
}
@@ -7,6 +7,8 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
import type { WorkspaceQuotaItem } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
type PluginLocalPreview = Awaited<
ReturnType<typeof httpClient.previewPluginInstallFromLocal>
@@ -16,6 +18,8 @@ interface PluginLocalPreviewPanelProps {
file: File;
onInstallStarted?: () => void;
onCancel?: () => void;
quota?: WorkspaceQuotaItem;
quotaResource?: string;
}
function formatFileSize(bytes: number): string {
@@ -30,6 +34,8 @@ export default function PluginLocalPreviewPanel({
file,
onInstallStarted,
onCancel,
quota,
quotaResource = '',
}: PluginLocalPreviewPanelProps) {
const { t } = useTranslation();
const { addTask, setSelectedTaskId } = usePluginInstallTasks();
@@ -63,6 +69,7 @@ export default function PluginLocalPreviewPanel({
}, [loadPreview]);
async function handleInstall() {
if (quota?.disabled) return;
setInstalling(true);
setErrorMessage(null);
try {
@@ -190,13 +197,27 @@ export default function PluginLocalPreviewPanel({
{t('common.cancel')}
</Button>
)}
<Button
type="button"
onClick={handleInstall}
disabled={!preview || previewing || installing}
>
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
</Button>
{quota ? (
<WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
<Button
type="button"
onClick={handleInstall}
disabled={quota.disabled || !preview || previewing || installing}
>
{installing
? t('plugins.installing')
: t('plugins.confirmInstall')}
</Button>
</WorkspaceQuotaTooltip>
) : (
<Button
type="button"
onClick={handleInstall}
disabled={!preview || previewing || installing}
>
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
</Button>
)}
</div>
</div>
);
@@ -80,9 +80,13 @@ function loadMarketFilters(): MarketFilters {
function MarketPageContent({
installPlugin,
headerActions,
installDisabled,
installDisabledTooltip,
}: {
installPlugin: (plugin: PluginV4) => void;
headerActions?: React.ReactNode;
installDisabled?: boolean;
installDisabledTooltip?: string;
}) {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
@@ -847,6 +851,8 @@ function MarketPageContent({
lists={recommendationLists}
tagNames={tagNames}
onInstall={handleInstallPlugin}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
/>
)}
@@ -876,6 +882,8 @@ function MarketPageContent({
cardVO={plugin}
onInstall={handleInstallPlugin}
tagNames={tagNames}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
/>
))}
</div>
@@ -915,9 +923,13 @@ function MarketPageContent({
export default function MarketPage({
installPlugin,
headerActions,
installDisabled,
installDisabledTooltip,
}: {
installPlugin: (plugin: PluginV4) => void;
headerActions?: React.ReactNode;
installDisabled?: boolean;
installDisabledTooltip?: string;
}) {
return (
<Suspense
@@ -932,6 +944,8 @@ export default function MarketPage({
<MarketPageContent
installPlugin={installPlugin}
headerActions={headerActions}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
/>
</Suspense>
);
@@ -54,11 +54,15 @@ function RecommendationListRow({
list,
tagNames,
onInstall,
installDisabled,
installDisabledTooltip,
isLast,
}: {
list: RecommendationList;
tagNames: Record<string, string>;
onInstall: (cardVO: PluginMarketCardVO) => void;
installDisabled?: boolean;
installDisabledTooltip?: string;
isLast: boolean;
}) {
const { t } = useTranslation();
@@ -263,6 +267,8 @@ function RecommendationListRow({
cardVO={pluginToVO(plugin, t)}
tagNames={tagNames}
onInstall={onInstall}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
/>
))}
</div>
@@ -277,10 +283,14 @@ export function RecommendationLists({
lists,
tagNames,
onInstall,
installDisabled,
installDisabledTooltip,
}: {
lists: RecommendationList[];
tagNames: Record<string, string>;
onInstall: (cardVO: PluginMarketCardVO) => void;
installDisabled?: boolean;
installDisabledTooltip?: string;
}) {
if (!lists || lists.length === 0) return null;
@@ -292,6 +302,8 @@ export function RecommendationLists({
list={list}
tagNames={tagNames}
onInstall={onInstall}
installDisabled={installDisabled}
installDisabledTooltip={installDisabledTooltip}
isLast={index === lists.length - 1}
/>
))}
@@ -23,10 +23,14 @@ export default function PluginMarketCardComponent({
cardVO,
onInstall,
tagNames = {},
installDisabled = false,
installDisabledTooltip,
}: {
cardVO: PluginMarketCardVO;
onInstall?: (cardVO: PluginMarketCardVO) => void;
tagNames?: Record<string, string>;
installDisabled?: boolean;
installDisabledTooltip?: string;
}) {
const { t } = useTranslation();
const bottomRef = useRef<HTMLDivElement>(null);
@@ -127,6 +131,7 @@ export default function PluginMarketCardComponent({
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
const handleInstallClick = () => {
if (installDisabled) return;
onInstall?.(cardVO);
};
@@ -153,12 +158,17 @@ export default function PluginMarketCardComponent({
}
};
return (
const cardContent = (
<div
role="button"
role={installDisabled ? 'group' : 'button'}
tabIndex={0}
aria-disabled={installDisabled}
aria-label={t('market.installCard', { name: cardVO.label })}
className="w-[100%] h-[10rem] cursor-pointer bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] transition-shadow duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)] relative"
className={`w-[100%] h-[10rem] bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] transition-shadow duration-200 outline-none dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] relative ${
installDisabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)]'
}`}
onClick={handleInstallClick}
onKeyDown={(event) => {
if (
@@ -382,4 +392,17 @@ export default function PluginMarketCardComponent({
</div>
</div>
);
if (!installDisabled || !installDisabledTooltip) return cardContent;
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>{cardContent}</TooltipTrigger>
<TooltipContent side="top" className="max-w-72 text-center">
{installDisabledTooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -7,6 +7,8 @@ import { Checkbox } from '@/components/ui/checkbox';
import { httpClient } from '@/app/infra/http/HttpClient';
import type { Skill } from '@/app/infra/entities/api';
import { cn } from '@/lib/utils';
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
import type { WorkspaceQuotaItem } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
interface PreviewSkill extends Skill {
source_path?: string;
@@ -16,6 +18,8 @@ interface SkillZipPreviewPanelProps {
file: File;
onImported: (skillNames: string[]) => void;
onCancel?: () => void;
quota?: WorkspaceQuotaItem;
quotaResource?: string;
}
function formatFileSize(bytes: number): string {
@@ -45,6 +49,8 @@ export default function SkillZipPreviewPanel({
file,
onImported,
onCancel,
quota,
quotaResource = '',
}: SkillZipPreviewPanelProps) {
const { t } = useTranslation();
const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
@@ -117,6 +123,7 @@ export default function SkillZipPreviewPanel({
}
async function handleInstall() {
if (quota?.disabled) return;
if (selectedPaths.length === 0) return;
setInstalling(true);
@@ -249,28 +256,56 @@ export default function SkillZipPreviewPanel({
{t('common.cancel')}
</Button>
)}
<Button
type="button"
onClick={handleInstall}
disabled={
previewing ||
installing ||
previewSkills.length === 0 ||
selectedPaths.length === 0
}
>
{installing ? (
<>
<Loader2 className="size-4 animate-spin" />
{t('skills.installing')}
</>
) : (
<>
<PackageOpen className="size-4" />
{t('skills.confirmInstall')}
</>
)}
</Button>
{quota ? (
<WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
<Button
type="button"
onClick={handleInstall}
disabled={
quota.disabled ||
previewing ||
installing ||
previewSkills.length === 0 ||
selectedPaths.length === 0
}
>
{installing ? (
<>
<Loader2 className="size-4 animate-spin" />
{t('skills.installing')}
</>
) : (
<>
<PackageOpen className="size-4" />
{t('skills.confirmInstall')}
</>
)}
</Button>
</WorkspaceQuotaTooltip>
) : (
<Button
type="button"
onClick={handleInstall}
disabled={
previewing ||
installing ||
previewSkills.length === 0 ||
selectedPaths.length === 0
}
>
{installing ? (
<>
<Loader2 className="size-4 animate-spin" />
{t('skills.installing')}
</>
) : (
<>
<PackageOpen className="size-4" />
{t('skills.confirmInstall')}
</>
)}
</Button>
)}
</div>
</div>
);
+1
View File
@@ -328,6 +328,7 @@ export interface SystemLimitation {
max_bots: number;
max_pipelines: number;
max_extensions: number;
max_knowledge_bases?: number;
/** When non-empty, every pipeline is forced to this Box sandbox-scope
* template (e.g. ``{global}``) and the per-pipeline "Sandbox Scope"
* selector is locked. Used by SaaS deployments. Empty = no restriction. */
+6
View File
@@ -1678,6 +1678,12 @@ const enUS = {
'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.',
maxExtensionsReached:
'Maximum number of extensions ({{max}}) reached. Please remove an existing extension before adding a new one.',
quotaLoadingTooltip:
'Workspace usage is still loading. Please wait before creating a resource.',
quotaCheckFailed:
'Unable to verify the current workspace quota. Please try again.',
createDisabledTooltip:
'The {{resource}} limit ({{max}}) for this workspace has been reached. Delete one existing item before creating another.',
},
skills: {
title: 'Skills',
+6
View File
@@ -1634,6 +1634,12 @@ const esES = {
'Se ha alcanzado el número máximo de Pipelines ({{max}}). Por favor, elimina un Pipeline existente antes de crear uno nuevo.',
maxExtensionsReached:
'Se ha alcanzado el número máximo de extensiones ({{max}}). Por favor, elimina un servidor MCP o plugin existente antes de añadir uno nuevo.',
quotaLoadingTooltip:
'El uso del espacio de trabajo aún se está cargando. Espera antes de crear un recurso.',
quotaCheckFailed:
'No se pudo verificar la cuota actual del espacio de trabajo. Inténtalo de nuevo.',
createDisabledTooltip:
'Se alcanzó el límite de {{resource}} ({{max}}) de este espacio de trabajo. Elimina uno existente antes de crear otro.',
},
wizard: {
sidebarDescription: 'Crea un Bot con pasos guiados',
+6
View File
@@ -1685,6 +1685,12 @@ const jaJP = {
'パイプライン数が上限({{max}}個)に達しました。新しいパイプラインを作成するには、既存のパイプラインを削除してください。',
maxExtensionsReached:
'拡張機能数が上限({{max}}個)に達しました。新しい MCP サーバーやプラグインを追加するには、既存のものを削除してください。',
quotaLoadingTooltip:
'ワークスペースの使用状況を読み込んでいます。リソースを作成する前にお待ちください。',
quotaCheckFailed:
'現在のワークスペース上限を確認できません。もう一度お試しください。',
createDisabledTooltip:
'このワークスペースの{{resource}}数が上限({{max}}個)に達しました。新しく作成する前に既存の{{resource}}を削除してください。',
},
wizard: {
sidebarDescription: 'ガイド付きステップでボットを作成',
+6
View File
@@ -1609,6 +1609,12 @@ const ruRU = {
'Достигнуто максимальное количество конвейеров ({{max}}). Удалите существующий конвейер перед созданием нового.',
maxExtensionsReached:
'Достигнуто максимальное количество расширений ({{max}}). Удалите существующий MCP-сервер или плагин перед добавлением нового.',
quotaLoadingTooltip:
'Данные об использовании рабочего пространства загружаются. Подождите перед созданием ресурса.',
quotaCheckFailed:
'Не удалось проверить текущую квоту рабочего пространства. Повторите попытку.',
createDisabledTooltip:
'Достигнут лимит {{resource}} ({{max}}) для этого рабочего пространства. Удалите существующий ресурс перед созданием нового.',
},
wizard: {
sidebarDescription: 'Создать бота с пошаговым руководством',
+6
View File
@@ -1576,6 +1576,12 @@ const thTH = {
'จำนวน Pipeline สูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบ Pipeline ที่มีอยู่ก่อนสร้างใหม่',
maxExtensionsReached:
'จำนวนส่วนขยายสูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบเซิร์ฟเวอร์ MCP หรือปลั๊กอินที่มีอยู่ก่อนเพิ่มใหม่',
quotaLoadingTooltip:
'กำลังโหลดการใช้งานพื้นที่ทำงาน โปรดรอก่อนสร้างทรัพยากร',
quotaCheckFailed:
'ไม่สามารถตรวจสอบโควตาปัจจุบันของพื้นที่ทำงานได้ โปรดลองอีกครั้ง',
createDisabledTooltip:
'ถึงขีดจำกัด {{resource}} ({{max}}) ของเวิร์กสเปซนี้แล้ว โปรดลบรายการเดิมก่อนสร้างรายการใหม่',
},
wizard: {
sidebarDescription: 'สร้าง Bot ด้วยขั้นตอนที่แนะนำ',
+6
View File
@@ -1602,6 +1602,12 @@ const viVN = {
'Đã đạt số lượng Pipeline tối đa ({{max}}). Vui lòng xóa một Pipeline hiện có trước khi tạo mới.',
maxExtensionsReached:
'Đã đạt số lượng tiện ích mở rộng tối đa ({{max}}). Vui lòng xóa một máy chủ MCP hoặc plugin hiện có trước khi thêm mới.',
quotaLoadingTooltip:
'Dữ liệu sử dụng không gian làm việc đang tải. Vui lòng chờ trước khi tạo tài nguyên.',
quotaCheckFailed:
'Không thể kiểm tra hạn mức hiện tại của không gian làm việc. Vui lòng thử lại.',
createDisabledTooltip:
'Đã đạt giới hạn {{resource}} ({{max}}) của workspace này. Hãy xóa một mục hiện có trước khi tạo mới.',
},
wizard: {
sidebarDescription: 'Tạo Bot với các bước hướng dẫn',
+4
View File
@@ -1606,6 +1606,10 @@ const zhHans = {
'已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。',
maxExtensionsReached:
'已达到扩展数量上限({{max}}个)。请先删除已有扩展后再添加新的。',
quotaLoadingTooltip: '正在加载工作空间用量,请稍后再创建资源。',
quotaCheckFailed: '无法确认当前工作空间额度,请重试。',
createDisabledTooltip:
'当前工作区的{{resource}}数量已达到上限({{max}}个)。请先删除一个已有{{resource}}后再创建。',
},
skills: {
title: '技能',
+4
View File
@@ -1531,6 +1531,10 @@ const zhHant = {
'已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。',
maxExtensionsReached:
'已達到擴充功能數量上限({{max}}個)。請先刪除已有擴充功能後再新增。',
quotaLoadingTooltip: '正在載入工作空間用量,請稍後再建立資源。',
quotaCheckFailed: '無法確認目前工作空間額度,請重試。',
createDisabledTooltip:
'目前工作區的{{resource}}數量已達上限({{max}}個)。請先刪除一個現有{{resource}}後再建立。',
},
wizard: {
sidebarDescription: '透過引導步驟建立機器人',
+148
View File
@@ -0,0 +1,148 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
function wrapped(data: unknown) {
return JSON.stringify({
code: 0,
message: 'ok',
data,
timestamp: Date.now(),
});
}
async function fulfill(
route: Parameters<Parameters<import('@playwright/test').Page['route']>[1]>[0],
data: unknown,
) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: wrapped(data),
});
}
test('quota-reached create actions are disabled and explain the current limit', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/system/info', (route) =>
fulfill(route, {
debug: false,
version: 'quota-e2e',
edition: 'community',
cloud_service_url: 'https://space.langbot.app',
enable_marketplace: true,
allow_modify_login_info: true,
disable_models_service: false,
limitation: {
max_bots: 2,
max_pipelines: 3,
max_extensions: 3,
max_knowledge_bases: 2,
},
outbound_ips: [],
wizard_status: 'completed',
wizard_progress: null,
}),
);
await page.route('**/api/v1/platform/bots**', (route) =>
fulfill(route, {
bots: Array.from({ length: 2 }, (_, index) => ({
uuid: `bot-${index}`,
name: `Bot ${index + 1}`,
description: '',
adapter: 'aiocqhttp',
enable: true,
updated_at: new Date().toISOString(),
})),
}),
);
await page.route('**/api/v1/pipelines**', (route) =>
fulfill(route, {
pipelines: Array.from({ length: 3 }, (_, index) => ({
uuid: `pipeline-${index}`,
name: `Pipeline ${index + 1}`,
description: '',
emoji: '⚙️',
updated_at: new Date().toISOString(),
})),
}),
);
await page.route('**/api/v1/knowledge/bases**', (route) =>
fulfill(route, {
bases: Array.from({ length: 2 }, (_, index) => ({
uuid: `kb-${index}`,
name: `Knowledge ${index + 1}`,
description: '',
emoji: '📚',
updated_at: new Date().toISOString(),
})),
}),
);
await page.route('**/api/v1/plugins**', (route) =>
fulfill(route, { plugins: [] }),
);
await page.route('**/api/v1/mcp/servers**', (route) =>
fulfill(route, {
servers: Array.from({ length: 3 }, (_, index) => ({
name: `mcp-${index}`,
mode: 'http',
enable: true,
runtime_info: { status: 'connected' },
})),
}),
);
await page.route('**/api/v1/skills**', (route) =>
fulfill(route, { skills: [] }),
);
await page.goto('/home/bots');
const botCreate = page.getByRole('button', {
name: 'Create Bots',
exact: true,
});
const pipelineCreate = page.getByRole('button', {
name: 'Create Pipelines',
exact: true,
});
const knowledgeCreate = page.getByRole('button', {
name: 'Create Knowledge',
exact: true,
});
const addExtension = page.getByRole('button', {
name: 'Add Extension',
exact: true,
});
await expect(botCreate).toBeDisabled();
await expect(pipelineCreate).toBeDisabled();
await expect(knowledgeCreate).toBeDisabled();
await expect(addExtension).toBeDisabled();
const botQuotaTrigger = botCreate.locator('..');
await botQuotaTrigger.hover();
await expect(
page.getByText(
'The Bots limit (2) for this workspace has been reached. Delete one existing item before creating another.',
),
).toBeVisible();
await botQuotaTrigger.focus();
await expect(botQuotaTrigger).toBeFocused();
await expect(
page.getByText(
'The Bots limit (2) for this workspace has been reached. Delete one existing item before creating another.',
),
).toBeVisible();
await page.goto('/home/add-extension');
const manualAdd = page.getByRole('button', { name: 'Manual Add' });
await expect(manualAdd).toBeDisabled();
await manualAdd.locator('..').hover();
await expect(
page.getByText(
'The Extensions limit (3) for this workspace has been reached. Delete one existing item before creating another.',
),
).toBeVisible();
});
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const quotaPath = path.join(
root,
'src/app/home/components/workspace-quota/useWorkspaceQuotaStatus.ts',
);
const sidebarPath = path.join(
root,
'src/app/home/components/home-sidebar/HomeSidebar.tsx',
);
const tooltipPath = path.join(
root,
'src/app/home/components/workspace-quota/WorkspaceQuotaTooltip.tsx',
);
const addExtensionPath = path.join(root, 'src/app/home/add-extension/page.tsx');
const marketPath = path.join(
root,
'src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx',
);
const marketCardPath = path.join(
root,
'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx',
);
const recommendationPath = path.join(
root,
'src/app/home/plugins/components/plugin-market/RecommendationLists.tsx',
);
const zhPath = path.join(root, 'src/i18n/locales/zh-Hans.ts');
test('workspace quota hook exposes reached states for every creatable resource', () => {
assert.equal(
fs.existsSync(quotaPath),
true,
'workspace quota hook is missing',
);
const source = fs.readFileSync(quotaPath, 'utf8');
for (const token of [
'botsReached',
'pipelinesReached',
'knowledgeBasesReached',
'extensionsReached',
'max_bots',
'max_pipelines',
'max_knowledge_bases',
'max_extensions',
]) {
assert.match(source, new RegExp(token));
}
});
test('sidebar quota-disables create controls and renders a tooltip', () => {
const source = fs.readFileSync(sidebarPath, 'utf8');
const tooltip = fs.readFileSync(tooltipPath, 'utf8');
assert.match(source, /useWorkspaceQuotaStatus/);
assert.match(source, /quota\.disabled/);
assert.match(source, /disabled=\{quota\.disabled\}/);
assert.match(source, /WorkspaceQuotaTooltip/);
assert.match(tooltip, /TooltipContent/);
assert.match(tooltip, /limitation\.createDisabledTooltip/);
assert.match(tooltip, /limitation\.quotaLoadingTooltip/);
assert.match(tooltip, /tabIndex=\{0\}/);
assert.match(source, /config\.id === 'add-extension'/);
});
test('add-extension page disables all install entry points at the quota', () => {
const page = fs.readFileSync(addExtensionPath, 'utf8');
const market = fs.readFileSync(marketPath, 'utf8');
const card = fs.readFileSync(marketCardPath, 'utf8');
const recommendations = fs.readFileSync(recommendationPath, 'utf8');
assert.match(page, /extensionsReached/);
assert.match(page, /installDisabled=\{extensionsReached\}/);
assert.match(page, /disabled=\{extensionsReached/);
assert.match(page, /limitation\.createDisabledTooltip/);
assert.match(market, /installDisabled/);
assert.match(card, /installDisabled/);
assert.match(card, /disabled=\{installDisabled\}/);
assert.match(card, /TooltipContent/);
assert.match(recommendations, /installDisabled=\{installDisabled\}/);
assert.match(
recommendations,
/installDisabledTooltip=\{installDisabledTooltip\}/,
);
assert.match(page, /quota=\{extensionQuota\}/);
});
test('extension confirmation checks fail closed and enter an in-flight state first', () => {
const page = fs.readFileSync(addExtensionPath, 'utf8');
assert.match(page, /limitation\.quotaCheckFailed/);
assert.doesNotMatch(page, /If we can't check, let backend handle it/);
assert.match(
page,
/setGithubInstallStatus\(GithubInstallStatus\.INSTALLING\);\s+if \(!\(await checkExtensionsLimit\(\)\)\)/,
);
assert.match(
page,
/setGithubInstallStatus\(GithubInstallStatus\.SKILL_INSTALLING\);\s+if \(!\(await checkExtensionsLimit\(\)\)\)/,
);
});
test('quota tooltip copy is localized in Simplified Chinese', () => {
const source = fs.readFileSync(zhPath, 'utf8');
assert.match(source, /createDisabledTooltip/);
assert.match(source, /已达到.*上限/);
assert.match(source, /删除.*后再/);
});