Files
LangBot/web/src/app/home/components/home-sidebar/HomeSidebar.tsx
T
Junyan Chin 8e558ad3a1 Feat/saas sandbox adaptation (#2234)
* fix(box): trust Box-reported skill paths when filesystem is not shared

In separated deployments (Docker Compose, k8s sidecar, --standalone-box,
remote runtime.endpoint) the Box runtime owns its own filesystem, so the
skill package_root it reports via list_skills is not resolvable on the
LangBot side. LangBot's reload_skills and build_skill_extra_mounts
validated those paths with os.path.isdir() against its own filesystem,
which silently dropped every skill in such deployments — breaking the
sandbox skill feature for the nsjail/SaaS backend.

Add BoxService.shares_filesystem_with_box, derived from the connector
transport (stdio = shared, WebSocket = separated), with an explicit
override seam for tests/embedders. Gate both isdir() guards on it: keep
local validation in shared-fs stdio mode, trust Box-reported paths
otherwise. The Box runtime only reports skills found on its own
filesystem, so those paths are valid there by construction.

Adds topology-derivation tests (real connector, no mocks) and
skill-retention tests for both shared and separated filesystems.

* build(docker): ship a self-contained nsjail sandbox backend in the image

Compile nsjail 3.6 from source in a dedicated multi-stage build and carry
only the binary plus its runtime libs (libprotobuf32, libnl-route-3-200)
into the final image. This lets the Box runtime isolate sandboxed code via
nsjail user/mount/pid/net namespaces without a host Docker socket — the
prerequisite for running Box on LangBot Cloud (k8s), where mounting
docker.sock would grant node root and is not acceptable for multi-tenant.

The build toolchain (build-essential/bison/flex/protobuf-dev/libnl-dev)
stays in the nsjail-build stage and is not present in the shipped image.

Verified: image builds (583MB), nsjail --help exits 0, libraries resolve,
and the real NsjailBackend executes an isolated command end-to-end on a
v6.1/cgroup2 host matching LangBot Cloud prod (rlimit fallback path, since
container /sys/fs/cgroup is read-only; PID-namespace isolation confirmed).

* feat(box): SaaS guard to force a single global sandbox scope

Add system.limitation.force_box_session_id_template: when non-empty it
overrides every pipeline's box-session-id-template at resolve time, pinning
all queries to one shared sandbox (e.g. {global}). This is the authoritative,
unbypassable guard — it runs on every exec call, so editing the pipeline
config via API cannot escape it. The web UI locks the Sandbox Scope selector
via a combined box_scope_editable flag (box available AND not forced).

* build(deps): pin langbot-plugin==0.4.2b1 (nsjail cgroup container-safety beta)

* fix(web): show forced sandbox scope + make disabled tooltip tap-friendly

When a SaaS deployment pins every pipeline to a fixed sandbox scope via
system.limitation.force_box_session_id_template, the Sandbox Scope selector was
correctly locked but still displayed the pipeline's stored value (e.g. the
per-chat default), misrepresenting the scope that the runtime actually enforces
on every exec. Coerce the displayed/saved value to the forced template so the
locked selector truthfully shows the active scope (e.g. Global).

Also fix the disabled_tooltip being invisible on touch devices: hover-only Radix
tooltips never open without a pointer, so the explanation of why the field is
locked could not be read on mobile. Wrap the info icon so a tap toggles the
tooltip while desktop hover still works.

* feat(web): hide sidebar new-version prompt for edition=cloud

Cloud instances are upgraded centrally by the operator, so surfacing a GitHub
'new version available' badge to tenants is misleading and actionable only by
the operator. Skip the release check entirely when edition=cloud.

* style(web): prettier formatting for DisabledTooltipIcon ternary

* chore(deps): bump langbot-plugin to 0.4.2b2

