feat: support layout custom sort and fix copy (#13812)

* fix: menu locale keys

* feat: support resort sidebar

* feat: add lock to middle messages

* feat: add memory menu and default hidden

* fix: lint error

* fix: legacy secion order

* chore: add test cases

* chore: remove top zone

* feat: custom sidebar reorder

* chore: fix sidebar items
This commit is contained in:
Rdmclin2 2026-04-14 23:49:47 +08:00 committed by GitHub
parent 41efd16bba
commit fd0d846975
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 823 additions and 306 deletions

View file

@ -343,14 +343,15 @@
"mail.support": "Email Support",
"more": "More",
"navPanel.agent": "Agents",
"navPanel.customizeSidebar": "Customize sidebar",
"navPanel.customizeSidebar": "Customize Sidebar",
"navPanel.displayItems": "Display Items",
"navPanel.hidden": "Hidden",
"navPanel.hideSection": "Hide section",
"navPanel.hideSection": "Hide Section",
"navPanel.library": "Library",
"navPanel.moveDown": "Move down",
"navPanel.moveUp": "Move up",
"navPanel.moveDown": "Move Down",
"navPanel.moveUp": "Move Up",
"navPanel.pinned": "Pinned",
"navPanel.resetDefault": "Reset to Default",
"navPanel.searchAgent": "Search Agent...",
"navPanel.searchRecent": "Search Recent...",
"navPanel.searchResultEmpty": "No search results found",

View file

@ -351,6 +351,7 @@
"navPanel.moveDown": "下移",
"navPanel.moveUp": "上移",
"navPanel.pinned": "已固定",
"navPanel.resetDefault": "恢复默认",
"navPanel.searchAgent": "搜索助理…",
"navPanel.searchRecent": "搜索最近…",
"navPanel.searchResultEmpty": "暂无搜索结果",

View file

@ -185,6 +185,7 @@
"@codesandbox/sandpack-react": "^2.20.0",
"@discordjs/rest": "^2.6.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",

View file

@ -78,6 +78,12 @@ export const useNavLayout = (): NavLayout => {
title: t('tab.resource'),
url: '/resource',
},
{
icon: getRouteById('memory')!.icon,
key: SidebarTabKey.Memory,
title: t('tab.memory'),
url: '/memory',
},
] as NavItem[],
[t, showMarket],
);

View file

@ -424,14 +424,15 @@ export default {
'mail.support': 'Email Support',
'more': 'More',
'navPanel.agent': 'Agents',
'navPanel.customizeSidebar': 'Customize sidebar',
'navPanel.customizeSidebar': 'Customize Sidebar',
'navPanel.displayItems': 'Display Items',
'navPanel.resetDefault': 'Reset to Default',
'navPanel.hidden': 'Hidden',
'navPanel.hideSection': 'Hide section',
'navPanel.hideSection': 'Hide Section',
'navPanel.library': 'Library',
'navPanel.moveDown': 'Move down',
'navPanel.moveDown': 'Move Down',
'navPanel.pinned': 'Pinned',
'navPanel.moveUp': 'Move up',
'navPanel.moveUp': 'Move Up',
'navPanel.show': 'Show',
'navPanel.visible': 'Visible',
'navPanel.searchAgent': 'Search Agent...',

View file

@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { openCustomizeSidebarModal } from '@/routes/(main)/home/_layout/Body/CustomizeSidebarModal';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { reorderSidebarItems } from '@/store/global/selectors/systemStatus';
import { useCreateMenuItems } from '../../hooks';
@ -19,30 +20,27 @@ export const useAgentActionsDropdownMenu = ({
}: AgentActionsDropdownMenuProps): MenuProps['items'] => {
const { t } = useTranslation('common');
const [agentPageSize, sidebarSectionOrder, hiddenSections, updateSystemStatus] = useGlobalStore(
(s) => [
systemStatusSelectors.agentPageSize(s),
systemStatusSelectors.sidebarSectionOrder(s),
systemStatusSelectors.hiddenSidebarSections(s),
s.updateSystemStatus,
],
);
const [agentPageSize, sidebarItems, hiddenSections, updateSystemStatus] = useGlobalStore((s) => [
systemStatusSelectors.agentPageSize(s),
systemStatusSelectors.sidebarItems(s),
systemStatusSelectors.hiddenSidebarSections(s),
s.updateSystemStatus,
]);
const visibleOrder = sidebarSectionOrder.filter((k) => !hiddenSections.includes(k));
const visibleIndex = visibleOrder.indexOf('agent');
const visibleItems = sidebarItems.filter((k) => !hiddenSections.includes(k));
const visibleIndex = visibleItems.indexOf('agent');
const isFirst = visibleIndex === 0;
const isLast = visibleIndex === visibleOrder.length - 1;
const isLast = visibleIndex === visibleItems.length - 1;
const moveSection = useCallback(
(direction: 'up' | 'down') => {
const newOrder = [...sidebarSectionOrder];
const idx = newOrder.indexOf('agent');
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= newOrder.length) return;
[newOrder[idx], newOrder[swapIdx]] = [newOrder[swapIdx], newOrder[idx]];
updateSystemStatus({ sidebarSectionOrder: newOrder });
const idx = sidebarItems.indexOf('agent');
if (idx === -1) return;
const next = reorderSidebarItems(sidebarItems, idx, direction === 'up' ? idx - 1 : idx + 1);
if (next === sidebarItems) return;
updateSystemStatus({ sidebarItems: next });
},
[sidebarSectionOrder, updateSystemStatus],
[sidebarItems, updateSystemStatus],
);
// Create menu items
@ -104,7 +102,7 @@ export const useAgentActionsDropdownMenu = ({
isFirst,
isLast,
moveSection,
visibleOrder.length,
visibleItems.length,
t,
]);
};

View file

