fix(web): confirm automation deletion (#3129)

This commit is contained in:
Carlos Florêncio
2026-08-28 22:41:54 +00:00
committed by GitHub
parent ace26dfd99
commit a9af759d42
21 changed files with 594 additions and 10 deletions
@@ -0,0 +1,27 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AutomationDeleteConfirmDialog } from "./automation-delete-confirm-dialog";
afterEach(cleanup);
describe("AutomationDeleteConfirmDialog", () => {
it("keeps the controlled dialog open while confirmation is pending", () => {
const onConfirm = vi.fn(() => new Promise<void>(() => {}));
const onOpenChange = vi.fn();
render(
<AutomationDeleteConfirmDialog
open
automationName="Nightly cleanup"
onOpenChange={onOpenChange}
onConfirm={onConfirm}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onOpenChange).not.toHaveBeenCalledWith(false);
expect(screen.getByRole("alertdialog")).toBeTruthy();
});
});
@@ -0,0 +1,65 @@
"use client";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import { Button } from "@kandev/ui/button";
import { useTranslation } from "react-i18next";
type AutomationDeleteConfirmDialogProps = {
open: boolean;
automationName: string;
isDeleting?: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void | Promise<void>;
};
export function AutomationDeleteConfirmDialog({
open,
automationName,
isDeleting = false,
onOpenChange,
onConfirm,
}: AutomationDeleteConfirmDialogProps) {
const { t } = useTranslation();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent
data-testid="automation-delete-confirm-dialog"
className="w-[calc(100vw-2rem)] sm:max-w-sm"
>
<AlertDialogHeader>
<AlertDialogTitle>{t("automations:deleteAutomationTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("automations:deleteAutomationDescription", { name: automationName })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={isDeleting}
className="min-h-12 w-full cursor-pointer sm:min-h-9 sm:w-auto"
>
{t("common:cancel")}
</AlertDialogCancel>
<Button
type="button"
variant="destructive"
disabled={isDeleting}
data-testid="automation-delete-confirm"
className="min-h-12 w-full cursor-pointer sm:min-h-9 sm:w-auto"
onClick={onConfirm}
>
{t("automations:delete")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -32,6 +32,7 @@ import {
import { resolveExecutorType } from "./automation-repository-selection";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { useAutomationTriggerDrafts } from "./automation-trigger-drafts";
import { AutomationDeleteConfirmDialog } from "./automation-delete-confirm-dialog";
import {
CreatedWebhookDialogHost,
EditorFooter,
@@ -380,6 +381,52 @@ function useEditorDirtyState(
return { dirtyBaseline, triggersDirty };
}
function AutomationDeleteControls({
saving,
isNew,
automationName,
onDelete,
}: {
saving: boolean;
isNew: boolean;
automationName: string;
onDelete: () => Promise<void>;
}) {
const [confirmationOpen, setConfirmationOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleConfirm = useCallback(async () => {
if (deleting) return;
setDeleting(true);
try {
await onDelete();
} catch {
// onDelete already reports the deletion error to the user.
} finally {
setDeleting(false);
}
}, [deleting, onDelete]);
return (
<>
<EditorFooter
saving={saving || deleting}
isNew={isNew}
onDelete={() => setConfirmationOpen(true)}
/>
<AutomationDeleteConfirmDialog
open={confirmationOpen}
automationName={automationName}
isDeleting={deleting}
onOpenChange={(open) => {
if (!deleting) setConfirmationOpen(open);
}}
onConfirm={handleConfirm}
/>
</>
);
}
export function AutomationEditor({ workspaceId, automationId }: AutomationEditorProps) {
const router = useRouter();
const { create, update, remove } = useAutomations(workspaceId);
@@ -466,7 +513,12 @@ export function AutomationEditor({ workspaceId, automationId }: AutomationEditor
<SettingsSection form={form} savedForm={dirtyBaseline} updateField={updateField} />
<Separator />
<RunsSection automationId={currentId} workspaceId={workspaceId} />
<EditorFooter saving={saving} isNew={isNew} onDelete={handleRemove} />
<AutomationDeleteControls
saving={saving}
isNew={isNew}
automationName={savedForm.name}
onDelete={handleRemove}
/>
<CreatedWebhookDialogHost
details={createdWebhook}
onClose={() => {
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsSaveProvider } from "@/components/settings/settings-save-provider";
@@ -40,14 +40,14 @@ function mkAutomation(id: string, overrides: Partial<Automation> = {}): Automati
};
}
function renderPage(items: Automation[], workspaceId = "ws-1") {
function renderPage(items: Automation[], workspaceId = "ws-1", remove = vi.fn()) {
mockUseAutomations.mockReturnValue({
items,
loading: false,
enable: vi.fn(),
disable: vi.fn(),
trigger: vi.fn(),
remove: vi.fn(),
remove,
});
return render(
<SettingsSaveProvider>
@@ -110,4 +110,16 @@ describe("AutomationsListPage board-move notice", () => {
expect(screen.queryByTestId(NOTICE)).toBeNull();
});
it("keeps the delete confirmation open when deletion fails", async () => {
const remove = vi.fn().mockRejectedValue(new Error("temporary failure"));
renderPage([mkAutomation("automation-a", { name: "Delete me" })], "ws-1", remove);
fireEvent.click(screen.getByTestId("automation-delete-button-automation-a"));
fireEvent.click(screen.getByTestId("automation-delete-confirm"));
await waitFor(() => expect(remove).toHaveBeenCalledWith("automation-a"));
expect(screen.getByRole("alertdialog")).toBeTruthy();
expect(screen.getByText("Delete me")).toBeTruthy();
});
});
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "@/lib/routing/client-router";
import { Button } from "@kandev/ui/button";
@@ -12,6 +13,8 @@ import { AutomationBoardMoveNotice } from "./board-move-notice";
import { AutomationsExportButton } from "./automations-export-button";
import { useAutomationEnabledDrafts } from "./use-automation-enabled-drafts";
import { WorkspaceSectionHeader } from "@/components/settings/workspaces/workspace-section-header";
import { AutomationDeleteConfirmDialog } from "./automation-delete-confirm-dialog";
import type { Automation } from "@/lib/types/automation";
type AutomationsListPageProps = {
workspaceId: string;
@@ -22,6 +25,8 @@ export function AutomationsListPage({ workspaceId }: AutomationsListPageProps) {
const router = useRouter();
const { items, loading, enable, disable, trigger, remove } = useAutomations(workspaceId);
const enabledDrafts = useAutomationEnabledDrafts({ automations: items, enable, disable });
const [automationToDelete, setAutomationToDelete] = useState<Automation | null>(null);
const [deletingAutomationId, setDeletingAutomationId] = useState<string | null>(null);
const handleTrigger = async (id: string) => {
// Triggering can legitimately do nothing — the concurrency cap turns the
@@ -43,8 +48,25 @@ export function AutomationsListPage({ workspaceId }: AutomationsListPageProps) {
}
};
const handleDelete = async (id: string) => {
await remove(id);
const handleDelete = (id: string) => {
const automation = items.find((item) => item.id === id);
if (automation) setAutomationToDelete(automation);
};
const handleConfirmDelete = async () => {
if (!automationToDelete || deletingAutomationId) return;
const { id } = automationToDelete;
setDeletingAutomationId(id);
try {
await remove(id);
setAutomationToDelete(null);
} catch (error) {
toast.error(t("automations:failedToDeleteAutomation"), {
description: error instanceof Error ? error.message : t("common:requestFailed"),
});
} finally {
setDeletingAutomationId(null);
}
};
return (
@@ -87,6 +109,17 @@ export function AutomationsListPage({ workspaceId }: AutomationsListPageProps) {
onDelete={handleDelete}
/>
)}
<AutomationDeleteConfirmDialog
open={automationToDelete !== null}
automationName={automationToDelete?.name ?? ""}
isDeleting={deletingAutomationId !== null}
onOpenChange={(open) => {
if (!open && deletingAutomationId !== automationToDelete?.id) {
setAutomationToDelete(null);
}
}}
onConfirm={handleConfirmDelete}
/>
</div>
);
}
@@ -91,6 +91,8 @@ function RowActions({
variant="ghost"
size="icon-sm"
className="cursor-pointer"
data-testid={`automation-delete-button-${id}`}
aria-label={t("automations:delete")}
onClick={() => onDelete(id)}
>
<IconTrash className="h-3.5 w-3.5 text-destructive" />
+4
View File
@@ -10,6 +10,8 @@ export class AutomationsPage {
readonly nameInput: Locator;
readonly saveButton: Locator;
readonly deleteButton: Locator;
readonly deleteConfirmation: Locator;
readonly deleteConfirmButton: Locator;
readonly frequencySelector: Locator;
readonly customScheduleInput: Locator;
readonly timeInput: Locator;
@@ -33,6 +35,8 @@ export class AutomationsPage {
.getByTestId("settings-floating-save")
.getByRole("button", { name: /save changes/i });
this.deleteButton = page.getByTestId("automation-delete-button");
this.deleteConfirmation = page.getByTestId("automation-delete-confirm-dialog");
this.deleteConfirmButton = this.deleteConfirmation.getByTestId("automation-delete-confirm");
this.frequencySelector = page.getByTestId("schedule-frequency");
this.customScheduleInput = page.getByTestId("schedule-custom-input");
this.timeInput = page.getByTestId("schedule-time");
@@ -151,7 +151,7 @@ test.describe("Automations settings page", () => {
await expect(testPage.getByText("Updated Name")).toBeVisible();
});
test("delete automation from editor", async ({ testPage, seedData }) => {
test("delete automation from editor requires confirmation", async ({ testPage, seedData }) => {
const automations = new AutomationsPage(testPage, seedData.workspaceId);
// Create an automation first
@@ -168,8 +168,26 @@ test.describe("Automations settings page", () => {
await automations.openByName("To Be Deleted");
await expect(testPage).toHaveURL(/automations\/[a-f0-9-]+$/, { timeout: 10_000 });
// Delete it
// The confirmation must identify the persisted automation, not an
// unsaved draft name from the editor.
await automations.nameInput.fill("Unsaved Draft Name");
await automations.deleteButton.click();
await expect(automations.deleteConfirmation).toBeVisible();
await expect(automations.deleteConfirmation).toContainText(
"This will permanently delete To Be Deleted. This action cannot be undone.",
);
await expect(automations.deleteConfirmation).not.toContainText("Unsaved Draft Name");
// Cancelling must leave the editor and the automation untouched.
await automations.deleteConfirmation.getByRole("button", { name: "Cancel" }).click();
await expect(automations.deleteConfirmation).not.toBeVisible();
await expect(testPage).toHaveURL(/automations\/[a-f0-9-]+$/, { timeout: 5_000 });
await expect(automations.deleteButton).toBeVisible();
// Confirming performs the existing delete flow.
await automations.deleteButton.click();
await expect(automations.deleteConfirmation).toBeVisible();
await automations.deleteConfirmButton.click();
// Should redirect to list page
await expect(testPage).toHaveURL(/automations$/, { timeout: 10_000 });
@@ -178,6 +196,41 @@ test.describe("Automations settings page", () => {
await expect(testPage.getByText("To Be Deleted")).not.toBeVisible({ timeout: 10_000 });
});
test("delete automation from list requires confirmation", async ({
testPage,
seedData,
apiClient,
prCapture,
}) => {
const automation = await apiClient.seedAutomation({
workspaceId: seedData.workspaceId,
name: "List Delete Automation",
workflowId: seedData.workflowId,
workflowStepId: seedData.startStepId,
});
const automations = new AutomationsPage(testPage, seedData.workspaceId);
await automations.goto();
const row = automations.automationRow(automation.id);
const deleteButton = row.getByTestId(`automation-delete-button-${automation.id}`);
await deleteButton.click();
await expect(automations.deleteConfirmation).toBeVisible();
await expect(automations.deleteConfirmation).toContainText(
"This will permanently delete List Delete Automation. This action cannot be undone.",
);
await prCapture.screenshot("automation-delete-confirmation-desktop", {
caption: "Desktop automation deletion confirmation",
});
await automations.deleteConfirmation.getByRole("button", { name: "Cancel" }).click();
await expect(automations.deleteConfirmation).not.toBeVisible();
await expect(row).toBeVisible();
await deleteButton.click();
await automations.deleteConfirmButton.click();
await expect(row).toHaveCount(0, { timeout: 10_000 });
});
test("create webhook automation shows reveal dialog with URL and secret", async ({
testPage,
seedData,
@@ -70,6 +70,11 @@ test.describe("Pull request merged automation on mobile", () => {
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
await automations.deleteButton.tap();
await expect(automations.deleteConfirmation).toBeVisible();
await expect(automations.deleteConfirmation).toContainText(
"This will permanently delete Mobile PR merged trigger. This action cannot be undone.",
);
await automations.deleteConfirmButton.tap();
await expect(testPage).toHaveURL(/automations$/, { timeout: 10_000 });
await expect(testPage.getByText(automationName, { exact: true })).not.toBeVisible();
});
@@ -0,0 +1,71 @@
import { test, expect } from "../../fixtures/test-base";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
test.describe("Automation deletion confirmation on mobile", () => {
test("keeps cancellation safe and confirms deletion from the editor", async ({
testPage,
seedData,
apiClient,
prCapture,
}) => {
const automation = await apiClient.seedAutomation({
workspaceId: seedData.workspaceId,
name: "Mobile Delete Automation",
workflowId: seedData.workflowId,
workflowStepId: seedData.startStepId,
});
await testPage.goto(
`/settings/workspaces/${seedData.workspaceId}/automations/${automation.id}`,
);
await expect(testPage.getByTestId("automation-editor")).toBeVisible({ timeout: 15_000 });
const deleteButton = testPage.getByTestId("automation-delete-button");
await deleteButton.tap();
const confirmation = testPage.getByTestId("automation-delete-confirm-dialog");
await expect(confirmation).toBeVisible();
await expect(confirmation).toContainText(
"This will permanently delete Mobile Delete Automation. This action cannot be undone.",
);
await expect(testPage.getByRole("alertdialog")).toHaveCount(1);
const viewport = testPage.viewportSize();
const dialogBox = await confirmation.boundingBox();
expect(viewport).not.toBeNull();
expect(dialogBox).not.toBeNull();
expect(dialogBox!.x).toBeGreaterThanOrEqual(0);
expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(viewport!.width);
expect(dialogBox!.y).toBeGreaterThanOrEqual(0);
expect(dialogBox!.y + dialogBox!.height).toBeLessThanOrEqual(viewport!.height);
for (const label of ["Cancel", "Delete"]) {
const action = confirmation.getByRole("button", { name: label, exact: true });
await expect(action).toBeVisible();
const box = await action.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThanOrEqual(44);
}
await assertNoDocumentHorizontalOverflow(testPage, "mobile automation deletion confirmation");
await prCapture.screenshot("automation-delete-confirmation-mobile", {
caption: "Mobile automation deletion confirmation",
});
await confirmation.getByRole("button", { name: "Cancel", exact: true }).tap();
await expect(confirmation).not.toBeVisible();
await expect(deleteButton).toBeVisible();
await deleteButton.tap();
await testPage
.getByTestId("automation-delete-confirm-dialog")
.getByTestId("automation-delete-confirm")
.tap();
await expect(testPage).toHaveURL(/\/settings\/workspaces\/[^/]+\/automations$/, {
timeout: 15_000,
});
await expect(testPage.getByText("Mobile Delete Automation")).not.toBeVisible({
timeout: 10_000,
});
});
});
+2
View File
@@ -51,6 +51,8 @@
"cronExpressionLabel": "Cron expression",
"cronHelp": "Standard 5-field cron or shortcuts ({{shortcuts}})",
"delete": "Delete",
"deleteAutomationTitle": "Delete automation?",
"deleteAutomationDescription": "This will permanently delete {{name}}. This action cannot be undone.",
"deleteAll": "Delete all",
"deleteAllRuns": "Delete all runs",
"deleteAllRunsDescription": "This will permanently remove all run records for this automation; including any not currently loaded; and their associated tasks. This cannot be undone.",
@@ -51,6 +51,8 @@
"cronExpressionLabel": "Ćŕōń ēxƥŕēśśĩōń",
"cronHelp": "Śţàńďàŕď 5-ƒĩēĺď ćŕōń ōŕ śĥōŕţćũţś ({{shortcuts}})",
"delete": "Ďēĺēţē",
"deleteAutomationTitle": "Ďēĺēţē àũţōḿàţĩōń?",
"deleteAutomationDescription": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ďēĺēţē {{name}}. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"deleteAll": "Ďēĺēţē àĺĺ",
"deleteAllRuns": "Ďēĺēţē àĺĺ ŕũńś",
"deleteAllRunsDescription": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē àĺĺ ŕũń ŕēćōŕďś ƒōŕ ţĥĩś àũţōḿàţĩōń; ĩńćĺũďĩńĝ àńŷ ńōţ ćũŕŕēńţĺŷ ĺōàďēď; àńď ţĥēĩŕ àśśōćĩàţēď ţàśķś. Ţĥĩś ćàńńōţ ƀē ũńďōńē.",
@@ -51,6 +51,8 @@
"cronExpressionLabel": "Expressão cron",
"cronHelp": "Cron padrão de 5 campos ou atalhos ({{shortcuts}})",
"delete": "Eliminar",
"deleteAutomationTitle": "Eliminar automatização?",
"deleteAutomationDescription": "Esta ação irá eliminar permanentemente {{name}}. Esta ação não pode ser anulada.",
"deleteAll": "Eliminar todas",
"deleteAllRuns": "Eliminar todas as execuções",
"deleteAllRunsDescription": "Isto remove permanentemente todos os registos de execução desta automatização; incluindo os que não estão carregados neste momento; e as tarefas associadas. Esta ação não pode ser anulada.",
@@ -51,6 +51,8 @@
"cronExpressionLabel": "Cron 表达式",
"cronHelp": "标准五字段 cron 或快捷方式({{shortcuts}})",
"delete": "删除",
"deleteAutomationTitle": "删除自动化?",
"deleteAutomationDescription": "这将永久删除 {{name}}。此操作无法撤销。",
"deleteAll": "全部删除",
"deleteAllRuns": "删除所有运行记录",
"deleteAllRunsDescription": "这将永久删除此自动化的所有运行记录;包括当前未加载的记录;以及关联的任务。此操作无法撤销。",
@@ -51,6 +51,8 @@
"cronExpressionLabel": "Cron 表達式",
"cronHelp": "標準五欄位 cron 或快捷方式({{shortcuts}})",
"delete": "刪除",
"deleteAutomationTitle": "刪除自動化?",
"deleteAutomationDescription": "這將永久刪除 {{name}}。此操作無法復原。",
"deleteAll": "全部刪除",
"deleteAllRuns": "刪除所有執行記錄",
"deleteAllRunsDescription": "這將永久刪除此自動化的所有執行記錄;包括目前未載入的記錄;以及關聯的任務。此操作無法撤銷。",
@@ -51,6 +51,8 @@
"cronExpressionLabel": "Cron 表示式",
"cronHelp": "標準五欄位 cron 或快捷方式({{shortcuts}})",
"delete": "刪除",
"deleteAutomationTitle": "刪除自動化?",
"deleteAutomationDescription": "這將永久刪除 {{name}}。此操作無法復原。",
"deleteAll": "全部刪除",
"deleteAllRuns": "刪除所有執行記錄",
"deleteAllRunsDescription": "這將永久刪除此自動化的所有執行記錄;包括目前未載入的記錄;以及關聯的任務。此操作無法撤銷。",
@@ -0,0 +1,114 @@
---
created: 2026-08-28
status: done
requirements:
- REQ-OFFICE-AUTOMATIONS-SETTINGS-001
system_design:
- ../../specs/office/system-design/automations-settings-01.md
- ../../specs/office/system-design/automations-settings-02.md
legacy_specs: []
---
# Implementation Plan: Automation deletion confirmation
## Overview
Add one shared confirmation surface to both automation deletion entry points in
Settings. The list and editor keep their existing mutation and navigation
ownership, while the shared dialog prevents accidental deletion and gives the
same desktop, keyboard, and phone outcome.
## Scope
### In scope
- Confirmation before deleting an automation from the workspace list.
- Confirmation before deleting an automation from the automation editor.
- Localized dialog title and permanent-deletion warning in all supported locales.
- Desktop and mobile browser coverage for cancel and confirm behavior.
### Out of scope
- Changes to the automation delete API or backend lifecycle.
- Confirmation for deleting individual automation runs or triggers.
- Changes to the settings unsaved-changes save coordinator.
## Technical approach
- Add `apps/web/components/automations/automation-delete-confirm-dialog.tsx`,
wrapping the existing `@kandev/ui/alert-dialog` primitives. The component
receives the automation name and an async confirmation callback; it does not
own API or routing state.
- Keep the list page responsible for the selected row and call its existing
`useAutomations.remove` mutation only after confirmation.
- Keep the editor's existing `useRemoveAutomation` callback responsible for
deletion and redirect, with `EditorFooter` becoming the dialog trigger.
- Add the dialog copy to the five complete automation catalogs and preserve
the current `automations:delete` action label. Keep the pseudo catalog in
sync with the English catalog.
### Mobile design contract
- Desktop outcome: the list trash action and editor Delete button open a
blocking confirmation; Confirm/Delete performs the existing deletion result.
- Mobile entry point: the same visible trash/Delete control remains available;
tapping it opens the shared alert dialog.
- Nearest shipped exemplars: `quick-chat-delete-dialog.tsx` for the existing
short destructive alert-dialog anatomy and
`settings/mobile-agent-profile-delete.spec.ts` for settings deletion focus
and 44px touch-target checks.
- Hierarchy and surface: title, named automation warning, then Cancel and
Delete in the existing centered `AlertDialog`; the content is short, so a
bottom drawer would add no value to the blocking decision.
- Scroll and safe area: the intrinsic alert dialog is viewport-contained, has
no internal scroll owner, and uses full-width touch-sized footer actions on
phone-sized viewports.
- State: list/editor mutation, routing, and error handling remain shared with
desktop; only the dialog presentation is responsive.
- Mobile proof: a `mobile-*.spec.ts` flow opens the editor on a Pixel 5,
verifies cancel leaves the automation intact, then confirms deletion and
verifies the list destination and backend-visible removal.
## Tests
- `AC-OFFICE-AUTOMATIONS-SETTINGS-001.9` and `.001.10`: extend
`apps/web/e2e/tests/automations-settings.spec.ts` to cover the editor
dialog, cancellation, and confirmation; add a list-row confirmation check.
- `AC-OFFICE-AUTOMATIONS-SETTINGS-001.11`: add
`apps/web/e2e/tests/settings/mobile-automations-settings.spec.ts` using the
`mobile-chrome` project, including viewport containment and touch hit-area
assertions.
- Run the automation catalog and specification checks after the implementation.
## E2E tests
- Desktop Chromium: `tests/automations-settings.spec.ts`, deletion from the
editor and list.
- Mobile Chrome: `tests/settings/mobile-automations-settings.spec.ts`,
deletion from the editor with cancel and confirm paths.
## Work orders
- [x] [Task 01: Add automation deletion confirmation](task-01-add-confirmation.md)
## Verification results
- Passed `pnpm --filter @kandev/web run typecheck`.
- Passed `pnpm --filter @kandev/web run lint`.
- Passed `pnpm --filter @kandev/web run i18n:check` for all five complete
catalogs and the pseudo catalog.
- Passed focused Chromium E2E: 2 tests.
- Passed focused mobile Chrome E2E: 1 test.
- Passed focused automation unit tests: 6 tests, including failed-deletion
dialog retention and controlled dialog behavior.
- Passed specification tests and all-file specification lint.
- Passed `git diff --check`.
## Risks
- The editor participates in the shared unsaved-settings navigation guard;
confirmation must not bypass that guard until the delete mutation itself
succeeds.
- The list action is inside a clickable table row, so opening the dialog must
continue to stop row navigation.
- The production E2E build must be refreshed after frontend changes.
@@ -0,0 +1,111 @@
---
id: "01-add-confirmation"
title: "Add automation deletion confirmation"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-OFFICE-AUTOMATIONS-SETTINGS-001
acceptance_criteria:
- AC-OFFICE-AUTOMATIONS-SETTINGS-001.9
- AC-OFFICE-AUTOMATIONS-SETTINGS-001.10
- AC-OFFICE-AUTOMATIONS-SETTINGS-001.11
system_design:
- ../../specs/office/system-design/automations-settings-01.md
- ../../specs/office/system-design/automations-settings-02.md
---
# Task 01: Add automation deletion confirmation
## Summary
Place a shared, localized alert-dialog confirmation between both Settings
automation deletion controls and the existing delete mutation. Preserve the
list-row removal and editor redirect outcomes, and prove the phone path with a
mobile Playwright flow.
## In scope
- Shared automation deletion confirmation component using the existing alert
dialog primitives.
- List-page selected-automation state and editor confirmation trigger.
- Five-locale dialog title and warning copy.
- Desktop and mobile E2E coverage for cancellation and confirmation.
## Out of scope
- Backend/API changes.
- Automation run or trigger deletion behavior.
- New settings save-coordinator behavior.
## Acceptance
- Both list-row and editor deletion actions open the named confirmation before
any delete mutation; cancel or dismissal leaves the automation in place.
- Confirming performs the existing deletion flow once, removes the list row or
returns the editor to the automation list, and shows the same result on phone.
- The phone dialog remains viewport-contained and its Cancel/Delete controls
have at least 44px active hitbox height.
## Verification
```bash
(cd apps && pnpm --filter @kandev/web run i18n:check)
(cd apps/web && pnpm e2e:run --project chromium tests/automations-settings.spec.ts -- --grep "delete automation")
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/settings/mobile-automations-settings.spec.ts)
(python3 scripts/lint-spec-files.test.py)
(python3 scripts/lint-spec-files.py --all)
(git diff --check -- docs/specs docs/plans)
```
## Files likely touched
- `apps/web/components/automations/automation-delete-confirm-dialog.tsx`
- `apps/web/components/automations/automations-list-page.tsx`
- `apps/web/components/automations/automations-table.tsx`
- `apps/web/components/automations/automation-editor.tsx`
- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/automations.json`
- `apps/web/src/locales/pseudo/automations.json`
- `apps/web/e2e/pages/automations-page.ts`
- `apps/web/e2e/tests/automations-settings.spec.ts`
- `apps/web/e2e/tests/settings/mobile-automations-settings.spec.ts`
## Dependencies
None.
## Risks
- A controlled dialog must not leave stale selected automation state after a
cancel, successful delete, or live list refresh.
- The mobile project only discovers files named `mobile-*.spec.ts`.
## Parallelism
`sequential`
## Inputs
- `docs/specs/office/requirements/automations-settings.md`, AC 001.9-001.11.
- `docs/specs/office/system-design/automations-settings-01.md`, Deletion confirmation.
- Existing `quick-chat-delete-dialog.tsx` and settings mobile deletion coverage.
## Results
- Added the shared AlertDialog confirmation and wired it to list-row and editor
deletion controls without changing the existing delete API or redirect.
- Added localized title and permanent-deletion warning copy to all supported
catalogs, including pseudo-locale coverage.
- Added desktop cancellation/confirmation coverage and mobile viewport,
overflow, and touch-target coverage.
- Passed `pnpm --filter @kandev/web run typecheck`.
- Passed `pnpm --filter @kandev/web run lint`.
- Passed `pnpm --filter @kandev/web run i18n:check`.
- Passed focused Chromium E2E: 2 tests.
- Passed focused mobile Chrome E2E: 1 test.
- Added unit regression coverage for failed deletion keeping the confirmation
open, and for the dialog not closing before the async confirmation settles.
- Passed `python3 scripts/lint-spec-files.test.py` and
`python3 scripts/lint-spec-files.py --all`.
- Passed `git diff --check`.
@@ -27,6 +27,9 @@ Users want to schedule an agent to run a prompt on a cron (or on a GitHub PR eve
- **AC-OFFICE-AUTOMATIONS-SETTINGS-001.6:** When the selected executor profile's type does not support multi-repo, the picker renders as today's single dropdown (registered/discovered repos, or "Auto").
- **AC-OFFICE-AUTOMATIONS-SETTINGS-001.7:** The Executor Profile picker disables executor profiles that don't support multi-repo whenever two or more repositories are currently selected, with the same disabled-reason text as the task-creation dialog, so a user cannot silently strand a multi-repository automation on an incompatible executor.
- **AC-OFFICE-AUTOMATIONS-SETTINGS-001.8:** Automations created via the WS API directly (bypassing the editor) may still combine an incompatible executor with multiple repository IDs; this is a client-side authoring guard, not a backend rejection. Task launch on an incompatible executor fails the same way manual multi-repo task creation would.
- **AC-OFFICE-AUTOMATIONS-SETTINGS-001.9:** When a user initiates automation deletion from the workspace automation list or the automation editor, the settings UI shall show a confirmation dialog that identifies the automation and states that the deletion cannot be undone.
- **AC-OFFICE-AUTOMATIONS-SETTINGS-001.10:** When the user dismisses or cancels the deletion confirmation, the automation shall remain and no delete request shall be made. When the user confirms, the UI shall perform one deletion and preserve the existing result for that entry point: remove the list item or return the editor to the workspace automation list.
- **AC-OFFICE-AUTOMATIONS-SETTINGS-001.11:** On a phone viewport, the deletion confirmation shall remain inside the viewport and expose reachable Cancel and Delete actions with touch-sized hit areas, while preserving the same deletion outcome as the desktop flow.
## System design
@@ -18,7 +18,7 @@ This design preserves the technical source detail for `REQ-OFFICE-AUTOMATIONS-SE
| Requirement | Design section |
| --- | --- |
| `REQ-OFFICE-AUTOMATIONS-SETTINGS-001` | [Migrated source detail](#migrated-source-detail) |
| `REQ-OFFICE-AUTOMATIONS-SETTINGS-001` | [Migrated source detail](#migrated-source-detail), [Deletion confirmation](automations-settings-02.md#deletion-confirmation) |
## Migrated source detail
@@ -17,10 +17,30 @@ This design preserves the technical source detail for `REQ-OFFICE-AUTOMATIONS-SE
| Requirement | Design section |
| --- | --- |
| `REQ-OFFICE-AUTOMATIONS-SETTINGS-001` | [Migrated source detail](#migrated-source-detail) |
| `REQ-OFFICE-AUTOMATIONS-SETTINGS-001` | [Migrated source detail](#migrated-source-detail), [Deletion confirmation](#deletion-confirmation) |
## Migrated source detail
## Deletion confirmation
The workspace automation list and the automation editor share an
`AutomationDeleteConfirmDialog` built on the existing `@kandev/ui/alert-dialog`
primitive. The dialog owns the warning copy and accessible Cancel/Delete
actions; its caller owns the selected automation and the mutation callback.
The list page records the row selected by its action cell and calls
`useAutomations.remove` only from the dialog's confirmation callback. The editor
opens the same dialog from `EditorFooter` and keeps the existing
`useRemoveAutomation` callback, including its navigation-blocker bypass and
redirect, behind confirmation. Closing or cancelling the dialog only clears its
open state.
The modal remains short and viewport-contained on phones, so a centered alert
dialog is preferred over a drawer. Its footer actions use the existing
responsive alert-dialog layout, full-width touch-sized controls on coarse
pointers, and the destructive action styling already used by automation run
deletion.
## Migration
Withdrawing `execution_mode` is a behaviour change for existing installs, and the size of it is easy to understate. `task` was the **default**, so this is not an exotic minority: every automation created before this change that nobody explicitly set to `run` was producing a visible kanban card, and after upgrade it will not. The automations feature has been in the product since 2026-05-22, long enough for that to be somebody's working setup rather than a hypothetical.