mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-26 06:16:09 +00:00
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:
@@ -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}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user