🔧 refactor: improve Tools popover component structure and fix UI consistency (#11356)

*  feat: add nativeButton prop to various components for improved UI consistency

- Updated SwitchPanel, HeaderActions, ActionPopover, and ModelSwitchPanel to include nativeButton={false} for better button behavior.
- Introduced ToolsList component to enhance the tools dropdown functionality in the ActionBar.
- Refactored Tools component to utilize the new ToolsList and streamline the rendering of tool items.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 refactor: correct CheckboxItem import and enhance Tools component structure

- Fixed import path for CheckboxItem in multiple files to ensure consistent naming.
- Introduced PopoverContent component to streamline the rendering of tool items in the Tools component.
- Refactored Tools component to utilize PopoverContent for improved organization and maintainability.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat: enhance PopoverContent and ToolsList components for improved UI

- Introduced static styles for header and footer in PopoverContent to enhance layout consistency.
- Updated ToolsList to include itemIcon styling for better alignment and presentation of icons.
- Modified ToolItem to remove padding for a cleaner appearance.
- Added hasPadding prop to CheckboxItem for flexible padding control.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-01-09 13:20:49 +08:00
committed by GitHub
parent f413c9ecdf
commit f46837a031
13 changed files with 288 additions and 153 deletions

View File

@@ -42,6 +42,7 @@ const SwitchPanel = memo<PropsWithChildren>(({ children }) => {
<Popover
classNames={{ trigger: styles.trigger }}
content={content}
nativeButton={false}
placement="bottomLeft"
styles={{
content: {

View File

@@ -12,7 +12,7 @@ const HeaderActions = memo(() => {
const { menuItems } = useMenu();
return (
<DropdownMenu items={menuItems}>
<DropdownMenu items={menuItems} nativeButton={false}>
<ActionIcon icon={MoreHorizontal} size={DESKTOP_HEADER_ICON_SIZE} />
</DropdownMenu>
);

View File

@@ -9,7 +9,7 @@ import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import CheckboxItem from '../components/CheckbokWithLoading';
import CheckboxItem from '../components/CheckboxWithLoading';
export const useControls = ({
setModalOpen,

View File

@@ -0,0 +1,92 @@
import { Flexbox, Icon, type ItemType, Segmented, usePopoverContext } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { ChevronRight, Store } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import ToolsList, { toolsListStyles } from './ToolsList';
const styles = createStaticStyles(({ css }) => ({
footer: css`
padding: 4px;
border-block-start: 1px solid ${cssVar.colorBorderSecondary};
`,
header: css`
padding: 8px;
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
`,
trailingIcon: css`
opacity: 0.5;
`,
}));
type TabType = 'all' | 'installed';
interface PopoverContentProps {
activeTab: TabType;
currentItems: ItemType[];
enableKlavis: boolean;
onOpenStore: () => void;
onTabChange: (tab: TabType) => void;
}
const PopoverContent = memo<PopoverContentProps>(
({ activeTab, currentItems, enableKlavis, onTabChange, onOpenStore }) => {
const { t } = useTranslation('setting');
const { close: closePopover } = usePopoverContext();
return (
<Flexbox gap={0}>
<div className={styles.header}>
<Segmented
block
onChange={(v) => onTabChange(v as TabType)}
options={[
{
label: t('tools.tabs.all', { defaultValue: 'all' }),
value: 'all',
},
{
label: t('tools.tabs.installed', { defaultValue: 'Installed' }),
value: 'installed',
},
]}
size="small"
value={activeTab}
/>
</div>
<div
style={{
maxHeight: 500,
minHeight: enableKlavis ? 500 : undefined,
overflowY: 'auto',
}}
>
<ToolsList items={currentItems} />
</div>
<div className={styles.footer}>
<div
className={toolsListStyles.item}
onClick={() => {
closePopover();
onOpenStore();
}}
role="button"
tabIndex={0}
>
<div className={toolsListStyles.itemIcon}>
<Icon icon={Store} size={20} />
</div>
<div className={toolsListStyles.itemContent}>{t('tools.plugins.store')}</div>
<Icon className={styles.trailingIcon} icon={ChevronRight} size={16} />
</div>
</div>
</Flexbox>
);
},
);
PopoverContent.displayName = 'PopoverContent';
export default PopoverContent;

View File

@@ -5,7 +5,7 @@ import PluginTag from '@/components/Plugins/PluginTag';
import { useToolStore } from '@/store/tool';
import { customPluginSelectors } from '@/store/tool/selectors';
import CheckboxItem, { type CheckboxItemProps } from '../components/CheckbokWithLoading';
import CheckboxItem, { type CheckboxItemProps } from '../components/CheckboxWithLoading';
const ToolItem = memo<CheckboxItemProps>(({ id, onUpdate, label, checked }) => {
const isCustom = useToolStore((s) => customPluginSelectors.isCustomPlugin(id)(s));
@@ -13,6 +13,7 @@ const ToolItem = memo<CheckboxItemProps>(({ id, onUpdate, label, checked }) => {
return (
<CheckboxItem
checked={checked}
hasPadding={false}
id={id}
label={
<Flexbox align={'center'} gap={8} horizontal>

View File

@@ -0,0 +1,107 @@
import { Flexbox, Icon, type ItemType, Text } from '@lobehub/ui';
import { Divider } from 'antd';
import { createStaticStyles, cssVar } from 'antd-style';
import type { ReactNode } from 'react';
import { Fragment, isValidElement, memo } from 'react';
export const toolsListStyles = createStaticStyles(({ css }) => ({
groupLabel: css`
padding-block: 4px;
padding-inline: 12px;
`,
item: css`
cursor: pointer;
display: flex;
gap: 12px;
align-items: center;
padding-block: 8px;
padding-inline: 12px;
border-radius: 6px;
transition: background-color 0.2s;
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
itemContent: css`
flex: 1;
min-width: 0;
`,
itemIcon: css`
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
`,
}));
interface ToolItemData {
children?: ToolItemData[];
extra?: ReactNode;
icon?: ReactNode;
key?: string;
label?: ReactNode;
onClick?: () => void;
type?: 'group' | 'divider';
}
interface ToolsListProps {
items: ItemType[];
}
const ToolsList = memo<ToolsListProps>(({ items }) => {
const renderItem = (item: ToolItemData, index: number) => {
if (item.type === 'divider') {
return <Divider key={`divider-${index}`} style={{ margin: '4px 0' }} />;
}
if (item.type === 'group') {
return (
<Fragment key={item.key || `group-${index}`}>
<Text className={toolsListStyles.groupLabel} fontSize={12} type="secondary">
{item.label}
</Text>
{item.children?.map((child, childIndex) => renderItem(child, childIndex))}
</Fragment>
);
}
// Regular item
// icon can be: ReactNode (already rendered), LucideIcon/ForwardRef (needs Icon wrapper), or undefined
const iconNode = item.icon ? (
isValidElement(item.icon) ? (
item.icon
) : (
<Icon icon={item.icon as any} size={20} />
)
) : null;
return (
<div
className={toolsListStyles.item}
key={item.key || `item-${index}`}
onClick={item.onClick}
role="button"
tabIndex={0}
>
{iconNode && <div className={toolsListStyles.itemIcon}>{iconNode}</div>}
<div className={toolsListStyles.itemContent}>{item.label}</div>
{item.extra}
</div>
);
};
return (
<Flexbox gap={0} padding={4}>
{items.map((item, index) => renderItem(item as ToolItemData, index))}
</Flexbox>
);
});
export default ToolsList;

View File

@@ -1,5 +1,3 @@
import { Segmented } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { Blocks } from 'lucide-react';
import { Suspense, memo, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -12,46 +10,17 @@ import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfi
import { useAgentId } from '../../hooks/useAgentId';
import Action from '../components/Action';
import PopoverContent from './PopoverContent';
import { useControls } from './useControls';
type TabType = 'all' | 'installed';
const prefixCls = 'ant';
const styles = createStaticStyles(({ css }) => ({
dropdown: css`
overflow: hidden;
width: 100%;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorBgElevated};
box-shadow: ${cssVar.boxShadowSecondary};
.${prefixCls}-dropdown-menu {
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
`,
header: css`
padding: ${cssVar.paddingXS};
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
background: transparent;
`,
scroller: css`
overflow: hidden auto;
`,
}));
const Tools = memo(() => {
const { t } = useTranslation('setting');
const [modalOpen, setModalOpen] = useState(false);
const [updating, setUpdating] = useState(false);
const [activeTab, setActiveTab] = useState<TabType | null>(null);
const { marketItems, installedPluginItems } = useControls({
setModalOpen,
setUpdating,
});
@@ -82,52 +51,26 @@ const Tools = memo(() => {
return (
<Suspense fallback={<Action disabled icon={Blocks} title={t('tools.title')} />}>
<Action
dropdown={{
maxWidth: 320,
menu: {
items: [...currentItems],
style: {
// let only the custom scroller scroll
maxHeight: 'unset',
overflowY: 'visible',
},
},
minHeight: enableKlavis ? 500 : undefined,
minWidth: 320,
popupRender: (menu) => (
<div className={styles.dropdown}>
<div className={styles.header}>
<Segmented
block
onChange={(v) => setActiveTab(v as TabType)}
options={[
{
label: t('tools.tabs.all', { defaultValue: 'all' }),
value: 'all',
},
{
label: t('tools.tabs.installed', { defaultValue: 'Installed' }),
value: 'installed',
},
]}
size="small"
value={effectiveTab}
/>
</div>
<div
className={styles.scroller}
style={{
maxHeight: 500,
minHeight: enableKlavis ? 500 : undefined,
}}
>
{menu}
</div>
</div>
),
}}
icon={Blocks}
loading={updating}
popover={{
content: (
<PopoverContent
activeTab={effectiveTab}
currentItems={currentItems}
enableKlavis={enableKlavis}
onOpenStore={() => setModalOpen(true)}
onTabChange={setActiveTab}
/>
),
maxWidth: 320,
minWidth: 320,
styles: {
content: {
padding: 0,
},
},
}}
showTooltip={false}
title={t('tools.title')}
/>

View File

@@ -7,7 +7,7 @@ import {
import { Avatar, Flexbox, Icon, Image, type ItemType } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { ArrowRight, Store, ToyBrick } from 'lucide-react';
import { ToyBrick } from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -61,13 +61,7 @@ const LobehubSkillIcon = memo<Pick<LobehubSkillProviderType, 'icon' | 'label'>>(
LobehubSkillIcon.displayName = 'LobehubSkillIcon';
export const useControls = ({
setModalOpen,
setUpdating,
}: {
setModalOpen: (open: boolean) => void;
setUpdating: (updating: boolean) => void;
}) => {
export const useControls = ({ setUpdating }: { setUpdating: (updating: boolean) => void }) => {
const { t } = useTranslation('setting');
const agentId = useAgentId();
const list = useToolStore(pluginSelectors.installedPluginMetaList, isEqual);
@@ -234,18 +228,6 @@ export const useControls = ({
),
type: 'group',
},
{
type: 'divider',
},
{
extra: <Icon icon={ArrowRight} />,
icon: Store,
key: 'plugin-store',
label: t('tools.plugins.store'),
onClick: () => {
setModalOpen(true);
},
},
];
// 已安装 tab 的 items - 只显示已安装的插件

View File

@@ -21,7 +21,7 @@ import { preferenceSelectors } from '@/store/user/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import Action from '../components/Action';
import CheckboxItem from '../components/CheckbokWithLoading';
import CheckboxItem from '../components/CheckboxWithLoading';
const hotArea = css`
&::before {

View File

@@ -84,6 +84,7 @@ const ActionPopover = memo<ActionPopoverProps>(
content: contentClassName,
}}
content={popoverContent}
nativeButton={false}
placement={isMobile ? 'top' : placement}
styles={{
...(typeof resolvedStyles === 'object' ? resolvedStyles : {}),

View File

@@ -1,53 +0,0 @@
import { Center, Flexbox, Icon , Checkbox } from '@lobehub/ui';
import { Loader2 } from 'lucide-react';
import { type ReactNode, memo, useState } from 'react';
export interface CheckboxItemProps {
checked?: boolean;
id: string;
label?: ReactNode;
onUpdate: (id: string, enabled: boolean) => Promise<void>;
}
const CheckboxItem = memo<CheckboxItemProps>(({ id, onUpdate, label, checked }) => {
const [loading, setLoading] = useState(false);
const updateState = async () => {
setLoading(true);
await onUpdate(id, !checked);
setLoading(false);
};
return (
<Flexbox
align={'center'}
gap={24}
horizontal
justify={'space-between'}
onClick={async (e) => {
e.stopPropagation();
updateState();
}}
style={{
paddingLeft: 8,
}}
>
{label || id}
{loading ? (
<Center width={18}>
<Icon icon={Loader2} spin />
</Center>
) : (
<Checkbox
checked={checked}
onClick={async (e) => {
e.stopPropagation();
await updateState();
}}
/>
)}
</Flexbox>
);
});
export default CheckboxItem;

View File

@@ -0,0 +1,60 @@
import { Center, Checkbox, Flexbox, Icon } from '@lobehub/ui';
import { Loader2 } from 'lucide-react';
import { type ReactNode, memo, useState } from 'react';
export interface CheckboxItemProps {
checked?: boolean;
hasPadding?: boolean;
id: string;
label?: ReactNode;
onUpdate: (id: string, enabled: boolean) => Promise<void>;
}
const CheckboxItem = memo<CheckboxItemProps>(
({ id, onUpdate, label, checked, hasPadding = true }) => {
const [loading, setLoading] = useState(false);
const updateState = async () => {
setLoading(true);
await onUpdate(id, !checked);
setLoading(false);
};
return (
<Flexbox
align={'center'}
gap={24}
horizontal
justify={'space-between'}
onClick={async (e) => {
e.stopPropagation();
updateState();
}}
style={
hasPadding
? {
paddingLeft: 8,
}
: void 0
}
>
{label || id}
{loading ? (
<Center width={18}>
<Icon icon={Loader2} spin />
</Center>
) : (
<Checkbox
checked={checked}
onClick={async (e) => {
e.stopPropagation();
await updateState();
}}
/>
)}
</Flexbox>
);
},
);
export default CheckboxItem;

View File

@@ -43,6 +43,7 @@ const ModelSwitchPanel = memo<ModelSwitchPanelProps>(
provider={providerProp}
/>
}
nativeButton={false}
onOpenChange={handleOpenChange}
open={isOpen}
placement={placement}