feat(ui): skill 树修复长文件名与删除确认,任务列表支持多选删除

Skill 管理:
- 长文件名把行撑出侧栏导致操作按钮不可见,约束 ScrollArea viewport
  内层并把按钮组改为绝对定位固定在右侧
- Skill / 文件夹 / 文件三处删除补上二次确认对话框

任务列表:
- 表格新增多选列(本页全选 + 跨页保留选择)
- 批量删除复用单任务的关联数据清理选项,串行执行并显示进度,
  结果按成功/失败聚合提示

Claude-Session: https://claude.ai/code/session_01BNN7npEJs4X6LfTtgniMd8
This commit is contained in:
Autumn-27 committed 2026-08-20 11:18:24 -04:00
1 parent def7649baa
commit d14ce4d9f4
2 files changed
+430 -112

No files matched your search

+340 -96
View File
@@ -172,7 +172,11 @@ function taskDuration(task: Task, nowSec: number): number {
return end > start ? end - start : 0;
}
function deleteSummary(result: DeleteTaskResult): string {
// deleteDetails takes only the countable part of the result so callers can pass an
// aggregate accumulated over a bulk delete.
type DeleteCounts = Omit<DeleteTaskResult, "deleted" | "cleanup_warning">;
function deleteDetails(result: DeleteCounts): string[] {
const details: string[] = [];
if (result.assets_deleted > 0) details.push(`删除资产 ${result.assets_deleted} 条`);
if (result.assets_detached > 0) details.push(`解除共享资产关联 ${result.assets_detached} 条`);
@@ -180,6 +184,11 @@ function deleteSummary(result: DeleteTaskResult): string {
if (result.files_deleted) details.push("删除任务文件");
if (result.findings_deleted > 0) details.push(`删除漏洞 ${result.findings_deleted} 条`);
if (result.llm_records_deleted > 0) details.push(`删除 LLM 请求/响应记录 ${result.llm_records_deleted} 条`);
return details;
}
function deleteSummary(result: DeleteTaskResult): string {
const details = deleteDetails(result);
return details.length > 0 ? `任务已删除(${details.join(",")})` : "任务已删除";
}
@@ -232,6 +241,48 @@ export default function TasksPage() {
[filtered, page, pageSize],
);
// 多选删除:选择跨翻页/筛选保留,只在任务真的消失(被删或后端不再返回)时收敛。
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(() => new Set());
React.useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const live = new Set(tasks.map((t) => t.id));
const next = new Set([...prev].filter((id) => live.has(id)));
return next.size === prev.size ? prev : next;
});
}, [tasks]);
const toggleSelected = React.useCallback((id: string, checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) next.add(id); else next.delete(id);
return next;
});
}, []);
const pageIds = React.useMemo(() => paginated.map((t) => t.id), [paginated]);
const pageSelectedCount = React.useMemo(
() => pageIds.filter((id) => selectedIds.has(id)).length,
[pageIds, selectedIds],
);
let headerChecked: boolean | "indeterminate" = false;
if (pageIds.length > 0 && pageSelectedCount === pageIds.length) {
headerChecked = true;
} else if (pageSelectedCount > 0) {
headerChecked = "indeterminate";
}
const toggleSelectedPage = React.useCallback((checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of pageIds) {
if (checked) next.add(id); else next.delete(id);
}
return next;
});
}, [pageIds]);
// lastRef holds the previous poll's serialized payload: the list is re-fetched every
// POLL_MS but usually comes back unchanged, and setTasks on an identical payload would
// re-render the whole page for nothing. Bail out when it matches.
@@ -284,6 +335,63 @@ export default function TasksPage() {
}
}, [load]);
// deleteTasks 逐个删除所选任务:后端没有批量接口,且单次删除会连带清理资产/流量/文件,
// 串行执行以免一次性打爆后端;成功的从选中集移除,失败的保留以便重试。
const deleteTasks = React.useCallback(async (
ids: string[],
options: DeleteTaskOptions,
onProgress: (done: number) => void,
) => {
const total: DeleteCounts = {
assets_deleted: 0,
assets_detached: 0,
traffic_deleted: 0,
files_deleted: false,
findings_deleted: 0,
llm_records_deleted: 0,
};
const deleted: string[] = [];
const failed: { id: string; message: string }[] = [];
const warnings: string[] = [];
for (const id of ids) {
try {
const r = await api.deleteTask(id, options);
total.assets_deleted += r.assets_deleted;
total.assets_detached += r.assets_detached;
total.traffic_deleted += r.traffic_deleted;
total.files_deleted = total.files_deleted || r.files_deleted;
total.findings_deleted += r.findings_deleted;
total.llm_records_deleted += r.llm_records_deleted;
if (r.cleanup_warning) warnings.push(`#${id}:${r.cleanup_warning}`);
deleted.push(id);
} catch (e) {
failed.push({ id, message: (e as Error).message });
}
onProgress(deleted.length + failed.length);
}
if (deleted.length > 0) {
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of deleted) next.delete(id);
return next;
});
const details = deleteDetails(total);
const summary = `已删除 ${deleted.length} 个任务` + (details.length > 0 ? `(${details.join(",")})` : "");
if (warnings.length > 0) {
toast.warning(`${summary};部分外部数据清理未完成:${warnings.join(";")}`);
} else {
toast.success(summary);
}
}
if (failed.length > 0) {
const head = failed.slice(0, 3).map((f) => `#${f.id}(${f.message})`).join(";");
toast.error(`${failed.length} 个任务删除失败:${head}${failed.length > 3 ? " 等" : ""}`);
}
load();
}, [load]);
return (
<Card>
<CardContent className="flex flex-col gap-4 px-0 pt-6">
@@ -325,6 +433,15 @@ export default function TasksPage() {
<span className="text-muted-foreground text-xs tabular-nums">
{filtered.length}/{tasks.length} 条
</span>
{selectedIds.size > 0 && (
<>
<span className="text-xs tabular-nums">已选 {selectedIds.size} 个</span>
<Button size="sm" variant="ghost" onClick={() => setSelectedIds(new Set())}>
取消选择
</Button>
<BulkDeleteTasksDialog ids={[...selectedIds]} onDelete={deleteTasks} />
</>
)}
<ConcurrencySettingsDialog />
<CreateTaskSheet tasks={tasks} onCreated={load} />
</div>
@@ -341,6 +458,13 @@ export default function TasksPage() {
<Table className="**:data-[slot='table-cell']:px-4 **:data-[slot='table-head']:px-4">
<TableHeader className="[&_tr]:border-t">
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={headerChecked}
onCheckedChange={(checked) => toggleSelectedPage(checked === true)}
aria-label="选择本页全部任务"
/>
</TableHead>
<TableHead className="font-mono">ID</TableHead>
<TableHead>描述</TableHead>
<TableHead>目标</TableHead>
@@ -361,6 +485,8 @@ export default function TasksPage() {
// 带来的整表重渲染,只让在跑的那几行走时长。
nowSec={task.status === "running" ? nowSec : 0}
onDelete={deleteTask}
selected={selectedIds.has(task.id)}
onSelectedChange={toggleSelected}
/>
))}
</TableBody>
@@ -461,13 +587,24 @@ const TaskRow = React.memo(function TaskRow({
task,
nowSec,
onDelete,
selected,
onSelectedChange,
}: {
task: Task;
nowSec: number;
onDelete: (id: string, options: DeleteTaskOptions) => Promise<void>;
selected: boolean;
onSelectedChange: (id: string, checked: boolean) => void;
}) {
return (
<TableRow className="group border-border/60">
<TableRow className="group border-border/60" data-state={selected ? "selected" : undefined}>
<TableCell>
<Checkbox
checked={selected}
onCheckedChange={(checked) => onSelectedChange(task.id, checked === true)}
aria-label={`选择任务 ${task.id}`}
/>
</TableCell>
<TableCell>
<code className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">{task.id}</code>
</TableCell>
@@ -554,6 +691,117 @@ const deleteOptionKeys: (keyof DeleteTaskOptions)[] = [
"delete_llm_records",
];
// DeleteOptionFields renders the「同时清理关联数据」checkbox block shared by the
// single-task and bulk delete dialogs. idPrefix keeps the label/input ids unique when
// several dialogs live in the same table.
function DeleteOptionFields({
idPrefix,
options,
onOptionsChange,
disabled,
}: {
idPrefix: string;
options: DeleteTaskOptions;
onOptionsChange: React.Dispatch<React.SetStateAction<DeleteTaskOptions>>;
disabled: boolean;
}) {
const selectedCount = deleteOptionKeys.filter((key) => options[key]).length;
let allChecked: boolean | "indeterminate" = false;
if (selectedCount === deleteOptionKeys.length) {
allChecked = true;
} else if (selectedCount > 0) {
allChecked = "indeterminate";
}
const updateOption = (key: keyof DeleteTaskOptions, checked: boolean) => {
onOptionsChange((current) => ({ ...current, [key]: checked }));
};
const updateAllOptions = (checked: boolean) => {
onOptionsChange({
delete_assets: checked,
delete_traffic: checked,
delete_files: checked,
delete_findings: checked,
delete_llm_records: checked,
});
};
return (
<FieldSet disabled={disabled}>
<FieldLegend variant="label">同时清理关联数据</FieldLegend>
<FieldGroup className="gap-3">
<Field orientation="horizontal">
<Checkbox
id={`delete-all-${idPrefix}`}
checked={allChecked}
onCheckedChange={(checked) => updateAllOptions(checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-all-${idPrefix}`}>全部删除</FieldLabel>
<FieldDescription>选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-assets-${idPrefix}`}
checked={options.delete_assets}
onCheckedChange={(checked) => updateOption("delete_assets", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-assets-${idPrefix}`}>关联资产</FieldLabel>
<FieldDescription>删除仅属于该任务的资产;共享资产只解除当前任务关联。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-traffic-${idPrefix}`}
checked={options.delete_traffic}
onCheckedChange={(checked) => updateOption("delete_traffic", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-traffic-${idPrefix}`}>关联流量</FieldLabel>
<FieldDescription>按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-files-${idPrefix}`}
checked={options.delete_files}
onCheckedChange={(checked) => updateOption("delete_files", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-files-${idPrefix}`}>任务文件</FieldLabel>
<FieldDescription>删除该任务工作目录中的上传文件、命令输出和其他产物。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-findings-${idPrefix}`}
checked={options.delete_findings}
onCheckedChange={(checked) => updateOption("delete_findings", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-findings-${idPrefix}`}>关联漏洞</FieldLabel>
<FieldDescription>永久删除该任务产生的独立漏洞记录与漏洞报告。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-llm-records-${idPrefix}`}
checked={options.delete_llm_records}
onCheckedChange={(checked) => updateOption("delete_llm_records", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-llm-records-${idPrefix}`}>LLM 请求/响应记录</FieldLabel>
<FieldDescription>永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。</FieldDescription>
</FieldContent>
</Field>
</FieldGroup>
</FieldSet>
);
}
function DeleteTaskDialog({
task,
onDelete,
@@ -565,29 +813,6 @@ function DeleteTaskDialog({
const [deleting, setDeleting] = React.useState(false);
const [options, setOptions] = React.useState<DeleteTaskOptions>(emptyDeleteOptions);
const selectedCount = deleteOptionKeys.filter((key) => options[key]).length;
const allSelected = selectedCount === deleteOptionKeys.length;
let allChecked: boolean | "indeterminate" = false;
if (allSelected) {
allChecked = true;
} else if (selectedCount > 0) {
allChecked = "indeterminate";
}
const updateOption = (key: keyof DeleteTaskOptions, checked: boolean) => {
setOptions((current) => ({ ...current, [key]: checked }));
};
const updateAllOptions = (checked: boolean) => {
setOptions({
delete_assets: checked,
delete_traffic: checked,
delete_files: checked,
delete_findings: checked,
delete_llm_records: checked,
});
};
const handleOpenChange = (next: boolean) => {
if (deleting) return;
setOpen(next);
@@ -621,77 +846,12 @@ function DeleteTaskDialog({
的执行记录与探索链路将被永久删除。
</AlertDialogDescription>
</AlertDialogHeader>
<FieldSet disabled={deleting}>
<FieldLegend variant="label">同时清理关联数据</FieldLegend>
<FieldGroup className="gap-3">
<Field orientation="horizontal">
<Checkbox
id={`delete-all-${task.id}`}
checked={allChecked}
onCheckedChange={(checked) => updateAllOptions(checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-all-${task.id}`}>全部删除</FieldLabel>
<FieldDescription>选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-assets-${task.id}`}
checked={options.delete_assets}
onCheckedChange={(checked) => updateOption("delete_assets", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-assets-${task.id}`}>关联资产</FieldLabel>
<FieldDescription>删除仅属于该任务的资产;共享资产只解除当前任务关联。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-traffic-${task.id}`}
checked={options.delete_traffic}
onCheckedChange={(checked) => updateOption("delete_traffic", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-traffic-${task.id}`}>关联流量</FieldLabel>
<FieldDescription>按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-files-${task.id}`}
checked={options.delete_files}
onCheckedChange={(checked) => updateOption("delete_files", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-files-${task.id}`}>任务文件</FieldLabel>
<FieldDescription>删除该任务工作目录中的上传文件、命令输出和其他产物。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-findings-${task.id}`}
checked={options.delete_findings}
onCheckedChange={(checked) => updateOption("delete_findings", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-findings-${task.id}`}>关联漏洞</FieldLabel>
<FieldDescription>永久删除该任务产生的独立漏洞记录与漏洞报告。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-llm-records-${task.id}`}
checked={options.delete_llm_records}
onCheckedChange={(checked) => updateOption("delete_llm_records", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-llm-records-${task.id}`}>LLM 请求/响应记录</FieldLabel>
<FieldDescription>永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。</FieldDescription>
</FieldContent>
</Field>
</FieldGroup>
</FieldSet>
<DeleteOptionFields
idPrefix={task.id}
options={options}
onOptionsChange={setOptions}
disabled={deleting}
/>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
@@ -711,6 +871,90 @@ function DeleteTaskDialog({
);
}
// BulkDeleteTasksDialog deletes every checked task with one shared set of cleanup options.
// The backend has no batch endpoint, so deletion runs one task at a time (see deleteTasks)
// and the button shows live progress.
function BulkDeleteTasksDialog({
ids,
onDelete,
}: {
ids: string[];
onDelete: (
ids: string[],
options: DeleteTaskOptions,
onProgress: (done: number) => void,
) => Promise<void>;
}) {
const [open, setOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState(false);
const [done, setDone] = React.useState(0);
const [options, setOptions] = React.useState<DeleteTaskOptions>(emptyDeleteOptions);
const handleOpenChange = (next: boolean) => {
if (deleting) return;
setOpen(next);
if (next) {
setOptions(emptyDeleteOptions());
setDone(0);
}
};
const handleDelete = async () => {
setDeleting(true);
setDone(0);
try {
await onDelete(ids, options, setDone);
setOpen(false);
} finally {
setDeleting(false);
}
};
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline">
<Trash2Icon className="text-destructive" /> 删除所选 {ids.length}
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="max-h-[85vh] overflow-y-auto">
<AlertDialogHeader>
<AlertDialogTitle>确认删除所选 {ids.length} 个任务?</AlertDialogTitle>
<AlertDialogDescription>
这些任务的执行记录与探索链路将被永久删除,下方清理选项对所选任务统一生效。
</AlertDialogDescription>
</AlertDialogHeader>
<div className="text-muted-foreground flex flex-wrap gap-1 text-xs">
{ids.slice(0, 30).map((id) => (
<code key={id} className="bg-muted rounded px-1.5 py-0.5 font-mono">#{id}</code>
))}
{ids.length > 30 && <span className="self-center">…等 {ids.length} 个</span>}
</div>
<DeleteOptionFields
idPrefix="bulk"
options={options}
onOptionsChange={setOptions}
disabled={deleting}
/>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={(event) => {
event.preventDefault();
void handleDelete();
}}
>
{deleting && <Spinner data-icon="inline-start" />}
{deleting ? `删除中 ${done}/${ids.length}` : `删除 ${ids.length} 个任务`}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// CreateTaskSheet is the 新建任务 drawer. Its form state lives HERE, not in TasksPage: with
// 描述/目标 held by the page component every keystroke re-rendered the whole task table
// behind the drawer (plus its sticky column and 20 AlertDialog trees), which showed up as
+90 -16
View File
@@ -14,6 +14,16 @@ import {
UploadIcon,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -99,6 +109,12 @@ type Creating = {
kind: "file" | "dir";
} | null;
type PendingDelete =
| { kind: "skill"; skill: string }
| { kind: "file"; skill: string; path: string }
| { kind: "dir"; skill: string; path: string }
| null;
// ── Page ──────────────────────────────────────────────────────────────────
export default function SkillsPage() {
const [skills, setSkills] = React.useState<SkillItem[]>([]);
@@ -137,6 +153,10 @@ export default function SkillsPage() {
// skill detail — MCP edit state (optimistic, rolls back on error)
const [detailMcps, setDetailMcps] = React.useState<string[]>([]);
// delete confirmation
const [pendingDelete, setPendingDelete] = React.useState<PendingDelete>(null);
const [deleting, setDeleting] = React.useState(false);
// ── Data ──────────────────────────────────────────────────────────────────
const load = React.useCallback(() => {
api.agents().then(setAgents).catch(() => {});
@@ -296,6 +316,19 @@ export default function SkillsPage() {
}
}
async function runPendingDelete() {
const p = pendingDelete;
if (!p) return;
setDeleting(true);
try {
if (p.kind === "skill") await deleteSkill(p.skill);
else await deletePath(p.skill, p.path);
} finally {
setDeleting(false);
setPendingDelete(null);
}
}
async function saveFile() {
if (!selected || selected.path === null) return;
setSaving(true);
@@ -400,7 +433,7 @@ export default function SkillsPage() {
return (
<div key={node.path}>
<div
className="group flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm hover:bg-muted"
className="group relative flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm hover:bg-muted"
style={{ paddingLeft: baseIndent }}
onClick={() => toggleExpanded(key)}
>
@@ -409,8 +442,9 @@ export default function SkillsPage() {
? <FolderOpenIcon className="size-3.5 shrink-0 text-amber-500" />
: <FolderIcon className="size-3.5 shrink-0 text-amber-500" />
}
<span className="flex-1 truncate">{node.name}</span>
<span className="ml-auto hidden shrink-0 items-center gap-0.5 group-hover:flex">
<span className="min-w-0 flex-1 truncate" title={node.path}>{node.name}</span>
{/* Absolute so a long name can never push the actions out of view */}
<span className="absolute inset-y-0 right-1 hidden items-center gap-0.5 rounded bg-muted pl-1 group-hover:flex">
<Button size="icon" variant="ghost" className="size-5" title="新建文件"
onClick={(e) => { e.stopPropagation(); startCreate(skill, node.path, "file"); }}>
<FilePlusIcon className="size-3 text-muted-foreground" />
@@ -420,7 +454,7 @@ export default function SkillsPage() {
<FolderPlusIcon className="size-3 text-muted-foreground" />
</Button>
<Button size="icon" variant="ghost" className="size-5" title="删除文件夹"
onClick={(e) => { e.stopPropagation(); deletePath(skill, node.path); }}>
onClick={(e) => { e.stopPropagation(); setPendingDelete({ kind: "dir", skill, path: node.path }); }}>
<Trash2Icon className="size-3 text-destructive" />
</Button>
</span>
@@ -442,19 +476,24 @@ export default function SkillsPage() {
<div
key={node.path}
className={cn(
"group flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm",
"group relative flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
style={{ paddingLeft: baseIndent + 16 }}
onClick={() => setSelected({ skill, path: node.path })}
>
<FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate font-mono text-xs">{node.name}</span>
<Button size="icon" variant="ghost" className="ml-auto hidden size-5 group-hover:flex"
title="删除文件"
onClick={(e) => { e.stopPropagation(); deletePath(skill, node.path); }}>
<Trash2Icon className="size-3 text-destructive" />
</Button>
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={node.path}>{node.name}</span>
<span className={cn(
"absolute inset-y-0 right-1 hidden items-center rounded pl-1 group-hover:flex",
isSelected ? "bg-accent" : "bg-muted",
)}>
<Button size="icon" variant="ghost" className="size-5"
title="删除文件"
onClick={(e) => { e.stopPropagation(); setPendingDelete({ kind: "file", skill, path: node.path }); }}>
<Trash2Icon className="size-3 text-destructive" />
</Button>
</span>
</div>
);
});
@@ -496,7 +535,10 @@ export default function SkillsPage() {
{uploading ? "上传中…" : "上传压缩包"}
</Button>
</div>
<ScrollArea className="flex-1">
{/* Radix viewport wraps children in a display:table div that grows with
content — force it to block so long names truncate instead of
widening the rows past the sidebar. */}
<ScrollArea className="flex-1 [&>[data-slot=scroll-area-viewport]>div]:!block">
<div className="p-1">
{skills.map((s) => {
const isOpen = expanded.has(s.name);
@@ -507,7 +549,7 @@ export default function SkillsPage() {
{/* skill 根节点 */}
<div
className={cn(
"group flex cursor-pointer select-none items-center gap-1 rounded px-2 py-1 text-sm",
"group relative flex cursor-pointer select-none items-center gap-1 rounded px-2 py-1 text-sm",
isSkillSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
onClick={() => {
@@ -520,8 +562,11 @@ export default function SkillsPage() {
? <FolderOpenIcon className="size-3.5 shrink-0 text-blue-500" />
: <FolderIcon className="size-3.5 shrink-0 text-blue-500" />
}
<span className="flex-1 truncate font-semibold">{s.name}</span>
<span className="ml-auto hidden shrink-0 items-center gap-0.5 group-hover:flex">
<span className="min-w-0 flex-1 truncate font-semibold" title={s.name}>{s.name}</span>
<span className={cn(
"absolute inset-y-0 right-1 hidden items-center gap-0.5 rounded pl-1 group-hover:flex",
isSkillSelected ? "bg-accent" : "bg-muted",
)}>
<Button size="icon" variant="ghost" className="size-5" title="新建文件"
onClick={(e) => { e.stopPropagation(); startCreate(s.name, "", "file"); }}>
<FilePlusIcon className="size-3 text-muted-foreground" />
@@ -531,7 +576,7 @@ export default function SkillsPage() {
<FolderPlusIcon className="size-3 text-muted-foreground" />
</Button>
<Button size="icon" variant="ghost" className="size-5" title="删除 Skill"
onClick={(e) => { e.stopPropagation(); deleteSkill(s.name); }}>
onClick={(e) => { e.stopPropagation(); setPendingDelete({ kind: "skill", skill: s.name }); }}>
<Trash2Icon className="size-3 text-destructive" />
</Button>
</span>
@@ -649,6 +694,35 @@ export default function SkillsPage() {
</div>
</div>
{/* ── 删除二次确认 ── */}
<AlertDialog open={!!pendingDelete} onOpenChange={(o) => { if (!o) setPendingDelete(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pendingDelete?.kind === "skill" && `删除 Skill「${pendingDelete.skill}」?`}
{pendingDelete?.kind === "dir" && `删除文件夹「${pendingDelete.path}」?`}
{pendingDelete?.kind === "file" && `删除文件「${pendingDelete.path}」?`}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete?.kind === "skill"
? "将删除该 Skill 的全部文件、MCP 关联与可见性配置。此操作不可撤销。"
: pendingDelete?.kind === "dir"
? "将一并删除该文件夹下的所有文件。此操作不可撤销。"
: "此操作不可撤销。"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
disabled={deleting}
onClick={(e) => { e.preventDefault(); void runPendingDelete(); }}
>
{deleting ? "删除中…" : "删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── 新建 Skill 对话框 ── */}
<Sheet open={newOpen} onOpenChange={setNewOpen}>
<SheetContent side="right" className="flex w-full flex-col gap-0 data-[side=right]:sm:max-w-xl">