feat(web): add plugin install dropdown to sidebar with context-based action dispatch

Add '+' dropdown menu to plugins sidebar category with three install
options: marketplace, upload local, and install from GitHub. Use shared
React context (pendingPluginInstallAction) instead of URL params to
reliably trigger install actions across components. Add e.stopPropagation
on all DropdownMenuItem handlers to prevent React portal event bubbling
from triggering parent SidebarMenuButton navigation.
This commit is contained in:
Junyan Qin
2026-03-27 20:39:26 +08:00
parent 42e1e038bd
commit 6570f276d2
7 changed files with 177 additions and 29 deletions
@@ -24,6 +24,9 @@ import {
ExternalLink, ExternalLink,
Trash, Trash,
Bug, Bug,
Upload,
Store,
Github,
} from 'lucide-react'; } from 'lucide-react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
@@ -135,6 +138,7 @@ const CREATABLE_CATEGORIES: EntityCategoryId[] = [
'pipelines', 'pipelines',
'knowledge', 'knowledge',
'mcp', 'mcp',
'plugins',
]; ];
// Categories where clicking the parent only toggles collapse (no list page) // Categories where clicking the parent only toggles collapse (no list page)
@@ -243,6 +247,7 @@ function NavItems({
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const sidebarData = useSidebarData(); const sidebarData = useSidebarData();
const { setPendingPluginInstallAction } = sidebarData;
const { state: sidebarState, isMobile } = useSidebar(); const { state: sidebarState, isMobile } = useSidebar();
const { t } = useTranslation(); const { t } = useTranslation();
// Track which entity categories have their full list expanded // Track which entity categories have their full list expanded
@@ -601,21 +606,78 @@ function NavItems({
> >
<div className="flex items-center justify-between mb-1 px-2"> <div className="flex items-center justify-between mb-1 px-2">
<span className="text-sm font-medium">{config.name}</span> <span className="text-sm font-medium">{config.name}</span>
{canCreate && ( {canCreate &&
<button (isPlugin ? (
type="button" <DropdownMenu>
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors" <DropdownMenuTrigger asChild>
onClick={() => { <button
router.push(`${routePrefix}?id=new`); type="button"
setPopoverOpen((prev) => ({ className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
...prev, >
[config.id]: false, <Plus className="size-3.5" />
})); </button>
}} </DropdownMenuTrigger>
> <DropdownMenuContent align="end">
<Plus className="size-3.5" /> {systemInfo.enable_marketplace && (
</button> <DropdownMenuItem
)} onClick={(e) => {
e.stopPropagation();
router.push('/home/market');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Store className="size-4" />
{t('plugins.goToMarketplace')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('local');
router.push('/home/plugins');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Upload className="size-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('github');
router.push('/home/plugins');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={() => {
router.push(`${routePrefix}?id=new`);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Plus className="size-3.5" />
</button>
))}
</div> </div>
<div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto"> <div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto">
{renderEntityList(true)} {renderEntityList(true)}
@@ -651,18 +713,64 @@ function NavItems({
{config.icon} {config.icon}
<span>{config.name}</span> <span>{config.name}</span>
<div className="ml-auto flex items-center gap-0.5 -mr-1"> <div className="ml-auto flex items-center gap-0.5 -mr-1">
{canCreate && ( {canCreate &&
<button (isPlugin ? (
type="button" <DropdownMenu>
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground opacity-0 group-hover/category-header:opacity-100 transition-all" <DropdownMenuTrigger asChild>
onClick={(e) => { <button
e.stopPropagation(); type="button"
router.push(`${routePrefix}?id=new`); className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground opacity-0 group-hover/category-header:opacity-100 transition-all"
}} onClick={(e) => e.stopPropagation()}
> >
<Plus className="size-3.5" /> <Plus className="size-3.5" />
</button> </button>
)} </DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push('/home/market');
}}
>
<Store className="size-4" />
{t('plugins.goToMarketplace')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('local');
router.push('/home/plugins');
}}
>
<Upload className="size-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('github');
router.push('/home/plugins');
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => {
e.stopPropagation();
router.push(`${routePrefix}?id=new`);
}}
>
<Plus className="size-3.5" />
</button>
))}
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <button
type="button" type="button"
@@ -30,6 +30,9 @@ export interface SidebarEntityItem {
debug?: boolean; debug?: boolean;
} }
// Install action types that can be triggered from sidebar
export type PluginInstallAction = 'local' | 'github' | null;
// Entity lists and refresh functions exposed via context // Entity lists and refresh functions exposed via context
export interface SidebarDataContextValue { export interface SidebarDataContextValue {
bots: SidebarEntityItem[]; bots: SidebarEntityItem[];
@@ -46,6 +49,9 @@ export interface SidebarDataContextValue {
// Breadcrumb: entity name shown when viewing a detail page // Breadcrumb: entity name shown when viewing a detail page
detailEntityName: string | null; detailEntityName: string | null;
setDetailEntityName: (name: string | null) => void; setDetailEntityName: (name: string | null) => void;
// Pending plugin install action triggered from sidebar
pendingPluginInstallAction: PluginInstallAction;
setPendingPluginInstallAction: (action: PluginInstallAction) => void;
} }
const SidebarDataContext = createContext<SidebarDataContextValue | null>(null); const SidebarDataContext = createContext<SidebarDataContextValue | null>(null);
@@ -61,6 +67,8 @@ export function SidebarDataProvider({
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]); const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]); const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
const [detailEntityName, setDetailEntityName] = useState<string | null>(null); const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
const [pendingPluginInstallAction, setPendingPluginInstallAction] =
useState<PluginInstallAction>(null);
const refreshBots = useCallback(async () => { const refreshBots = useCallback(async () => {
try { try {
@@ -216,6 +224,8 @@ export function SidebarDataProvider({
refreshAll, refreshAll,
detailEntityName, detailEntityName,
setDetailEntityName, setDetailEntityName,
pendingPluginInstallAction,
setPendingPluginInstallAction,
}} }}
> >
{children} {children}
+28 -2
View File
@@ -94,7 +94,11 @@ export default function PluginConfigPage() {
function PluginListView() { function PluginListView() {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const { refreshPlugins } = useSidebarData(); const {
refreshPlugins,
pendingPluginInstallAction,
setPendingPluginInstallAction,
} = useSidebarData();
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [installSource, setInstallSource] = useState<string>('local'); const [installSource, setInstallSource] = useState<string>('local');
const [installInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any const [installInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -408,6 +412,28 @@ function PluginListView() {
[uploadPluginFile, isPluginSystemReady, t], [uploadPluginFile, isPluginSystemReady, t],
); );
// Auto-trigger install action from sidebar via shared context
useEffect(() => {
if (!pendingPluginInstallAction || statusLoading || !isPluginSystemReady)
return;
// Consume the action immediately
const action = pendingPluginInstallAction;
setPendingPluginInstallAction(null);
if (action === 'local') {
// Small delay to ensure file input ref is ready
setTimeout(() => fileInputRef.current?.click(), 100);
} else if (action === 'github') {
setInstallSource('github');
setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT);
setInstallError(null);
resetGithubState();
setModalOpen(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingPluginInstallAction, statusLoading, isPluginSystemReady]);
const handleShowDebugInfo = async () => { const handleShowDebugInfo = async () => {
try { try {
const info = await httpClient.getPluginDebugInfo(); const info = await httpClient.getPluginDebugInfo();
@@ -627,7 +653,7 @@ function PluginListView() {
}} }}
> >
<StoreIcon className="w-4 h-4" /> <StoreIcon className="w-4 h-4" />
{t('plugins.marketplace')} {t('plugins.goToMarketplace')}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem onClick={handleFileSelect}> <DropdownMenuItem onClick={handleFileSelect}>
+1
View File
@@ -476,6 +476,7 @@ const enUS = {
assetSize: 'Size: {{size}}', assetSize: 'Size: {{size}}',
confirmInstall: 'Confirm Install', confirmInstall: 'Confirm Install',
installFromGithubDesc: 'Install plugin from GitHub Release', installFromGithubDesc: 'Install plugin from GitHub Release',
goToMarketplace: 'Go to Marketplace',
}, },
market: { market: {
searchPlaceholder: 'Search plugins...', searchPlaceholder: 'Search plugins...',
+1
View File
@@ -476,6 +476,7 @@
assetSize: 'サイズ: {{size}}', assetSize: 'サイズ: {{size}}',
confirmInstall: 'インストールを確認', confirmInstall: 'インストールを確認',
installFromGithubDesc: 'GitHubリリースからプラグインをインストール', installFromGithubDesc: 'GitHubリリースからプラグインをインストール',
goToMarketplace: 'マーケットプレイスへ',
}, },
market: { market: {
searchPlaceholder: 'プラグインを検索...', searchPlaceholder: 'プラグインを検索...',
+1
View File
@@ -452,6 +452,7 @@ const zhHans = {
assetSize: '大小: {{size}}', assetSize: '大小: {{size}}',
confirmInstall: '确认安装', confirmInstall: '确认安装',
installFromGithubDesc: '从 GitHub Release 安装插件', installFromGithubDesc: '从 GitHub Release 安装插件',
goToMarketplace: '前往插件市场',
}, },
market: { market: {
searchPlaceholder: '搜索插件...', searchPlaceholder: '搜索插件...',
+1
View File
@@ -445,6 +445,7 @@ const zhHant = {
assetSize: '大小: {{size}}', assetSize: '大小: {{size}}',
confirmInstall: '確認安裝', confirmInstall: '確認安裝',
installFromGithubDesc: '從 GitHub Release 安裝插件', installFromGithubDesc: '從 GitHub Release 安裝插件',
goToMarketplace: '前往外掛市場',
}, },
market: { market: {
searchPlaceholder: '搜尋插件...', searchPlaceholder: '搜尋插件...',