mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +00:00
fix(frontend): confirm sidebar chat deletion (#5406)
* fix(frontend): confirm sidebar chat deletion * fix(frontend): preserve chat deletion retries after partial cleanup * fix(frontend): improve chat deletion failure feedback
This commit is contained in:
parent
2814bd5d49
commit
c3adc51ec9
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleDelete}>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => requestDelete({ thread, recentThreadId })}
|
||||
>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>{t.common.delete}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
173
frontend/src/components/workspace/thread-delete-dialog.tsx
Normal file
173
frontend/src/components/workspace/thread-delete-dialog.tsx
Normal file
@ -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<DeleteTarget | null>(null);
|
||||
const deleteInFlight = useRef(false);
|
||||
const deleteCancelRef = useRef<HTMLButtonElement>(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 (
|
||||
<ThreadDeleteContext.Provider value={requestDelete}>
|
||||
{children}
|
||||
<Dialog
|
||||
open={target !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !deleteInFlight.current) setTarget(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={!isDeleting}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
deleteCancelRef.current?.focus();
|
||||
}}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (deleteInFlight.current) event.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
if (deleteInFlight.current) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.chats.deleteChat}</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
{target && t.chats.deleteConfirm(titleOfThread(target.thread))}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
ref={deleteCancelRef}
|
||||
variant="outline"
|
||||
onClick={() => setTarget(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? t.common.loading : t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ThreadDeleteContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -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<typeof Sidebar>) {
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
return (
|
||||
<>
|
||||
<ThreadDeleteDialogProvider>
|
||||
<Sidebar variant="sidebar" collapsible="icon" {...props}>
|
||||
<SidebarHeader className="py-0">
|
||||
<WorkspaceHeader />
|
||||
@ -41,6 +42,6 @@ export function WorkspaceSidebar({
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</>
|
||||
</ThreadDeleteDialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -598,6 +598,9 @@ export interface Translations {
|
||||
|
||||
// Chats
|
||||
chats: {
|
||||
deleteChat: string;
|
||||
deleteConfirm: (title: string) => string;
|
||||
deleteFailed: string;
|
||||
noActiveChats: string;
|
||||
activeChats: string;
|
||||
archivedChats: string;
|
||||
|
||||
@ -686,6 +686,10 @@ export const zhCN: Translations = {
|
||||
|
||||
// Chats
|
||||
chats: {
|
||||
deleteChat: "删除对话",
|
||||
deleteConfirm: (title) =>
|
||||
`确定删除“${title}”吗?这将删除对话及其文件,此操作不可撤销。`,
|
||||
deleteFailed: "删除对话失败,请重试。",
|
||||
noActiveChats: "暂无近期会话",
|
||||
activeChats: "近期会话",
|
||||
archivedChats: "已归档",
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
191
frontend/tests/e2e/thread-delete-confirm.spec.ts
Normal file
191
frontend/tests/e2e/thread-delete-confirm.spec.ts
Normal file
@ -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<void>((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<void>((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}$`),
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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 (
|
||||
<button
|
||||
onClick={() =>
|
||||
requestDelete({
|
||||
thread: {
|
||||
thread_id: "thread-1",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
metadata: {},
|
||||
status: "idle",
|
||||
values: { title: "Keep me", messages: [] },
|
||||
interrupts: {},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Open deletion
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<I18nProvider initialLocale={DEFAULT_LOCALE}>
|
||||
<QueryClientProvider client={client}>
|
||||
<ThreadDeleteDialogProvider>
|
||||
<Trigger />
|
||||
</ThreadDeleteDialogProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -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<typeof render> {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider initialUser={user}>
|
||||
<SidebarProvider>
|
||||
<ThreadSidebarItem thread={makeThread()} isActive={false} />
|
||||
<ThreadDeleteDialogProvider>
|
||||
<ThreadSidebarItem thread={makeThread()} isActive={false} />
|
||||
</ThreadDeleteDialogProvider>
|
||||
</SidebarProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
102
frontend/tests/unit/core/threads/delete-thread.dom.test.tsx
Normal file
102
frontend/tests/unit/core/threads/delete-thread.dom.test.tsx
Normal file
@ -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) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
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" },
|
||||
);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user