feat(agent-explorer): support multi-select delete in document tree (#15125)

*  feat(agent-explorer): support multi-select delete in document tree

- Right-click on a multi-selected row deletes the whole selection; dedupe descendants when an ancestor folder is also selected
- Reserve chevron slot in SkillsList rows so atomic and bundled skills align
- Centralize EMPTY_ARRAY (typed `never[]`, frozen) in @lobechat/const

* ♻️ refactor: migrate delete confirm dialog from antd modal to confirmModal

*  test: stabilize bun vitest environment

* 🔧 ci: avoid authenticated checkout for PR tests
This commit is contained in:
Innei
2026-05-23 16:44:00 +08:00
committed by GitHub
parent 7eee016abe
commit f685d5c217
14 changed files with 255 additions and 74 deletions
+6
View File
@@ -10,6 +10,10 @@ inputs:
description: Pass-through to actions/setup-node package-manager-cache
required: false
default: 'false'
bun-version:
description: Bun version
required: false
default: '1.3.2'
runs:
using: composite
@@ -21,6 +25,8 @@ runs:
- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ inputs.bun-version }}
- name: Setup Node.js
uses: actions/setup-node@v6
+8 -1
View File
@@ -59,7 +59,14 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
+45 -5
View File
@@ -35,7 +35,15 @@ jobs:
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/builtin-tool-lobe-agent model-bank'
steps:
- uses: actions/checkout@v6
- name: Checkout
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
@@ -101,7 +109,15 @@ jobs:
name: Test App (shard ${{ matrix.shard }}/3)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Checkout
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
@@ -128,7 +144,15 @@ jobs:
name: Merge and Upload App Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Checkout
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
@@ -161,7 +185,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Checkout
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
@@ -207,7 +239,15 @@ jobs:
- 5432:5432
steps:
- uses: actions/checkout@v6
- name: Checkout
env:
REF_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
run: |
git init .
git remote add origin "https://github.com/${REPOSITORY}.git"
git fetch --no-tags --depth=1 origin "${REF_SHA}"
git checkout --force FETCH_HEAD
- name: Setup environment
uses: ./.github/actions/setup-env
+2
View File
@@ -962,6 +962,7 @@
"workingPanel.resources.deleteError": "Failed to delete document",
"workingPanel.resources.deleteSuccess": "Document deleted",
"workingPanel.resources.deleteTitle": "Delete document?",
"workingPanel.resources.deleteTitleMulti": "Delete {{count}} items?",
"workingPanel.resources.empty": "No webpages. Webpages crawled in this agent will show up here.",
"workingPanel.resources.error": "Failed to load resources",
"workingPanel.resources.filter.documents": "Documents",
@@ -974,6 +975,7 @@
"workingPanel.resources.renameError": "Failed to rename document",
"workingPanel.resources.renameSuccess": "Document renamed",
"workingPanel.resources.tree.createError": "Failed to create",
"workingPanel.resources.tree.deleteSelected": "Delete selected ({{count}})",
"workingPanel.resources.tree.moveError": "Failed to move",
"workingPanel.resources.tree.newDocument": "New document",
"workingPanel.resources.tree.newFolder": "New folder",
+2
View File
@@ -962,6 +962,7 @@
"workingPanel.resources.deleteError": "删除失败",
"workingPanel.resources.deleteSuccess": "文档已删除",
"workingPanel.resources.deleteTitle": "删除该文档?",
"workingPanel.resources.deleteTitleMulti": "删除 {{count}} 项?",
"workingPanel.resources.empty": "暂无网页。本智能体抓取的网页将显示在这里。",
"workingPanel.resources.error": "资源加载失败",
"workingPanel.resources.filter.documents": "文档",
@@ -974,6 +975,7 @@
"workingPanel.resources.renameError": "重命名失败",
"workingPanel.resources.renameSuccess": "重命名成功",
"workingPanel.resources.tree.createError": "创建失败",
"workingPanel.resources.tree.deleteSelected": "删除所选({{count}}",
"workingPanel.resources.tree.moveError": "移动失败",
"workingPanel.resources.tree.newDocument": "新建文档",
"workingPanel.resources.tree.newFolder": "新建文件夹",
+3
View File
@@ -0,0 +1,3 @@
// Type `never[]` so `x ?? EMPTY_ARRAY` doesn't widen `x`'s element type.
// Frozen to prevent accidental mutation of the shared reference.
export const EMPTY_ARRAY: never[] = Object.freeze([]) as never[];
+1
View File
@@ -4,6 +4,7 @@ export * from './desktop';
export * from './desktopGlobalShortcuts';
export * from './discover';
export * from './editor';
export * from './empty';
export * from './fetch';
export * from './file';
export * from './interests';
@@ -28,6 +28,10 @@ vi.mock('@lobehub/ui', () => ({
Text: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
}));
vi.mock('@lobehub/ui/base-ui', () => ({
confirmModal: modalConfirm,
}));
vi.mock('antd', () => ({
App: {
useApp: () => ({
@@ -194,9 +194,17 @@ const DocumentExplorerTree = memo<Props>(({ agentId, data, mutate, style }) => {
const isFolder = !!node.isFolder;
const targetParentId = isFolder ? node.id : (node.parentId ?? null);
// Right-click on a row that's part of the current multi-selection acts
// on the whole selection; otherwise it targets only the right-clicked
// row (which matches typical file-tree UX where right-clicking outside
// the selection narrows the action).
const selectedIds = treeRef.current?.getSelectedIds() ?? [];
const isMulti = selectedIds.length > 1 && selectedIds.includes(node.id);
const deleteIds = isMulti ? selectedIds : [node.id];
const items: NonNullable<MenuProps['items']> = [];
if (isFolder && !isSkill) {
if (isFolder && !isSkill && !isMulti) {
items.push(
{
key: 'new-folder',
@@ -212,7 +220,7 @@ const DocumentExplorerTree = memo<Props>(({ agentId, data, mutate, style }) => {
);
}
if (!isSkill) {
if (!isSkill && !isMulti) {
items.push({
key: 'rename',
label: t('workingPanel.resources.tree.rename'),
@@ -224,8 +232,10 @@ const DocumentExplorerTree = memo<Props>(({ agentId, data, mutate, style }) => {
danger: true,
icon: <Trash2Icon size={14} />,
key: 'delete',
label: t('delete', { ns: 'common' }),
onClick: () => ops.deleteDocument(node.id),
label: isMulti
? t('workingPanel.resources.tree.deleteSelected', { count: deleteIds.length })
: t('delete', { ns: 'common' }),
onClick: () => ops.deleteDocuments(deleteIds),
});
return items;
@@ -1,3 +1,4 @@
import { confirmModal } from '@lobehub/ui/base-ui';
import { App } from 'antd';
import { useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -44,7 +45,7 @@ export interface CreateOptions {
export interface DocumentTreeOps {
createDocument: (parentId: string | null, opts?: CreateOptions) => Promise<void>;
createFolder: (parentId: string | null, opts?: CreateOptions) => Promise<void>;
deleteDocument: (id: string) => void;
deleteDocuments: (ids: string[]) => void;
moveDocument: (params: {
sourceIds: string[];
sourceNodes: { data?: AgentDocumentItem }[];
@@ -60,7 +61,7 @@ export const useDocumentTreeOps = ({
topicId,
}: UseDocumentTreeOpsArgs): DocumentTreeOps => {
const { t } = useTranslation(['chat', 'common']);
const { message, modal } = App.useApp();
const { message } = App.useApp();
const dataRef = useRef(data);
dataRef.current = data;
@@ -354,41 +355,70 @@ export const useDocumentTreeOps = ({
[agentId, buildItemPath, buildParentPathFromRowId, byRowId, message, mutate, t],
);
const deleteDocument = useCallback(
(id: string) => {
const target = dataRef.current.find((doc) => doc.id === id);
if (!target) return;
if (isProtectedManagedSkillItem(target, dataRef.current)) return;
const deleteDocuments = useCallback(
(ids: string[]) => {
const uniqueIds = Array.from(new Set(ids));
const resolved = uniqueIds
.map((id) => dataRef.current.find((doc) => doc.id === id))
.filter((doc): doc is AgentDocumentItem => !!doc)
.filter((doc) => !isProtectedManagedSkillItem(doc, dataRef.current));
modal.confirm({
if (resolved.length === 0) return;
// When a selected folder is an ancestor of another selection, skip the
// descendant — the folder's recursive delete already covers it.
const docById = new Map(dataRef.current.map((doc) => [doc.documentId, doc]));
const folderDocIds = new Set(
resolved.filter((doc) => doc.isFolder && !doc.isSkillBundle).map((doc) => doc.documentId),
);
const hasAncestorIn = (item: AgentDocumentItem, ancestors: Set<string>): boolean => {
let cursor = item.parentId;
while (cursor) {
if (ancestors.has(cursor)) return true;
cursor = docById.get(cursor)?.parentId ?? null;
}
return false;
};
const targets = resolved.filter((doc) => !hasAncestorIn(doc, folderDocIds));
if (targets.length === 0) return;
confirmModal({
cancelText: t('cancel', { ns: 'common' }),
centered: true,
content: t('workingPanel.resources.deleteConfirm'),
okButtonProps: { danger: true, type: 'primary' },
okButtonProps: { danger: true },
okText: t('delete', { ns: 'common' }),
onOk: () => {
// Use isFolder rather than the bundle-inclusive isFolder field —
// skill bundles are deleted via the bundle row (single doc), not
// via path-based recursive removal.
const isFolder = target.isFolder && !target.isSkillBundle;
const folderPath = isFolder ? buildItemPath(target) : null;
if (isFolder && folderPath === null) {
message.error(t('workingPanel.resources.tree.parentMissing'));
return;
}
const removedIds = new Set<string>();
const plans: Array<
| { kind: 'folder'; path: string; target: AgentDocumentItem }
| { kind: 'row'; target: AgentDocumentItem }
> = [];
// Collect target + all descendants (parentId chain via documentId) for
// optimistic recursive removal. Server is the source of truth and a
// final `mutate()` reconciles either way.
const removedIds = new Set<string>([target.id]);
if (isFolder) {
const queue: string[] = [target.documentId];
while (queue.length > 0) {
const parentDocId = queue.shift()!;
for (const doc of dataRef.current) {
if (doc.parentId === parentDocId && !removedIds.has(doc.id)) {
removedIds.add(doc.id);
queue.push(doc.documentId);
for (const target of targets) {
// Skill bundles are removed via the bundle row, not path-based
// recursive removal.
const isFolder = target.isFolder && !target.isSkillBundle;
if (isFolder) {
const folderPath = buildItemPath(target);
if (!folderPath) {
message.error(t('workingPanel.resources.tree.parentMissing'));
return;
}
plans.push({ kind: 'folder', path: folderPath, target });
} else {
plans.push({ kind: 'row', target });
}
removedIds.add(target.id);
if (isFolder) {
const queue: string[] = [target.documentId];
while (queue.length > 0) {
const parentDocId = queue.shift()!;
for (const doc of dataRef.current) {
if (doc.parentId === parentDocId && !removedIds.has(doc.id)) {
removedIds.add(doc.id);
queue.push(doc.documentId);
}
}
}
}
@@ -399,49 +429,62 @@ export const useDocumentTreeOps = ({
revalidate: false,
});
// Returning synchronously closes the modal immediately; the server
// call runs in the background and toasts on success/rollback.
void (async () => {
try {
if (isFolder) {
await agentDocumentService.deleteByPath({
agentId,
path: folderPath!,
recursive: true,
});
} else {
await agentDocumentService.removeDocument({
agentId,
documentId: target.documentId,
id: target.id,
topicId,
});
}
await mutate();
} catch (error) {
const errors: Error[] = [];
await Promise.all(
plans.map(async (plan) => {
try {
if (plan.kind === 'folder') {
await agentDocumentService.deleteByPath({
agentId,
path: plan.path,
recursive: true,
});
} else {
await agentDocumentService.removeDocument({
agentId,
documentId: plan.target.documentId,
id: plan.target.id,
topicId,
});
}
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
}),
);
if (errors.length === plans.length) {
mutate(snapshot, { revalidate: false });
message.error(
error instanceof Error
? `${t('workingPanel.resources.deleteError')}: ${error.message}`
: t('workingPanel.resources.deleteError'),
);
const detail = errors.map((e) => e.message).join('; ');
message.error(`${t('workingPanel.resources.deleteError')}: ${detail}`);
return;
}
await mutate();
if (errors.length > 0) {
const detail = errors.map((e) => e.message).join('; ');
message.error(`${t('workingPanel.resources.deleteError')}: ${detail}`);
}
})();
},
title: t('workingPanel.resources.deleteTitle'),
title:
targets.length > 1
? t('workingPanel.resources.deleteTitleMulti', { count: targets.length })
: t('workingPanel.resources.deleteTitle'),
});
},
[agentId, buildItemPath, message, modal, mutate, t, topicId],
[agentId, buildItemPath, message, mutate, t, topicId],
);
return useMemo(
() => ({
createDocument,
createFolder,
deleteDocument,
deleteDocuments,
moveDocument,
renameDocument,
}),
[createDocument, createFolder, deleteDocument, moveDocument, renameDocument],
[createDocument, createFolder, deleteDocuments, moveDocument, renameDocument],
);
};
+12 -4
View File
@@ -1,3 +1,4 @@
import { EMPTY_ARRAY } from '@lobechat/const';
import { Flexbox, Icon, Text, Tooltip } from '@lobehub/ui';
import { SkillsIcon } from '@lobehub/ui/icons';
import { createStaticStyles } from 'antd-style';
@@ -225,11 +226,12 @@ interface SkillRowProps {
onOpenFile?: (relativePath: string) => void;
onOpenSkill?: () => void;
onToggle: () => void;
reserveChevronSlot: boolean;
}
const SkillRow = memo<SkillRowProps>(
({ expanded, item, onDragStart, onOpenFile, onOpenSkill, onToggle }) => {
const files = item.files ?? [];
({ expanded, item, onDragStart, onOpenFile, onOpenSkill, onToggle, reserveChevronSlot }) => {
const files = item.files ?? EMPTY_ARRAY;
const hasFiles = files.length > 0;
const tree = useMemo(() => buildSkillTree(files), [files]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
@@ -277,9 +279,9 @@ const SkillRow = memo<SkillRowProps>(
size={14}
/>
</Flexbox>
) : (
) : reserveChevronSlot ? (
<span style={{ flexShrink: 0, height: 20, width: 20 }} />
)}
) : null}
<Icon className={styles.itemIcon} icon={SkillsIcon} size={14} />
<Text ellipsis style={{ color: 'inherit', flex: 1, minWidth: 0 }} onClick={onOpenSkill}>
{item.name}
@@ -321,6 +323,11 @@ const SkillsList = memo<SkillsListProps>(({ items, onOpenFile, onOpenSkill, onSk
});
}, []);
const reserveChevronSlot = useMemo(
() => items.some((item) => (item.files?.length ?? 0) > 0),
[items],
);
return (
<Flexbox gap={2}>
{items.map((item) => (
@@ -328,6 +335,7 @@ const SkillsList = memo<SkillsListProps>(({ items, onOpenFile, onOpenSkill, onSk
expanded={expanded.has(item.id)}
item={item}
key={item.id}
reserveChevronSlot={reserveChevronSlot}
onDragStart={onSkillDragStart ? (event) => onSkillDragStart(item, event) : undefined}
onOpenFile={onOpenFile ? (relativePath) => onOpenFile(item, relativePath) : undefined}
onOpenSkill={onOpenSkill ? () => onOpenSkill(item) : undefined}
+2
View File
@@ -1019,6 +1019,7 @@ export default {
'workingPanel.resources.deleteError': 'Failed to delete document',
'workingPanel.resources.deleteSuccess': 'Document deleted',
'workingPanel.resources.deleteTitle': 'Delete document?',
'workingPanel.resources.deleteTitleMulti': 'Delete {{count}} items?',
'workingPanel.resources.empty': 'No webpages. Webpages crawled in this agent will show up here.',
'workingPanel.resources.error': 'Failed to load resources',
'workingPanel.resources.filter.documents': 'Documents',
@@ -1031,6 +1032,7 @@ export default {
'workingPanel.resources.renameError': 'Failed to rename document',
'workingPanel.resources.renameSuccess': 'Document renamed',
'workingPanel.resources.tree.createError': 'Failed to create',
'workingPanel.resources.tree.deleteSelected': 'Delete selected ({{count}})',
'workingPanel.resources.tree.moveError': 'Failed to move',
'workingPanel.resources.tree.parentMissing': 'Parent folder is unavailable',
'workingPanel.resources.tree.newDocument': 'New document',
+50 -1
View File
@@ -7,7 +7,7 @@ import { theme } from 'antd';
import i18n from 'i18next';
import { enableMapSet, enablePatches } from 'immer';
import React from 'react';
import { vi } from 'vitest';
import { beforeEach, vi } from 'vitest';
import chat from '@/locales/default/chat';
import common from '@/locales/default/common';
@@ -15,6 +15,52 @@ import discover from '@/locales/default/discover';
import home from '@/locales/default/home';
import oauth from '@/locales/default/oauth';
class TestMemoryStorage implements Storage {
private readonly store = new Map<string, string>();
get length() {
return this.store.size;
}
clear() {
this.store.clear();
}
getItem(key: string) {
return this.store.get(key) ?? null;
}
key(index: number) {
return Array.from(this.store.keys())[index] ?? null;
}
removeItem(key: string) {
this.store.delete(key);
}
setItem(key: string, value: string) {
this.store.set(key, String(value));
}
}
const installTestStorage = () => {
const localStorage = new TestMemoryStorage();
const sessionStorage = new TestMemoryStorage();
Object.defineProperties(globalThis, {
Storage: { configurable: true, value: TestMemoryStorage, writable: true },
localStorage: { configurable: true, value: localStorage, writable: true },
sessionStorage: { configurable: true, value: sessionStorage, writable: true },
});
if (typeof globalThis.window !== 'undefined') {
Object.defineProperties(window, {
localStorage: { configurable: true, value: localStorage, writable: true },
sessionStorage: { configurable: true, value: sessionStorage, writable: true },
});
}
};
// Enable Immer MapSet plugin so store code using Map/Set in produce() works in tests
enablePatches();
enableMapSet();
@@ -50,6 +96,9 @@ if (typeof globalThis.window === 'undefined') {
});
}
installTestStorage();
beforeEach(installTestStorage);
// remove antd hash on test
theme.defaultConfig.hashed = false;
+4
View File
@@ -3,6 +3,10 @@ import { dirname, join, resolve } from 'node:path';
import tsconfigPaths from 'vite-tsconfig-paths';
import { coverageConfigDefaults, defineConfig } from 'vitest/config';
if (process.env.NODE_ENV === 'production') {
Reflect.set(process.env, 'NODE_ENV', 'test');
}
const alias = {
// Downstream workspaces sometimes pnpm-override @lobechat/business-* packages to
// internal implementations whose source files import alias paths that only exist