From a3ffff95302736154d0627065e1e6fa74014a2f9 Mon Sep 17 00:00:00 2001 From: Avinash Thorat Date: Thu, 6 Aug 2026 22:37:39 +0530 Subject: [PATCH] feat: implement DeleteConfirmModal for delete confirmations across components --- .../components/DeleteConfirmModal.test.tsx | 46 +++++++++++++ .../gui/src/components/DeleteConfirmModal.tsx | 51 ++++++++++++++ surfaces/gui/src/components/ScheduledView.tsx | 69 +++++++++++++++++-- surfaces/gui/src/components/SettingsView.tsx | 16 ++++- surfaces/gui/src/components/Sidebar.test.tsx | 6 +- surfaces/gui/src/components/Sidebar.tsx | 61 ++++++++-------- surfaces/gui/src/components/SkillsTab.tsx | 6 +- surfaces/gui/src/styles.css | 1 + 8 files changed, 215 insertions(+), 41 deletions(-) create mode 100644 surfaces/gui/src/components/DeleteConfirmModal.test.tsx create mode 100644 surfaces/gui/src/components/DeleteConfirmModal.tsx diff --git a/surfaces/gui/src/components/DeleteConfirmModal.test.tsx b/surfaces/gui/src/components/DeleteConfirmModal.test.tsx new file mode 100644 index 00000000..0be7ca35 --- /dev/null +++ b/surfaces/gui/src/components/DeleteConfirmModal.test.tsx @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { DeleteConfirmModal } from "./DeleteConfirmModal"; + +afterEach(cleanup); + +describe("DeleteConfirmModal", () => { + it("renders the warning copy and calls the supplied handlers", async () => { + const onCancel = vi.fn(); + const onConfirm = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(screen.getByText("Delete automation?")).toBeTruthy(); + expect(screen.getByText(/cannot be restored/i)).toBeTruthy(); + + fireEvent.click(screen.getByTestId("delete-confirm-cancel")); + expect(onCancel).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByTestId("delete-confirm-delete")); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it("renders nothing when closed", () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/surfaces/gui/src/components/DeleteConfirmModal.tsx b/surfaces/gui/src/components/DeleteConfirmModal.tsx new file mode 100644 index 00000000..712b77af --- /dev/null +++ b/surfaces/gui/src/components/DeleteConfirmModal.tsx @@ -0,0 +1,51 @@ +interface DeleteConfirmModalProps { + isOpen: boolean; + title: string; + description: string; + confirmLabel?: string; + cancelLabel?: string; + onCancel: () => void; + onConfirm: () => void | Promise; +} + +export function DeleteConfirmModal({ + isOpen, + title, + description, + confirmLabel = "Delete", + cancelLabel = "Cancel", + onCancel, + onConfirm, +}: DeleteConfirmModalProps) { + if (!isOpen) return null; + + const handleConfirm = async () => { + await onConfirm(); + }; + + return ( +
+
+
+

+ {title} +

+

{description}

+
+
+ + +
+
+
+ ); +} diff --git a/surfaces/gui/src/components/ScheduledView.tsx b/surfaces/gui/src/components/ScheduledView.tsx index 6c557349..aec3a771 100644 --- a/surfaces/gui/src/components/ScheduledView.tsx +++ b/surfaces/gui/src/components/ScheduledView.tsx @@ -13,6 +13,7 @@ import { import { Icon } from "./Icon"; import { PanelHead } from "./IntegrationsView"; import { AutomationQuickstart } from "./AutomationQuickstart"; +import { DeleteConfirmModal } from "./DeleteConfirmModal"; // Shared utility strings (the §28 page shell — mirrors IntegrationsView's constants). const CARD = "rounded-xl2 border border-line bg-panel"; @@ -68,6 +69,11 @@ export function ScheduledView({ onOpenRun, onRunNow, initialOpenId }: Props) { const [openId, setOpenId] = useState(initialOpenId ?? null); const [showForm, setShowForm] = useState(false); const [busy, setBusy] = useState(null); + const [pendingDelete, setPendingDelete] = useState<{ + id: string; + title: string; + onConfirm: () => void | Promise; + } | null>(null); // The sidebar's Scheduled band can retarget an ALREADY-open Automations surface — // initial state alone would ignore the change (UX-023). @@ -118,9 +124,27 @@ export function ScheduledView({ onOpenRun, onRunNow, initialOpenId }: Props) { } const empty = tasks.length === 0; + const closeDeleteModal = () => setPendingDelete(null); + const handleDeleteConfirm = async () => { + if (!pendingDelete) return; + try { + await pendingDelete.onConfirm(); + } finally { + closeDeleteModal(); + } + }; return ( + {pendingDelete && ( + + )}
@@ -174,10 +198,17 @@ export function ScheduledView({ onOpenRun, onRunNow, initialOpenId }: Props) { className="sched-card-del" title="Delete automation" aria-label={`Delete ${t.title}`} - onClick={async (e) => { + onClick={(e) => { e.stopPropagation(); - await deleteAutomation(t.id); - refresh(); + setPendingDelete({ + id: t.id, + title: t.title, + onConfirm: async () => { + await deleteAutomation(t.id); + announceAutomationsChanged(); + await refresh(); + }, + }); }} > @@ -296,6 +327,10 @@ function TaskDetail({ const [time, setTime] = useState("09:00"); const [freq, setFreq] = useState("daily"); const [saving, setSaving] = useState(false); + const [pendingDelete, setPendingDelete] = useState<{ + title: string; + onConfirm: () => void | Promise; + } | null>(null); // The seen mark AS OF opening — the "new" pills compare against this frozen value // while mark-seen advances the stored one (badge clears; highlights survive). @@ -358,6 +393,15 @@ function TaskDetail({ await updateAutomation(id, { enabled: !task.enabled }); refresh(); }; + const closeDeleteModal = () => setPendingDelete(null); + const handleDeleteConfirm = async () => { + if (!pendingDelete) return; + try { + await pendingDelete.onConfirm(); + } finally { + closeDeleteModal(); + } + }; const remove = async () => { await deleteAutomation(id); announceAutomationsChanged(); // the sidebar band must not wait out its poll @@ -366,6 +410,15 @@ function TaskDetail({ return ( + {pendingDelete && ( + + )} @@ -395,7 +448,15 @@ function TaskDetail({ ▶ Run now - diff --git a/surfaces/gui/src/components/SettingsView.tsx b/surfaces/gui/src/components/SettingsView.tsx index 8a722f12..a861a045 100644 --- a/surfaces/gui/src/components/SettingsView.tsx +++ b/surfaces/gui/src/components/SettingsView.tsx @@ -42,6 +42,7 @@ import { ModelsTab } from "./ManageTabs"; import { GalleryModal } from "./GalleryModal"; import { PersonasTab } from "./PersonasTab"; import { SkillsTab } from "./SkillsTab"; +import { DeleteConfirmModal } from "./DeleteConfirmModal"; import { showPersonas } from "../flags"; // Settings, restructured (Option 2) into a full-page surface that mirrors IntegrationsView's shell: @@ -158,6 +159,7 @@ function VoiceInputSection() { const [phase, setPhase] = useState<"idle" | "downloading" | "verifying" | "testing" | "transcribing">("idle"); const [error, setError] = useState(null); const [testTranscript, setTestTranscript] = useState(""); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const desktop = isTauri(); const publish = (next: DictationStatus) => { @@ -227,7 +229,6 @@ function VoiceInputSection() { }; const remove = async () => { - if (!window.confirm("Delete the local Whisper model and disable Voice Input?")) return; setError(null); try { publish(await deleteDictationModel()); @@ -317,7 +318,7 @@ function VoiceInputSection() { <> Verified - + ) : downloading ? ( @@ -356,6 +357,17 @@ function VoiceInputSection() { {error &&
{error}
}
)} + setShowDeleteConfirm(false)} + onConfirm={async () => { + setShowDeleteConfirm(false); + await remove(); + }} + /> ); } diff --git a/surfaces/gui/src/components/Sidebar.test.tsx b/surfaces/gui/src/components/Sidebar.test.tsx index 81d69e16..06515851 100644 --- a/surfaces/gui/src/components/Sidebar.test.tsx +++ b/surfaces/gui/src/components/Sidebar.test.tsx @@ -137,12 +137,12 @@ describe("Chronological list row actions (⋮ menu)", () => { fireEvent.click(screen.getByTestId("row-menu-archive")); expect(baseProps.onArchiveSession).toHaveBeenCalledWith("s-ops-1", true); - // Delete is two-step: first click arms ("Delete?"), the second deletes. + // Delete now confirms through the shared modal. openOpsMenu(); fireEvent.click(screen.getByTestId("row-menu-delete")); expect(baseProps.onDeleteSession).not.toHaveBeenCalled(); - expect(screen.getByTestId("row-menu-delete").textContent).toContain("Delete?"); - fireEvent.click(screen.getByTestId("row-menu-delete")); + expect(screen.getByTestId("delete-confirm-delete")).toBeTruthy(); + fireEvent.click(screen.getByTestId("delete-confirm-delete")); expect(baseProps.onDeleteSession).toHaveBeenCalledWith("s-ops-1"); }); diff --git a/surfaces/gui/src/components/Sidebar.tsx b/surfaces/gui/src/components/Sidebar.tsx index 9f0215dd..7f9afa13 100644 --- a/surfaces/gui/src/components/Sidebar.tsx +++ b/surfaces/gui/src/components/Sidebar.tsx @@ -25,6 +25,7 @@ import { ConnectorIcon } from "../connectors/ConnectorIcon"; import { Icon, type IconName } from "./Icon"; import { PersonaGlyph, personaGlyph } from "./personaIcon"; import { SearchModal } from "./SearchModal"; +import { DeleteConfirmModal } from "./DeleteConfirmModal"; import { baseName } from "../paths"; import { showPersonas } from "../flags"; @@ -214,9 +215,8 @@ export function Sidebar(props: Props) { }, []); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(""); - // Two-step delete inside the row's ⋮ menu: Delete arms ("Delete?"), a second click deletes. - // Archive is the primary way to put a conversation away — one click, reversible. - const [confirmDelId, setConfirmDelId] = useState(null); + // Delete confirmation is handled by the shared modal, while archive stays one-click and reversible. + const [pendingDelete, setPendingDelete] = useState<{ sessionId: string; title: string } | null>(null); // The open row-actions ⋮ menu (one at a time). Fixed-position, not absolute: the expanded // accordion group clips overflow (its rounded fill), so an absolute popover on its lower rows // would be cut off — same constraint as SlackDetail's person picker. @@ -228,13 +228,12 @@ export function Sidebar(props: Props) { } | null>(null); const closeRowMenu = () => { setRowMenu(null); - setConfirmDelId(null); }; const openRowMenu = (id: string, anchor: HTMLElement) => { const r = anchor.getBoundingClientRect(); const MENU_W = 160; // w-40 const MENU_H = 150; // ~4 items + divider; only used to flip upward near the window bottom - setConfirmDelId(null); + setPendingDelete(null); setRowMenu({ id, top: r.bottom + 4 + MENU_H > window.innerHeight ? r.top - MENU_H : r.bottom + 4, @@ -486,31 +485,17 @@ export function Sidebar(props: Props) { props.onArchiveSession(s.session_id, !s.archived), )}
- {confirmDelId === s.session_id ? ( - - ) : ( - - )} +
)} @@ -981,6 +966,13 @@ export function Sidebar(props: Props) { ); }; + const handleDeleteConfirm = () => { + if (!pendingDelete) return; + closeRowMenu(); + setPendingDelete(null); + props.onDeleteSession(pendingDelete.sessionId); + }; + return (
+ {pendingDelete && ( + setPendingDelete(null)} + onConfirm={handleDeleteConfirm} + /> + )} {searchModalOpen && (