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

View File

@@ -29,6 +29,8 @@ import {
FilePlus2,
Sparkles,
HardDrive,
Server,
Puzzle,
} from 'lucide-react';
import { useTheme } from '@/components/providers/theme-provider';
@@ -370,7 +372,23 @@ function NavItems({
// Entity categories: collapsible with sub-items
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 hasDetailPages = DETAIL_PAGE_CATEGORIES.includes(config.id);
const canCreate = CREATABLE_CATEGORIES.includes(config.id);
@@ -379,6 +397,18 @@ function NavItems({
const isSkill = config.id === 'skills';
const isBot = config.id === 'bots';
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 =
selectedChild?.id === config.id ||
pathname === routePrefix ||
@@ -394,7 +424,13 @@ function NavItems({
// Shared entity list renderer used by both popover and collapsible
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 maxItems = inPopover ? 10 : MAX_VISIBLE_ITEMS;
const visibleItems =
@@ -416,152 +452,212 @@ function NavItems({
);
}
return (
<>
{visibleItems.map((item) => {
const itemRoute = hasDetailPages
? `${routePrefix}?id=${encodeURIComponent(item.id)}`
: routePrefix;
const isItemActive =
hasDetailPages &&
pathname === routePrefix &&
searchParams.get('id') === item.id;
const itemActiveCheck = (item: SidebarEntityItem): boolean => {
if (item.extensionType === 'mcp') {
return (
pathname === '/home/mcp' && searchParams.get('id') === item.id
);
}
if (item.extensionType === 'skill') {
return (
pathname === '/home/skills' &&
searchParams.get('id') === item.id
);
}
return (
hasDetailPages &&
pathname === routePrefix &&
searchParams.get('id') === item.id
);
};
if (inPopover) {
return (
<button
key={item.id}
type="button"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-left',
'hover:bg-accent hover:text-accent-foreground transition-colors',
isItemActive &&
'bg-accent text-accent-foreground font-medium',
)}
onClick={() => {
navigate(itemRoute);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
{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
className={cn(
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-popover',
isMCP
? mcpStatusColor(item)
: item.enabled === false
? 'bg-muted-foreground/40'
: 'bg-green-500',
)}
/>
)}
</span>
) : isMCP ? (
const itemIsPlugin = (item: SidebarEntityItem): boolean =>
isExtensionsCategory ? item.extensionType === 'plugin' : isPlugin;
const showGroupHeaders =
isExtensionsCategory &&
!inPopover &&
sidebarData.extensionsGroupByType;
const groupOrder: Array<'plugin' | 'mcp' | 'skill'> = [
'plugin',
'mcp',
'skill',
];
const groupLabelKey: Record<'plugin' | 'mcp' | 'skill', string> = {
plugin: 'market.typePlugin',
mcp: 'market.typeMCP',
skill: 'market.typeSkill',
};
const renderItem = (item: SidebarEntityItem) => {
const itemRoute = resolveItemRoute(item);
const isItemActive = itemActiveCheck(item);
const itemIsPluginType = itemIsPlugin(item);
if (inPopover) {
return (
<button
key={item.id}
type="button"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-left',
'hover:bg-accent hover:text-accent-foreground transition-colors',
isItemActive &&
'bg-accent text-accent-foreground font-medium',
)}
onClick={() => {
navigate(itemRoute);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
{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
className={cn(
'size-2 shrink-0 rounded-full',
mcpStatusColor(item),
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-popover',
isMCP
? mcpStatusColor(item)
: item.enabled === false
? 'bg-muted-foreground/40'
: 'bg-green-500',
)}
/>
) : null}
<span className="truncate">{item.name}</span>
</button>
);
}
)}
</span>
) : 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
return (
<SidebarMenuSubItem
key={item.id}
className={isPlugin ? 'group/plugin-item relative' : ''}
>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<SidebarMenuSubButton asChild isActive={isItemActive}>
<a
href={itemRoute}
className={cn(
isPlugin && !item.debug ? 'pr-6' : '',
)}
onClick={(e) => {
e.preventDefault();
navigate(itemRoute);
}}
>
{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
className={cn(
'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 ? (
// Normal sidebar sub-item rendering
return (
<SidebarMenuSubItem
key={item.id}
className={itemIsPluginType ? 'group/plugin-item relative' : ''}
>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<SidebarMenuSubButton asChild isActive={isItemActive}>
<a
href={itemRoute}
className={cn(
itemIsPluginType && !item.debug ? 'pr-6' : '',
)}
onClick={(e) => {
e.preventDefault();
navigate(itemRoute);
}}
>
{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
className={cn(
'size-2 shrink-0 rounded-full',
mcpStatusColor(item),
'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',
)}
/>
) : null}
<span className="truncate">{item.name}</span>
{item.debug && (
<Bug className="size-3.5 shrink-0 text-orange-400" />
)}
</a>
</SidebarMenuSubButton>
</TooltipTrigger>
{item.description && (
<TooltipContent
side="right"
align="center"
className="max-w-64"
>
{item.description.length > 80
? item.description.slice(0, 80) + '…'
: item.description}
</TooltipContent>
)}
</Tooltip>
{/* Plugin context menu - shown on hover (not for debug plugins) */}
{isPlugin && !item.debug && (
<PluginItemMenu
item={item}
onUpdate={() => handlePluginUpdate(item)}
onDelete={() => handlePluginDelete(item)}
/>
)}
</SidebarMenuSubItem>
);
})}
</span>
) : 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>
{item.debug && (
<Bug className="size-3.5 shrink-0 text-orange-400" />
)}
</a>
</SidebarMenuSubButton>
</TooltipTrigger>
{item.description && (
<TooltipContent
side="right"
align="center"
className="max-w-64"
>
{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 */}
{sortedItems.length > maxItems && !inPopover && (
<SidebarMenuSubItem>

View File

@@ -26,6 +26,8 @@ export interface SidebarEntityItem {
installInfo?: Record<string, unknown>;
hasUpdate?: 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
@@ -69,6 +71,9 @@ export interface SidebarDataContextValue {
// Pending skill install action triggered from sidebar
pendingSkillInstallAction: SkillInstallAction;
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);
@@ -90,6 +95,19 @@ export function SidebarDataProvider({
useState<PluginInstallAction>(null);
const [pendingSkillInstallAction, setPendingSkillInstallAction] =
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 () => {
try {
@@ -306,6 +324,8 @@ export function SidebarDataProvider({
setPendingPluginInstallAction,
pendingSkillInstallAction,
setPendingSkillInstallAction,
extensionsGroupByType,
setExtensionsGroupByType,
}}
>
{children}

View File

@@ -134,14 +134,8 @@ function PluginListView() {
const [copiedDebugUrl, setCopiedDebugUrl] = useState(false);
const [copiedDebugKey, setCopiedDebugKey] = useState(false);
const [filterType, setFilterType] = useState<FilterType>('all');
const [groupByType, setGroupByType] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('extensions_group_by_type') === 'true';
});
useEffect(() => {
localStorage.setItem('extensions_group_by_type', String(groupByType));
}, [groupByType]);
const groupByType = useSidebarData().extensionsGroupByType;
const setGroupByType = useSidebarData().setExtensionsGroupByType;
useEffect(() => {
const fetchPluginSystemStatus = async () => {