mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 10:56:02 +00:00
fix(sidecar): panel button deletes the side chat instead of hiding it (#3961)
* fix(sidecar): make panel button delete the side chat instead of hiding it The side chat panel's top-right button called `sidecar.close()`, which only hid the panel — duplicating the header `SidecarTrigger` toggle. Repurpose the button so the panel owns deletion and the header toggle owns hide/show. - When a conversation exists, the button shows a trash icon and opens a confirmation dialog before deleting via `useDeleteThread` (backend cascade). - In the draft state (references only, no thread yet) the header trigger is not rendered, so the button falls back to a plain close (X) that discards the draft without a confirm — there is nothing persisted to delete. Also fix a latent double-delete bug surfaced by the new dialog: `useDeleteThread` deletes through the LangGraph route (which drops the thread_meta row) and then calls `deleteLocalThreadData`, which hits the same gateway handler whose `require_existing=True` guard now 404s. Treat that 404 as idempotent success. The recent-chat delete used fire-and-forget `mutate`, so it swallowed this error; the sidecar's `mutateAsync` surfaced it as a toast. The e2e mock now mirrors the gateway ownership guard (second DELETE 404s once the thread is gone) so the regression stays covered. Adds tests for delete, draft-state close, and header-owned hide/show. * fix(sidecar): lock delete dialog while deletion is in flight The delete confirmation dialog disabled Cancel during an in-flight delete but still allowed dismissal via the overlay, Esc, and the built-in close (X). Those paths could imply the delete was cancelled when it was actually still running. Ignore dismissals and drop the close button while `isDeleting` so only the (disabled) Cancel path controls the dialog.
This commit is contained in:
parent
48e5856c24
commit
34f87f6c92
@ -8,6 +8,7 @@ import {
|
|||||||
MessageSquareTextIcon,
|
MessageSquareTextIcon,
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
|
Trash2Icon,
|
||||||
XIcon,
|
XIcon,
|
||||||
ZapIcon,
|
ZapIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@ -35,6 +36,14 @@ import {
|
|||||||
type PromptInputMessage,
|
type PromptInputMessage,
|
||||||
} from "@/components/ai-elements/prompt-input";
|
} from "@/components/ai-elements/prompt-input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
@ -50,6 +59,7 @@ import {
|
|||||||
} from "@/core/sidecar";
|
} from "@/core/sidecar";
|
||||||
import { createSidecarThread } from "@/core/sidecar/api";
|
import { createSidecarThread } from "@/core/sidecar/api";
|
||||||
import {
|
import {
|
||||||
|
useDeleteThread,
|
||||||
useThreadStream,
|
useThreadStream,
|
||||||
type ThreadStreamOptions,
|
type ThreadStreamOptions,
|
||||||
} from "@/core/threads/hooks";
|
} from "@/core/threads/hooks";
|
||||||
@ -137,6 +147,9 @@ export function SidecarPanel({ className }: { className?: string }) {
|
|||||||
const { models, tokenUsageEnabled } = useModels();
|
const { models, tokenUsageEnabled } = useModels();
|
||||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||||
const [creatingThread, setCreatingThread] = useState(false);
|
const [creatingThread, setCreatingThread] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const { mutateAsync: deleteThread, isPending: isDeleting } =
|
||||||
|
useDeleteThread();
|
||||||
const [queuedSubmit, setQueuedSubmit] = useState<{
|
const [queuedSubmit, setQueuedSubmit] = useState<{
|
||||||
message: PromptInputMessage;
|
message: PromptInputMessage;
|
||||||
references: SidecarReference[];
|
references: SidecarReference[];
|
||||||
@ -437,6 +450,39 @@ export function SidecarPanel({ className }: { className?: string }) {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const discardDraftAndClose = useCallback(() => {
|
||||||
|
sidecar.clearActiveReferences();
|
||||||
|
sidecar.setSidecarThreadId(null);
|
||||||
|
sidecar.close();
|
||||||
|
}, [sidecar]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
const threadId = sidecar.sidecarThreadId;
|
||||||
|
// Guard: the trash button only opens this dialog once a thread exists, so a
|
||||||
|
// missing id here means the draft was cleared underneath us — just close.
|
||||||
|
if (!threadId) {
|
||||||
|
discardDraftAndClose();
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteThread({ threadId });
|
||||||
|
discardDraftAndClose();
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
toast.success(t.sidecar.deleteSuccess);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : t.sidecar.deleteFailed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
deleteThread,
|
||||||
|
discardDraftAndClose,
|
||||||
|
sidecar.sidecarThreadId,
|
||||||
|
t.sidecar.deleteFailed,
|
||||||
|
t.sidecar.deleteSuccess,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("flex size-full min-h-0 flex-col", className)}
|
className={cn("flex size-full min-h-0 flex-col", className)}
|
||||||
@ -454,16 +500,35 @@ export function SidecarPanel({ className }: { className?: string }) {
|
|||||||
: t.sidecar.noContext}
|
: t.sidecar.noContext}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip content={t.common.close}>
|
{hasSidecarThread ? (
|
||||||
<Button
|
<Tooltip content={t.sidecar.delete}>
|
||||||
aria-label={t.common.close}
|
<Button
|
||||||
size="icon-sm"
|
aria-label={t.sidecar.delete}
|
||||||
variant="ghost"
|
className="text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => sidecar.close()}
|
data-testid="sidecar-delete-button"
|
||||||
>
|
size="icon-sm"
|
||||||
<XIcon />
|
variant="ghost"
|
||||||
</Button>
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
</Tooltip>
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
// No conversation yet — nothing to delete, so this just discards the
|
||||||
|
// draft and closes the panel. A plain X (no confirm) keeps it light.
|
||||||
|
<Tooltip content={t.common.close}>
|
||||||
|
<Button
|
||||||
|
aria-label={t.common.close}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
data-testid="sidecar-close-button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => discardDraftAndClose()}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
@ -558,6 +623,55 @@ export function SidecarPanel({ className }: { className?: string }) {
|
|||||||
</PromptInput>
|
</PromptInput>
|
||||||
</PromptInputProvider>
|
</PromptInputProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
// While the delete is in flight the only way out is the (disabled)
|
||||||
|
// Cancel button, so ignore overlay/Esc/close-button dismissals that
|
||||||
|
// would otherwise hide the dialog and imply the delete was cancelled.
|
||||||
|
if (!open && isDeleting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleteDialogOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={!isDeleting}
|
||||||
|
onEscapeKeyDown={(event) => {
|
||||||
|
if (isDeleting) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onInteractOutside={(event) => {
|
||||||
|
if (isDeleting) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t.sidecar.delete}</DialogTitle>
|
||||||
|
<DialogDescription>{t.sidecar.deleteConfirm}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteDialogOpen(false)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{t.common.cancel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
data-testid="sidecar-delete-confirm-button"
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? t.common.loading : t.common.delete}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -452,6 +452,11 @@ export const enUS: Translations = {
|
|||||||
title: "Side chat",
|
title: "Side chat",
|
||||||
open: "Open side chat",
|
open: "Open side chat",
|
||||||
close: "Close side chat",
|
close: "Close side chat",
|
||||||
|
delete: "Delete side chat",
|
||||||
|
deleteConfirm:
|
||||||
|
"Are you sure you want to delete this side chat? This action cannot be undone. To simply hide it, use the side chat toggle in the header instead.",
|
||||||
|
deleteSuccess: "Side chat deleted",
|
||||||
|
deleteFailed: "Failed to delete side chat.",
|
||||||
addToConversation: "Add to conversation",
|
addToConversation: "Add to conversation",
|
||||||
askInSideChat: "Ask in side chat",
|
askInSideChat: "Ask in side chat",
|
||||||
reference: "Reference",
|
reference: "Reference",
|
||||||
|
|||||||
@ -362,6 +362,10 @@ export interface Translations {
|
|||||||
title: string;
|
title: string;
|
||||||
open: string;
|
open: string;
|
||||||
close: string;
|
close: string;
|
||||||
|
delete: string;
|
||||||
|
deleteConfirm: string;
|
||||||
|
deleteSuccess: string;
|
||||||
|
deleteFailed: string;
|
||||||
addToConversation: string;
|
addToConversation: string;
|
||||||
askInSideChat: string;
|
askInSideChat: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
|
|||||||
@ -436,6 +436,11 @@ export const zhCN: Translations = {
|
|||||||
title: "侧边对话",
|
title: "侧边对话",
|
||||||
open: "打开侧边对话",
|
open: "打开侧边对话",
|
||||||
close: "关闭侧边对话",
|
close: "关闭侧边对话",
|
||||||
|
delete: "删除侧边对话",
|
||||||
|
deleteConfirm:
|
||||||
|
"确定要删除该侧边对话吗?此操作不可撤销。如果只是想隐藏,请使用顶部的侧边对话开关。",
|
||||||
|
deleteSuccess: "侧边对话已删除",
|
||||||
|
deleteFailed: "删除侧边对话失败。",
|
||||||
addToConversation: "添加到对话",
|
addToConversation: "添加到对话",
|
||||||
askInSideChat: "在侧边聊天中提问",
|
askInSideChat: "在侧边聊天中提问",
|
||||||
reference: "引用",
|
reference: "引用",
|
||||||
|
|||||||
@ -2044,7 +2044,12 @@ async function deleteLocalThreadData(threadId: string) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
// A 404 means the thread is already gone — the desired end state. The prior
|
||||||
|
// `apiClient.threads.delete` call hits the same gateway handler (nginx
|
||||||
|
// rewrites /api/langgraph/threads/* to /api/threads/*) and removes the
|
||||||
|
// thread_meta row, so this second delete's ownership guard 404s. Treat it as
|
||||||
|
// success to keep the delete idempotent.
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
const error = await response
|
const error = await response
|
||||||
.json()
|
.json()
|
||||||
.catch(() => ({ detail: "Failed to delete local thread data." }));
|
.catch(() => ({ detail: "Failed to delete local thread data." }));
|
||||||
|
|||||||
@ -781,6 +781,10 @@ test.describe("Side chat", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "Ask a follow-up" }),
|
page.getByRole("heading", { name: "Ask a follow-up" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
// Draft state (no thread created yet): the header shows a plain close (X),
|
||||||
|
// not the destructive delete — there is nothing persisted to delete.
|
||||||
|
await expect(page.getByTestId("sidecar-close-button")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("sidecar-delete-button")).toBeHidden();
|
||||||
const sidecarReference = page.getByTestId("sidecar-reference-attachment");
|
const sidecarReference = page.getByTestId("sidecar-reference-attachment");
|
||||||
const sidecarInputForm = page.locator("form").filter({
|
const sidecarInputForm = page.locator("form").filter({
|
||||||
has: page.getByPlaceholder(/deeper follow-up/i),
|
has: page.getByPlaceholder(/deeper follow-up/i),
|
||||||
@ -943,14 +947,8 @@ test.describe("Side chat", () => {
|
|||||||
await expectComposerHeightsEqual(page);
|
await expectComposerHeightsEqual(page);
|
||||||
await expect(page.getByTestId("sidecar-header-trigger")).toBeVisible();
|
await expect(page.getByTestId("sidecar-header-trigger")).toBeVisible();
|
||||||
|
|
||||||
await page
|
// Hiding the side chat is owned by the header trigger; the panel's own
|
||||||
.getByTestId("sidecar-panel")
|
// button deletes the side chat instead of hiding it.
|
||||||
.getByRole("button", { name: /close/i })
|
|
||||||
.click();
|
|
||||||
await expect(page.getByTestId("sidecar-panel")).toBeHidden();
|
|
||||||
await expect(page.getByTestId("sidecar-header-trigger")).toBeVisible();
|
|
||||||
await page.getByTestId("sidecar-header-trigger").click();
|
|
||||||
await expect(page.getByTestId("sidecar-panel")).toBeVisible();
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId("sidecar-header-trigger"),
|
page.getByTestId("sidecar-header-trigger"),
|
||||||
).toHaveAccessibleName("Close side chat");
|
).toHaveAccessibleName("Close side chat");
|
||||||
@ -1279,4 +1277,273 @@ test.describe("Side chat", () => {
|
|||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("deletes the side chat from the panel's own button", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
mockLangGraphAPI(page, {
|
||||||
|
threads: [
|
||||||
|
{
|
||||||
|
thread_id: MOCK_THREAD_ID,
|
||||||
|
title: "Main conversation",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "human",
|
||||||
|
id: "parent-human-1",
|
||||||
|
content: [{ type: "text", text: "Plan the feature." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "ai",
|
||||||
|
id: "parent-ai-1",
|
||||||
|
content: "Build it as a side conversation.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
thread_id: MOCK_SIDECAR_THREAD_ID,
|
||||||
|
title: "Restored side chat",
|
||||||
|
updated_at: "2025-01-01T00:00:01Z",
|
||||||
|
metadata: {
|
||||||
|
deerflow_sidecar: true,
|
||||||
|
parent_thread_id: MOCK_THREAD_ID,
|
||||||
|
sidecar_context_type: "referenced_message",
|
||||||
|
sidecar_context_label: "Selected assistant text #2",
|
||||||
|
sidecar_context_count: 1,
|
||||||
|
referenced_message_id: "parent-ai-1",
|
||||||
|
referenced_message_ids: ["parent-ai-1"],
|
||||||
|
referenced_message_role: "assistant",
|
||||||
|
referenced_message_roles: ["assistant"],
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "ai",
|
||||||
|
id: "side-ai-1",
|
||||||
|
content: "Restored side answer.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||||
|
await expect(
|
||||||
|
page.getByText("Build it as a side conversation."),
|
||||||
|
).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId("sidecar-header-trigger")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open the side chat panel via the header trigger.
|
||||||
|
await page.getByTestId("sidecar-header-trigger").click();
|
||||||
|
await expect(page.getByTestId("sidecar-panel")).toBeVisible();
|
||||||
|
|
||||||
|
// The panel's own button deletes the side chat (it does not merely hide it).
|
||||||
|
await page.getByTestId("sidecar-delete-button").click();
|
||||||
|
await expect(
|
||||||
|
page.getByText("This action cannot be undone", { exact: false }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
const deleteRequestPromise = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === "DELETE" &&
|
||||||
|
request.url().includes(`/threads/${MOCK_SIDECAR_THREAD_ID}`),
|
||||||
|
);
|
||||||
|
await page.getByTestId("sidecar-delete-confirm-button").click();
|
||||||
|
await deleteRequestPromise;
|
||||||
|
|
||||||
|
// The panel closes and, because the sidecar thread is gone, the header
|
||||||
|
// trigger unmounts too — hiding is owned by the trigger, deleting by this.
|
||||||
|
await expect(page.getByTestId("sidecar-panel")).toBeHidden();
|
||||||
|
await expect(page.getByTestId("sidecar-header-trigger")).toBeHidden({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the delete dialog open while the delete is in flight", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
mockLangGraphAPI(page, {
|
||||||
|
threads: [
|
||||||
|
{
|
||||||
|
thread_id: MOCK_THREAD_ID,
|
||||||
|
title: "Main conversation",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "human",
|
||||||
|
id: "parent-human-1",
|
||||||
|
content: [{ type: "text", text: "Plan the feature." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "ai",
|
||||||
|
id: "parent-ai-1",
|
||||||
|
content: "Build it as a side conversation.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
thread_id: MOCK_SIDECAR_THREAD_ID,
|
||||||
|
title: "Restored side chat",
|
||||||
|
updated_at: "2025-01-01T00:00:01Z",
|
||||||
|
metadata: {
|
||||||
|
deerflow_sidecar: true,
|
||||||
|
parent_thread_id: MOCK_THREAD_ID,
|
||||||
|
sidecar_context_type: "referenced_message",
|
||||||
|
sidecar_context_label: "Selected assistant text #2",
|
||||||
|
sidecar_context_count: 1,
|
||||||
|
referenced_message_id: "parent-ai-1",
|
||||||
|
referenced_message_ids: ["parent-ai-1"],
|
||||||
|
referenced_message_role: "assistant",
|
||||||
|
referenced_message_roles: ["assistant"],
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "ai",
|
||||||
|
id: "side-ai-1",
|
||||||
|
content: "Restored side answer.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hold the local-delete step open so the mutation stays pending while we
|
||||||
|
// probe every dismissal path Radix would otherwise honor.
|
||||||
|
let releaseDelete: (() => void) | undefined;
|
||||||
|
const deleteGate = new Promise<void>((resolve) => {
|
||||||
|
releaseDelete = resolve;
|
||||||
|
});
|
||||||
|
await page.route(/\/api\/threads\/[^/]+$/, async (route) => {
|
||||||
|
if (route.request().method() !== "DELETE") {
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
|
await deleteGate;
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||||
|
await expect(
|
||||||
|
page.getByText("Build it as a side conversation."),
|
||||||
|
).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId("sidecar-header-trigger")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("sidecar-header-trigger").click();
|
||||||
|
await expect(page.getByTestId("sidecar-panel")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("sidecar-delete-button").click();
|
||||||
|
const dialogTitle = page.getByRole("heading", { name: "Delete side chat" });
|
||||||
|
await expect(dialogTitle).toBeVisible();
|
||||||
|
// The built-in Radix close (X) is present before the delete starts.
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-slot="dialog-content"] [data-slot="dialog-close"]'),
|
||||||
|
).toHaveCount(1);
|
||||||
|
|
||||||
|
await page.getByTestId("sidecar-delete-confirm-button").click();
|
||||||
|
|
||||||
|
// Delete is in flight: confirm shows the loading label and Cancel disables.
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("sidecar-delete-confirm-button"),
|
||||||
|
).toBeDisabled();
|
||||||
|
await expect(page.getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||||||
|
// The built-in close (X) is removed so it can't imply a cancel.
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-slot="dialog-content"] [data-slot="dialog-close"]'),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
// Esc and overlay clicks must not dismiss the dialog mid-delete.
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(dialogTitle).toBeVisible();
|
||||||
|
await page
|
||||||
|
.locator('[data-slot="dialog-overlay"]')
|
||||||
|
.click({ position: { x: 5, y: 5 } });
|
||||||
|
await expect(dialogTitle).toBeVisible();
|
||||||
|
|
||||||
|
// Once the delete resolves the dialog closes and the panel goes away.
|
||||||
|
releaseDelete?.();
|
||||||
|
await expect(dialogTitle).toBeHidden({ timeout: 10_000 });
|
||||||
|
await expect(page.getByTestId("sidecar-panel")).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closes the draft side chat without deleting when no conversation exists", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
mockLangGraphAPI(page, {
|
||||||
|
threads: [
|
||||||
|
{
|
||||||
|
thread_id: MOCK_THREAD_ID,
|
||||||
|
title: "Main conversation",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "human",
|
||||||
|
id: "parent-human-1",
|
||||||
|
content: [{ type: "text", text: "Plan the feature." }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "ai",
|
||||||
|
id: "parent-ai-1",
|
||||||
|
content: "Build it as a side conversation.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await page.route("**/api/models", (route) => {
|
||||||
|
if (route.request().method() !== "GET") {
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "deepseek-v4-pro",
|
||||||
|
name: "deepseek-v4-pro",
|
||||||
|
model: "deepseek-v4-pro",
|
||||||
|
display_name: "DeepSeek V4 Pro",
|
||||||
|
supports_thinking: true,
|
||||||
|
supports_reasoning_effort: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
token_usage: { enabled: false },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let deleteRequestFired = false;
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (
|
||||||
|
request.method() === "DELETE" &&
|
||||||
|
request.url().includes("/threads/")
|
||||||
|
) {
|
||||||
|
deleteRequestFired = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||||
|
await expect(
|
||||||
|
page.getByText("Build it as a side conversation."),
|
||||||
|
).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Open the side chat as a draft (references only, no thread created yet).
|
||||||
|
await selectTextAndClickToolbarButton(
|
||||||
|
page,
|
||||||
|
"Build it as a side conversation.",
|
||||||
|
"Ask in side chat",
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId("sidecar-panel")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("sidecar-reference-attachment"),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// The draft has no persisted thread, so the header offers a plain close (X)
|
||||||
|
// instead of the destructive delete button.
|
||||||
|
await expect(page.getByTestId("sidecar-delete-button")).toBeHidden();
|
||||||
|
await page.getByTestId("sidecar-close-button").click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("sidecar-panel")).toBeHidden();
|
||||||
|
// No thread was ever created, so closing must not issue a DELETE request.
|
||||||
|
expect(deleteRequestFired).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -691,6 +691,22 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
|
|
||||||
void page.route(/\/api\/threads\/[^/]+$/, (route) => {
|
void page.route(/\/api\/threads\/[^/]+$/, (route) => {
|
||||||
if (route.request().method() === "DELETE") {
|
if (route.request().method() === "DELETE") {
|
||||||
|
const threadId = decodeURIComponent(
|
||||||
|
new URL(route.request().url()).pathname.split("/").at(-1) ?? "",
|
||||||
|
);
|
||||||
|
// Mirror the gateway's `require_existing=True` ownership guard: deleting
|
||||||
|
// an already-removed thread 404s. `useDeleteThread` first deletes via the
|
||||||
|
// LangGraph route (which drops the thread_meta row) and then hits this
|
||||||
|
// route, so this reproduces the real double-delete 404 the frontend must
|
||||||
|
// treat as idempotent success.
|
||||||
|
if (!threads.some((thread) => thread.thread_id === threadId)) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ detail: `Thread ${threadId} not found` }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
threads = threads.filter((thread) => thread.thread_id !== threadId);
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 204,
|
status: 204,
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user