@ -1,94 +1,12 @@
import { type MenuProps } from '@lobehub/ui';
import { ActionIcon, DropdownMenu, Flexbox, Icon } from '@lobehub/ui';
import { EyeOffIcon, MoreHorizontalIcon, SlidersHorizontalIcon } from 'lucide-react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import NavItem from '@/features/NavPanel/components/NavItem';
import { useActiveTabKey } from '@/hooks/useActiveTabKey';
import { useNavLayout } from '@/hooks/useNavLayout';
import { openCustomizeSidebarModal } from '@/routes/(main)/home/_layout/Body/CustomizeSidebarModal';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { isModifierClick } from '@/utils/navigation';
import { prefetchRoute } from '@/utils/router';
import { memo } from 'react';
/**
* BottomMenu items (Community, Resources) are now rendered by Body
* based on sidebarSectionOrder for drag-and-drop reordering support.
* This component is kept as a placeholder for potential future use.
*/
const BottomMenu = memo(() => {
const { t } = useTranslation('common');
const tab = useActiveTabKey();
const navigate = useNavigate();
const { bottomMenuItems: items } = useNavLayout();
const [hiddenSections, updateSystemStatus] = useGlobalStore((s) => [
systemStatusSelectors.hiddenSidebarSections(s),
s.updateSystemStatus,
]);
const hideSection = useCallback(
(key: string) => {
updateSystemStatus({ hiddenSidebarSections: [...hiddenSections, key] });
},
[hiddenSections, updateSystemStatus],
);
const getContextMenuItems = useCallback(
(key: string): MenuProps['items'] => [
{
icon: <Icon icon={EyeOffIcon} />,
key: 'hideSection',
label: t('navPanel.hideSection'),
onClick: () => hideSection(key),
},
{ type: 'divider' as const },
{
icon: <Icon icon={SlidersHorizontalIcon} />,
key: 'customizeSidebar',
label: t('navPanel.customizeSidebar'),
onClick: () => openCustomizeSidebarModal(),
},
],
[t, hideSection],
);
const visibleItems = items.filter((item) => !item.hidden && !hiddenSections.includes(item.key));
if (visibleItems.length === 0) return null;
return (
<Flexbox
gap={1}
paddingBlock={4}
style={{
marginTop: 12,
overflow: 'hidden',
}}
>
{visibleItems.map((item) => (
<Link
key={item.key}
to={item.url!}
onMouseEnter={() => prefetchRoute(item.url!)}
onClick={(e) => {
if (isModifierClick(e)) return;
e.preventDefault();
navigate(item.url!);
}}
>
<NavItem
active={tab === item.key}
contextMenuItems={getContextMenuItems(item.key)}
icon={item.icon}
title={item.title}
actions={
<DropdownMenu items={getContextMenuItems(item.key)} nativeButton={false}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} style={{ flex: 'none' }} />
</DropdownMenu>
}
/>
</Link>
))}
</Flexbox>
);
return null;
});
export default BottomMenu;

View file

