From 34f87f6c92ce3d7ec30d41436618e4aff2e36ad7 Mon Sep 17 00:00:00 2001
From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com>
Date: Mon, 6 Jul 2026 22:14:44 +0800
Subject: [PATCH] fix(sidecar): panel button deletes the side chat instead of
hiding it (#3961)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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.
---
.../workspace/sidecar/sidecar-panel.tsx | 134 ++++++++-
frontend/src/core/i18n/locales/en-US.ts | 5 +
frontend/src/core/i18n/locales/types.ts | 4 +
frontend/src/core/i18n/locales/zh-CN.ts | 5 +
frontend/src/core/threads/hooks.ts | 7 +-
frontend/tests/e2e/sidecar-chat.spec.ts | 283 +++++++++++++++++-
frontend/tests/e2e/utils/mock-api.ts | 16 +
7 files changed, 435 insertions(+), 19 deletions(-)
diff --git a/frontend/src/components/workspace/sidecar/sidecar-panel.tsx b/frontend/src/components/workspace/sidecar/sidecar-panel.tsx
index 009dd15b8..4ebf7948e 100644
--- a/frontend/src/components/workspace/sidecar/sidecar-panel.tsx
+++ b/frontend/src/components/workspace/sidecar/sidecar-panel.tsx
@@ -8,6 +8,7 @@ import {
MessageSquareTextIcon,
PaperclipIcon,
RocketIcon,
+ Trash2Icon,
XIcon,
ZapIcon,
} from "lucide-react";
@@ -35,6 +36,14 @@ import {
type PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
import {
DropdownMenuGroup,
DropdownMenuLabel,
@@ -50,6 +59,7 @@ import {
} from "@/core/sidecar";
import { createSidecarThread } from "@/core/sidecar/api";
import {
+ useDeleteThread,
useThreadStream,
type ThreadStreamOptions,
} from "@/core/threads/hooks";
@@ -137,6 +147,9 @@ export function SidecarPanel({ className }: { className?: string }) {
const { models, tokenUsageEnabled } = useModels();
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const [creatingThread, setCreatingThread] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const { mutateAsync: deleteThread, isPending: isDeleting } =
+ useDeleteThread();
const [queuedSubmit, setQueuedSubmit] = useState<{
message: PromptInputMessage;
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 (
-
-
-
+ {hasSidecarThread ? (
+
+
+
+ ) : (
+ // No conversation yet — nothing to delete, so this just discards the
+ // draft and closes the panel. A plain X (no confirm) keeps it light.
+
+
+
+ )}