feat(sidebar): unify installed-extensions list with plugins, MCP and skills

- Render plugins, MCP servers and skills together under the "Installed
  Extensions" sidebar entry, alphabetically sorted to match the list page.
- Resolve per-item routes by extension type (plugin -> /home/extensions,
  mcp -> /home/mcp, skill -> /home/skills) and gate the plugin-only hover
  context menu on extensionType === 'plugin'.
- Lift the "group by type" toggle into SidebarDataContext (still persisted
  in localStorage) so the sidebar groups items with section headers
  whenever the list page has the toggle enabled.
- Show lucide fallback icons (Server / Sparkles / Puzzle) tinted in the
  LangBot blue for MCP, skill, and missing-icon plugin items, overriding
  the SidebarMenuSubButton svg color rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Junyan Qin
2026-05-10 00:10:04 +08:00
parent 14c2da4d29
commit 6f97877a5a
3 changed files with 255 additions and 145 deletions
@@ -29,6 +29,8 @@ import {
FilePlus2, FilePlus2,
Sparkles, Sparkles,
HardDrive, HardDrive,
Server,
Puzzle,
} from 'lucide-react'; } from 'lucide-react';
import { useTheme } from '@/components/providers/theme-provider'; import { useTheme } from '@/components/providers/theme-provider';
@@ -370,7 +372,23 @@ function NavItems({
// Entity categories: collapsible with sub-items // Entity categories: collapsible with sub-items
const entityKey = ENTITY_KEY_MAP[config.id]; const entityKey = ENTITY_KEY_MAP[config.id];
const items: SidebarEntityItem[] = sidebarData[entityKey]; const isExtensionsCategory = config.id === 'plugins';
const items: SidebarEntityItem[] = isExtensionsCategory
? [
...sidebarData.plugins.map((p) => ({
...p,
extensionType: 'plugin' as const,
})),
...sidebarData.mcpServers.map((m) => ({
...m,
extensionType: 'mcp' as const,
})),
...sidebarData.skills.map((s) => ({
...s,
extensionType: 'skill' as const,
})),
]
: sidebarData[entityKey];
const routePrefix = ENTITY_ROUTE_MAP[config.id]; const routePrefix = ENTITY_ROUTE_MAP[config.id];
const hasDetailPages = DETAIL_PAGE_CATEGORIES.includes(config.id); const hasDetailPages = DETAIL_PAGE_CATEGORIES.includes(config.id);
const canCreate = CREATABLE_CATEGORIES.includes(config.id); const canCreate = CREATABLE_CATEGORIES.includes(config.id);
@@ -379,6 +397,18 @@ function NavItems({
const isSkill = config.id === 'skills'; const isSkill = config.id === 'skills';
const isBot = config.id === 'bots'; const isBot = config.id === 'bots';
const isMCP = config.id === 'mcp'; const isMCP = config.id === 'mcp';
const resolveItemRoute = (item: SidebarEntityItem): string => {
if (item.extensionType === 'mcp') {
return `/home/mcp?id=${encodeURIComponent(item.id)}`;
}
if (item.extensionType === 'skill') {
return `/home/skills?id=${encodeURIComponent(item.id)}`;
}
return hasDetailPages
? `${routePrefix}?id=${encodeURIComponent(item.id)}`
: routePrefix;
};
const isActive = const isActive =
selectedChild?.id === config.id || selectedChild?.id === config.id ||
pathname === routePrefix || pathname === routePrefix ||
@@ -394,7 +424,13 @@ function NavItems({
// Shared entity list renderer used by both popover and collapsible // Shared entity list renderer used by both popover and collapsible
const renderEntityList = (inPopover: boolean) => { const renderEntityList = (inPopover: boolean) => {
const sortedItems = sortByRecent(items); const sortedItems = isExtensionsCategory
? [...items].sort((a, b) =>
a.name.localeCompare(b.name, undefined, {
sensitivity: 'base',
}),
)
: sortByRecent(items);
const isExpanded = expandedLists[config.id] ?? false; const isExpanded = expandedLists[config.id] ?? false;
const maxItems = inPopover ? 10 : MAX_VISIBLE_ITEMS; const maxItems = inPopover ? 10 : MAX_VISIBLE_ITEMS;
const visibleItems = const visibleItems =
@@ -416,152 +452,212 @@ function NavItems({
); );
} }
return ( const itemActiveCheck = (item: SidebarEntityItem): boolean => {
<> if (item.extensionType === 'mcp') {
{visibleItems.map((item) => { return (
const itemRoute = hasDetailPages pathname === '/home/mcp' && searchParams.get('id') === item.id
? `${routePrefix}?id=${encodeURIComponent(item.id)}` );
: routePrefix; }
const isItemActive = if (item.extensionType === 'skill') {
hasDetailPages && return (
pathname === routePrefix && pathname === '/home/skills' &&
searchParams.get('id') === item.id; searchParams.get('id') === item.id
);
}
return (
hasDetailPages &&
pathname === routePrefix &&
searchParams.get('id') === item.id
);
};
if (inPopover) { const itemIsPlugin = (item: SidebarEntityItem): boolean =>
return ( isExtensionsCategory ? item.extensionType === 'plugin' : isPlugin;
<button
key={item.id} const showGroupHeaders =
type="button" isExtensionsCategory &&
className={cn( !inPopover &&
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-left', sidebarData.extensionsGroupByType;
'hover:bg-accent hover:text-accent-foreground transition-colors',
isItemActive && const groupOrder: Array<'plugin' | 'mcp' | 'skill'> = [
'bg-accent text-accent-foreground font-medium', 'plugin',
)} 'mcp',
onClick={() => { 'skill',
navigate(itemRoute); ];
setPopoverOpen((prev) => ({ const groupLabelKey: Record<'plugin' | 'mcp' | 'skill', string> = {
...prev, plugin: 'market.typePlugin',
[config.id]: false, mcp: 'market.typeMCP',
})); skill: 'market.typeSkill',
}} };
>
{item.emoji ? ( const renderItem = (item: SidebarEntityItem) => {
<span className="text-sm shrink-0">{item.emoji}</span> const itemRoute = resolveItemRoute(item);
) : item.iconURL ? ( const isItemActive = itemActiveCheck(item);
<span className="relative shrink-0"> const itemIsPluginType = itemIsPlugin(item);
<img if (inPopover) {
src={item.iconURL} return (
alt="" <button
className="size-4 rounded" key={item.id}
/> type="button"
{(isBot || isMCP) && ( className={cn(
<span 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-left',
className={cn( 'hover:bg-accent hover:text-accent-foreground transition-colors',
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-popover', isItemActive &&
isMCP 'bg-accent text-accent-foreground font-medium',
? mcpStatusColor(item) )}
: item.enabled === false onClick={() => {
? 'bg-muted-foreground/40' navigate(itemRoute);
: 'bg-green-500', setPopoverOpen((prev) => ({
)} ...prev,
/> [config.id]: false,
)} }));
</span> }}
) : isMCP ? ( >
{item.extensionType === 'mcp' ? (
<Server className="size-4 shrink-0 !text-blue-500" />
) : item.extensionType === 'skill' ? (
<Sparkles className="size-4 shrink-0 !text-blue-500" />
) : item.emoji ? (
<span className="text-sm shrink-0">{item.emoji}</span>
) : item.iconURL ? (
<span className="relative shrink-0">
<img
src={item.iconURL}
alt=""
className="size-4 rounded"
/>
{(isBot || isMCP) && (
<span <span
className={cn( className={cn(
'size-2 shrink-0 rounded-full', 'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-popover',
mcpStatusColor(item), isMCP
? mcpStatusColor(item)
: item.enabled === false
? 'bg-muted-foreground/40'
: 'bg-green-500',
)} )}
/> />
) : null} )}
<span className="truncate">{item.name}</span> </span>
</button> ) : item.extensionType === 'plugin' ? (
); <Puzzle className="size-4 shrink-0 !text-blue-500" />
} ) : isMCP ? (
<span
className={cn(
'size-2 shrink-0 rounded-full',
mcpStatusColor(item),
)}
/>
) : null}
<span className="truncate">{item.name}</span>
</button>
);
}
// Normal sidebar sub-item rendering // Normal sidebar sub-item rendering
return ( return (
<SidebarMenuSubItem <SidebarMenuSubItem
key={item.id} key={item.id}
className={isPlugin ? 'group/plugin-item relative' : ''} className={itemIsPluginType ? 'group/plugin-item relative' : ''}
> >
<Tooltip delayDuration={500}> <Tooltip delayDuration={500}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<SidebarMenuSubButton asChild isActive={isItemActive}> <SidebarMenuSubButton asChild isActive={isItemActive}>
<a <a
href={itemRoute} href={itemRoute}
className={cn( className={cn(
isPlugin && !item.debug ? 'pr-6' : '', itemIsPluginType && !item.debug ? 'pr-6' : '',
)} )}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
navigate(itemRoute); navigate(itemRoute);
}} }}
> >
{item.emoji ? ( {item.extensionType === 'mcp' ? (
<span className="text-sm shrink-0"> <Server className="size-4 shrink-0 !text-blue-500" />
{item.emoji} ) : item.extensionType === 'skill' ? (
</span> <Sparkles className="size-4 shrink-0 !text-blue-500" />
) : item.iconURL ? ( ) : item.emoji ? (
<span className="relative shrink-0"> <span className="text-sm shrink-0">{item.emoji}</span>
<img ) : item.iconURL ? (
src={item.iconURL} <span className="relative shrink-0">
alt="" <img
className="size-4 rounded" src={item.iconURL}
/> alt=""
{(isBot || isMCP) && ( className="size-4 rounded"
<span />
className={cn( {(isBot || isMCP) && (
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-sidebar',
isMCP
? mcpStatusColor(item)
: item.enabled === false
? 'bg-muted-foreground/40'
: 'bg-green-500',
)}
/>
)}
</span>
) : isMCP ? (
<span <span
className={cn( className={cn(
'size-2 shrink-0 rounded-full', 'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-sidebar',
mcpStatusColor(item), isMCP
? mcpStatusColor(item)
: item.enabled === false
? 'bg-muted-foreground/40'
: 'bg-green-500',
)} )}
/> />
) : null}
<span className="truncate">{item.name}</span>
{item.debug && (
<Bug className="size-3.5 shrink-0 text-orange-400" />
)} )}
</a> </span>
</SidebarMenuSubButton> ) : item.extensionType === 'plugin' ? (
</TooltipTrigger> <Puzzle className="size-4 shrink-0 !text-blue-500" />
{item.description && ( ) : isMCP ? (
<TooltipContent <span
side="right" className={cn(
align="center" 'size-2 shrink-0 rounded-full',
className="max-w-64" mcpStatusColor(item),
> )}
{item.description.length > 80 />
? item.description.slice(0, 80) + '…' ) : null}
: item.description} <span className="truncate">{item.name}</span>
</TooltipContent> {item.debug && (
)} <Bug className="size-3.5 shrink-0 text-orange-400" />
</Tooltip> )}
{/* Plugin context menu - shown on hover (not for debug plugins) */} </a>
{isPlugin && !item.debug && ( </SidebarMenuSubButton>
<PluginItemMenu </TooltipTrigger>
item={item} {item.description && (
onUpdate={() => handlePluginUpdate(item)} <TooltipContent
onDelete={() => handlePluginDelete(item)} side="right"
/> align="center"
)} className="max-w-64"
</SidebarMenuSubItem> >
); {item.description.length > 80
})} ? item.description.slice(0, 80) + '…'
: item.description}
</TooltipContent>
)}
</Tooltip>
{/* Plugin context menu - shown on hover (not for debug plugins) */}
{itemIsPluginType && !item.debug && (
<PluginItemMenu
item={item}
onUpdate={() => handlePluginUpdate(item)}
onDelete={() => handlePluginDelete(item)}
/>
)}
</SidebarMenuSubItem>
);
};
return (
<>
{showGroupHeaders
? groupOrder.map((type) => {
const groupItems = visibleItems.filter(
(it) => it.extensionType === type,
);
if (groupItems.length === 0) return null;
return (
<div key={type} className="flex flex-col gap-0.5 mt-0.5">
<div className="px-2 pt-1 pb-0.5 text-[0.65rem] font-semibold uppercase tracking-wide text-muted-foreground">
{t(groupLabelKey[type])}
</div>
{groupItems.map((item) => renderItem(item))}
</div>
);
})
: visibleItems.map((item) => renderItem(item))}
{/* Show more / less toggle when items exceed limit */} {/* Show more / less toggle when items exceed limit */}
{sortedItems.length > maxItems && !inPopover && ( {sortedItems.length > maxItems && !inPopover && (
<SidebarMenuSubItem> <SidebarMenuSubItem>
@@ -26,6 +26,8 @@ export interface SidebarEntityItem {
installInfo?: Record<string, unknown>; installInfo?: Record<string, unknown>;
hasUpdate?: boolean; hasUpdate?: boolean;
debug?: boolean; debug?: boolean;
// Set when this item appears in the unified extensions list
extensionType?: 'plugin' | 'mcp' | 'skill';
} }
// Install action types that can be triggered from sidebar // Install action types that can be triggered from sidebar
@@ -69,6 +71,9 @@ export interface SidebarDataContextValue {
// Pending skill install action triggered from sidebar // Pending skill install action triggered from sidebar
pendingSkillInstallAction: SkillInstallAction; pendingSkillInstallAction: SkillInstallAction;
setPendingSkillInstallAction: (action: SkillInstallAction) => void; setPendingSkillInstallAction: (action: SkillInstallAction) => void;
// Whether the extensions list is grouped by type (shared between page and sidebar)
extensionsGroupByType: boolean;
setExtensionsGroupByType: (enabled: boolean) => void;
} }
const SidebarDataContext = createContext<SidebarDataContextValue | null>(null); const SidebarDataContext = createContext<SidebarDataContextValue | null>(null);
@@ -90,6 +95,19 @@ export function SidebarDataProvider({
useState<PluginInstallAction>(null); useState<PluginInstallAction>(null);
const [pendingSkillInstallAction, setPendingSkillInstallAction] = const [pendingSkillInstallAction, setPendingSkillInstallAction] =
useState<SkillInstallAction>(null); useState<SkillInstallAction>(null);
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('extensions_group_by_type') === 'true';
});
const setExtensionsGroupByType = useCallback((enabled: boolean) => {
setExtensionsGroupByTypeState(enabled);
try {
localStorage.setItem('extensions_group_by_type', String(enabled));
} catch {
// ignore
}
}, []);
const refreshBots = useCallback(async () => { const refreshBots = useCallback(async () => {
try { try {
@@ -306,6 +324,8 @@ export function SidebarDataProvider({
setPendingPluginInstallAction, setPendingPluginInstallAction,
pendingSkillInstallAction, pendingSkillInstallAction,
setPendingSkillInstallAction, setPendingSkillInstallAction,
extensionsGroupByType,
setExtensionsGroupByType,
}} }}
> >
{children} {children}
+2 -8
View File
@@ -134,14 +134,8 @@ function PluginListView() {
const [copiedDebugUrl, setCopiedDebugUrl] = useState(false); const [copiedDebugUrl, setCopiedDebugUrl] = useState(false);
const [copiedDebugKey, setCopiedDebugKey] = useState(false); const [copiedDebugKey, setCopiedDebugKey] = useState(false);
const [filterType, setFilterType] = useState<FilterType>('all'); const [filterType, setFilterType] = useState<FilterType>('all');
const [groupByType, setGroupByType] = useState<boolean>(() => { const groupByType = useSidebarData().extensionsGroupByType;
if (typeof window === 'undefined') return false; const setGroupByType = useSidebarData().setExtensionsGroupByType;
return localStorage.getItem('extensions_group_by_type') === 'true';
});
useEffect(() => {
localStorage.setItem('extensions_group_by_type', String(groupByType));
}, [groupByType]);
useEffect(() => { useEffect(() => {
const fetchPluginSystemStatus = async () => { const fetchPluginSystemStatus = async () => {