Picks up the SDK fix that creates a read-write host_path before the
nsjail bind-mount, fixing the SaaS MCP shared-workspace sandbox failure
(exec exit 255 with empty output when host_path didn't exist).

* chore(deps): bump langbot-plugin to 0.4.2b3

Picks up the nsjail /dev-node fix so stdio MCP servers (uvx-launched) can
start under force_global_sandbox instead of failing with 'Connection closed
/ please check URL'.

* fix(web): show real MCP runtime status on installed extensions list

The installed-extensions list badge keyed solely off the enable flag, so a
server that was still CONNECTING (or in ERROR) was shown as 'Connected'.
Reflect the actual runtime_info.status (connecting/connected/error/disabled)
with matching colors, and poll quietly every 3s while any MCP server is
connecting so the badge transitions without a manual refresh.

* chore(deps): bump langbot-plugin to 0.4.2b4

Picks up the 30s start_managed_process timeout so cold uvx MCP bootstraps
don't get torn down mid-install.

* style(web): satisfy prettier — parenthesize nullish-coalescing in ternary

* fix(mcp): isolate transient test sessions from the shared Box session

A config-page 'test' (server_name='_', no persisted UUID) ran in the same
shared 'mcp-shared' Box session as live MCP servers. A failing test (e.g.
empty args) churned that shared session and tore down healthy, already-
connected servers — leaving them stuck after exhausting their retries.

Mark UUID-less sessions as transient, give them their own isolated Box
session ('mcp-test-<uuid>'), and fully delete that session on cleanup so
tests can never disturb live servers and don't leak sessions.

* fix(mcp): tear down transient test session after test completes

A successful config-page test left its isolated 'mcp-test-<uuid>' Box
session running (the lifecycle task blocks until shutdown). Wrap the
transient test coroutine so it always shuts the session down afterward,
preventing isolated test sessions from leaking.
2026-06-09 19:30:17 +08:00

2150 lines
79 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import { systemInfo, httpClient } from '@/app/infra/http/HttpClient';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { useTranslation } from 'react-i18next';
import {
Moon,
Sun,
Monitor,
ChevronsUpDown,
CircleHelp,
Lightbulb,
LogOut,
KeyRound,
Settings,
Star,
Ellipsis,
ArrowUp,
ExternalLink,
Trash,
Bug,
Upload,
Store,
Github,
Zap,
FilePlus2,
Sparkles,
HardDrive,
Server,
Puzzle,
RefreshCcw,
} from 'lucide-react';
import { useTheme } from '@/components/providers/theme-provider';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { LanguageSelector } from '@/components/ui/language-selector';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import AccountSettingsDialog from '@/app/home/components/account-settings-dialog/AccountSettingsDialog';
import ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
import StorageAnalysisDialog from '@/app/home/components/storage-analysis-dialog/StorageAnalysisDialog';
import { GitHubRelease } from '@/app/infra/http/CloudServiceClient';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { toast } from 'sonner';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from '@/components/ui/sidebar';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
// Compare two version strings, returns true if v1 > v2
function compareVersions(v1: string, v2: string): boolean {
const clean1 = v1.replace(/^v/, '');
const clean2 = v2.replace(/^v/, '');
const parts1 = clean1.split('.').map((p) => parseInt(p, 10) || 0);
const parts2 = clean2.split('.').map((p) => parseInt(p, 10) || 0);
const maxLen = Math.max(parts1.length, parts2.length);
for (let i = 0; i < maxLen; i++) {
const p1 = parts1[i] || 0;
const p2 = parts2[i] || 0;
if (p1 > p2) return true;
if (p1 < p2) return false;
}
return false;
}
// Discord brand glyph (lucide-react has no Discord icon).
function DiscordIcon({ className }: { className?: string }) {
return (
<svg
role="img"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden="true"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
);
}
// IDs of sidebar entries that have collapsible entity sub-items
const ENTITY_CATEGORY_IDS = [
'bots',
'pipelines',
'knowledge',
'plugins',
'mcp',
'skills',
] as const;
type EntityCategoryId = (typeof ENTITY_CATEGORY_IDS)[number];
// Categories that support detail pages via ?id= query param
const DETAIL_PAGE_CATEGORIES: EntityCategoryId[] = [
'bots',
'pipelines',
'knowledge',
'plugins',
'mcp',
'skills',
];
// Categories that support creating new entities from the sidebar
const CREATABLE_CATEGORIES: EntityCategoryId[] = [
'bots',
'pipelines',
'knowledge',
'mcp',
'skills',
];
// Categories where clicking the parent only toggles collapse (no list page)
const COLLAPSIBLE_ONLY_CATEGORIES: EntityCategoryId[] = [
'bots',
'pipelines',
'knowledge',
'mcp',
'skills',
];
function isEntityCategory(id: string): id is EntityCategoryId {
return (ENTITY_CATEGORY_IDS as readonly string[]).includes(id);
}
// Map sidebar config IDs to SidebarDataContext keys
const ENTITY_KEY_MAP: Record<
EntityCategoryId,
'bots' | 'pipelines' | 'knowledgeBases' | 'plugins' | 'mcpServers' | 'skills'
> = {
bots: 'bots',
pipelines: 'pipelines',
knowledge: 'knowledgeBases',
plugins: 'plugins',
mcp: 'mcpServers',
skills: 'skills',
};
// Route prefix map for entity detail pages
const ENTITY_ROUTE_MAP: Record<EntityCategoryId, string> = {
bots: '/home/bots',
pipelines: '/home/pipelines',
knowledge: '/home/knowledge',
plugins: '/home/extensions',
mcp: '/home/mcp',
skills: '/home/skills',
};
// localStorage key for collapsible section open/closed state
const SIDEBAR_SECTIONS_KEY = 'sidebar_sections';
const SIDEBAR_LIST_EXPANSION_KEY = 'sidebar_entity_list_expansion';
const SCROLL_HINT_BOTTOM_THRESHOLD = 40;
type SidebarNavSection = 'home' | 'extensions';
type SidebarListExpansionState = Record<
SidebarNavSection,
Partial<Record<EntityCategoryId, boolean>>
>;
function createEmptyListExpansionState(): SidebarListExpansionState {
return {
home: {},
extensions: {},
};
}
function loadSectionState(): Record<string, boolean> {
if (typeof window === 'undefined') return {};
try {
const stored = localStorage.getItem(SIDEBAR_SECTIONS_KEY);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
function saveSectionState(state: Record<string, boolean>) {
try {
localStorage.setItem(SIDEBAR_SECTIONS_KEY, JSON.stringify(state));
} catch {
// Ignore storage errors
}
}
function loadListExpansionState(): SidebarListExpansionState {
if (typeof window === 'undefined') return createEmptyListExpansionState();
try {
const stored = localStorage.getItem(SIDEBAR_LIST_EXPANSION_KEY);
if (!stored) return createEmptyListExpansionState();
const parsed = JSON.parse(stored) as Partial<SidebarListExpansionState>;
return {
home: parsed.home ?? {},
extensions: parsed.extensions ?? {},
};
} catch {
return createEmptyListExpansionState();
}
}
function saveListExpansionState(state: SidebarListExpansionState) {
try {
localStorage.setItem(SIDEBAR_LIST_EXPANSION_KEY, JSON.stringify(state));
} catch {
// Ignore storage errors
}
}
// Maximum number of entity sub-items visible before "More" toggle
const MAX_VISIBLE_ITEMS = 5;
// Sort entity items by updatedAt descending (most recent first), items without updatedAt go last
function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] {
return [...items].sort((a, b) => {
if (!a.updatedAt && !b.updatedAt) return 0;
if (!a.updatedAt) return 1;
if (!b.updatedAt) return -1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
}
// MCP status dot color: disabled → gray, error → red, connecting → yellow, connected → green
function mcpStatusColor(item: SidebarEntityItem): string {
if (item.enabled === false) return 'bg-muted-foreground/40';
switch (item.runtimeStatus) {
case 'connected':
return 'bg-green-500';
case 'connecting':
return 'bg-yellow-500';
case 'error':
return 'bg-red-500';
default:
return 'bg-muted-foreground/40';
}
}
function MCPStatusIcon({
item,
borderClass,
}: {
item: SidebarEntityItem;
borderClass: string;
}) {
return (
<span className="relative shrink-0">
<Server className="size-4 !text-blue-500" />
<span
className={cn(
'absolute -bottom-1 -right-1 size-3 rounded-full border-2',
borderClass,
mcpStatusColor(item),
)}
/>
</span>
);
}
// Plugin operation type enum
enum PluginOperationType {
DELETE = 'DELETE',
UPDATE = 'UPDATE',
}
// Renders sidebar navigation items with collapsible sub-items for entity categories
function NavItems({
selectedChild,
onChildClick,
section,
sectionOpenState,
onSectionToggle,
}: {
selectedChild: SidebarChildVO | undefined;
onChildClick: (child: SidebarChildVO) => void;
section: SidebarNavSection;
sectionOpenState: Record<string, boolean>;
onSectionToggle: (id: string, open: boolean) => void;
}) {
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const sidebarData = useSidebarData();
const { state: sidebarState, isMobile } = useSidebar();
const { t } = useTranslation();
// Track which entity categories have their full list expanded
const [expandedLists, setExpandedLists] = useState<SidebarListExpansionState>(
loadListExpansionState,
);
// Track popover open state for collapsed sidebar entity categories
const [popoverOpen, setPopoverOpen] = useState<Record<string, boolean>>({});
// Spin state for the installed-extensions refresh button
const [extRefreshing, setExtRefreshing] = useState(false);
const handleRefreshExtensions = async (e: React.MouseEvent) => {
e.stopPropagation();
if (extRefreshing) return;
setExtRefreshing(true);
try {
await Promise.all([
sidebarData.refreshPlugins(),
sidebarData.refreshMCPServers(),
sidebarData.refreshSkills(),
]);
} finally {
setExtRefreshing(false);
}
};
// Plugin operation state
const [showPluginOpModal, setShowPluginOpModal] = useState(false);
const [pluginOpType, setPluginOpType] = useState<PluginOperationType>(
PluginOperationType.DELETE,
);
const [targetPluginItem, setTargetPluginItem] =
useState<SidebarEntityItem | null>(null);
const [deleteData, setDeleteData] = useState(false);
const asyncTask = useAsyncTask({
onSuccess: () => {
const msg =
pluginOpType === PluginOperationType.DELETE
? t('plugins.deleteSuccess')
: t('plugins.updateSuccess');
toast.success(msg);
setShowPluginOpModal(false);
sidebarData.refreshPlugins();
},
});
function handlePluginDelete(item: SidebarEntityItem) {
setTargetPluginItem(item);
setPluginOpType(PluginOperationType.DELETE);
setDeleteData(false);
asyncTask.reset();
setShowPluginOpModal(true);
}
function handlePluginUpdate(item: SidebarEntityItem) {
setTargetPluginItem(item);
setPluginOpType(PluginOperationType.UPDATE);
asyncTask.reset();
setShowPluginOpModal(true);
}
function executePluginOperation() {
if (!targetPluginItem) return;
const slashIdx = targetPluginItem.id.indexOf('/');
const author =
slashIdx >= 0 ? targetPluginItem.id.substring(0, slashIdx) : '';
const name =
slashIdx >= 0
? targetPluginItem.id.substring(slashIdx + 1)
: targetPluginItem.id;
const apiCall =
pluginOpType === PluginOperationType.DELETE
? httpClient.removePlugin(author, name, deleteData)
: httpClient.upgradePlugin(author, name);
apiCall
.then((res) => {
asyncTask.startTask(res.task_id);
})
.catch((error) => {
const errorMessage =
pluginOpType === PluginOperationType.DELETE
? t('plugins.deleteError') + error.message
: t('plugins.updateError') + error.message;
toast.error(errorMessage);
});
}
const sectionItems = sidebarConfigList.filter((c) => c.section === section);
function handleListExpansionToggle(id: EntityCategoryId, expanded: boolean) {
setExpandedLists(() => {
const latest = loadListExpansionState();
const next = {
...latest,
[section]: {
...latest[section],
[id]: expanded,
},
};
saveListExpansionState(next);
return next;
});
}
// Persist open state for sections that become active through navigation,
// so they remain expanded when the user switches to a different section.
const sectionOpenRef = useRef(sectionOpenState);
sectionOpenRef.current = sectionOpenState;
useEffect(() => {
sectionItems.forEach((config) => {
if (!isEntityCategory(config.id)) return;
const routePrefix = ENTITY_ROUTE_MAP[config.id];
const active =
pathname === routePrefix || pathname.startsWith(routePrefix + '/');
if (active && sectionOpenRef.current[config.id] === undefined) {
onSectionToggle(config.id, true);
}
});
}, [pathname, sectionItems, onSectionToggle]);
return (
<>
{sectionItems.map((config) => {
if (!isEntityCategory(config.id)) {
// Non-entity entries (e.g. monitoring, market, mcp) render as plain links
return (
<SidebarMenuItem key={config.id}>
<SidebarMenuButton
isActive={selectedChild?.id === config.id}
onClick={() => onChildClick(config)}
tooltip={config.name}
>
{config.icon}
<span className="cursor-pointer select-none">
{config.name}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
// Entity categories: collapsible with sub-items
const categoryId = config.id;
const entityKey = ENTITY_KEY_MAP[categoryId];
const isExtensionsCategory = categoryId === '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[categoryId];
const hasDetailPages = DETAIL_PAGE_CATEGORIES.includes(categoryId);
const canCreate = CREATABLE_CATEGORIES.includes(categoryId);
const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(categoryId);
const isPlugin = categoryId === 'plugins';
const isSkill = categoryId === 'skills';
const isBot = categoryId === 'bots';
const isMCP = categoryId === '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 === categoryId ||
pathname === routePrefix ||
pathname.startsWith(routePrefix + '/');
// Use stored open state if available, otherwise default to active state
const isOpen = sectionOpenState[categoryId] ?? isActive;
// When sidebar is collapsed on desktop and category is collapse-only,
// show a popover flyout instead of the hidden collapsible sub-items
const isCollapsed = sidebarState === 'collapsed' && !isMobile;
const showPopover = isCollapsed && isCollapseOnly;
// Shared entity list renderer used by both popover and collapsible
const renderEntityList = (inPopover: boolean) => {
const sortedItems = isExtensionsCategory
? [...items].sort((a, b) =>
a.name.localeCompare(b.name, undefined, {
sensitivity: 'base',
}),
)
: sortByRecent(items);
const isExpanded = expandedLists[section]?.[categoryId] ?? false;
const maxItems = inPopover ? 10 : MAX_VISIBLE_ITEMS;
const visibleItems =
sortedItems.length > maxItems && !isExpanded
? sortedItems.slice(0, maxItems)
: sortedItems;
const hiddenCount = sortedItems.length - maxItems;
if (sortedItems.length === 0) {
return (
<div
className={cn(
'text-muted-foreground text-xs',
inPopover ? 'px-2 py-3 text-center' : 'px-2 py-1.5',
)}
>
{t('common.noItems')}
</div>
);
}
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
);
};
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,
[categoryId]: false,
}));
}}
>
{item.extensionType === 'mcp' ? (
<MCPStatusIcon item={item} borderClass="border-popover" />
) : 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(
'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>
) : 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={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' ? (
<MCPStatusIcon
item={item}
borderClass="border-sidebar"
/>
) : 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(
'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>
) : 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>
<TooltipContent
side="right"
align="center"
className="max-w-64"
>
{/* Full name — so truncated sidebar items are readable on hover */}
<div className="break-words font-medium">{item.name}</div>
{item.description && (
<div className="mt-0.5 break-words text-xs text-muted-foreground">
{item.description.length > 80
? item.description.slice(0, 80) + '…'
: item.description}
</div>
)}
</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>
<SidebarMenuSubButton
asChild
className="text-muted-foreground"
>
<button
type="button"
onClick={() =>
handleListExpansionToggle(categoryId, !isExpanded)
}
>
<span className="text-xs">
{isExpanded
? t('common.less')
: t('common.more', { count: hiddenCount })}
</span>
</button>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)}
{hiddenCount > 0 && inPopover && !isExpanded && (
<button
type="button"
className="flex w-full items-center justify-center rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent transition-colors"
onClick={() => handleListExpansionToggle(categoryId, true)}
>
{t('common.more', { count: hiddenCount })}
</button>
)}
</>
);
};
// Popover flyout for collapsed sidebar
if (showPopover) {
return (
<SidebarMenuItem key={config.id}>
<Popover
open={popoverOpen[config.id] ?? false}
onOpenChange={(open) =>
setPopoverOpen((prev) => ({ ...prev, [config.id]: open }))
}
>
<PopoverTrigger asChild>
<SidebarMenuButton
isActive={isActive}
tooltip={config.name}
className="group/category-header"
>
{config.icon}
<span>{config.name}</span>
</SidebarMenuButton>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
sideOffset={8}
className="w-56 p-2"
>
<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 && (
<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?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>
))}
</div>
<div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto">
{renderEntityList(true)}
</div>
</PopoverContent>
</Popover>
</SidebarMenuItem>
);
}
// Normal expanded sidebar with collapsible sub-items
return (
<Collapsible
key={config.id}
asChild
open={isOpen}
onOpenChange={(open) => onSectionToggle(config.id, open)}
className="group/collapsible"
>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={false}
tooltip={config.name}
className="group/category-header"
>
<div
role="button"
tabIndex={0}
onClick={() => {
if (isCollapseOnly) {
onSectionToggle(config.id, !isOpen);
} else {
onChildClick(config);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (isCollapseOnly) {
onSectionToggle(config.id, !isOpen);
} else {
onChildClick(config);
}
}
}}
>
{config.icon}
<span className="cursor-pointer select-none">
{config.name}
</span>
<div className="ml-auto flex items-center gap-0.5 -mr-1">
{isExtensionsCategory && (
<button
type="button"
title={t('common.refresh', '刷新')}
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={handleRefreshExtensions}
>
<RefreshCcw
className={cn(
'size-3.5',
extRefreshing && 'animate-spin',
)}
/>
</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 && (
<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?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>
))}
<CollapsibleTrigger asChild>
<button
type="button"
className="p-1 rounded-sm hover:bg-sidebar-accent"
onClick={(e) => e.stopPropagation()}
>
<ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</button>
</CollapsibleTrigger>
</div>
</div>
</SidebarMenuButton>
<CollapsibleContent>
<SidebarMenuSub>{renderEntityList(false)}</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
})}
{/* Plugin operation confirmation dialog */}
<Dialog
open={showPluginOpModal}
onOpenChange={(open) => {
if (!open) {
setShowPluginOpModal(false);
setTargetPluginItem(null);
asyncTask.reset();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{pluginOpType === PluginOperationType.DELETE
? t('plugins.deleteConfirm')
: t('plugins.updateConfirm')}
</DialogTitle>
</DialogHeader>
<DialogDescription>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<div className="flex flex-col gap-4">
<div>
{(() => {
const slashIdx = targetPluginItem?.id.indexOf('/') ?? -1;
const author =
slashIdx >= 0
? targetPluginItem!.id.substring(0, slashIdx)
: '';
const name =
slashIdx >= 0
? targetPluginItem!.id.substring(slashIdx + 1)
: (targetPluginItem?.id ?? '');
return pluginOpType === PluginOperationType.DELETE
? t('plugins.confirmDeletePlugin', { author, name })
: t('plugins.confirmUpdatePlugin', { author, name });
})()}
</div>
{pluginOpType === PluginOperationType.DELETE && (
<div className="flex items-center space-x-2">
<Checkbox
id="sidebar-delete-data"
checked={deleteData}
onCheckedChange={(checked) =>
setDeleteData(checked === true)
}
/>
<label
htmlFor="sidebar-delete-data"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{t('plugins.deleteDataCheckbox')}
</label>
</div>
)}
</div>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<div>
{pluginOpType === PluginOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</div>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<div>
{pluginOpType === PluginOperationType.DELETE
? t('plugins.deleteError')
: t('plugins.updateError')}
<div className="text-red-500">{asyncTask.error}</div>
</div>
)}
</DialogDescription>
<DialogFooter>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant="outline"
onClick={() => {
setShowPluginOpModal(false);
setTargetPluginItem(null);
asyncTask.reset();
}}
>
{t('common.cancel')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant={
pluginOpType === PluginOperationType.DELETE
? 'destructive'
: 'default'
}
onClick={executePluginOperation}
>
{pluginOpType === PluginOperationType.DELETE
? t('plugins.confirmDelete')
: t('plugins.confirmUpdate')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<Button
variant={
pluginOpType === PluginOperationType.DELETE
? 'destructive'
: 'default'
}
disabled
>
{pluginOpType === PluginOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<Button
variant="default"
onClick={() => {
setShowPluginOpModal(false);
asyncTask.reset();
}}
>
{t('plugins.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
// Dropdown menu for plugin sidebar sub-items (shown on hover)
function PluginItemMenu({
item,
onUpdate,
onDelete,
}: {
item: SidebarEntityItem;
onUpdate: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const isMarketplace = item.installSource === 'marketplace';
const isGithub = item.installSource === 'github';
const hasSourceLink = isMarketplace || isGithub;
function handleViewSource() {
const slashIdx = item.id.indexOf('/');
const author = slashIdx >= 0 ? item.id.substring(0, slashIdx) : '';
const name = slashIdx >= 0 ? item.id.substring(slashIdx + 1) : item.id;
if (isGithub && item.installInfo?.github_url) {
window.open(item.installInfo.github_url as string, '_blank');
} else if (isMarketplace) {
window.open(
getCloudServiceClientSync().getPluginMarketplaceURL(
systemInfo.cloud_service_url,
author,
name,
),
'_blank',
);
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={`absolute right-1 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ${
open
? 'opacity-100'
: item.hasUpdate
? 'opacity-100'
: 'opacity-0 group-hover/plugin-item:opacity-100'
} transition-opacity`}
onClick={(e) => e.stopPropagation()}
>
<Ellipsis className="size-4" />
{item.hasUpdate && !open && (
<span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-red-500" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start">
{isMarketplace && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
onUpdate();
setOpen(false);
}}
>
<ArrowUp className="size-4" />
<span>{t('plugins.update')}</span>
{item.hasUpdate && (
<Badge className="ml-auto bg-red-500 hover:bg-red-500 text-white text-[0.6rem] px-1.5 py-0 h-4">
{t('plugins.new')}
</Badge>
)}
</DropdownMenuItem>
)}
{hasSourceLink && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
handleViewSource();
setOpen(false);
}}
>
<ExternalLink className="size-4" />
<span>{t('plugins.viewSource')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer text-red-600 focus:text-red-600"
onClick={() => {
onDelete();
setOpen(false);
}}
>
<Trash className="size-4" />
<span>{t('plugins.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
// Plugin pages navigation section — grouped by plugin
function PluginPagesNav() {
const { pluginPages } = useSidebarData();
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const { t } = useTranslation();
if (pluginPages.length === 0) return null;
const pathname = location.pathname;
const currentId =
pathname === '/home/plugin-pages' ? searchParams.get('id') : null;
// Group pages by plugin (author/name)
const grouped = new Map<
string,
{ label: string; iconURL: string; pages: typeof pluginPages }
>();
for (const page of pluginPages) {
const key = `${page.pluginAuthor}/${page.pluginName}`;
if (!grouped.has(key)) {
grouped.set(key, {
label: page.pluginLabel,
iconURL: page.pluginIconURL,
pages: [],
});
}
grouped.get(key)!.pages.push(page);
}
return (
<SidebarGroup>
<SidebarGroupLabel title={t('sidebar.pluginPagesTooltip')}>
{t('sidebar.pluginPages')}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{Array.from(grouped.entries()).map(
([pluginKey, { label, iconURL, pages }]) => {
const hasActivePage = pages.some((p) => p.id === currentId);
const pluginIcon = (
<img
src={iconURL}
alt=""
className="size-4 rounded-sm object-cover shrink-0"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
);
// Single page — render directly without nesting
if (pages.length === 1) {
const page = pages[0];
const isActive = currentId === page.id;
const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`;
return (
<SidebarMenuItem key={page.id}>
<SidebarMenuButton
isActive={isActive}
tooltip={page.name}
onClick={() => navigate(route)}
className="select-none"
>
{pluginIcon}
<span className="cursor-pointer">{page.name}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
// Multiple pages — collapsible group
return (
<Collapsible
key={pluginKey}
defaultOpen={hasActivePage}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={label}
className="select-none"
>
{pluginIcon}
<span className="cursor-pointer">{label}</span>
<ChevronRight className="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{pages.map((page) => {
const isActive = currentId === page.id;
const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`;
return (
<SidebarMenuSubItem key={page.id}>
<SidebarMenuSubButton
isActive={isActive}
onClick={() => navigate(route)}
className="select-none"
>
<span className="cursor-pointer">
{page.name}
</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
},
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
function findSidebarChildForPath(pathname: string): SidebarChildVO | undefined {
const matchedChild =
sidebarConfigList.find((childConfig) => childConfig.route === pathname) ||
sidebarConfigList.find((childConfig) =>
pathname.startsWith(childConfig.route + '/'),
);
if (matchedChild) return matchedChild;
if (
pathname === '/home/mcp' ||
pathname === '/home/skills' ||
pathname === '/home/plugin-pages' ||
pathname.startsWith('/home/mcp/') ||
pathname.startsWith('/home/skills/') ||
pathname.startsWith('/home/plugin-pages/')
) {
return sidebarConfigList.find(
(childConfig) => childConfig.id === 'plugins',
);
}
if (
pathname === '/home/add-extension' ||
pathname.startsWith('/home/add-extension/')
) {
return sidebarConfigList.find(
(childConfig) => childConfig.id === 'add-extension',
);
}
return undefined;
}
export default function HomeSidebar({
onSelectedChangeAction,
}: {
onSelectedChangeAction: (sidebarChild: SidebarChildVO) => void;
}) {
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const { isMobile } = useSidebar();
useEffect(() => {
handleRouteChange(pathname);
}, [pathname]);
useEffect(() => {
if (searchParams.get('action') === 'showModelSettings') {
setModelsDialogOpen(true);
}
if (searchParams.get('action') === 'showAccountSettings') {
setAccountSettingsOpen(true);
}
if (searchParams.get('action') === 'showApiIntegrationSettings') {
setApiKeyDialogOpen(true);
}
if (searchParams.get('action') === 'showStorageAnalysis') {
setStorageAnalysisOpen(true);
}
}, [searchParams]);
const [selectedChild, setSelectedChild] = useState<SidebarChildVO>();
const [sectionOpenState, setSectionOpenState] =
useState<Record<string, boolean>>(loadSectionState);
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(
null,
);
const [hasNewVersion, setHasNewVersion] = useState(false);
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const [storageAnalysisOpen, setStorageAnalysisOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string>('');
const [starCount, setStarCount] = useState<number | null>(null);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const navigationContentRef = useRef<HTMLDivElement | null>(null);
const [showScrollHint, setShowScrollHint] = useState(false);
function scrollNavigationToBottom() {
const contentEl = navigationContentRef.current;
if (!contentEl) return;
const maxScrollTop = contentEl.scrollHeight - contentEl.clientHeight;
contentEl.scrollTo({
top: maxScrollTop,
behavior: 'smooth',
});
setShowScrollHint(false);
window.setTimeout(() => {
if (contentEl.scrollTop < maxScrollTop - 2) {
contentEl.scrollTop = maxScrollTop;
}
setShowScrollHint(false);
}, 250);
}
function handleModelsDialogChange(open: boolean) {
setModelsDialogOpen(open);
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showModelSettings');
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
} else {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
navigate(newUrl, { preventScrollReset: true });
}
}
function handleAccountSettingsChange(open: boolean) {
setAccountSettingsOpen(open);
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showAccountSettings');
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
} else {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
navigate(newUrl, { preventScrollReset: true });
}
}
function handleStorageAnalysisChange(open: boolean) {
setStorageAnalysisOpen(open);
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showStorageAnalysis');
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
} else {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
navigate(newUrl, { preventScrollReset: true });
}
}
useEffect(() => {
initSelect();
if (!localStorage.getItem('token')) {
localStorage.setItem('token', 'test-token');
localStorage.setItem('userEmail', 'test@example.com');
}
const storedEmail = localStorage.getItem('userEmail');
if (storedEmail) {
setUserEmail(storedEmail);
} else {
httpClient
.getUserInfo()
.then((info) => {
setUserEmail(info.user);
localStorage.setItem('userEmail', info.user);
})
.catch(() => {});
}
// Cloud edition is updated centrally by the operator, so end users should
// not see a "new version available" prompt in the sidebar. Skip the GitHub
// release check entirely for edition=cloud.
if (systemInfo?.edition !== 'cloud') {
getCloudServiceClientSync()
.getLangBotReleases()
.then((releases) => {
if (releases && releases.length > 0) {
const latestStable = releases.find(
(r) => !r.prerelease && !r.draft,
);
const latest = latestStable || releases[0];
setLatestRelease(latest);
const currentVersion = systemInfo?.version;
if (currentVersion && latest.tag_name) {
const isNewer = compareVersions(latest.tag_name, currentVersion);
setHasNewVersion(isNewer);
}
}
})
.catch((error) => {
console.error('Failed to fetch releases:', error);
});
}
getCloudServiceClientSync()
.getGitHubRepoInfo()
.then((info) => {
if (info?.repo?.stargazers_count != null) {
setStarCount(info.repo.stargazers_count);
}
})
.catch(() => {});
}, []);
useEffect(() => {
const contentEl = navigationContentRef.current;
if (!contentEl) return;
let animationFrame = 0;
const updateScrollHint = () => {
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(() => {
const hasHiddenContent =
contentEl.scrollTop + contentEl.clientHeight <
contentEl.scrollHeight - SCROLL_HINT_BOTTOM_THRESHOLD;
setShowScrollHint(hasHiddenContent);
});
};
updateScrollHint();
contentEl.addEventListener('scroll', updateScrollHint, { passive: true });
const resizeObserver = new ResizeObserver(updateScrollHint);
resizeObserver.observe(contentEl);
if (contentEl.firstElementChild) {
resizeObserver.observe(contentEl.firstElementChild);
}
const mutationObserver = new MutationObserver(updateScrollHint);
mutationObserver.observe(contentEl, {
childList: true,
subtree: true,
attributes: true,
});
window.addEventListener('resize', updateScrollHint);
return () => {
cancelAnimationFrame(animationFrame);
contentEl.removeEventListener('scroll', updateScrollHint);
resizeObserver.disconnect();
mutationObserver.disconnect();
window.removeEventListener('resize', updateScrollHint);
};
}, []);
// Update selected state + notify parent without navigating
function selectChild(child: SidebarChildVO) {
setSelectedChild(child);
onSelectedChangeAction(child);
}
// Toggle collapsible section open/closed with localStorage persistence
function handleSectionToggle(id: string, open: boolean) {
setSectionOpenState((prev) => {
const next = { ...prev, [id]: open };
saveSectionState(next);
return next;
});
}
// User click: update state AND navigate
function handleChildClick(child: SidebarChildVO) {
selectChild(child);
navigate(child.route);
}
function initSelect() {
const currentPath = pathname;
const matchedChild = findSidebarChildForPath(currentPath);
if (matchedChild) {
// Route already matches — just select without navigating (preserves ?id= query params)
selectChild(matchedChild);
} else {
// No match — redirect to the first route under /home
const defaultChild =
sidebarConfigList.find((c) => c.route.startsWith('/home')) ??
sidebarConfigList[0];
handleChildClick(defaultChild);
}
}
function handleRouteChange(pathname: string) {
if (!pathname.startsWith('/home')) return;
const routeSelectChild = findSidebarChildForPath(pathname);
if (routeSelectChild) {
setSelectedChild(routeSelectChild);
onSelectedChangeAction(routeSelectChild);
}
}
function handleLogout() {
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
window.location.href = '/login';
}
// Get the initial letter for user avatar
const userInitial = userEmail ? userEmail.charAt(0).toUpperCase() : 'U';
return (
<>
<Sidebar variant="inset" collapsible="icon">
{/* Header: Logo using sidebar-07 team-switcher pattern */}
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
className="cursor-default hover:bg-transparent active:bg-transparent"
tooltip="LangBot"
>
<img
src={langbotIcon}
alt="LangBot"
className="size-8 rounded-lg"
/>
<div className="grid flex-1 text-left text-sm leading-tight">
<div className="flex items-center gap-1.5">
<span className="truncate font-semibold">LangBot</span>
<Badge
variant="secondary"
className={`shrink-0 px-1 py-0 h-3.5 text-[0.55rem] font-medium ${
systemInfo?.edition === 'cloud'
? 'border-transparent bg-blue-500 text-white'
: ''
}`}
>
{systemInfo?.edition === 'cloud'
? t('sidebar.editionCloud')
: t('sidebar.editionCommunity')}
</Badge>
</div>
<div className="flex items-center gap-1.5">
<span className="truncate text-xs text-muted-foreground">
{systemInfo?.version}
</span>
{hasNewVersion && (
<Badge
onClick={() => setVersionDialogOpen(true)}
className="bg-red-500 hover:bg-red-600 text-white text-[0.55rem] px-1 py-0 h-3.5 cursor-pointer"
>
{t('plugins.new')}
</Badge>
)}
</div>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
{/* Navigation items grouped by section */}
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<SidebarContent ref={navigationContentRef} className="min-h-0 pb-8">
<SidebarGroup>
<SidebarGroupLabel>{t('sidebar.home')}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<NavItems
selectedChild={selectedChild}
onChildClick={handleChildClick}
section="home"
sectionOpenState={sectionOpenState}
onSectionToggle={handleSectionToggle}
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>{t('sidebar.extensions')}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<NavItems
selectedChild={selectedChild}
onChildClick={handleChildClick}
section="extensions"
sectionOpenState={sectionOpenState}
onSectionToggle={handleSectionToggle}
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<PluginPagesNav />
</SidebarContent>
<button
type="button"
onClick={scrollNavigationToBottom}
disabled={!showScrollHint}
aria-label={t('sidebar.scrollToBottom')}
aria-hidden={!showScrollHint}
tabIndex={showScrollHint ? 0 : -1}
className={cn(
'absolute inset-x-0 bottom-2 z-10 mx-auto flex w-fit justify-center rounded-full transition-opacity duration-200 group-data-[collapsible=icon]:hidden',
showScrollHint
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0',
)}
>
<span className="flex size-7 items-center justify-center rounded-full border border-sidebar-border bg-sidebar/95 text-sidebar-foreground/70 shadow-sm backdrop-blur transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground">
<ChevronDown className="size-4" />
</span>
</button>
</div>
{/* Footer */}
<SidebarFooter>
{/* API Integration entry */}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => setApiKeyDialogOpen(true)}
tooltip={t('common.apiIntegration')}
>
<KeyRound className="size-4 text-blue-500" />
<span>{t('common.apiIntegration')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{/* Models entry */}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => handleModelsDialogChange(true)}
tooltip={t('models.title')}
>
<Sparkles className="text-blue-500" />
<span>{t('models.title')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{/* User menu using sidebar-07 nav-user DropdownMenu pattern */}
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu open={userMenuOpen} onOpenChange={setUserMenuOpen}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
tooltip={t('common.accountOptions')}
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarFallback className="rounded-lg bg-primary text-primary-foreground text-xs">
{userInitial}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">
{userEmail || t('common.accountOptions')}
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
{/* User info header */}
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarFallback className="rounded-lg bg-primary text-primary-foreground text-xs">
{userInitial}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">
{userEmail || t('common.accountOptions')}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Language & Theme row */}
<div className="flex items-center gap-2 px-1 py-1">
<LanguageSelector triggerClassName="flex-1" />
<Button
variant="outline"
size="icon"
onClick={() =>
setTheme(
theme === 'light'
? 'dark'
: theme === 'dark'
? 'system'
: 'light',
)
}
className="h-9 w-9 shrink-0"
>
{theme === 'light' && (
<Sun className="h-[1.2rem] w-[1.2rem]" />
)}
{theme === 'dark' && (
<Moon className="h-[1.2rem] w-[1.2rem]" />
)}
{theme === 'system' && (
<Monitor className="h-[1.2rem] w-[1.2rem]" />
)}
</Button>
</div>
<DropdownMenuSeparator />
{/* Account actions */}
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => handleAccountSettingsChange(true)}
>
<Settings />
{t('account.settings')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
handleStorageAnalysisChange(true);
}}
>
<HardDrive />
{t('storageAnalysis.title')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
navigate('/wizard');
}}
>
<Zap className="text-blue-500" />
{t('sidebar.quickStart')}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
{/* External links */}
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
const language =
localStorage.getItem('langbot_language');
if (language === 'zh-Hans' || language === 'zh-Hant') {
window.open(
'https://link.langbot.app/zh/docs/guide',
'_blank',
);
} else {
window.open(
'https://link.langbot.app/en/docs/guide',
'_blank',
);
}
}}
>
<CircleHelp />
{t('common.helpDocs')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
window.open(
'https://github.com/langbot-app/LangBot/issues',
'_blank',
);
}}
>
<Lightbulb />
{t('common.featureRequest')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
window.open(
'https://github.com/langbot-app/LangBot',
'_blank',
);
}}
>
<Star
className={cn(
'text-yellow-500',
userMenuOpen && 'animate-twinkle',
)}
/>
<span className="flex-1">{t('common.starOnGitHub')}</span>
{starCount != null && (
<Badge variant="secondary" className="ml-auto text-xs">
{starCount >= 1000
? `${(starCount / 1000).toFixed(1)}k`
: starCount}
</Badge>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
window.open('https://discord.gg/wdNEHETs87', '_blank');
}}
>
<DiscordIcon className="text-[#5865F2]" />
{t('common.joinDiscord')}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
{/* Logout */}
<DropdownMenuItem onClick={() => handleLogout()}>
<LogOut />
{t('common.logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<AccountSettingsDialog
open={accountSettingsOpen}
onOpenChange={handleAccountSettingsChange}
/>
<ApiIntegrationDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
/>
<NewVersionDialog
open={versionDialogOpen}
onOpenChange={setVersionDialogOpen}
release={latestRelease}
/>
<ModelsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
/>
<StorageAnalysisDialog
open={storageAnalysisOpen}
onOpenChange={handleStorageAnalysisChange}
/>
</>
);
}