feat(skill): add delete/remove actions to settings/skill items (#15708)

*  feat: add delete/uninstall actions to settings/skill items

- LobehubSkillItem: show compact `...` dropdown in list mode for connected items with Disconnect action (revokes OAuth)
- KlavisSkillItem: show compact `...` dropdown in list mode for connected/pending servers with Remove action (true delete via removeKlavisServer)
- ConnectorDetail: add Delete button for custom (mcp) connectors; calls deleteConnector + notifies parent via onDelete
- SkillDetail / Page: thread onDelete callback so selecting null after deletion triggers auto-select of next item
- Locales: add tools.klavis.remove / removeConfirm.title / removeConfirm.desc in en-US, zh-CN, and default source

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(skill): gate Klavis remove by canEdit and clear selected after removal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(skill): show dropdown for all Klavis/Lobehub items in list mode

Previously, the ... button was gated behind `server` (Klavis) and
`isConnected` (LobehubSkill), so disconnected/never-connected items
showed no actions. Remove those guards so the dropdown always renders
in list mode. handleRemove/handleDisconnect now skip the server call
when no server instance exists and instead clear the selected item.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(skill): move delete/uninstall actions from list dropdown to detail panel

- Remove heavy ... dropdown from KlavisSkillItem / LobehubSkillItem list items
- Add danger Uninstall button to builtin-skill detail header (matches ConnectorDetail style)
- Add slim action bar with Uninstall to agent-skill detail panel
- All actions respect canEdit / canCreate permissions with confirmModal gating

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
LiJian
2026-06-12 12:38:22 +08:00
committed by GitHub
parent ca91d2d756
commit 87b1f39c0f
9 changed files with 150 additions and 29 deletions
+3
View File
@@ -1178,6 +1178,9 @@
"tools.klavis.disconnect": "Disconnect",
"tools.klavis.disconnected": "Disconnected",
"tools.klavis.error": "Error",
"tools.klavis.remove": "Remove",
"tools.klavis.removeConfirm.desc": "{{name}} will be permanently removed from your connected services. This action cannot be undone.",
"tools.klavis.removeConfirm.title": "Remove {{name}}?",
"tools.klavis.groupName": "Klavis Tools",
"tools.klavis.manage": "Manage Klavis",
"tools.klavis.manageTitle": "Manage Klavis Integration",
+3
View File
@@ -1178,6 +1178,9 @@
"tools.klavis.disconnect": "断开连接",
"tools.klavis.disconnected": "已断开连接",
"tools.klavis.error": "错误",
"tools.klavis.remove": "移除",
"tools.klavis.removeConfirm.desc": "{{name}} 将从您的已连接服务中永久移除,此操作不可撤销。",
"tools.klavis.removeConfirm.title": "移除 {{name}}",
"tools.klavis.groupName": "Klavis 工具",
"tools.klavis.manage": "管理 Klavis",
"tools.klavis.manageTitle": "管理 Klavis 集成",
+4
View File
@@ -2322,6 +2322,10 @@ When I am ___, I need ___
'tools.klavis.disconnect': 'Disconnect',
'tools.klavis.disconnected': 'Disconnected',
'tools.klavis.error': 'Error',
'tools.klavis.remove': 'Remove',
'tools.klavis.removeConfirm.desc':
'{{name}} will be permanently removed from your connected services. This action cannot be undone.',
'tools.klavis.removeConfirm.title': 'Remove {{name}}?',
'tools.klavis.groupName': 'Klavis Tools',
'tools.klavis.manage': 'Manage Klavis',
'tools.klavis.manageTitle': 'Manage Klavis Integration',
@@ -1,5 +1,6 @@
import { confirmModal } from '@lobehub/ui/base-ui';
import { Button } from 'antd';
import { RefreshCwIcon } from 'lucide-react';
import { RefreshCwIcon, Trash2 } from 'lucide-react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -12,9 +13,10 @@ import ToolPermissionGroup from './ToolPermissionGroup';
interface ConnectorDetailProps {
connectorId: string;
onDelete?: () => void;
}
const ConnectorDetail = memo<ConnectorDetailProps>(({ connectorId }) => {
const ConnectorDetail = memo<ConnectorDetailProps>(({ connectorId, onDelete }) => {
const { t } = useTranslation('tool');
const connector = useToolStore(connectorSelectors.connectorById(connectorId));
@@ -28,6 +30,7 @@ const ConnectorDetail = memo<ConnectorDetailProps>(({ connectorId }) => {
const syncPluginTools = useToolStore((s) => s.syncPluginTools);
const resetConnectorPermissions = useToolStore((s) => s.resetConnectorPermissions);
const disconnectConnector = useToolStore((s) => s.disconnectConnector);
const deleteConnector = useToolStore((s) => s.deleteConnector);
const updateToolPermission = useToolStore((s) => s.updateToolPermission);
const isMcpConnector = connector?.sourceType === ConnectorSourceType.custom;
@@ -89,9 +92,28 @@ const ConnectorDetail = memo<ConnectorDetailProps>(({ connectorId }) => {
{syncLabel}
</Button>
{isMcpConnector && (
<Button danger size="small" onClick={() => disconnectConnector(connectorId)}>
{t('connector.disconnect', 'Disconnect')}
</Button>
<>
<Button danger size="small" onClick={() => disconnectConnector(connectorId)}>
{t('connector.disconnect', 'Disconnect')}
</Button>
<Button
danger
icon={<Trash2 size={14} />}
size="small"
onClick={() => {
confirmModal({
okButtonProps: { danger: true },
onOk: async () => {
await deleteConnector(connectorId);
onDelete?.();
},
title: t('connector.deleteConfirm', 'Delete this connector?'),
});
}}
>
{t('connector.delete', 'Delete')}
</Button>
</>
)}
</div>
</div>
@@ -24,13 +24,14 @@ const POLL_TIMEOUT_MS = 15_000;
interface KlavisSkillItemProps {
isSelected?: boolean;
onDelete?: () => void;
onSelect?: () => void;
server?: KlavisServer;
serverType: KlavisServerType;
}
const KlavisSkillItem = memo<KlavisSkillItemProps>(
({ serverType, server, isSelected, onSelect }) => {
({ serverType, server, isSelected, onSelect, onDelete }) => {
const { t } = useTranslation('setting');
const { allowed: canCreate, reason: createReason } = usePermission('create_content');
const { allowed: canEdit, reason: editReason } = usePermission('edit_own_content');
@@ -22,13 +22,14 @@ const POLL_TIMEOUT_MS = 15_000;
interface LobehubSkillItemProps {
isSelected?: boolean;
onDelete?: () => void;
onSelect?: () => void;
provider: LobehubSkillProviderType;
server?: LobehubSkillServer;
}
const LobehubSkillItem = memo<LobehubSkillItemProps>(
({ provider, server, isSelected, onSelect }) => {
({ provider, server, isSelected, onSelect, onDelete }) => {
const { t } = useTranslation('setting');
const { allowed: canCreate, reason: createReason } = usePermission('create_content');
const { allowed: canEdit, reason: editReason } = usePermission('edit_own_content');
@@ -175,14 +176,17 @@ const LobehubSkillItem = memo<LobehubSkillItemProps>(
const handleDisconnect = () => {
if (!canEdit) return;
if (!server) return;
confirmModal({
cancelText: t('cancel', { ns: 'common' }),
content: t('tools.lobehubSkill.disconnectConfirm.desc', { name: provider.label }),
okButtonProps: { danger: true },
okText: t('tools.lobehubSkill.disconnect'),
onOk: async () => {
await revokeConnect(server.identifier);
if (server) {
await revokeConnect(server.identifier);
} else if (isSelected) {
onDelete?.();
}
},
title: t('tools.lobehubSkill.disconnectConfirm.title', { name: provider.label }),
});
@@ -1,13 +1,18 @@
'use client';
import { Avatar, Markdown, Skeleton } from '@lobehub/ui';
import { confirmModal } from '@lobehub/ui/base-ui';
import { Button } from 'antd';
import { createStaticStyles } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { Plus, Trash2 } from 'lucide-react';
import { lazy, memo, Suspense, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ConnectorDetail } from '@/features/Connectors';
import { usePermission } from '@/hooks/usePermission';
import { useToolStore } from '@/store/tool';
import { lobehubSkillStoreSelectors } from '@/store/tool/selectors';
import { builtinToolSelectors, lobehubSkillStoreSelectors } from '@/store/tool/selectors';
import { connectorSelectors } from '@/store/tool/slices/connector';
const AgentSkillDetail = lazy(() => import('@/features/AgentSkillDetail'));
@@ -29,8 +34,9 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
`,
header: css`
display: flex;
gap: 12px;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding-block: 20px 16px;
padding-inline: 24px;
@@ -50,6 +56,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
interface SkillDetailProps {
identifier: string;
onDelete?: () => void;
type: ToolDetailType;
}
@@ -60,14 +67,21 @@ interface SkillDetailProps {
* - 'builtin-skill': renders BuiltinSkill description panel (Artifacts, Task, etc.)
* - 'builtin'/'plugin'/'mcp-connector': syncs connector entry, renders permission editor
*/
const SkillDetail = memo<SkillDetailProps>(({ identifier, type }) => {
const SkillDetail = memo<SkillDetailProps>(({ identifier, type, onDelete }) => {
const { t } = useTranslation('plugin');
const [syncing, setSyncing] = useState(false);
const [noManifest, setNoManifest] = useState(false);
const { allowed: canCreate } = usePermission('create_content');
const { allowed: canEdit } = usePermission('edit_own_content');
const syncBuiltinTool = useToolStore((s) => s.syncBuiltinTool);
const syncPluginTools = useToolStore((s) => s.syncPluginTools);
const syncToolsFromClient = useToolStore((s) => s.syncToolsFromClient);
const fetchConnectors = useToolStore((s) => s.fetchConnectors);
const installBuiltinTool = useToolStore((s) => s.installBuiltinTool);
const uninstallBuiltinTool = useToolStore((s) => s.uninstallBuiltinTool);
const deleteAgentSkill = useToolStore((s) => s.deleteAgentSkill);
const connector = useToolStore(connectorSelectors.connectorByIdentifier(identifier));
// For lobehub-connector: get the server's tool list from the store
@@ -78,6 +92,7 @@ const SkillDetail = memo<SkillDetailProps>(({ identifier, type }) => {
(s) => s.builtinSkills?.find((sk) => sk.identifier === identifier),
isEqual,
);
const isBuiltinInstalled = useToolStore(builtinToolSelectors.isBuiltinToolInstalled(identifier));
const isConnectorType =
type === 'builtin' ||
@@ -127,20 +142,63 @@ const SkillDetail = memo<SkillDetailProps>(({ identifier, type }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identifier, type, isConnectorType]);
const handleUninstallBuiltin = () => {
confirmModal({
okButtonProps: { danger: true },
onOk: async () => {
await uninstallBuiltinTool(identifier);
},
title: t('store.actions.confirmUninstall'),
});
};
const handleDeleteAgentSkill = () => {
confirmModal({
okButtonProps: { danger: true },
onOk: async () => {
await deleteAgentSkill(identifier);
onDelete?.();
},
title: t('store.actions.confirmUninstall'),
});
};
// ── Render by type ──────────────────────────────────────────────────────────
if (type === 'agent-skill') {
return (
<div style={{ flex: 1, overflow: 'auto' }}>
<Suspense
fallback={
<div style={{ padding: 24 }}>
<Skeleton active paragraph={{ rows: 6 }} title={false} />
</div>
}
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div
style={{
alignItems: 'center',
borderBlockEnd: '1px solid var(--ant-color-border-secondary)',
display: 'flex',
flexShrink: 0,
justifyContent: 'flex-end',
padding: '8px 16px',
}}
>
<AgentSkillDetail skillId={identifier} />
</Suspense>
<Button
danger
disabled={!canEdit}
icon={<Trash2 size={14} />}
size="small"
onClick={handleDeleteAgentSkill}
>
{t('store.actions.uninstall')}
</Button>
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
<Suspense
fallback={
<div style={{ padding: 24 }}>
<Skeleton active paragraph={{ rows: 6 }} title={false} />
</div>
}
>
<AgentSkillDetail skillId={identifier} />
</Suspense>
</div>
</div>
);
}
@@ -149,11 +207,29 @@ const SkillDetail = memo<SkillDetailProps>(({ identifier, type }) => {
return (
<div style={{ flex: 1, overflow: 'auto' }}>
<div className={styles.header}>
{builtinSkill?.avatar && <Avatar avatar={builtinSkill.avatar} size={40} />}
<div>
<div className={styles.name}>{builtinSkill?.name || identifier}</div>
{builtinSkill?.description && (
<div className={styles.description}>{builtinSkill.description}</div>
<div style={{ alignItems: 'flex-start', display: 'flex', gap: 12 }}>
{builtinSkill?.avatar && <Avatar avatar={builtinSkill.avatar} size={40} />}
<div>
<div className={styles.name}>{builtinSkill?.name || identifier}</div>
{builtinSkill?.description && (
<div className={styles.description}>{builtinSkill.description}</div>
)}
</div>
</div>
<div style={{ display: 'flex', flexShrink: 0, gap: 8 }}>
{isBuiltinInstalled ? (
<Button danger disabled={!canEdit} size="small" onClick={handleUninstallBuiltin}>
{t('store.actions.uninstall')}
</Button>
) : (
<Button
disabled={!canCreate}
icon={<Plus size={14} />}
size="small"
onClick={() => installBuiltinTool(identifier)}
>
{t('store.actions.install')}
</Button>
)}
</div>
</div>
@@ -184,7 +260,7 @@ const SkillDetail = memo<SkillDetailProps>(({ identifier, type }) => {
);
}
return <ConnectorDetail connectorId={connector.id} />;
return <ConnectorDetail connectorId={connector.id} onDelete={onDelete} />;
});
SkillDetail.displayName = 'SkillDetail';
@@ -81,13 +81,14 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
export type SkillViewMode = 'connector' | 'skill';
interface SkillListProps {
onDeleteSelected?: () => void;
onSelect?: (identifier: string, type: ToolDetailType) => void;
selectedIdentifier?: string;
viewMode?: SkillViewMode;
}
const SkillList = memo<SkillListProps>(
({ onSelect, selectedIdentifier, viewMode = 'connector' }) => {
({ onSelect, onDeleteSelected, selectedIdentifier, viewMode = 'connector' }) => {
const { t } = useTranslation('setting');
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
@@ -507,6 +508,7 @@ const SkillList = memo<SkillListProps>(
key={item.provider.id}
provider={item.provider}
server={getLobehubSkillServerByProvider(item.provider.id)}
onDelete={onDeleteSelected}
onSelect={
onSelect ? () => onSelect(item.provider.id, 'lobehub-connector') : undefined
}
@@ -519,6 +521,7 @@ const SkillList = memo<SkillListProps>(
key={item.serverType.identifier}
server={getKlavisServerByIdentifier(item.serverType.identifier)}
serverType={item.serverType}
onDelete={onDeleteSelected}
onSelect={
onSelect ? () => onSelect(item.serverType.identifier, 'plugin') : undefined
}
+6 -1
View File
@@ -225,6 +225,7 @@ const Page = memo(() => {
<SkillList
selectedIdentifier={selected?.identifier}
viewMode={viewMode}
onDeleteSelected={() => setSelected(null)}
onSelect={handleSelect}
/>
</div>
@@ -233,7 +234,11 @@ const Page = memo(() => {
{/* Right: tool detail + permissions */}
{selected && (
<div className={styles.detail}>
<SkillDetail identifier={selected.identifier} type={selected.type} />
<SkillDetail
identifier={selected.identifier}
type={selected.type}
onDelete={() => setSelected(null)}
/>
</div>
)}
</div>