Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions surfaces/gui/src/components/DeleteConfirmModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<DeleteConfirmModal
isOpen
title="Delete automation?"
description="This action permanently removes the automation and it cannot be restored."
onCancel={onCancel}
onConfirm={onConfirm}
/>,
);

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(
<DeleteConfirmModal
isOpen={false}
title="Delete automation?"
description="This action permanently removes the automation and it cannot be restored."
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>,
);

expect(container.firstChild).toBeNull();
});
});
51 changes: 51 additions & 0 deletions surfaces/gui/src/components/DeleteConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
interface DeleteConfirmModalProps {
isOpen: boolean;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
onCancel: () => void;
onConfirm: () => void | Promise<void>;
}

export function DeleteConfirmModal({
isOpen,
title,
description,
confirmLabel = "Delete",
cancelLabel = "Cancel",
onCancel,
onConfirm,
}: DeleteConfirmModalProps) {
if (!isOpen) return null;

const handleConfirm = async () => {
await onConfirm();
};

return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/35 px-4" role="presentation">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel shadow-2xl" role="dialog" aria-modal="true" aria-labelledby="delete-confirm-title">
<div className="px-5 py-5">
<h2 id="delete-confirm-title" className="text-[16px] font-semibold tracking-tight text-ink">
{title}
</h2>
<p className="mt-2 text-[13px] leading-5 text-muted">{description}</p>
</div>
<div className="flex items-center justify-end gap-2 border-t border-line px-5 py-4">
<button className="btn sm" data-testid="delete-confirm-cancel" onClick={onCancel}>
{cancelLabel}
</button>
<button
className="btn sm"
data-testid="delete-confirm-delete"
onClick={() => void handleConfirm()}
style={{ backgroundColor: "var(--danger)", color: "#fff", borderColor: "var(--danger)" }}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
69 changes: 65 additions & 4 deletions surfaces/gui/src/components/ScheduledView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -68,6 +69,11 @@ export function ScheduledView({ onOpenRun, onRunNow, initialOpenId }: Props) {
const [openId, setOpenId] = useState<string | null>(initialOpenId ?? null);
const [showForm, setShowForm] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<{
id: string;
title: string;
onConfirm: () => void | Promise<void>;
} | null>(null);

// The sidebar's Scheduled band can retarget an ALREADY-open Automations surface —
// initial state alone would ignore the change (UX-023).
Expand Down Expand Up @@ -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 (
<Shell>
{pendingDelete && (
<DeleteConfirmModal
isOpen
title={`Delete “${pendingDelete.title}”?`}
description="This action permanently removes the automation and it cannot be restored."
onCancel={closeDeleteModal}
onConfirm={handleDeleteConfirm}
/>
)}
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<PanelHead title="Automations" sub="Recurring tasks OpenWorker runs on a schedule." />
Expand Down Expand Up @@ -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();
},
});
}}
>
<Icon name="trash" size={14} />
Expand Down Expand Up @@ -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<void>;
} | 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).
Expand Down Expand Up @@ -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
Expand All @@ -366,6 +410,15 @@ function TaskDetail({

return (
<Shell>
{pendingDelete && (
<DeleteConfirmModal
isOpen
title={`Delete “${pendingDelete.title}”?`}
description="This action permanently removes the automation and it cannot be restored."
onCancel={closeDeleteModal}
onConfirm={handleDeleteConfirm}
/>
)}
<button className="text-[13px] text-muted hover:text-ink mb-3" onClick={onBack}>
← Automations
</button>
Expand Down Expand Up @@ -395,7 +448,15 @@ function TaskDetail({
▶ Run now
</button>
<button className="btn sm" onClick={startEdit}>Edit</button>
<button className="btn sm danger-btn" onClick={remove}>
<button
className="btn sm danger-btn"
onClick={() =>
setPendingDelete({
title: task.title,
onConfirm: remove,
})
}
>
<Icon name="trash" size={14} /> Delete
</button>
</>
Expand Down
16 changes: 14 additions & 2 deletions surfaces/gui/src/components/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -158,6 +159,7 @@ function VoiceInputSection() {
const [phase, setPhase] = useState<"idle" | "downloading" | "verifying" | "testing" | "transcribing">("idle");
const [error, setError] = useState<string | null>(null);
const [testTranscript, setTestTranscript] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const desktop = isTauri();

const publish = (next: DictationStatus) => {
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -317,7 +318,7 @@ function VoiceInputSection() {
<>
<span className="text-[11.5px] px-2 py-1 rounded-full bg-green-50 text-green-700">Verified</span>
<button className={BTN_BORDERED} onClick={() => void repair()}>Repair</button>
<button className="text-[12px] text-red-600 px-2 py-2" onClick={() => void remove()}>Delete</button>
<button className="text-[12px] text-red-600 px-2 py-2" onClick={() => setShowDeleteConfirm(true)}>Delete</button>
</>
) : downloading ? (
<button className={BTN_BORDERED} onClick={() => void cancelDownload()}>Cancel</button>
Expand Down Expand Up @@ -356,6 +357,17 @@ function VoiceInputSection() {
{error && <div role="alert" className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-[12px] text-red-700">{error}</div>}
</div>
)}
<DeleteConfirmModal
isOpen={showDeleteConfirm}
title="Delete voice model?"
description="This removes the local Whisper model and disables Voice Input until you download it again."
confirmLabel="Delete"
onCancel={() => setShowDeleteConfirm(false)}
onConfirm={async () => {
setShowDeleteConfirm(false);
await remove();
}}
/>
</section>
);
}
Expand Down
6 changes: 3 additions & 3 deletions surfaces/gui/src/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down
Loading