@ -1,34 +1,68 @@
'use client';
import { ActionIcon, Block, Flexbox, Icon, Text, Tooltip } from '@lobehub/ui';
import {
closestCenter,
type CollisionDetection,
defaultDropAnimationSideEffects,
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ActionIcon, Button, Flexbox, Icon, Text, Tooltip } from '@lobehub/ui';
import { Modal } from '@lobehub/ui/base-ui';
import { Divider } from 'antd';
import { Eye, EyeOff, PinIcon } from 'lucide-react';
import { memo } from 'react';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Eye, EyeOff, GripVertical, PinIcon, RotateCcw } from 'lucide-react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { create } from 'zustand';
import { getRouteById } from '@/config/routes';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { SIDEBAR_ACCORDION_KEYS } from '@/store/global/selectors/systemStatus';
// Top nav items (Pages)
const TOP_NAV_ITEMS: { key: string; labelKey: string; routeId?: string }[] = [
{ key: 'pages', labelKey: 'tab.pages', routeId: 'page' },
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
const ACCORDION_GROUP_ID = 'accordion-group';
interface SidebarItemConfig {
alwaysVisible?: boolean;
id: string;
labelKey: string;
routeId?: string;
}
const ALL_SIDEBAR_ITEMS: SidebarItemConfig[] = [
{ id: 'pages', labelKey: 'tab.pages', routeId: 'page' },
{ id: 'recents', labelKey: 'recents' },
{ alwaysVisible: true, id: 'agent', labelKey: 'navPanel.agent' },
{ id: 'community', labelKey: 'tab.community', routeId: 'community' },
{ id: 'resource', labelKey: 'tab.resource', routeId: 'resource' },
{ id: 'memory', labelKey: 'tab.memory', routeId: 'memory' },
];
// Accordion sections (Recents, Agents)
// `alwaysVisible` sections cannot be hidden by the user
const SECTION_ITEMS: { alwaysVisible?: boolean; icon?: any; key: string; labelKey: string }[] = [
{ key: 'recents', labelKey: 'recents' },
{ alwaysVisible: true, key: 'agent', labelKey: 'navPanel.agent' },
];
const ITEM_MAP = new Map(ALL_SIDEBAR_ITEMS.map((item) => [item.id, item]));
// Bottom menu items (Community, Resources)
const BOTTOM_ITEMS: { key: string; labelKey: string; routeId?: string }[] = [
{ key: 'community', labelKey: 'tab.community', routeId: 'community' },
{ key: 'resource', labelKey: 'tab.resource', routeId: 'resource' },
];
const isAccordionKey = (id: string) => SIDEBAR_ACCORDION_KEYS.has(id);
// ---------------------------------------------------------------------------
// Modal store
// ---------------------------------------------------------------------------
const useCustomizeSidebarModalStore = create<{
open: boolean;
@ -41,118 +75,338 @@ const useCustomizeSidebarModalStore = create<{
export const openCustomizeSidebarModal = () =>
useCustomizeSidebarModalStore.getState().setOpen(true);
const SectionRow = memo<{
alwaysVisible?: boolean;
icon?: any;
isHidden: boolean;
label: string;
pinnedTooltip?: string;
toggleTooltip?: string;
onToggle: () => void;
}>(({ label, icon, isHidden, alwaysVisible, pinnedTooltip, toggleTooltip, onToggle }) => (
<Block style={{ opacity: isHidden ? 0.5 : 1 }} variant={isHidden ? 'filled' : 'borderless'}>
<Flexbox horizontal align={'center'} height={40} justify={'space-between'} paddingInline={8}>
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles = createStaticStyles(({ css }) => ({
accordionGroup: css`
margin-inline: -5px;
padding: 4px;
border: 1px dashed ${cssVar.colorBorderSecondary};
border-radius: ${cssVar.borderRadius};
`,
item: css`
height: 40px;
padding-inline: 8px;
border-radius: ${cssVar.borderRadius};
transition: background 0.2s ease-in-out;
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
itemDragging: css`
opacity: 0;
`,
overlay: css`
height: 40px;
padding-inline: 8px;
border-radius: ${cssVar.borderRadius};
background: ${cssVar.colorBgElevated};
box-shadow: ${cssVar.boxShadowSecondary};
`,
}));
// ---------------------------------------------------------------------------
// SortableItem
// ---------------------------------------------------------------------------
const SortableItem = memo<{
hiddenSections: string[];
id: string;
onToggle: (key: string) => void;
}>(({ id, hiddenSections, onToggle }) => {
const { t } = useTranslation('common');
const item = ITEM_MAP.get(id);
const {
attributes,
isDragging,
listeners,
setActivatorNodeRef,
setNodeRef,
transform,
transition,
} = useSortable({ id });
if (!item) return null;
const route = item.routeId ? getRouteById(item.routeId) : undefined;
const isHidden = !item.alwaysVisible && hiddenSections.includes(id);
return (
<Flexbox
horizontal
align={'center'}
className={isDragging ? cx(styles.item, styles.itemDragging) : styles.item}
gap={4}
justify={'space-between'}
ref={setNodeRef}
style={{
opacity: isHidden && !isDragging ? 0.5 : undefined,
transform: CSS.Translate.toString(transform),
transition,
}}
{...attributes}
>
<Flexbox horizontal align={'center'} gap={8}>
{icon && <Icon icon={icon} size={18} />}
<Text>{label}</Text>
<Flexbox
ref={setActivatorNodeRef}
style={{ cursor: isDragging ? 'grabbing' : 'grab', flexShrink: 0, touchAction: 'none' }}
{...listeners}
>
<Icon icon={GripVertical} size={14} style={{ color: cssVar.colorTextQuaternary }} />
</Flexbox>
{route?.icon && <Icon icon={route.icon} size={18} />}
<Text>{t(item.labelKey as any)}</Text>
</Flexbox>
{alwaysVisible ? (
<Tooltip title={pinnedTooltip}>
{item.alwaysVisible ? (
<Tooltip title={t('navPanel.pinned' as any)}>
<ActionIcon icon={PinIcon} size={'small'} style={{ cursor: 'default', opacity: 0.45 }} />
</Tooltip>
) : (
<Tooltip title={toggleTooltip}>
<ActionIcon icon={isHidden ? EyeOff : Eye} size={'small'} onClick={onToggle} />
<Tooltip title={t(isHidden ? ('navPanel.hidden' as any) : ('navPanel.visible' as any))}>
<ActionIcon icon={isHidden ? EyeOff : Eye} size={'small'} onClick={() => onToggle(id)} />
</Tooltip>
)}
</Flexbox>
</Block>
));
);
});
const CustomizeSidebarContent = memo(() => {
// ---------------------------------------------------------------------------
// AccordionGroup — a non-draggable slot at the outer level that wraps a nested
// SortableContext for accordion items. Registers with useSortable so other outer
// items can reorder relative to its position, but has no drag activator of its own.
// ---------------------------------------------------------------------------
const AccordionGroup = memo<{ children: React.ReactNode }>(({ children }) => {
const { setNodeRef, transform, transition } = useSortable({ id: ACCORDION_GROUP_ID });
return (
<div
className={styles.accordionGroup}
ref={setNodeRef}
style={{
transform: CSS.Translate.toString(transform),
transition,
}}
>
<Flexbox gap={2}>{children}</Flexbox>
</div>
);
});
// ---------------------------------------------------------------------------
// Drag overlay item (static, no sortable hooks)
// ---------------------------------------------------------------------------
const OverlayItem = memo<{ id: string }>(({ id }) => {
const { t } = useTranslation('common');
const [sidebarSectionOrder, hiddenSections, updateSystemStatus] = useGlobalStore((s) => [
systemStatusSelectors.sidebarSectionOrder(s),
// Accordion group overlay: render a compact representation
if (id === ACCORDION_GROUP_ID) {
return (
<Flexbox horizontal align={'center'} className={styles.overlay} gap={8}>
<Icon icon={GripVertical} size={14} style={{ color: cssVar.colorTextQuaternary }} />
<Text>{t('navPanel.agent' as any)}</Text>
<Text type={'secondary'}>+ {t('recents' as any)}</Text>
</Flexbox>
);
}
const item = ITEM_MAP.get(id);
if (!item) return null;
const route = item.routeId ? getRouteById(item.routeId) : undefined;
return (
<Flexbox horizontal align={'center'} className={styles.overlay} gap={8}>
<Icon icon={GripVertical} size={14} style={{ color: cssVar.colorTextQuaternary }} />
{route?.icon && <Icon icon={route.icon} size={18} />}
<Text>{t(item.labelKey as any)}</Text>
</Flexbox>
);
});
// ---------------------------------------------------------------------------
// Main content
// ---------------------------------------------------------------------------
/** Flatten outer list (with ACCORDION_GROUP_ID placeholder) + inner accordion items → full list. */
const flattenItems = (outer: string[], inner: string[]): string[] =>
outer.flatMap((id) => (id === ACCORDION_GROUP_ID ? inner : [id]));
const CustomizeSidebarContent = memo(() => {
const [storeItems, hiddenSections, updateSystemStatus] = useGlobalStore((s) => [
systemStatusSelectors.sidebarItems(s),
systemStatusSelectors.hiddenSidebarSections(s),
s.updateSystemStatus,
]);
const toggleSection = (sectionKey: string) => {
const isHidden = hiddenSections.includes(sectionKey);
const newHidden = isHidden
? hiddenSections.filter((k) => k !== sectionKey)
: [...hiddenSections, sectionKey];
updateSystemStatus({ hiddenSidebarSections: newHidden });
};
// Local state for drag operations — only persisted on dragEnd
const [items, setItems] = useState<string[]>(storeItems);
const [activeId, setActiveId] = useState<string | null>(null);
// Sync local state when store changes (e.g. reset)
useEffect(() => {
setItems(storeItems);
}, [storeItems]);
// Derive outer (with group placeholder) and inner (accordion items)
const { innerItems, outerItems } = useMemo(() => {
const outer: string[] = [];
const inner: string[] = [];
let insertedGroup = false;
for (const id of items) {
if (isAccordionKey(id)) {
inner.push(id);
if (!insertedGroup) {
outer.push(ACCORDION_GROUP_ID);
insertedGroup = true;
}
} else {
outer.push(id);
}
}
return { innerItems: inner, outerItems: outer };
}, [items]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor),
);
const toggleSection = useCallback(
(key: string) => {
const isHidden = hiddenSections.includes(key);
const newHidden = isHidden
? hiddenSections.filter((k) => k !== key)
: [...hiddenSections, key];
updateSystemStatus({ hiddenSidebarSections: newHidden });
},
[hiddenSections, updateSystemStatus],
);
// Collision detection: restrict targets to the same container as the active item.
// - Active in inner (recents/agent) → only collide with inner items
// - Active in outer (pages/community/... or the group itself) → only collide with outer items
const collisionDetection = useCallback<CollisionDetection>((args) => {
const activeId = args.active.id as string;
const isInner = isAccordionKey(activeId);
const droppableContainers = args.droppableContainers.filter((c) => {
const id = c.id as string;
const targetIsInner = isAccordionKey(id);
return isInner === targetIsInner;
});
return closestCenter({ ...args, droppableContainers });
}, []);
// ---- DnD handlers ----
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over || active.id === over.id) return;
const activeKey = active.id as string;
const overKey = over.id as string;
let next: string[];
if (isAccordionKey(activeKey)) {
// Inner reorder (recents ↔ agent)
const oldIdx = innerItems.indexOf(activeKey);
const newIdx = innerItems.indexOf(overKey);
if (oldIdx === -1 || newIdx === -1) return;
next = flattenItems(outerItems, arrayMove(innerItems, oldIdx, newIdx));
} else {
// Outer reorder (pages/community/... or the whole accordion group)
const oldIdx = outerItems.indexOf(activeKey);
const newIdx = outerItems.indexOf(overKey);
if (oldIdx === -1 || newIdx === -1) return;
next = flattenItems(arrayMove(outerItems, oldIdx, newIdx), innerItems);
}
setItems(next);
updateSystemStatus({ sidebarItems: next });
},
[innerItems, outerItems, updateSystemStatus],
);
const handleDragCancel = useCallback(() => {
setActiveId(null);
setItems(storeItems);
}, [storeItems]);
const renderItem = (id: string) => (
<SortableItem hiddenSections={hiddenSections} id={id} key={id} onToggle={toggleSection} />
);
return (
<Flexbox gap={2}>
{TOP_NAV_ITEMS.map((item) => {
const route = item.routeId ? getRouteById(item.routeId) : undefined;
const isHidden = hiddenSections.includes(item.key);
return (
<SectionRow
icon={route?.icon}
isHidden={isHidden}
key={item.key}
label={t(item.labelKey as any)}
toggleTooltip={t(isHidden ? ('navPanel.hidden' as any) : ('navPanel.visible' as any))}
onToggle={() => toggleSection(item.key)}
/>
);
})}
<Divider style={{ margin: '8px 0' }} />
{sidebarSectionOrder.map((key) => {
const item = SECTION_ITEMS.find((i) => i.key === key);
if (!item) return null;
const isHidden = !item.alwaysVisible && hiddenSections.includes(key);
<DndContext
collisionDetection={collisionDetection}
sensors={sensors}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
>
<SortableContext items={outerItems} strategy={verticalListSortingStrategy}>
<Flexbox gap={2}>
{outerItems.map((id) =>
id === ACCORDION_GROUP_ID ? (
<AccordionGroup key={id}>
<SortableContext items={innerItems} strategy={verticalListSortingStrategy}>
{innerItems.map(renderItem)}
</SortableContext>
</AccordionGroup>
) : (
renderItem(id)
),
)}
</Flexbox>
</SortableContext>
return (
<SectionRow
alwaysVisible={item.alwaysVisible}
isHidden={isHidden}
key={key}
label={t(item.labelKey as any)}
pinnedTooltip={t('navPanel.pinned' as any)}
toggleTooltip={t(isHidden ? ('navPanel.hidden' as any) : ('navPanel.visible' as any))}
onToggle={() => toggleSection(key)}
/>
);
})}
<Divider style={{ margin: '8px 0' }} />
{BOTTOM_ITEMS.map((item) => {
const route = item.routeId ? getRouteById(item.routeId) : undefined;
const icon = route?.icon;
const isHidden = hiddenSections.includes(item.key);
return (
<SectionRow
icon={icon}
isHidden={isHidden}
key={item.key}
label={t(item.labelKey as any)}
toggleTooltip={t(isHidden ? ('navPanel.hidden' as any) : ('navPanel.visible' as any))}
onToggle={() => toggleSection(item.key)}
/>
);
})}
</Flexbox>
{createPortal(
<DragOverlay dropAnimation={{ sideEffects: defaultDropAnimationSideEffects({}) }}>
{activeId ? <OverlayItem id={activeId} /> : null}
</DragOverlay>,
document.body,
)}
</DndContext>
);
});
// ---------------------------------------------------------------------------
// Modal wrapper
// ---------------------------------------------------------------------------
export const CustomizeSidebarModal = memo(() => {
const { t } = useTranslation('common');
const open = useCustomizeSidebarModalStore((s) => s.open);
const setOpen = useCustomizeSidebarModalStore((s) => s.setOpen);
const resetSidebarCustomization = useGlobalStore((s) => s.resetSidebarCustomization);
return (
<Modal
centered
destroyOnHidden
footer={null}
open={open}
title={t('navPanel.customizeSidebar')}
width={360}
footer={
<Button
block
icon={<Icon icon={RotateCcw} />}
type={'text'}
onClick={resetSidebarCustomization}
>
{t('navPanel.resetDefault' as any)}
</Button>
}
onCancel={() => setOpen(false)}
>
<CustomizeSidebarContent />

View file

@ -1,46 +1,160 @@
'use client';
import { Accordion, Flexbox } from '@lobehub/ui';
import { memo, type ReactElement, useMemo } from 'react';
import { Accordion, ActionIcon, DropdownMenu, Flexbox, Icon, type MenuProps } from '@lobehub/ui';
import { EyeOffIcon, MoreHorizontalIcon, SlidersHorizontalIcon } from 'lucide-react';
import { memo, type ReactElement, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import NavItem from '@/features/NavPanel/components/NavItem';
import { useActiveTabKey } from '@/hooks/useActiveTabKey';
import { type NavItem as NavItemType, useNavLayout } from '@/hooks/useNavLayout';
import Recents from '@/routes/(main)/home/features/Recents';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { isModifierClick } from '@/utils/navigation';
import { prefetchRoute } from '@/utils/router';
import Agent from './Agent';
import BottomMenu from './BottomMenu';
import { CustomizeSidebarModal } from './CustomizeSidebarModal';
import { CustomizeSidebarModal, openCustomizeSidebarModal } from './CustomizeSidebarModal';
export enum GroupKey {
Agent = 'agent',
Community = 'community',
Pages = 'pages',
Project = 'project',
Recents = 'recents',
Resource = 'resource',
}
const sectionComponents: Record<string, (key: string) => ReactElement> = {
const ACCORDION_KEYS = new Set<string>([GroupKey.Recents, GroupKey.Agent]);
const accordionComponents: Record<string, (key: string) => ReactElement> = {
[GroupKey.Agent]: (key) => <Agent itemKey={key} key={key} />,
[GroupKey.Recents]: (key) => <Recents itemKey={key} key={key} />,
};
const Body = memo(() => {
const sidebarSectionOrder = useGlobalStore(systemStatusSelectors.sidebarSectionOrder);
const { t } = useTranslation('common');
const tab = useActiveTabKey();
const navigate = useNavigate();
const { topNavItems, bottomMenuItems } = useNavLayout();
const sidebarItems = useGlobalStore(systemStatusSelectors.sidebarItems);
const hiddenSections = useGlobalStore(systemStatusSelectors.hiddenSidebarSections);
const updateSystemStatus = useGlobalStore((s) => s.updateSystemStatus);
const sections = useMemo(
() =>
sidebarSectionOrder
.filter((key) => key === GroupKey.Agent || !hiddenSections.includes(key))
.map((key) => sectionComponents[key]?.(key))
.filter(Boolean),
[sidebarSectionOrder, hiddenSections],
const hideSection = useCallback(
(key: string) => {
updateSystemStatus({ hiddenSidebarSections: [...hiddenSections, key] });
},
[hiddenSections, updateSystemStatus],
);
const getContextMenuItems = useCallback(
(key: string): MenuProps['items'] => [
{
icon: <Icon icon={EyeOffIcon} />,
key: 'hideSection',
label: t('navPanel.hideSection'),
onClick: () => hideSection(key),
},
{ type: 'divider' as const },
{
icon: <Icon icon={SlidersHorizontalIcon} />,
key: 'customizeSidebar',
label: t('navPanel.customizeSidebar'),
onClick: () => openCustomizeSidebarModal(),
},
],
[t, hideSection],
);
// Build a map of nav link items by key
const navLinkItems = useMemo(() => {
const map = new Map<string, NavItemType>();
for (const item of topNavItems) map.set(item.key, item);
for (const item of bottomMenuItems) map.set(item.key, item);
return map;
}, [topNavItems, bottomMenuItems]);
// Items that must always be visible regardless of hiddenSections
const isVisible = useCallback(
(k: string) => k === GroupKey.Agent || !hiddenSections.includes(k),
[hiddenSections],
);
const visibleKeys = useMemo(() => sidebarItems.filter(isVisible), [sidebarItems, isVisible]);
const renderNavLink = useCallback(
(key: string) => {
const navItem = navLinkItems.get(key);
if (!navItem || navItem.hidden) return null;
return (
<Link
key={key}
to={navItem.url!}
onMouseEnter={() => prefetchRoute(navItem.url!)}
onClick={(e) => {
if (isModifierClick(e)) return;
e.preventDefault();
navigate(navItem.url!);
}}
>
<NavItem
active={tab === key}
contextMenuItems={getContextMenuItems(key)}
icon={navItem.icon}
title={navItem.title}
actions={
<DropdownMenu items={getContextMenuItems(key)} nativeButton={false}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} style={{ flex: 'none' }} />
</DropdownMenu>
}
/>
</Link>
);
},
[navLinkItems, tab, getContextMenuItems, navigate],
);
// Render the flat list: group consecutive accordion items into an Accordion,
// interleave non-accordion keys as nav links.
const content = useMemo(() => {
const elements: ReactElement[] = [];
let accGroup: ReactElement[] = [];
const flushAccordion = () => {
if (accGroup.length > 0) {
elements.push(
<Accordion
defaultExpandedKeys={[GroupKey.Recents, GroupKey.Project, GroupKey.Agent]}
gap={8}
key={`acc-${elements.length}`}
>
{accGroup}
</Accordion>,
);
accGroup = [];
}
};
for (const key of visibleKeys) {
if (ACCORDION_KEYS.has(key)) {
const comp = accordionComponents[key]?.(key);
if (comp) accGroup.push(comp);
} else {
flushAccordion();
const link = renderNavLink(key);
if (link) elements.push(link);
}
}
flushAccordion();
return elements;
}, [visibleKeys, renderNavLink]);
return (
<Flexbox flex={1} justify={'space-between'} paddingInline={4}>
<Accordion defaultExpandedKeys={[GroupKey.Recents, GroupKey.Project, GroupKey.Agent]} gap={8}>
{sections}
</Accordion>
<BottomMenu />
<Flexbox flex={1} gap={4} paddingInline={4}>
{content}
<CustomizeSidebarModal />
</Flexbox>
);

View file

@ -1,9 +1,7 @@
'use client';
import { type MenuProps } from '@lobehub/ui';
import { ActionIcon, DropdownMenu, Flexbox, Icon, Tag } from '@lobehub/ui';
import { EyeOffIcon, MoreHorizontalIcon, SlidersHorizontalIcon } from 'lucide-react';
import { memo, useCallback } from 'react';
import { Flexbox, Tag } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
@ -11,50 +9,17 @@ import { type NavItemProps } from '@/features/NavPanel/components/NavItem';
import NavItem from '@/features/NavPanel/components/NavItem';
import { useActiveTabKey } from '@/hooks/useActiveTabKey';
import { useNavLayout } from '@/hooks/useNavLayout';
import { openCustomizeSidebarModal } from '@/routes/(main)/home/_layout/Body/CustomizeSidebarModal';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { isModifierClick } from '@/utils/navigation';
import { prefetchRoute } from '@/utils/router';
/** Keys that cannot be hidden and should not show section actions */
const PERMANENT_KEYS = new Set(['home', 'search']);
/** Keys that are rendered in the header; all others are managed by Body via sidebarSectionOrder */
const HEADER_KEYS = new Set(['home', 'search']);
const Nav = memo(() => {
const tab = useActiveTabKey();
const navigate = useNavigate();
const { t } = useTranslation('common');
const { topNavItems: items } = useNavLayout();
const [hiddenSections, updateSystemStatus] = useGlobalStore((s) => [
systemStatusSelectors.hiddenSidebarSections(s),
s.updateSystemStatus,
]);
const hideSection = useCallback(
(key: string) => {
updateSystemStatus({ hiddenSidebarSections: [...hiddenSections, key] });
},
[hiddenSections, updateSystemStatus],
);
const getSectionMenuItems = useCallback(
(key: string): MenuProps['items'] => [
{
icon: <Icon icon={EyeOffIcon} />,
key: 'hideSection',
label: t('navPanel.hideSection'),
onClick: () => hideSection(key),
},
{ type: 'divider' as const },
{
icon: <Icon icon={SlidersHorizontalIcon} />,
key: 'customizeSidebar',
label: t('navPanel.customizeSidebar'),
onClick: () => openCustomizeSidebarModal(),
},
],
[t, hideSection],
);
const newBadge = (
<Tag color="blue" size="small">
@ -65,27 +30,17 @@ const Nav = memo(() => {
return (
<Flexbox gap={1} paddingInline={4}>
{items
.filter((item) => !hiddenSections.includes(item.key))
.filter((item) => HEADER_KEYS.has(item.key))
.map((item) => {
const extra = item.isNew ? newBadge : undefined;
const canHide = !PERMANENT_KEYS.has(item.key);
const menuItems = canHide ? getSectionMenuItems(item.key) : undefined;
const navItem = (
<NavItem
active={tab === item.key}
contextMenuItems={menuItems}
extra={extra}
hidden={item.hidden}
icon={item.icon as NavItemProps['icon']}
title={item.title}
actions={
menuItems ? (
<DropdownMenu items={menuItems} nativeButton={false}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} style={{ flex: 'none' }} />
</DropdownMenu>
) : undefined
}
onClick={item.onClick}
/>
);

View file

@ -26,6 +26,7 @@ import { useInitRecents } from '@/hooks/useInitRecents';
import { openCustomizeSidebarModal } from '@/routes/(main)/home/_layout/Body/CustomizeSidebarModal';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { reorderSidebarItems } from '@/store/global/selectors/systemStatus';
import { useHomeStore } from '@/store/home';
import { homeRecentSelectors } from '@/store/home/selectors';
import { useUserStore } from '@/store/user';
@ -44,30 +45,27 @@ const Recents = memo<RecentsProps>(({ itemKey }) => {
const isLogin = useUserStore(authSelectors.isLogin);
const { isRevalidating } = useInitRecents();
const [recentPageSize, sidebarSectionOrder, hiddenSections, updateSystemStatus] = useGlobalStore(
(s) => [
systemStatusSelectors.recentPageSize(s),
systemStatusSelectors.sidebarSectionOrder(s),
systemStatusSelectors.hiddenSidebarSections(s),
s.updateSystemStatus,
],
);
const [recentPageSize, sidebarItems, hiddenSections, updateSystemStatus] = useGlobalStore((s) => [
systemStatusSelectors.recentPageSize(s),
systemStatusSelectors.sidebarItems(s),
systemStatusSelectors.hiddenSidebarSections(s),
s.updateSystemStatus,
]);
const visibleOrder = sidebarSectionOrder.filter((k) => !hiddenSections.includes(k));
const visibleIndex = visibleOrder.indexOf('recents');
const visibleItems = sidebarItems.filter((k) => !hiddenSections.includes(k));
const visibleIndex = visibleItems.indexOf('recents');
const isFirst = visibleIndex === 0;
const isLast = visibleIndex === visibleOrder.length - 1;
const isLast = visibleIndex === visibleItems.length - 1;
const moveSection = useCallback(
(direction: 'up' | 'down') => {
const newOrder = [...sidebarSectionOrder];
const idx = newOrder.indexOf('recents');
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= newOrder.length) return;
[newOrder[idx], newOrder[swapIdx]] = [newOrder[swapIdx], newOrder[idx]];
updateSystemStatus({ sidebarSectionOrder: newOrder });
const idx = sidebarItems.indexOf('recents');
if (idx === -1) return;
const next = reorderSidebarItems(sidebarItems, idx, direction === 'up' ? idx - 1 : idx + 1);
if (next === sidebarItems) return;
updateSystemStatus({ sidebarItems: next });
},
[sidebarSectionOrder, updateSystemStatus],
[sidebarItems, updateSystemStatus],
);
const hideSection = useCallback(() => {
@ -130,7 +128,7 @@ const Recents = memo<RecentsProps>(({ itemKey }) => {
isLast,
moveSection,
hideSection,
visibleOrder.length,
visibleItems.length,
]);
if (!isLogin) return null;

View file

@ -14,6 +14,7 @@ import { switchLang } from '@/utils/client/switchLang';
import { merge } from '@/utils/merge';
import { setNamespace } from '@/utils/storeDebug';
import { DEFAULT_HIDDEN_SECTIONS, DEFAULT_SIDEBAR_ITEMS } from '../selectors/systemStatus';
import { type GlobalStore } from '../store';
const n = setNamespace('g');
@ -130,6 +131,13 @@ export class GlobalGeneralActionImpl {
});
};
resetSidebarCustomization = (): void => {
this.#get().updateSystemStatus(
{ hiddenSidebarSections: DEFAULT_HIDDEN_SECTIONS, sidebarItems: DEFAULT_SIDEBAR_ITEMS },
n('resetSidebarCustomization'),
);
};
updateSystemStatus = (status: Partial<SystemStatus>, action?: any): void => {
if (!this.#get().isStatusInit) return;

View file

@ -185,7 +185,12 @@ export interface SystemStatus {
showVideoPanel?: boolean;
showVideoTopicPanel?: boolean;
/**
* Order of sidebar sections (e.g. ['recents', 'agent'])
* Flat ordered list of sidebar items.
*/
sidebarItems?: string[];
/**
* Legacy accordion-only ordering (recents/agent) from the pre-rework sidebar.
* @deprecated Kept for one-time migration into `sidebarItems`.
*/
sidebarSectionOrder?: string[];
systemRoleExpandedMap: Record<string, boolean>;

View file

@ -4,7 +4,7 @@ import { merge } from '@/utils/merge';
import { type GlobalState } from '../initialState';
import { INITIAL_STATUS, initialState } from '../initialState';
import { systemStatusSelectors } from './systemStatus';
import { DEFAULT_SIDEBAR_ITEMS, reorderSidebarItems, systemStatusSelectors } from './systemStatus';
// Mock version constants
vi.mock('@/const/version', () => ({
@ -88,4 +88,142 @@ describe('systemStatusSelectors', () => {
expect(systemStatusSelectors.portalWidth(noPortalWidth)).toBe(400);
});
});
describe('sidebarItems', () => {
it('should return DEFAULT_SIDEBAR_ITEMS when no data is set', () => {
expect(systemStatusSelectors.sidebarItems(initialState)).toEqual(DEFAULT_SIDEBAR_ITEMS);
});
it('should return stored items when set', () => {
const custom = ['agent', 'recents', 'pages', 'community', 'resource', 'memory'];
const s: GlobalState = merge(initialState, {
status: { sidebarItems: custom },
});
expect(systemStatusSelectors.sidebarItems(s)).toEqual(custom);
});
it('should append missing known keys to the end', () => {
const s: GlobalState = merge(initialState, {
status: { sidebarItems: ['agent', 'recents'] },
});
const items = systemStatusSelectors.sidebarItems(s);
// stored order preserved at the front
expect(items.slice(0, 2)).toEqual(['agent', 'recents']);
// every known key is present
expect(items).toContain('pages');
expect(items).toContain('community');
expect(items).toContain('resource');
expect(items).toContain('memory');
});
it('should migrate legacy `sidebarSectionOrder` accordion order into the default layout', () => {
const s: GlobalState = merge(initialState, {
status: { sidebarSectionOrder: ['agent', 'recents'] },
});
const items = systemStatusSelectors.sidebarItems(s);
// accordion slot in the default list now uses the user's legacy order
expect(items).toEqual(['pages', 'agent', 'recents', 'community', 'resource', 'memory']);
});
it('should fall back to default when legacy `sidebarSectionOrder` is the default order', () => {
const s: GlobalState = merge(initialState, {
status: { sidebarSectionOrder: ['recents', 'agent'] },
});
const items = systemStatusSelectors.sidebarItems(s);
expect(items).toEqual(DEFAULT_SIDEBAR_ITEMS);
});
it('should prefer `sidebarItems` over legacy `sidebarSectionOrder` when both are set', () => {
const s: GlobalState = merge(initialState, {
status: {
sidebarItems: ['pages', 'recents', 'agent', 'community', 'resource', 'memory'],
sidebarSectionOrder: ['agent', 'recents'],
},
});
const items = systemStatusSelectors.sidebarItems(s);
expect(items.indexOf('recents')).toBeLessThan(items.indexOf('agent'));
});
});
describe('reorderSidebarItems', () => {
const DEFAULT = ['pages', 'recents', 'agent', 'community', 'resource', 'memory'];
it('should move a non-accordion item normally', () => {
// move `community` (idx 3) up to idx 0
expect(reorderSidebarItems(DEFAULT, 3, 0)).toEqual([
'community',
'pages',
'recents',
'agent',
'resource',
'memory',
]);
});
it('should snap a non-accordion item out when dragged between accordion items (drag down)', () => {
// `pages` (idx 0) dragged down to idx 2 (between recents & agent)
// after drag-down: push past the accordion block
expect(reorderSidebarItems(DEFAULT, 0, 2)).toEqual([
'recents',
'agent',
'pages',
'community',
'resource',
'memory',
]);
});
it('should snap a non-accordion item out when dragged between accordion items (drag up)', () => {
// `community` (idx 3) dragged up to idx 2 (between recents & agent)
// after drag-up: place before the accordion block
expect(reorderSidebarItems(DEFAULT, 3, 2)).toEqual([
'pages',
'community',
'recents',
'agent',
'resource',
'memory',
]);
});
it('should move the whole accordion block when moving `recents` up past the block boundary', () => {
// `recents` (idx 1) moveUp → idx 0. Block [recents, agent] slides to top.
expect(reorderSidebarItems(DEFAULT, 1, 0)).toEqual([
'recents',
'agent',
'pages',
'community',
'resource',
'memory',
]);
});
it('should move the whole accordion block when moving `agent` down past the block boundary', () => {
// `agent` (idx 2) moveDown → idx 3. Block slides past community.
expect(reorderSidebarItems(DEFAULT, 2, 3)).toEqual([
'pages',
'community',
'recents',
'agent',
'resource',
'memory',
]);
});
it('should swap recents and agent within the block', () => {
// `recents` (idx 1) moveDown → idx 2. Within block, so just swap.
expect(reorderSidebarItems(DEFAULT, 1, 2)).toEqual([
'pages',
'agent',
'recents',
'community',
'resource',
'memory',
]);
});
it('should be a no-op when from === to', () => {
expect(reorderSidebarItems(DEFAULT, 2, 2)).toBe(DEFAULT);
});
});
});

View file

@ -18,9 +18,128 @@ const recentPageSize = (s: GlobalState): number => s.status.recentPageSize || 5;
const pagePageSize = (s: GlobalState): number => s.status.pagePageSize || 20;
const hiddenSidebarSections = (s: GlobalState): string[] => s.status.hiddenSidebarSections || [];
const sidebarSectionOrder = (s: GlobalState): string[] =>
s.status.sidebarSectionOrder || ['recents', 'agent'];
export const DEFAULT_HIDDEN_SECTIONS: string[] = ['memory'];
const hiddenSidebarSections = (s: GlobalState): string[] =>
s.status.hiddenSidebarSections ?? DEFAULT_HIDDEN_SECTIONS;
export const DEFAULT_SIDEBAR_ITEMS: string[] = [
'pages',
'recents',
'agent',
'community',
'resource',
'memory',
];
/** Items that must stay contiguous in the sidebar list (accordion block). */
export const SIDEBAR_ACCORDION_KEYS = new Set(['recents', 'agent']);
/** Append any known keys missing from `order` so new items don't disappear on upgrade. */
const withAllKnownKeys = (order: string[]): string[] => {
const present = new Set(order);
const missing = DEFAULT_SIDEBAR_ITEMS.filter((k) => !present.has(k));
return missing.length === 0 ? order : [...order, ...missing];
};
const accordionIndices = (items: string[]): number[] => {
const out: number[] = [];
for (let i = 0; i < items.length; i++) {
if (SIDEBAR_ACCORDION_KEYS.has(items[i])) out.push(i);
}
return out;
};
/**
* Reorder sidebar items while keeping the accordion block (recents + agent) contiguous.
* - If moving an accordion item across the block boundary, moves the whole block.
* - If moving a non-accordion item into the middle of the block, snaps it to the side
* matching the drag direction.
*/
export const reorderSidebarItems = (items: string[], from: number, to: number): string[] => {
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {
return items;
}
const key = items[from];
const accIdx = accordionIndices(items);
// Moving an accordion item across the block's outer boundary → move whole block together.
if (SIDEBAR_ACCORDION_KEYS.has(key) && accIdx.length >= 2) {
const first = accIdx[0];
const last = accIdx.at(-1)!;
const crossesBoundary = (from === first && to < first) || (from === last && to > last);
if (crossesBoundary) {
const block = items.slice(first, last + 1);
const without = [...items.slice(0, first), ...items.slice(last + 1)];
// After removing the block, adjust target index for upward/downward movement
const targetIdx = to < first ? to : to - (last - first + 1) + 1;
const clamped = Math.max(0, Math.min(without.length, targetIdx));
return [...without.slice(0, clamped), ...block, ...without.slice(clamped)];
}
}
// Standard reorder
const moved = [...items];
const [removed] = moved.splice(from, 1);
moved.splice(to, 0, removed);
// Non-accordion item that landed between accordion items → snap to the side matching drag direction.
if (!SIDEBAR_ACCORDION_KEYS.has(key)) {
const nextAcc = accordionIndices(moved);
if (nextAcc.length >= 2) {
const first = nextAcc[0];
const last = nextAcc.at(-1)!;
const contiguous = last - first === nextAcc.length - 1;
if (!contiguous) {
const pos = moved.indexOf(key);
if (pos > first && pos < last) {
const cleaned = [...moved];
cleaned.splice(pos, 1);
const cAcc = accordionIndices(cleaned);
const insertAt = from < to ? cAcc.at(-1)! + 1 : cAcc[0];
cleaned.splice(insertAt, 0, key);
return cleaned;
}
}
}
}
return moved;
};
const sidebarItems = (s: GlobalState): string[] => {
const items = s.status.sidebarItems;
if (items && items.length > 0) return withAllKnownKeys(items);
// Migrate from the legacy `sidebarSectionOrder` (canary) which only stored the
// accordion order (e.g. ['agent', 'recents']). Apply that order to the accordion
// slot inside the default list so users keep their custom accordion arrangement.
const legacy = s.status.sidebarSectionOrder;
if (legacy && legacy.length > 0) {
const legacyAcc = legacy.filter((k) => SIDEBAR_ACCORDION_KEYS.has(k));
if (legacyAcc.length > 0) {
const seen = new Set<string>();
const merged: string[] = [];
for (const k of DEFAULT_SIDEBAR_ITEMS) {
if (SIDEBAR_ACCORDION_KEYS.has(k)) {
for (const lk of legacyAcc) {
if (!seen.has(lk)) {
merged.push(lk);
seen.add(lk);
}
}
} else if (!seen.has(k)) {
merged.push(k);
seen.add(k);
}
}
return withAllKnownKeys(merged);
}
}
return DEFAULT_SIDEBAR_ITEMS;
};
const showSystemRole = (s: GlobalState) => s.status.showSystemRole;
const mobileShowTopic = (s: GlobalState) => s.status.mobileShowTopic;
const mobileShowPortal = (s: GlobalState) => s.status.mobileShowPortal;
@ -116,7 +235,7 @@ export const systemStatusSelectors = {
pagePageSize,
portalWidth,
recentPageSize,
sidebarSectionOrder,
sidebarItems,
sessionGroupKeys,
showChatHeader,
showFilePanel,