diff --git a/plugins/tasks/views/manage/manage-panel.tsx b/plugins/tasks/views/manage/manage-panel.tsx
index 0d0d544ad8..83528d4cca 100644
--- a/plugins/tasks/views/manage/manage-panel.tsx
+++ b/plugins/tasks/views/manage/manage-panel.tsx
@@ -440,11 +440,13 @@ function FolderRow({
rootFolders,
onRename,
onMove,
+ onDelete,
}: {
folder: Folder;
rootFolders: Folder[];
onRename: (name: string) => Promise;
onMove: (parentFolderId: string | null) => Promise;
+ onDelete: () => void;
}) {
const [renaming, setRenaming] = useState(false);
const [draftName, setDraftName] = useState(folder.name);
@@ -524,6 +526,15 @@ function FolderRow({
>
+
>
)}
@@ -533,7 +544,9 @@ function FolderRow({
function FoldersSection() {
const rpc = useTasksRpc();
const folders = useFolders();
+ const projects = useProjects();
const [error, setError] = useState(null);
+ const [confirmDelete, setConfirmDelete] = useState(null);
const folderList = folders.data ?? [];
// The sidebar nests folders one level deep, so only roots can be parents.
const rootFolders = useMemo(
@@ -550,6 +563,29 @@ function FoldersSection() {
}
};
+ // Deleting a folder only unfiles what it held: the schema's ON DELETE SET
+ // NULL moves its projects and subfolders to the top level. Nothing else is
+ // removed, so the confirmation names the move rather than warning about loss.
+ function describeDeleteImpact(folder: Folder): string {
+ const projectCount = (projects.data ?? []).filter(
+ (project) => project.folderId === folder.id,
+ ).length;
+ const subfolderCount = folderList.filter(
+ (entry) => entry.parentFolderId === folder.id,
+ ).length;
+ const moved = [
+ projectCount > 0
+ ? `${projectCount} project${projectCount > 1 ? "s" : ""}`
+ : null,
+ subfolderCount > 0
+ ? `${subfolderCount} subfolder${subfolderCount > 1 ? "s" : ""}`
+ : null,
+ ].filter((part) => part !== null);
+ return moved.length === 0
+ ? "The folder is empty."
+ : `${moved.join(" and ")} move to the top level. No tasks are deleted.`;
+ }
+
return (
{folderList.length === 0 ? (
@@ -576,6 +612,10 @@ function FoldersSection() {
}),
)
}
+ onDelete={() => {
+ setError(null);
+ setConfirmDelete(folder);
+ }}
/>
))}
@@ -585,6 +625,22 @@ function FoldersSection() {
{error}
) : null}
+ {
+ if (!open) setConfirmDelete(null);
+ }}
+ title={`Delete folder “${confirmDelete?.name ?? ""}”?`}
+ description={confirmDelete ? describeDeleteImpact(confirmDelete) : ""}
+ confirmLabel="Delete folder"
+ destructive
+ onConfirm={() => {
+ const target = confirmDelete;
+ if (target) {
+ void run(() => rpc.call("deleteFolder", { folderId: target.id }));
+ }
+ }}
+ />
);
}
diff --git a/plugins/tasks/views/manage/manage.test.tsx b/plugins/tasks/views/manage/manage.test.tsx
index 86beb529d3..c2177b03b8 100644
--- a/plugins/tasks/views/manage/manage.test.tsx
+++ b/plugins/tasks/views/manage/manage.test.tsx
@@ -750,6 +750,78 @@ describe("PresetDialog environment section", () => {
});
});
+describe("Manage folders", () => {
+ const parentFolder = {
+ id: "01HZZZZZZZZZZZZZZZZZZZZZF1",
+ name: "bb",
+ parentFolderId: null,
+ createdAt: "2026-07-15T00:00:00.000Z",
+ };
+ const childFolder = {
+ id: "01HZZZZZZZZZZZZZZZZZZZZZF2",
+ name: "archive",
+ parentFolderId: parentFolder.id,
+ createdAt: "2026-07-15T00:00:00.000Z",
+ };
+
+ function renderFolders(overrides: Record = {}) {
+ return renderSlot(
+ app.navPanels[0]!,
+ { subPath: "manage" },
+ {
+ rpc: {
+ listProjects: () => ({
+ projects: [{ ...project, folderId: parentFolder.id }],
+ }),
+ listFolders: () => ({ folders: [parentFolder, childFolder] }),
+ listPresets: () => ({ presets: [] }),
+ sidebarSummary: () => ({ projects: [] }),
+ listTasks: () => ({ tasks: [] }),
+ listLabels: () => ({ labels: [] }),
+ ...overrides,
+ },
+ },
+ );
+ }
+
+ it("deletes a folder after naming what the delete unfiles", async () => {
+ const deleteCalls: Array> = [];
+ const slot = renderFolders({
+ deleteFolder: (input: Record) => {
+ deleteCalls.push(input);
+ return { deleted: true };
+ },
+ });
+ fireEvent.mouseDown(await slot.findByRole("tab", { name: "Folders" }));
+ fireEvent.click(
+ await slot.findByRole("button", { name: "Delete folder bb" }),
+ );
+
+ // Nothing is destroyed: the schema re-parents the folder's contents.
+ await slot.findByText(
+ "1 project and 1 subfolder move to the top level. No tasks are deleted.",
+ );
+ fireEvent.click(slot.getByRole("button", { name: "Delete folder" }));
+ await waitFor(() => expect(deleteCalls).toHaveLength(1));
+ expect(deleteCalls[0]).toMatchObject({ folderId: parentFolder.id });
+ });
+
+ it("surfaces a failed delete instead of silently closing", async () => {
+ const slot = renderFolders({
+ deleteFolder: () => {
+ throw new Error("Folder not found");
+ },
+ });
+ fireEvent.mouseDown(await slot.findByRole("tab", { name: "Folders" }));
+ fireEvent.click(
+ await slot.findByRole("button", { name: "Delete folder archive" }),
+ );
+ await slot.findByText("The folder is empty.");
+ fireEvent.click(slot.getByRole("button", { name: "Delete folder" }));
+ await slot.findByRole("alert");
+ });
+});
+
describe("NewProjectDialog", () => {
function renderEmptyState(overrides: Record = {}) {
return renderSlot(