diff --git a/README.md b/README.md
index 41ecafd2b..4e313aa2b 100644
--- a/README.md
+++ b/README.md
@@ -1239,6 +1239,8 @@ See [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerfl
### Chat Archive
+Deleting a chat from the sidebar requires confirmation showing its title. Deletion removes the conversation and its files and cannot be undone.
+
Use **Archive chat** in a recent chat's sidebar menu to hide completed work while keeping its messages, files, and original link. The success message offers **Undo**. Open **Chats → Archived** to find archived conversations and restore them individually; an open archived conversation also shows a restore button in its header. Search filters the titles of loaded conversations, with **Load more** for older entries.
Archive and restore preserve the chat's activity time and pinned state. Archiving does not stop a running task or pause its schedules, and new activity does not automatically restore it. Use the existing Delete action when you intend to remove a conversation and its files.
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 67b0f07c6..ae5909731 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -172,3 +172,12 @@ Custom skill export is admin-only and disabled in static demos. The lazy
or user/skill changes. `core/skills/export.ts` owns the revision-bound Blob download;
HTTP 409 requires explicit preview refresh. Keep file lists paginated and diagnostics
localized. Browser handoff does not prove the file was saved to disk.
+
+Sidebar rows request deletion through `ThreadDeleteDialogProvider`, hosted in
+`WorkspaceSidebar` outside the virtualized flat/project lists. Keep the selected
+thread snapshot and retry UI alive when a partial deletion removes its row.
+Focus Cancel on open and after a failed deletion has re-enabled the actions;
+block dismissal while deletion is pending. Show the error message when available,
+with a localized fallback, and log the rejection for debugging. The shared
+delete helper accepts remote 404 (not 403) before retrying local cleanup, and
+`onDeleted` runs only after both deletion steps succeed.
diff --git a/frontend/src/components/workspace/recent-chat-list.tsx b/frontend/src/components/workspace/recent-chat-list.tsx
index 8bec3278b..46132e65c 100644
--- a/frontend/src/components/workspace/recent-chat-list.tsx
+++ b/frontend/src/components/workspace/recent-chat-list.tsx
@@ -13,7 +13,7 @@ import {
Trash2,
} from "lucide-react";
import Link from "next/link";
-import { useParams, usePathname, useRouter } from "next/navigation";
+import { useParams, usePathname } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -45,7 +45,6 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
-import { resetThreadChatAfterDelete } from "@/components/workspace/chats/use-thread-chat";
import { getAPIClient } from "@/core/api";
import { useAuth } from "@/core/auth/AuthProvider";
import { hasPermission, PERMISSIONS } from "@/core/auth/permissions";
@@ -56,7 +55,6 @@ import { useLocalSettings } from "@/core/settings";
import { isStaticWebsiteOnly } from "@/core/static-mode";
import { exportThread, type ThreadExportFormat } from "@/core/threads/export";
import {
- useDeleteThread,
useInfiniteThreads,
useMoveThreadToProject,
usePinThread,
@@ -80,6 +78,7 @@ import { isIMEComposing } from "@/lib/ime";
import { MoveToProjectMenu, NewProjectDialog } from "./move-to-project-menu";
import { ThreadChannelIcon } from "./thread-channel-source";
+import { useThreadDeleteDialog } from "./thread-delete-dialog";
import { VirtualThreadList } from "./thread-list-virtualizer";
import { useThreadArchiveAction } from "./use-thread-archive-action";
@@ -102,14 +101,7 @@ export function ThreadSidebarItem({
const { t } = useI18n();
const { user } = useAuth();
const canDeleteThreads = hasPermission(user, PERMISSIONS.THREADS_DELETE);
- const router = useRouter();
- const pathname = usePathname();
- const { thread_id: threadIdFromPath, agent_name: agentNameFromPath } =
- useParams<{
- thread_id: string;
- agent_name?: string;
- }>();
- const { mutate: deleteThread } = useDeleteThread();
+ const requestDelete = useThreadDeleteDialog();
const { mutate: renameThread } = useRenameThread();
const { mutate: updatePinnedThread } = usePinThread();
// The move mutation is owned here (not inside `MoveToProjectMenu`) because
@@ -140,42 +132,6 @@ export function ThreadSidebarItem({
const [renameValue, setRenameValue] = useState("");
const [newProjectDialogOpen, setNewProjectDialogOpen] = useState(false);
- const handleDelete = useCallback(() => {
- const currentPathname =
- typeof window === "undefined" ? pathname : window.location.pathname;
- const threadPath = pathOfThread(thread);
- const nextThreadPath = pathOfThread("new", {
- agent_name: agentNameFromPath,
- });
- const isNewThreadPath = currentPathname === nextThreadPath;
- const isCurrentThread =
- thread.thread_id === threadIdFromPath ||
- threadPath === currentPathname ||
- (isNewThreadPath && recentThreadId === thread.thread_id);
-
- deleteThread({
- threadId: thread.thread_id,
- onRemoteDeleted: isCurrentThread
- ? () => {
- resetThreadChatAfterDelete({
- deletedThreadId: thread.thread_id,
- nextPath: nextThreadPath,
- force: true,
- });
- void router.replace(nextThreadPath);
- }
- : undefined,
- });
- }, [
- agentNameFromPath,
- deleteThread,
- pathname,
- recentThreadId,
- router,
- thread,
- threadIdFromPath,
- ]);
-
const handleRenameSubmit = useCallback(() => {
if (renameValue.trim()) {
renameThread(
@@ -379,7 +335,9 @@ export function ThreadSidebarItem({
{canDeleteThreads && (
<>
-
+ requestDelete({ thread, recentThreadId })}
+ >
{t.common.delete}
diff --git a/frontend/src/components/workspace/thread-delete-dialog.tsx b/frontend/src/components/workspace/thread-delete-dialog.tsx
new file mode 100644
index 000000000..88030ac9a
--- /dev/null
+++ b/frontend/src/components/workspace/thread-delete-dialog.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { useParams, usePathname, useRouter } from "next/navigation";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { resetThreadChatAfterDelete } from "@/components/workspace/chats/use-thread-chat";
+import { useI18n } from "@/core/i18n/hooks";
+import { useDeleteThread } from "@/core/threads/hooks";
+import type { AgentThread } from "@/core/threads/types";
+import { pathOfThread, titleOfThread } from "@/core/threads/utils";
+
+type DeleteTarget = {
+ thread: AgentThread;
+ recentThreadId?: string | undefined;
+};
+const ThreadDeleteContext = createContext<
+ ((target: DeleteTarget) => void) | null
+>(null);
+
+export function useThreadDeleteDialog() {
+ const requestDelete = useContext(ThreadDeleteContext);
+ if (!requestDelete) throw new Error("ThreadDeleteDialogProvider is required");
+ return requestDelete;
+}
+
+export function ThreadDeleteDialogProvider({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ const { t } = useI18n();
+ const router = useRouter();
+ const pathname = usePathname();
+ const { thread_id: threadIdFromPath, agent_name: agentNameFromPath } =
+ useParams<{ thread_id: string; agent_name?: string }>();
+ const {
+ mutateAsync: deleteThread,
+ isPending: isDeleting,
+ isError: deleteFailed,
+ } = useDeleteThread();
+ // A partial deletion can remove the row on a list refresh. Keep its snapshot
+ // and the retry dialog in this stable host, outside the virtualized lists.
+ const [target, setTarget] = useState(null);
+ const deleteInFlight = useRef(false);
+ const deleteCancelRef = useRef(null);
+
+ useEffect(() => {
+ // Wait for React to re-enable the button before restoring focus.
+ if (target && deleteFailed && !isDeleting) deleteCancelRef.current?.focus();
+ }, [target, deleteFailed, isDeleting]);
+
+ const handleDelete = useCallback(async () => {
+ if (!target || deleteInFlight.current) return;
+ const { thread, recentThreadId } = target;
+ deleteInFlight.current = true;
+ const currentPathname =
+ typeof window === "undefined" ? pathname : window.location.pathname;
+ const threadPath = pathOfThread(thread);
+ const nextThreadPath = pathOfThread("new", {
+ agent_name: agentNameFromPath,
+ });
+ const isNewThreadPath = currentPathname === nextThreadPath;
+ const isCurrentThread =
+ thread.thread_id === threadIdFromPath ||
+ threadPath === currentPathname ||
+ (isNewThreadPath && recentThreadId === thread.thread_id);
+
+ try {
+ await deleteThread({
+ threadId: thread.thread_id,
+ onDeleted: isCurrentThread
+ ? () => {
+ resetThreadChatAfterDelete({
+ deletedThreadId: thread.thread_id,
+ nextPath: nextThreadPath,
+ force: true,
+ });
+ void router.replace(nextThreadPath);
+ }
+ : undefined,
+ });
+ setTarget(null);
+ } catch (error) {
+ console.error("Failed to delete chat:", error);
+ toast.error(
+ error instanceof Error && error.message
+ ? error.message
+ : t.chats.deleteFailed,
+ );
+ } finally {
+ deleteInFlight.current = false;
+ }
+ }, [
+ agentNameFromPath,
+ deleteThread,
+ pathname,
+ router,
+ target,
+ threadIdFromPath,
+ t.chats.deleteFailed,
+ ]);
+
+ const requestDelete = useCallback((next: DeleteTarget) => {
+ if (!deleteInFlight.current) setTarget(next);
+ }, []);
+ return (
+
+ {children}
+
+
+ );
+}
diff --git a/frontend/src/components/workspace/workspace-sidebar.tsx b/frontend/src/components/workspace/workspace-sidebar.tsx
index 25135d82a..32135120c 100644
--- a/frontend/src/components/workspace/workspace-sidebar.tsx
+++ b/frontend/src/components/workspace/workspace-sidebar.tsx
@@ -12,6 +12,7 @@ import {
import { WorkspaceChannelsList } from "./channels/workspace-channels-list";
import { ProjectsSection } from "./projects-section";
import { RecentChatList } from "./recent-chat-list";
+import { ThreadDeleteDialogProvider } from "./thread-delete-dialog";
import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
import { WorkspaceNavMenu } from "./workspace-nav-menu";
@@ -21,7 +22,7 @@ export function WorkspaceSidebar({
}: React.ComponentProps) {
const { open: isSidebarOpen } = useSidebar();
return (
- <>
+
@@ -41,6 +42,6 @@ export function WorkspaceSidebar({
- >
+
);
}
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts
index 07d8edd42..28e25611b 100644
--- a/frontend/src/core/i18n/locales/en-US.ts
+++ b/frontend/src/core/i18n/locales/en-US.ts
@@ -725,6 +725,10 @@ export const enUS: Translations = {
// Chats
chats: {
+ deleteChat: "Delete chat",
+ deleteConfirm: (title) =>
+ `Delete “${title}”? This will delete the conversation and its files. This action cannot be undone.`,
+ deleteFailed: "Failed to delete chat. Please try again.",
noActiveChats: "No recent chats",
activeChats: "Recent chats",
archivedChats: "Archived",
diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts
index b670f36ac..c4c3b57b7 100644
--- a/frontend/src/core/i18n/locales/types.ts
+++ b/frontend/src/core/i18n/locales/types.ts
@@ -598,6 +598,9 @@ export interface Translations {
// Chats
chats: {
+ deleteChat: string;
+ deleteConfirm: (title: string) => string;
+ deleteFailed: string;
noActiveChats: string;
activeChats: string;
archivedChats: string;
diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts
index 243f420c4..efd891cf8 100644
--- a/frontend/src/core/i18n/locales/zh-CN.ts
+++ b/frontend/src/core/i18n/locales/zh-CN.ts
@@ -686,6 +686,10 @@ export const zhCN: Translations = {
// Chats
chats: {
+ deleteChat: "删除对话",
+ deleteConfirm: (title) =>
+ `确定删除“${title}”吗?这将删除对话及其文件,此操作不可撤销。`,
+ deleteFailed: "删除对话失败,请重试。",
noActiveChats: "暂无近期会话",
activeChats: "近期会话",
archivedChats: "已归档",
diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts
index db2e25f08..f6f425cf9 100644
--- a/frontend/src/core/threads/hooks.ts
+++ b/frontend/src/core/threads/hooks.ts
@@ -3422,7 +3422,13 @@ async function deleteThreadEverywhere(
apiClient: ThreadDeleteClient,
threadId: string,
) {
- await apiClient.threads.delete(threadId);
+ try {
+ await apiClient.threads.delete(threadId);
+ } catch (error) {
+ // A previous attempt may have deleted the remote thread before local
+ // cleanup failed. Only 404 is success here; authorization failures are not.
+ if (getHttpStatus(error) !== 404) throw error;
+ }
await deleteLocalThreadData(threadId);
}
@@ -3517,18 +3523,17 @@ export function useDeleteThread() {
return useMutation({
mutationFn: async ({
threadId,
- onRemoteDeleted,
+ onDeleted,
}: {
threadId: string;
- onRemoteDeleted?: () => void;
+ onDeleted?: () => void;
}) => {
const deletedSidecarThreadIds = await deleteSidecarThreadsForParent(
apiClient,
threadId,
);
- await apiClient.threads.delete(threadId);
- onRemoteDeleted?.();
- await deleteLocalThreadData(threadId);
+ await deleteThreadEverywhere(apiClient, threadId);
+ onDeleted?.();
return deletedSidecarThreadIds;
},
onSuccess(deletedSidecarThreadIds, { threadId }) {
diff --git a/frontend/tests/e2e/thread-delete-confirm.spec.ts b/frontend/tests/e2e/thread-delete-confirm.spec.ts
new file mode 100644
index 000000000..e40d9f5bd
--- /dev/null
+++ b/frontend/tests/e2e/thread-delete-confirm.spec.ts
@@ -0,0 +1,191 @@
+import { expect, test, type Page } from "@playwright/test";
+
+import { mockLangGraphAPI } from "./utils/mock-api";
+
+const CHAT = "00000000-0000-0000-0000-000000000901";
+const TITLE = "Report to keep";
+
+async function openDeleteDialog(page: Page, captureEntry = false) {
+ const link = page.locator(
+ `a[data-sidebar="menu-button"][href="/workspace/chats/${CHAT}"]`,
+ );
+ await link.hover();
+ await link.locator("xpath=..").getByRole("button", { name: "More" }).click();
+ if (captureEntry) {
+ await page.screenshot({
+ path: test.info().outputPath("delete-entry-point.png"),
+ animations: "disabled",
+ });
+ }
+ await page.getByRole("menuitem", { name: "Delete", exact: true }).click();
+ return page.getByRole("dialog", { name: "Delete chat", exact: true });
+}
+
+test("opening and dismissing deletion never sends a delete request", async ({
+ page,
+}) => {
+ mockLangGraphAPI(page, { threads: [{ thread_id: CHAT, title: TITLE }] });
+ const deletes: string[] = [];
+ page.on("request", (request) => {
+ if (request.method() === "DELETE") deletes.push(request.url());
+ });
+ await page.goto(`/workspace/chats/${CHAT}`);
+ for (const dismissal of ["Cancel", "Escape", "Close"]) {
+ const dialog = await openDeleteDialog(page);
+ await expect(dialog).toContainText(TITLE);
+ await expect(dialog).toContainText("cannot be undone");
+ await expect(
+ dialog.getByRole("button", { name: "Cancel", exact: true }),
+ ).toBeFocused();
+ expect(deletes).toEqual([]);
+ if (dismissal === "Escape") await page.keyboard.press("Escape");
+ else
+ await dialog
+ .getByRole("button", { name: dismissal, exact: true })
+ .click();
+ await expect(dialog).toBeHidden();
+ await expect(page).toHaveURL(new RegExp(CHAT));
+ expect(deletes).toEqual([]);
+ }
+});
+
+test("confirmation waits for deletion, prevents dismissal, and permits retry after failure", async ({
+ page,
+}, testInfo) => {
+ mockLangGraphAPI(page, { threads: [{ thread_id: CHAT, title: TITLE }] });
+ let releaseDelete!: () => void;
+ const gate = new Promise((resolve) => {
+ releaseDelete = resolve;
+ });
+ let attempts = 0;
+ await page.route(`**/api/langgraph/threads/${CHAT}`, async (route) => {
+ if (route.request().method() !== "DELETE") return route.fallback();
+ attempts++;
+ if (attempts > 1) return route.fallback();
+ await gate;
+ await route.fulfill({
+ status: 403,
+ contentType: "application/json",
+ body: JSON.stringify({ detail: "Permission denied" }),
+ });
+ });
+ await page.goto(`/workspace/chats/${CHAT}`);
+ const dialog = await openDeleteDialog(page, true);
+ await expect(page.getByRole("menu")).toBeHidden();
+ await page.screenshot({
+ path: testInfo.outputPath("delete-confirm.png"),
+ animations: "disabled",
+ });
+ await dialog.getByRole("button", { name: "Delete", exact: true }).click();
+ await expect.poll(() => attempts).toBe(1);
+ await expect(
+ dialog.getByRole("button", { name: "Cancel", exact: true }),
+ ).toBeDisabled();
+ await expect(
+ dialog.getByRole("button", { name: "Loading", exact: false }),
+ ).toBeDisabled();
+ await expect(
+ dialog.getByRole("button", { name: "Close", exact: true }),
+ ).toHaveCount(0);
+ await page.keyboard.press("Escape");
+ await page.mouse.click(5, 5);
+ await expect(dialog).toBeVisible();
+ expect(attempts).toBe(1);
+ releaseDelete();
+ await expect.soft(page.getByText(/Permission denied/)).toBeVisible();
+ await expect(dialog).toBeVisible();
+ await expect(
+ dialog.getByRole("button", { name: "Cancel", exact: true }),
+ ).toBeFocused();
+ await page.screenshot({
+ path: testInfo.outputPath("delete-error.png"),
+ animations: "disabled",
+ });
+ await expect(page).toHaveURL(new RegExp(CHAT));
+ await page.keyboard.press("Tab");
+ await expect(
+ dialog.getByRole("button", { name: "Delete", exact: true }),
+ ).toBeFocused();
+ await page.keyboard.press("Enter");
+ await expect(dialog).toBeHidden();
+ await expect(page).toHaveURL(/\/workspace\/chats\/new$/);
+ await expect(
+ page.locator(
+ `a[data-sidebar="menu-button"][href="/workspace/chats/${CHAT}"]`,
+ ),
+ ).toHaveCount(0);
+ expect(attempts).toBe(2);
+});
+
+for (const active of [true, false]) {
+ test(`cleanup failure keeps ${active ? "active" : "inactive"} chat deletion retryable after the row disappears`, async ({
+ page,
+ }) => {
+ const other = "00000000-0000-0000-0000-000000000902";
+ mockLangGraphAPI(page, {
+ threads: [
+ { thread_id: CHAT, title: TITLE },
+ { thread_id: other, title: "Keep open" },
+ ],
+ });
+ let releaseCleanup!: () => void;
+ const cleanupGate = new Promise((resolve) => {
+ releaseCleanup = resolve;
+ });
+ let remoteAttempts = 0;
+ let cleanupAttempts = 0;
+ await page.route(`**/api/langgraph/threads/${CHAT}`, (route) => {
+ if (route.request().method() !== "DELETE") return route.fallback();
+ remoteAttempts++;
+ // The real gateway has require_existing=True: after the first successful
+ // deletion, a retry must accept 404 and continue with local cleanup.
+ if (remoteAttempts > 1)
+ return route.fulfill({
+ status: 404,
+ contentType: "application/json",
+ body: JSON.stringify({ detail: "Thread not found" }),
+ });
+ return route.fallback();
+ });
+ await page.route(`**/api/threads/${CHAT}`, async (route) => {
+ if (route.request().method() !== "DELETE") return route.fallback();
+ cleanupAttempts++;
+ if (cleanupAttempts > 1) return route.fallback();
+ await cleanupGate;
+ return route.fulfill({
+ status: 500,
+ contentType: "application/json",
+ body: JSON.stringify({ detail: "Cleanup failed" }),
+ });
+ });
+ const originalPath = `/workspace/chats/${active ? CHAT : other}`;
+ await page.goto(originalPath);
+ const dialog = await openDeleteDialog(page);
+ await dialog.getByRole("button", { name: "Delete", exact: true }).click();
+ await expect.poll(() => cleanupAttempts).toBe(1);
+ await expect(page).toHaveURL(new RegExp(`${originalPath}$`));
+ await expect(dialog).toBeVisible();
+ releaseCleanup();
+ await expect(
+ page.getByText("Cleanup failed", { exact: true }),
+ ).toBeVisible();
+ // onSettled refetches the list, which no longer contains this thread.
+ await expect(
+ page.locator(
+ `a[data-sidebar="menu-button"][href="/workspace/chats/${CHAT}"]`,
+ ),
+ ).toHaveCount(0);
+ await expect(dialog).toBeVisible();
+ await expect(dialog).toContainText(TITLE);
+ await expect(
+ dialog.getByRole("button", { name: "Cancel", exact: true }),
+ ).toBeFocused();
+ await dialog.getByRole("button", { name: "Delete", exact: true }).click();
+ await expect(dialog).toBeHidden();
+ await expect.poll(() => cleanupAttempts).toBe(2);
+ expect(remoteAttempts).toBe(2);
+ await expect(page).toHaveURL(
+ active ? /\/workspace\/chats\/new$/ : new RegExp(`${originalPath}$`),
+ );
+ });
+}
diff --git a/frontend/tests/e2e/thread-history.spec.ts b/frontend/tests/e2e/thread-history.spec.ts
index 2c8f02406..155098caa 100644
--- a/frontend/tests/e2e/thread-history.spec.ts
+++ b/frontend/tests/e2e/thread-history.spec.ts
@@ -533,6 +533,10 @@ test.describe("Thread history", () => {
await inactiveThreadItem.hover();
await inactiveThreadItem.getByRole("button", { name: /more/i }).click();
await page.getByRole("menuitem", { name: /delete/i }).click();
+ await page
+ .getByRole("dialog")
+ .getByRole("button", { name: "Delete", exact: true })
+ .click();
await expect(page).toHaveURL(new RegExp(MOCK_THREAD_ID));
await expect(
@@ -685,12 +689,13 @@ test.describe("Thread history", () => {
await expect(textarea).toBeVisible();
});
- test("deleting the active newly created chat returns to the new chat screen", async ({
+ test("retrying deletion of the active newly created chat returns to the new chat screen", async ({
page,
}) => {
mockLangGraphAPI(page);
+ let cleanupAttempts = 0;
await page.route(/\/api\/threads\/[^/]+$/, (route) => {
- if (route.request().method() === "DELETE") {
+ if (route.request().method() === "DELETE" && ++cleanupAttempts === 1) {
return route.fulfill({
status: 500,
contentType: "application/json",
@@ -722,6 +727,25 @@ test.describe("Thread history", () => {
await recentThreadItem.hover();
await recentThreadItem.getByRole("button", { name: /more/i }).click();
await page.getByRole("menuitem", { name: /delete/i }).click();
+ await page
+ .getByRole("dialog")
+ .getByRole("button", { name: "Delete", exact: true })
+ .click();
+
+ // Remote deletion succeeded, but local cleanup failed. Keep the dialog
+ // and streamed content until the user retries the remaining cleanup.
+ await expect(
+ page.getByText("Local cleanup failed", {
+ exact: true,
+ }),
+ ).toBeVisible();
+ await expect(page.getByText("Hello from DeerFlow!")).toBeVisible();
+ await page
+ .getByRole("dialog")
+ .getByRole("button", { name: "Delete", exact: true })
+ .click();
+ await expect(page.getByRole("dialog")).toBeHidden();
+ expect(cleanupAttempts).toBe(2);
await expect(page).toHaveURL(/\/workspace\/chats\/new$/);
await expect(page.getByText("Previous question")).toHaveCount(0);
diff --git a/frontend/tests/unit/components/workspace/thread-delete-dialog.dom.test.tsx b/frontend/tests/unit/components/workspace/thread-delete-dialog.dom.test.tsx
new file mode 100644
index 000000000..61ea819a1
--- /dev/null
+++ b/frontend/tests/unit/components/workspace/thread-delete-dialog.dom.test.tsx
@@ -0,0 +1,104 @@
+import { afterEach, describe, expect, it, rs } from "@rstest/core";
+import {
+ QueryClient,
+ QueryClientProvider,
+ useMutation,
+} from "@tanstack/react-query";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { toast } from "sonner";
+
+import {
+ ThreadDeleteDialogProvider,
+ useThreadDeleteDialog,
+} from "@/components/workspace/thread-delete-dialog";
+import { DEFAULT_LOCALE } from "@/core/i18n";
+import { I18nProvider } from "@/core/i18n/context";
+
+const { deleteThread } = rs.hoisted(() => ({ deleteThread: rs.fn() }));
+rs.mock("next/navigation", () => ({
+ useRouter: () => ({ replace: rs.fn() }),
+ usePathname: () => "/workspace/chats/new",
+ useParams: () => ({}),
+}));
+rs.mock("sonner", () => ({ toast: { error: rs.fn() } }));
+rs.mock("@/components/workspace/chats/use-thread-chat", () => ({
+ resetThreadChatAfterDelete: rs.fn(),
+}));
+rs.mock("@/core/threads/hooks", () => ({
+ useDeleteThread: () => useMutation({ mutationFn: deleteThread }),
+}));
+
+function Trigger() {
+ const requestDelete = useThreadDeleteDialog();
+ return (
+
+ );
+}
+
+afterEach(() => {
+ cleanup();
+ rs.restoreAllMocks();
+});
+
+describe("thread deletion failure feedback", () => {
+ for (const [name, error, message] of [
+ ["specific error", new Error("Permission denied"), "Permission denied"],
+ ["empty error", new Error(""), "Failed to delete chat. Please try again."],
+ ["non-Error rejection", null, "Failed to delete chat. Please try again."],
+ ] as const) {
+ it(`reports ${name} and restores Cancel focus`, async () => {
+ deleteThread.mockRejectedValueOnce(error);
+ const log = rs
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+ const client = new QueryClient({
+ defaultOptions: { mutations: { retry: false } },
+ });
+ render(
+
+
+
+
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Open deletion" }));
+ const cancel = await screen.findByRole("button", {
+ name: "Cancel",
+ });
+ const del = screen.getByRole("button", { name: "Delete" });
+ del.focus();
+ fireEvent.click(del);
+ await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message));
+ await waitFor(() => expect(document.activeElement).toBe(cancel));
+ expect(log).toHaveBeenCalledWith("Failed to delete chat:", error);
+ expect(screen.getByRole("dialog")).not.toBeNull();
+ fireEvent.click(cancel);
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ client.clear();
+ });
+ }
+});
diff --git a/frontend/tests/unit/components/workspace/thread-sidebar-item-delete-gating.dom.test.tsx b/frontend/tests/unit/components/workspace/thread-sidebar-item-delete-gating.dom.test.tsx
index 68ed68e2d..668255565 100644
--- a/frontend/tests/unit/components/workspace/thread-sidebar-item-delete-gating.dom.test.tsx
+++ b/frontend/tests/unit/components/workspace/thread-sidebar-item-delete-gating.dom.test.tsx
@@ -11,6 +11,7 @@ import type { ReactNode } from "react";
import { SidebarProvider } from "@/components/ui/sidebar";
import { ThreadSidebarItem } from "@/components/workspace/recent-chat-list";
+import { ThreadDeleteDialogProvider } from "@/components/workspace/thread-delete-dialog";
import { AuthProvider } from "@/core/auth/AuthProvider";
import type { User } from "@/core/auth/types";
import { DEFAULT_LOCALE } from "@/core/i18n";
@@ -49,7 +50,9 @@ function renderItem(user: User): ReturnType {
-
+
+
+
diff --git a/frontend/tests/unit/core/threads/delete-thread.dom.test.tsx b/frontend/tests/unit/core/threads/delete-thread.dom.test.tsx
new file mode 100644
index 000000000..1960e1dd7
--- /dev/null
+++ b/frontend/tests/unit/core/threads/delete-thread.dom.test.tsx
@@ -0,0 +1,102 @@
+import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act, cleanup, renderHook } from "@testing-library/react";
+import type { PropsWithChildren } from "react";
+
+const mocks = rs.hoisted(() => ({
+ remove: rs.fn(),
+ search: rs.fn(),
+ fetch: rs.fn(),
+}));
+rs.mock("@/core/api", () => ({
+ getAPIClient: () => ({
+ threads: { delete: mocks.remove, search: mocks.search },
+ }),
+}));
+rs.mock("@/core/api/fetcher", () => ({ fetch: mocks.fetch }));
+
+import { useDeleteThread } from "@/core/threads/hooks";
+
+beforeEach(() => {
+ mocks.remove.mockReset().mockResolvedValue(undefined);
+ mocks.search.mockReset().mockResolvedValue([]);
+ mocks.fetch
+ .mockReset()
+ .mockImplementation(async () => new Response(null, { status: 204 }));
+});
+afterEach(cleanup);
+
+function setup() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ const wrapper = ({ children }: PropsWithChildren) => (
+ {children}
+ );
+ return renderHook(() => useDeleteThread(), { wrapper });
+}
+
+test("cleanup failure does not invoke completion and a remote 404 retry finishes cleanup", async () => {
+ const onDeleted = rs.fn();
+ const { result } = setup();
+ mocks.fetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({ detail: "Cleanup unavailable" }), {
+ status: 500,
+ }),
+ );
+ await act(async () => {
+ await expect(
+ result.current.mutateAsync({ threadId: "parent", onDeleted }),
+ ).rejects.toThrow("Cleanup unavailable");
+ });
+ expect(onDeleted).not.toHaveBeenCalled();
+ mocks.remove.mockRejectedValueOnce(
+ Object.assign(new Error("Thread not found"), { status: 404 }),
+ );
+ await act(async () => {
+ await result.current.mutateAsync({ threadId: "parent", onDeleted });
+ });
+ expect(mocks.fetch).toHaveBeenCalledTimes(2);
+ expect(onDeleted).toHaveBeenCalledTimes(1);
+});
+
+for (const status of [403, 500]) {
+ test(`remote ${status} stays an error and never starts local cleanup`, async () => {
+ const onDeleted = rs.fn();
+ mocks.remove.mockRejectedValueOnce({ status });
+ const { result } = setup();
+ await act(async () => {
+ await expect(
+ result.current.mutateAsync({ threadId: "parent", onDeleted }),
+ ).rejects.toEqual({ status });
+ });
+ expect(mocks.fetch).not.toHaveBeenCalled();
+ expect(onDeleted).not.toHaveBeenCalled();
+ });
+}
+
+test("already-deleted sidecars still receive local cleanup", async () => {
+ mocks.search.mockResolvedValueOnce([
+ {
+ thread_id: "sidecar",
+ metadata: { deerflow_sidecar: true, parent_thread_id: "parent" },
+ },
+ ]);
+ mocks.remove.mockRejectedValueOnce(
+ Object.assign(new Error("Thread not found"), { response: { status: 404 } }),
+ );
+ const { result } = setup();
+ await act(async () => {
+ await expect(
+ result.current.mutateAsync({ threadId: "parent" }),
+ ).resolves.toEqual(["sidecar"]);
+ });
+ expect(mocks.fetch).toHaveBeenCalledWith(
+ expect.stringContaining("/api/threads/sidecar"),
+ { method: "DELETE" },
+ );
+ expect(mocks.fetch).toHaveBeenCalledWith(
+ expect.stringContaining("/api/threads/parent"),
+ { method: "DELETE" },
+ );
+});