feat(ui): confirm saved view deletion (#3461)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-09-07 16:14:58 +01:00
committed by GitHub
parent bcdb0ce5e9
commit 5ee5161166
51 changed files with 2942 additions and 232 deletions
@@ -35,6 +35,10 @@ export type ActionConfirmPopoverProps = {
onConfirm: () => void | Promise<void>;
};
export function isActionConfirmationTarget(target: EventTarget | null): boolean {
return target instanceof Element && target.closest("[data-confirmation-boundary]") !== null;
}
/**
* A non-modal confirmation surface for one anchored action.
*
@@ -0,0 +1,120 @@
import { useRef, useState } from "react";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { isActionConfirmationTarget } from "./action-confirm-popover";
import { SavedTaskViewDeleteConfirmation } from "./saved-task-view-delete-confirmation";
const TARGET = { id: "view-1", label: "Needs review" };
const TARGET_TITLE = "Delete Needs review?";
function Harness({
presentation,
onConfirm,
confirmDisabled = false,
}: {
presentation: "popover" | "inline";
onConfirm: (id: string) => void;
confirmDisabled?: boolean;
}) {
const [open, setOpen] = useState(true);
const anchorRef = useRef<HTMLButtonElement>(null);
return (
<>
<button ref={anchorRef} type="button" data-testid="delete-anchor">
Delete view
</button>
<SavedTaskViewDeleteConfirmation
target={TARGET}
presentation={presentation}
open={open}
anchorRef={anchorRef}
confirmDisabled={confirmDisabled}
onOpenChange={setOpen}
onConfirm={onConfirm}
/>
</>
);
}
function ReplacingHarness() {
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLButtonElement>(null);
if (!open) {
return (
<button
ref={anchorRef}
type="button"
data-testid="replacement-delete-anchor"
onClick={() => setOpen(true)}
>
Delete view
</button>
);
}
return (
<SavedTaskViewDeleteConfirmation
target={TARGET}
presentation="inline"
open
anchorRef={anchorRef}
onOpenChange={setOpen}
onConfirm={vi.fn()}
/>
);
}
describe("SavedTaskViewDeleteConfirmation", () => {
afterEach(cleanup);
it("names the target and dispatches its captured id only after fine-pointer confirmation", async () => {
const onConfirm = vi.fn();
render(<Harness presentation="popover" onConfirm={onConfirm} />);
const dialog = screen.getByRole("dialog", { name: TARGET_TITLE });
expect(dialog.textContent).toContain(
"This removes only the saved filters and view settings. It does not delete tasks or data in connected services.",
);
expect(isActionConfirmationTarget(dialog)).toBe(true);
expect(onConfirm).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole("button", { name: "Delete Needs review" }));
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
expect(onConfirm).toHaveBeenCalledWith("view-1");
});
it("cancels the inline presentation on Escape and returns focus to the trigger", async () => {
const onConfirm = vi.fn();
render(<Harness presentation="inline" onConfirm={onConfirm} />);
const confirmation = screen.getByRole("group", { name: TARGET_TITLE });
expect(confirmation.textContent).toContain(TARGET_TITLE);
fireEvent.keyDown(confirmation, { key: "Escape" });
expect(onConfirm).not.toHaveBeenCalled();
expect(screen.queryByRole("group", { name: TARGET_TITLE })).toBeNull();
await waitFor(() => expect(document.activeElement).toBe(screen.getByTestId("delete-anchor")));
});
it("returns focus after inline confirmation is replaced by its trigger", async () => {
render(<ReplacingHarness />);
fireEvent.click(screen.getByTestId("replacement-delete-anchor"));
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
const replacementAnchor = await screen.findByTestId("replacement-delete-anchor");
await waitFor(() => expect(document.activeElement).toBe(replacementAnchor));
});
it("keeps Cancel available while inline confirmation is disabled", () => {
render(<Harness presentation="inline" onConfirm={vi.fn()} confirmDisabled />);
expect(screen.getByRole("button", { name: "Cancel" }).hasAttribute("disabled")).toBe(false);
expect(
screen.getByRole("button", { name: "Delete Needs review" }).hasAttribute("disabled"),
).toBe(true);
});
});
@@ -0,0 +1,90 @@
"use client";
import type { RefObject } from "react";
import { useTranslation } from "react-i18next";
import { ActionConfirmPopover } from "./action-confirm-popover";
import { InlineConfirmActions } from "./inline-confirm-actions";
import type { SavedTaskViewDeleteTarget } from "./use-saved-task-view-delete-confirmation";
export type { SavedTaskViewDeleteTarget } from "./use-saved-task-view-delete-confirmation";
type SavedTaskViewDeleteConfirmationProps = {
target: SavedTaskViewDeleteTarget;
presentation: "popover" | "inline";
open: boolean;
anchorRef: RefObject<HTMLElement | null>;
focusBoundaryRef?: RefObject<HTMLElement | null>;
confirmDisabled?: boolean;
testId?: string;
confirmTestId?: string;
onOpenChange: (open: boolean) => void;
onConfirm: (id: string) => void | Promise<void>;
};
export function SavedTaskViewDeleteConfirmation({
target,
presentation,
open,
anchorRef,
focusBoundaryRef,
confirmDisabled = false,
testId = "saved-task-view-delete-confirmation",
confirmTestId = "saved-task-view-delete-confirm",
onOpenChange,
onConfirm,
}: SavedTaskViewDeleteConfirmationProps) {
const { t } = useTranslation();
const title = t("common:deleteSavedTaskViewTitle", { name: target.label });
const description = t("common:deleteSavedTaskViewDescription");
if (presentation === "popover") {
return (
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
focusBoundaryRef={focusBoundaryRef}
title={title}
description={description}
cancelLabel={t("common:cancel")}
confirmLabel={t("common:delete")}
confirmAriaLabel={t("common:deleteSavedTaskViewAction", { name: target.label })}
confirmDisabled={confirmDisabled}
testId={testId}
confirmTestId={confirmTestId}
confirmationBoundary
onOpenChange={onOpenChange}
onConfirm={() => onConfirm(target.id)}
/>
);
}
if (!open) return null;
return (
<InlineConfirmActions
density="touch"
testId={testId}
ariaLabel={title}
description={
<>
<span className="block font-medium text-foreground">{title}</span>
<span className="mt-1 block">{description}</span>
</>
}
cancelLabel={t("common:cancel")}
confirmLabel={t("common:delete")}
confirmAriaLabel={t("common:deleteSavedTaskViewAction", { name: target.label })}
confirmTestId={confirmTestId}
confirmDisabled={confirmDisabled}
onCancel={() => {
onOpenChange(false);
queueMicrotask(() => {
if (anchorRef.current?.isConnected) anchorRef.current.focus();
});
}}
onClose={() => onOpenChange(false)}
onConfirm={() => onConfirm(target.id)}
/>
);
}
@@ -0,0 +1,50 @@
"use client";
import { useEffect, useRef, useState } from "react";
export type SavedTaskViewDeleteTarget = {
id: string;
label: string;
};
export function useSavedTaskViewDeleteConfirmation<T extends HTMLElement = HTMLElement>(
availableTargets: ReadonlyArray<{ id: string }>,
) {
const [requestedTarget, setRequestedTarget] = useState<SavedTaskViewDeleteTarget | null>(null);
const anchorRef = useRef<T>(null);
const anchorIdRef = useRef<string | null>(null);
const anchorsRef = useRef(new Map<string, T>());
const target =
requestedTarget && availableTargets.some((item) => item.id === requestedTarget.id)
? requestedTarget
: null;
useEffect(() => {
if (requestedTarget && !target) setRequestedTarget(null);
}, [requestedTarget, target]);
function registerAnchor(id: string, element: T | null) {
if (element) {
anchorsRef.current.set(id, element);
if (anchorIdRef.current === id) anchorRef.current = element;
return;
}
anchorsRef.current.delete(id);
}
function request(targetToDelete: SavedTaskViewDeleteTarget) {
anchorIdRef.current = targetToDelete.id;
const registeredAnchor = anchorsRef.current.get(targetToDelete.id);
// Preserve a directly bound anchor when a surface does not register keyed anchors.
if (registeredAnchor) anchorRef.current = registeredAnchor;
setRequestedTarget(targetToDelete);
}
return {
target,
anchorRef,
close: () => setRequestedTarget(null),
registerAnchor,
request,
};
}
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SavedPreset } from "./saved-preset-model";
import { PresetsSidebar } from "./presets-sidebar";
@@ -15,7 +15,7 @@ const savedPreset: SavedPreset = {
isDefault: false,
};
function expectDeleteDoesNotSelect() {
async function expectDeleteDoesNotSelect() {
const onSelect = vi.fn();
const onDeleteSaved = vi.fn();
render(
@@ -35,7 +35,17 @@ function expectDeleteDoesNotSelect() {
fireEvent.click(screen.getByRole("button", { name: "Delete Kandev PRs saved query" }));
expect(onDeleteSaved).toHaveBeenCalledWith(savedPreset.id);
expect(onDeleteSaved).not.toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
const confirmation = screen.getByRole("group", { name: "Delete Kandev PRs?" });
fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" }));
expect(onDeleteSaved).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Delete Kandev PRs saved query" }));
fireEvent.click(screen.getByRole("button", { name: "Delete Kandev PRs" }));
await waitFor(() => expect(onDeleteSaved).toHaveBeenCalledWith(savedPreset.id));
expect(onDeleteSaved).toHaveBeenCalledOnce();
expect(onSelect).not.toHaveBeenCalled();
}
@@ -8,6 +8,11 @@ import { PR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "
import type { SavedPreset } from "./saved-preset-model";
import { useTranslation } from "react-i18next";
import { SavedQueryDefaultButton } from "@/components/integrations/saved-query-default-button";
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
import {
useSavedTaskViewDeleteConfirmation,
type SavedTaskViewDeleteTarget,
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
export type SidebarSelection = {
kind: "pr" | "issue";
@@ -157,6 +162,7 @@ function SavedSection({
defaultMutationPendingId: string | null;
}) {
const { t } = useTranslation();
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(saved);
const defaultMutationPending = defaultMutationPendingId !== null;
return (
<>
@@ -166,43 +172,19 @@ function SavedSection({
{t("github:noSavedQueriesYet")}
</div>
)}
{saved.map((s) => {
const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: s.label });
const accessibleDeleteLabel = defaultMutationPending
? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel })
: deleteLabel;
return (
<PresetItem
key={s.id}
label={s.label}
Icon={IconBookmark}
active={selected.source === "saved" && selected.id === s.id}
onClick={() => onSelect({ kind, source: "saved", id: s.id })}
trailing={
<div className="flex shrink-0 items-center">
<SavedQueryDefaultButton
label={s.label}
isDefault={s.isDefault}
disabled={defaultMutationPending}
pending={defaultMutationPendingId === s.id}
testId={`github-saved-query-default-${s.id}`}
onToggle={() => void onToggleSavedDefault(s)}
/>
<button
type="button"
disabled={defaultMutationPending}
onClick={() => onDelete(s.id)}
className="flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-wait disabled:opacity-50"
title={accessibleDeleteLabel}
aria-label={accessibleDeleteLabel}
>
<IconX className="h-4 w-4" />
</button>
</div>
}
/>
);
})}
{saved.map((preset) => (
<GitHubSavedPresetEntry
key={preset.id}
preset={preset}
active={selected.source === "saved" && selected.id === preset.id}
deletion={deletion}
defaultMutationPending={defaultMutationPending}
defaultMutationPendingForPreset={defaultMutationPendingId === preset.id}
onSelect={() => onSelect({ kind, source: "saved", id: preset.id })}
onDelete={onDelete}
onToggleDefault={() => void onToggleSavedDefault(preset)}
/>
))}
<button
type="button"
onClick={onSaveCurrent}
@@ -222,6 +204,88 @@ function SavedSection({
);
}
type GitHubSavedDeletion = {
target: SavedTaskViewDeleteTarget | null;
anchorRef: React.RefObject<HTMLButtonElement | null>;
close: () => void;
registerAnchor: (id: string, element: HTMLButtonElement | null) => void;
request: (target: SavedTaskViewDeleteTarget) => void;
};
function GitHubSavedPresetEntry({
preset,
active,
deletion,
defaultMutationPending,
defaultMutationPendingForPreset,
onSelect,
onDelete,
onToggleDefault,
}: {
preset: SavedPreset;
active: boolean;
deletion: GitHubSavedDeletion;
defaultMutationPending: boolean;
defaultMutationPendingForPreset: boolean;
onSelect: () => void;
onDelete: (id: string) => void;
onToggleDefault: () => void;
}) {
const { t } = useTranslation();
if (deletion.target?.id === preset.id) {
return (
<div className="mx-1 min-w-0 p-1">
<SavedTaskViewDeleteConfirmation
target={deletion.target}
presentation="inline"
open
anchorRef={deletion.anchorRef}
confirmDisabled={defaultMutationPending}
onOpenChange={(open) => {
if (!open) deletion.close();
}}
onConfirm={onDelete}
/>
</div>
);
}
const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: preset.label });
const accessibleDeleteLabel = defaultMutationPending
? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel })
: deleteLabel;
return (
<PresetItem
label={preset.label}
Icon={IconBookmark}
active={active}
onClick={onSelect}
trailing={
<div className="flex shrink-0 items-center">
<SavedQueryDefaultButton
label={preset.label}
isDefault={preset.isDefault}
disabled={defaultMutationPending}
pending={defaultMutationPendingForPreset}
testId={`github-saved-query-default-${preset.id}`}
onToggle={onToggleDefault}
/>
<button
ref={(element) => deletion.registerAnchor(preset.id, element)}
type="button"
disabled={defaultMutationPending}
onClick={() => deletion.request({ id: preset.id, label: preset.label })}
className="flex h-12 w-12 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-wait disabled:opacity-50"
title={accessibleDeleteLabel}
aria-label={accessibleDeleteLabel}
>
<IconX className="h-4 w-4" />
</button>
</div>
}
/>
);
}
export function PresetsSidebar({
selected,
onSelect,
@@ -0,0 +1,54 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SavedPreset } from "./use-saved-presets";
import { PresetsSidebar } from "./presets-sidebar";
const SAVED: SavedPreset = {
id: "saved-mr",
kind: "mr",
label: "Ready MRs",
customQuery: "state=opened",
projectFilter: "kdlbs/kandev",
milestone: "",
preset: "",
createdAt: "2026-09-07T00:00:00Z",
};
describe("GitLab PresetsSidebar", () => {
afterEach(cleanup);
it("uses visible touch controls and deletes without selecting only after confirmation", async () => {
const onDeleteSaved = vi.fn();
const onSelect = vi.fn();
render(
<PresetsSidebar
selected={{ kind: "mr", source: "saved", id: SAVED.id }}
onSelect={onSelect}
savedPresets={[SAVED]}
onDeleteSaved={onDeleteSaved}
canSaveCurrent={false}
onSaveCurrent={vi.fn()}
mrPresets={[]}
issuePresets={[]}
/>,
);
const deleteButton = screen.getByRole("button", { name: "Delete Ready MRs saved query" });
expect(deleteButton.className).toContain("h-12");
expect(deleteButton.className).not.toContain("opacity-0");
fireEvent.click(deleteButton);
expect(onDeleteSaved).not.toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
const confirmation = screen.getByRole("group", { name: "Delete Ready MRs?" });
fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" }));
fireEvent.click(screen.getByRole("button", { name: "Delete Ready MRs saved query" }));
fireEvent.click(screen.getByRole("button", { name: "Delete Ready MRs" }));
await waitFor(() => expect(onDeleteSaved).toHaveBeenCalledWith(SAVED.id));
expect(onDeleteSaved).toHaveBeenCalledOnce();
expect(onSelect).not.toHaveBeenCalled();
});
});
@@ -6,6 +6,8 @@ import { cn } from "@/lib/utils";
import { MR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./presets";
import type { SavedPreset } from "./use-saved-presets";
import { useTranslation } from "react-i18next";
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
import { useSavedTaskViewDeleteConfirmation } from "@/components/confirmation/use-saved-task-view-delete-confirmation";
export type SidebarSelection = {
kind: "mr" | "issue";
@@ -171,6 +173,7 @@ function SavedSection({
onSaveCurrent: () => void;
}) {
const { t } = useTranslation();
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(saved);
return (
<>
<SectionHeader id="saved" title={t("gitlab:saved")} />
@@ -179,36 +182,58 @@ function SavedSection({
{t("gitlab:noSavedQueriesYet")}
</div>
)}
{saved.map((s) => (
<PresetItem
key={s.id}
label={s.label}
Icon={IconBookmark}
active={selected.source === "saved" && selected.id === s.id}
onClick={() => onSelect({ kind, source: "saved", id: s.id })}
trailing={
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(s.id);
}}
className="opacity-0 group-hover/item:opacity-100 transition-opacity text-muted-foreground hover:text-foreground cursor-pointer"
title={t("gitlab:deleteSavedQuery")}
aria-label={t("gitlab:deleteSavedQuery2", { label: s.label })}
data-testid={`gitlab-saved-delete-${s.id}`}
>
<IconX className="h-3.5 w-3.5" />
</button>
}
/>
))}
{saved.map((s) => {
if (deletion.target?.id === s.id) {
return (
<div key={s.id} className="mx-1 min-w-0 p-1">
<SavedTaskViewDeleteConfirmation
target={deletion.target}
presentation="inline"
open
anchorRef={deletion.anchorRef}
onOpenChange={(open) => {
if (!open) deletion.close();
}}
onConfirm={onDelete}
/>
</div>
);
}
const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: s.label });
return (
<PresetItem
key={s.id}
label={s.label}
Icon={IconBookmark}
active={selected.source === "saved" && selected.id === s.id}
onClick={() => onSelect({ kind, source: "saved", id: s.id })}
trailing={
<button
ref={(element) => {
deletion.registerAnchor(s.id, element);
}}
type="button"
onClick={(event) => {
event.stopPropagation();
deletion.request({ id: s.id, label: s.label });
}}
className="flex h-12 w-12 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={deleteLabel}
aria-label={deleteLabel}
data-testid={`gitlab-saved-delete-${s.id}`}
>
<IconX className="h-4 w-4" />
</button>
}
/>
);
})}
<button
type="button"
onClick={onSaveCurrent}
disabled={!canSaveCurrent}
className={cn(
"mx-1 mt-1 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors",
"mx-1 mt-1 flex min-h-11 items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors",
canSaveCurrent
? "text-muted-foreground hover:bg-muted/50 hover:text-foreground cursor-pointer"
: "text-muted-foreground/50 cursor-not-allowed",
@@ -240,7 +265,7 @@ export function PresetsSidebar({
onSelect({ kind, source: "preset", id: fallback });
};
return (
<nav className="flex flex-col py-3">
<nav className="flex w-full min-w-0 flex-col overflow-x-hidden py-3">
<KindToggle kind={selected.kind} onChange={onKindChange} />
<PresetGroupList
presets={presets}
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { IconInbox } from "@tabler/icons-react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
@@ -207,7 +207,9 @@ describe("IntegrationScopeBar saved defaults", () => {
.getAttribute(ARIA_DISABLED_ATTRIBUTE),
).toBe("true");
});
});
describe("IntegrationScopeBar saved-query actions", () => {
it("renders no default actions when an integration omits the optional contract", () => {
renderBar({ onToggleSavedDefault: undefined });
@@ -227,13 +229,22 @@ describe("IntegrationScopeBar saved defaults", () => {
);
});
it("prevents the menu from closing when a saved query is deleted", () => {
it("keeps the menu open and waits for named confirmation before deletion", async () => {
const { onDeleteSaved } = renderBar();
fireEvent.click(screen.getByRole("menuitem", { name: FUTURE_DELETE_LABEL }));
expect(onDeleteSaved).toHaveBeenCalledWith("saved-b");
expect(onDeleteSaved).not.toHaveBeenCalled();
expect(lastMenuItemSelectEvent?.defaultPrevented).toBe(true);
const confirmation = await screen.findByRole("dialog", { name: "Delete Future default?" });
fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" }));
expect(onDeleteSaved).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("menuitem", { name: FUTURE_DELETE_LABEL }));
fireEvent.click(screen.getByRole("button", { name: "Delete Future default" }));
await waitFor(() => expect(onDeleteSaved).toHaveBeenCalledWith("saved-b"));
expect(onDeleteSaved).toHaveBeenCalledOnce();
});
it("uses a host icon when a plugin preset does not provide one", () => {
@@ -1,6 +1,6 @@
"use client";
import { useCallback } from "react";
import { useCallback, useRef, useState, type RefObject } from "react";
import { IconBookmark, IconChevronDown, IconDeviceFloppy, IconX } from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import {
@@ -14,6 +14,13 @@ import { cn } from "@/lib/utils";
import { IntegrationIcon, type IntegrationIconName } from "./integration-icon";
import { useTranslation } from "react-i18next";
import { SavedQueryDefaultDropdownItem } from "./saved-query-default-button";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { isActionConfirmationTarget } from "@/components/confirmation/action-confirm-popover";
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
import {
useSavedTaskViewDeleteConfirmation,
type SavedTaskViewDeleteTarget,
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
/**
* Shared, domain-agnostic scope bar for the integration dashboards (/github,
@@ -113,6 +120,31 @@ function PresetPill({
);
}
function SavedMenuTrigger({
testId,
active,
label,
}: {
testId: string;
active: boolean;
label: string | null;
}) {
const { t } = useTranslation();
return (
<DropdownMenuTrigger asChild>
<button
type="button"
data-testid={testId}
className={cn(PILL_BASE, active ? PILL_ACTIVE : PILL_IDLE)}
>
<IconBookmark className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-[140px] truncate">{label ?? t("integrations:savedQueries")}</span>
<IconChevronDown className="h-3 w-3 shrink-0 opacity-60" />
</button>
</DropdownMenuTrigger>
);
}
function SavedMenu<K extends string>({
testId,
selected,
@@ -135,69 +167,51 @@ function SavedMenu<K extends string>({
defaultMutationPendingId: string | null;
}) {
const { t } = useTranslation();
const { isFinePointer } = useResponsiveBreakpoint();
const [menuOpen, setMenuOpen] = useState(false);
const deletion = useSavedTaskViewDeleteConfirmation(saved);
const menuContentRef = useRef<HTMLDivElement>(null);
const defaultMutationPending = defaultMutationPendingId !== null;
const activeSaved = selected.source === "saved";
const activeLabel = activeSaved ? saved.find((s) => s.id === selected.id)?.label : null;
const activeLabel = activeSaved ? (saved.find((s) => s.id === selected.id)?.label ?? null) : null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-testid={testId}
className={cn(PILL_BASE, activeSaved ? PILL_ACTIVE : PILL_IDLE)}
>
<IconBookmark className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-[140px] truncate">
{activeLabel ?? t("integrations:savedQueries")}
</span>
<IconChevronDown className="h-3 w-3 shrink-0 opacity-60" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenu
open={menuOpen}
onOpenChange={(open) => {
setMenuOpen(open);
if (!open) deletion.close();
}}
>
<SavedMenuTrigger testId={testId} active={activeSaved} label={activeLabel} />
<DropdownMenuContent
ref={menuContentRef}
align="end"
className="w-56"
onFocusOutside={(event) => {
if (isActionConfirmationTarget(event.target)) event.preventDefault();
}}
onInteractOutside={(event) => {
if (isActionConfirmationTarget(event.target)) event.preventDefault();
}}
>
{saved.length === 0 ? (
<DropdownMenuItem disabled>{t("integrations:noSavedQueriesYet")}</DropdownMenuItem>
) : (
saved.map((s) => {
const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: s.label });
const accessibleDeleteLabel = defaultMutationPending
? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel })
: deleteLabel;
return (
<div key={s.id} role="none" className="group/saved flex items-center gap-0.5">
<DropdownMenuItem
onSelect={() => onSelect({ kind: s.kind, source: "saved", id: s.id })}
className="min-w-0 flex-1 cursor-pointer gap-2"
>
<IconBookmark className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{s.label}</span>
</DropdownMenuItem>
{onToggleSavedDefault && (
<SavedQueryDefaultDropdownItem
label={s.label}
isDefault={s.isDefault === true}
disabled={defaultMutationPending}
pending={defaultMutationPendingId === s.id}
testId={`saved-query-default-${s.id}`}
onToggle={() => onToggleSavedDefault(s.id)}
/>
)}
{/* A peer Radix item is intentional: arrow navigation reaches delete,
and focus opacity reveals it without pointer hover. */}
<DropdownMenuItem
disabled={defaultMutationPending}
onSelect={(event) => {
event.preventDefault();
onDeleteSaved(s.id);
}}
className="h-7 min-h-7 w-7 shrink-0 cursor-pointer justify-center p-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus:opacity-100 group-hover/saved:opacity-100 data-[disabled]:cursor-wait data-[disabled]:opacity-50 group-hover/saved:data-[disabled]:opacity-50"
title={accessibleDeleteLabel}
aria-label={accessibleDeleteLabel}
>
<IconX className="h-3.5 w-3.5" />
</DropdownMenuItem>
</div>
);
})
saved.map((preset) => (
<SavedMenuEntry
key={preset.id}
preset={preset}
isFinePointer={isFinePointer}
deletion={deletion}
defaultMutationPending={defaultMutationPending}
defaultMutationPendingForPreset={defaultMutationPendingId === preset.id}
onSelect={() => onSelect({ kind: preset.kind, source: "saved", id: preset.id })}
onToggleDefault={
onToggleSavedDefault ? () => onToggleSavedDefault(preset.id) : undefined
}
onDeleteSaved={onDeleteSaved}
/>
))
)}
<DropdownMenuSeparator />
<DropdownMenuItem
@@ -209,10 +223,112 @@ function SavedMenu<K extends string>({
<span>{t("integrations:saveCurrentQuery")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
{isFinePointer && deletion.target ? (
<SavedTaskViewDeleteConfirmation
target={deletion.target}
presentation="popover"
open
anchorRef={deletion.anchorRef}
focusBoundaryRef={menuContentRef}
confirmDisabled={defaultMutationPending}
onOpenChange={(open) => {
if (!open) deletion.close();
}}
onConfirm={onDeleteSaved}
/>
) : null}
</DropdownMenu>
);
}
type SavedMenuDeletion = {
target: SavedTaskViewDeleteTarget | null;
anchorRef: RefObject<HTMLElement | null>;
close: () => void;
registerAnchor: (id: string, element: HTMLElement | null) => void;
request: (target: SavedTaskViewDeleteTarget) => void;
};
function SavedMenuEntry({
preset,
isFinePointer,
deletion,
defaultMutationPending,
defaultMutationPendingForPreset,
onSelect,
onToggleDefault,
onDeleteSaved,
}: {
preset: ScopeSavedPreset<string>;
isFinePointer: boolean;
deletion: SavedMenuDeletion;
defaultMutationPending: boolean;
defaultMutationPendingForPreset: boolean;
onSelect: () => void;
onToggleDefault?: () => void;
onDeleteSaved: (id: string) => void;
}) {
const { t } = useTranslation();
if (!isFinePointer && deletion.target?.id === preset.id) {
return (
<div role="none" className="min-w-0 p-1">
<SavedTaskViewDeleteConfirmation
target={deletion.target}
presentation="inline"
open
anchorRef={deletion.anchorRef}
confirmDisabled={defaultMutationPending}
onOpenChange={(open) => {
if (!open) deletion.close();
}}
onConfirm={onDeleteSaved}
/>
</div>
);
}
const deleteLabel = t("integrations:deleteSavedQueryNamed", { label: preset.label });
const accessibleDeleteLabel = defaultMutationPending
? t("integrations:savedQueryDefaultUpdateInProgress", { action: deleteLabel })
: deleteLabel;
return (
<div role="none" className="group/saved flex items-center gap-0.5">
<DropdownMenuItem onSelect={onSelect} className="min-w-0 flex-1 cursor-pointer gap-2">
<IconBookmark className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{preset.label}</span>
</DropdownMenuItem>
{onToggleDefault && (
<SavedQueryDefaultDropdownItem
label={preset.label}
isDefault={preset.isDefault === true}
disabled={defaultMutationPending}
pending={defaultMutationPendingForPreset}
testId={`saved-query-default-${preset.id}`}
onToggle={onToggleDefault}
/>
)}
{/* A peer Radix item keeps delete keyboard-reachable without selecting the preset. */}
<DropdownMenuItem
ref={(element) => deletion.registerAnchor(preset.id, element)}
disabled={defaultMutationPending}
onSelect={(event) => {
event.preventDefault();
deletion.request({ id: preset.id, label: preset.label });
}}
className={cn(
"shrink-0 cursor-pointer justify-center p-0 text-muted-foreground transition-opacity hover:text-foreground data-[disabled]:cursor-wait data-[disabled]:opacity-50",
isFinePointer
? "h-7 min-h-7 w-7 opacity-0 focus:opacity-100 group-hover/saved:opacity-100 group-hover/saved:data-[disabled]:opacity-50"
: "min-h-12 min-w-12 opacity-100",
)}
title={accessibleDeleteLabel}
aria-label={accessibleDeleteLabel}
>
<IconX className="h-3.5 w-3.5" />
</DropdownMenuItem>
</div>
);
}
type SavedDefaultActionProps =
| {
/** Emits only the stable id; domain wrappers own richer saved-query data. */
@@ -0,0 +1,75 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_FILTERS } from "./filter-model";
import type { SavedView } from "./use-saved-views";
import { ListToolbar } from "./list-toolbar";
const responsive = vi.hoisted(() => ({ isFinePointer: false }));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => responsive,
}));
const BUILTIN: SavedView = {
id: "builtin:assigned",
name: "",
nameKey: "jira:builtinViewAssignedToMe",
builtin: true,
filters: DEFAULT_FILTERS,
};
const CUSTOM: SavedView = {
id: "view-sprint-bugs",
name: "Sprint bugs",
filters: DEFAULT_FILTERS,
};
function renderToolbar(onDeleteView = vi.fn()) {
return {
onDeleteView,
...render(
<ListToolbar
searchText=""
onSearchChange={vi.fn()}
views={[BUILTIN, CUSTOM]}
activeViewId={CUSTOM.id}
onSelectView={vi.fn()}
onDeleteView={onDeleteView}
onSaveView={vi.fn()}
count={1}
loading={false}
sort="updated"
onSortChange={vi.fn()}
onRefresh={vi.fn()}
showJqlEditor={false}
onToggleJqlEditor={vi.fn()}
/>,
),
};
}
describe("Jira ListToolbar saved views", () => {
afterEach(cleanup);
it("keeps built-ins protected and confirms custom deletion inline on coarse pointers", async () => {
const { onDeleteView } = renderToolbar();
fireEvent.click(screen.getByRole("button", { name: "Sprint bugs" }));
expect(screen.getAllByTitle("Delete view")).toHaveLength(1);
fireEvent.click(screen.getByTitle("Delete view"));
expect(onDeleteView).not.toHaveBeenCalled();
const confirmation = screen.getByRole("group", { name: "Delete Sprint bugs?" });
expect(within(confirmation).getByRole("button", { name: "Cancel" }).className).toContain(
"h-11",
);
fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" }));
expect(onDeleteView).not.toHaveBeenCalled();
fireEvent.click(screen.getByTitle("Delete view"));
fireEvent.click(screen.getByRole("button", { name: "Delete Sprint bugs" }));
await waitFor(() => expect(onDeleteView).toHaveBeenCalledWith(CUSTOM.id));
expect(onDeleteView).toHaveBeenCalledOnce();
});
});
+120 -17
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useRef, useState, type RefObject } from "react";
import {
IconArrowsSort,
IconBookmark,
@@ -23,6 +23,14 @@ import {
import { savedViewLabel, type SavedView } from "./use-saved-views";
import type { SortKey } from "./filter-model";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { isActionConfirmationTarget } from "@/components/confirmation/action-confirm-popover";
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
import {
useSavedTaskViewDeleteConfirmation,
type SavedTaskViewDeleteTarget,
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
/** `value` is the persisted SortKey; only the catalog key is copy. */
const SORT_OPTIONS: { value: SortKey; labelKey: string }[] = [
@@ -172,18 +180,50 @@ function ViewsDropdown({
activeName: string | undefined;
}) {
const { t } = useTranslation();
const { isFinePointer } = useResponsiveBreakpoint();
const [open, setOpen] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const builtin = views.filter((v) => v.builtin);
const custom = views.filter((v) => !v.builtin);
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(custom);
const deleteProps: ViewDeletionProps = {
isFinePointer,
deleteTarget: deletion.target,
deleteAnchorRef: deletion.anchorRef,
onDeleteOpenChange: (nextOpen) => {
if (!nextOpen) deletion.close();
},
onRequestDelete: (view) => deletion.request({ id: view.id, label: savedViewLabel(t, view) }),
onConfirmDelete: onDelete,
onRegisterDeleteAnchor: deletion.registerAnchor,
};
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) deletion.close();
}}
>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="cursor-pointer h-8 text-xs gap-1.5">
<IconBookmark className="h-3.5 w-3.5" />
{activeName ?? t("jira:noView")}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-60 p-0">
<PopoverContent
ref={contentRef}
align="start"
className="w-60 max-w-[calc(100vw-1rem)] overflow-x-hidden p-0"
onFocusOutside={(event) => {
if (isActionConfirmationTarget(event.target)) event.preventDefault();
}}
onInteractOutside={(event) => {
if (isActionConfirmationTarget(event.target)) event.preventDefault();
}}
>
<ViewsGroup
label={t("jira:builtIn")}
views={builtin}
@@ -192,7 +232,7 @@ function ViewsDropdown({
onSelect(id);
setOpen(false);
}}
onDelete={onDelete}
{...deleteProps}
/>
{custom.length > 0 && (
<>
@@ -205,28 +245,56 @@ function ViewsDropdown({
onSelect(id);
setOpen(false);
}}
onDelete={onDelete}
{...deleteProps}
/>
</>
)}
</PopoverContent>
{isFinePointer && deletion.target ? (
<SavedTaskViewDeleteConfirmation
target={deletion.target}
presentation="popover"
open
anchorRef={deletion.anchorRef}
focusBoundaryRef={contentRef}
onOpenChange={(nextOpen) => {
if (!nextOpen) deletion.close();
}}
onConfirm={onDelete}
/>
) : null}
</Popover>
);
}
type ViewDeletionProps = {
isFinePointer: boolean;
deleteTarget: SavedTaskViewDeleteTarget | null;
deleteAnchorRef: RefObject<HTMLElement | null>;
onDeleteOpenChange: (open: boolean) => void;
onRequestDelete: (view: SavedView) => void;
onConfirmDelete: (id: string) => void;
onRegisterDeleteAnchor: (id: string, element: HTMLButtonElement | null) => void;
};
function ViewsGroup({
label,
views,
activeViewId,
onSelect,
onDelete,
isFinePointer,
deleteTarget,
deleteAnchorRef,
onDeleteOpenChange,
onRequestDelete,
onConfirmDelete,
onRegisterDeleteAnchor,
}: {
label: string;
views: SavedView[];
activeViewId: string | null;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
}) {
} & ViewDeletionProps) {
return (
<div className="py-1">
<div className="px-3 py-1 text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">
@@ -238,7 +306,13 @@ function ViewsGroup({
view={v}
active={v.id === activeViewId}
onSelect={onSelect}
onDelete={onDelete}
isFinePointer={isFinePointer}
deleteTarget={deleteTarget}
deleteAnchorRef={deleteAnchorRef}
onDeleteOpenChange={onDeleteOpenChange}
onRequestDelete={onRequestDelete}
onConfirmDelete={onConfirmDelete}
onRegisterDeleteAnchor={onRegisterDeleteAnchor}
/>
))}
</div>
@@ -249,33 +323,62 @@ function ViewRow({
view,
active,
onSelect,
onDelete,
isFinePointer,
deleteTarget,
deleteAnchorRef,
onDeleteOpenChange,
onRequestDelete,
onConfirmDelete,
onRegisterDeleteAnchor,
}: {
view: SavedView;
active: boolean;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
}) {
} & ViewDeletionProps) {
const { t } = useTranslation();
const label = savedViewLabel(t, view);
if (!isFinePointer && deleteTarget?.id === view.id) {
return (
<div className="min-w-0 px-2 py-1">
<SavedTaskViewDeleteConfirmation
target={deleteTarget}
presentation="inline"
open
anchorRef={deleteAnchorRef}
onOpenChange={onDeleteOpenChange}
onConfirm={onConfirmDelete}
/>
</div>
);
}
return (
<div className="group flex items-center px-2">
<div className="group flex min-w-0 items-center px-2">
<button
type="button"
onClick={() => onSelect(view.id)}
className="flex-1 flex items-center gap-2 px-2 py-1.5 text-sm cursor-pointer rounded hover:bg-muted/50"
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
>
<IconCheck className={`h-3.5 w-3.5 ${active ? "opacity-100" : "opacity-0"}`} />
<span className="truncate">{savedViewLabel(t, view)}</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
</button>
{!view.builtin && (
<button
ref={(element) => onRegisterDeleteAnchor(view.id, element)}
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(view.id);
onRequestDelete(view);
}}
className="cursor-pointer opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-muted"
className={cn(
"flex shrink-0 cursor-pointer items-center justify-center rounded hover:bg-muted",
isFinePointer
? "h-7 w-7 opacity-0 group-hover:opacity-100 focus:opacity-100"
: "h-12 w-12 opacity-100",
)}
title={t("jira:deleteView")}
aria-label={t("common:deleteSavedTaskViewAction", { name: label })}
>
<IconTrash className="h-3.5 w-3.5 text-muted-foreground" />
</button>
@@ -1,8 +1,17 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types";
import { SidebarFilterPopover } from "./sidebar-filter-popover";
const responsive = vi.hoisted(() => ({
usesDesktopWorkbench: true,
isFinePointer: true,
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => responsive,
}));
const VIEW: SidebarView = {
id: "view-all",
name: "All tasks",
@@ -18,6 +27,12 @@ const VIEW: SidebarView = {
},
};
const SECOND_VIEW: SidebarView = {
...VIEW,
id: "view-review",
name: "Needs review",
};
const state = {
sidebarViews: {
views: [VIEW],
@@ -47,9 +62,58 @@ vi.mock("@/components/state-provider", () => ({
afterEach(() => {
cleanup();
vi.clearAllMocks();
state.sidebarViews.views = [VIEW];
state.sidebarViews.activeViewId = VIEW.id;
responsive.usesDesktopWorkbench = true;
responsive.isFinePointer = true;
});
describe("SidebarFilterPopover task-row editor", () => {
it("waits for named confirmation before deleting the active view", async () => {
state.sidebarViews.views = [VIEW, SECOND_VIEW];
render(
<SidebarFilterPopover
trigger={<button type="button">Open</button>}
open
onOpenChange={vi.fn()}
/>,
);
fireEvent.click(screen.getByTestId("view-delete-button"));
expect(state.deleteSidebarView).not.toHaveBeenCalled();
const confirmation = await screen.findByRole("dialog", { name: "Delete All tasks?" });
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(confirmation.isConnected).toBe(false);
expect(state.deleteSidebarView).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId("view-delete-button"));
fireEvent.click(screen.getByRole("button", { name: "Delete All tasks" }));
await waitFor(() => expect(state.deleteSidebarView).toHaveBeenCalledWith(VIEW.id));
expect(state.deleteSidebarView).toHaveBeenCalledOnce();
});
it("keeps deletion touch-reachable in a phone drawer with a fine pointer", async () => {
responsive.usesDesktopWorkbench = false;
responsive.isFinePointer = true;
state.sidebarViews.views = [VIEW, SECOND_VIEW];
render(
<SidebarFilterPopover
trigger={<button type="button">Open</button>}
open
onOpenChange={vi.fn()}
/>,
);
const deleteButton = screen.getByTestId("view-delete-button");
expect(deleteButton.className).toContain("min-h-11");
fireEvent.click(deleteButton);
expect(state.deleteSidebarView).not.toHaveBeenCalled();
expect(await screen.findByRole("group", { name: "Delete All tasks?" })).toBeTruthy();
});
it("keeps view settings collapsed until the user opens them", () => {
render(
<SidebarFilterPopover
@@ -94,7 +158,9 @@ describe("SidebarFilterPopover task-row editor", () => {
"border-t",
);
});
});
describe("SidebarFilterPopover option details", () => {
it("removes the popover primitive's default section gap", () => {
render(
<SidebarFilterPopover
@@ -1,6 +1,6 @@
"use client";
import type { ComponentProps } from "react";
import { useRef, type ComponentProps, type RefObject } from "react";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from "@kandev/ui/drawer";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { useAppStore } from "@/components/state-provider";
@@ -13,6 +13,13 @@ import {
} from "./sidebar-view-editor";
import { ViewHeaderRow } from "./view-manager";
import { useTranslation } from "react-i18next";
import { isActionConfirmationTarget } from "@/components/confirmation/action-confirm-popover";
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
import {
useSavedTaskViewDeleteConfirmation,
type SavedTaskViewDeleteTarget,
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
import { sidebarViewName } from "@/lib/state/slices/ui/sidebar-view-builtins";
type Props = {
trigger: React.ReactNode;
@@ -22,6 +29,15 @@ type Props = {
onRenameRequestHandled?: (viewId: string) => void;
};
type SidebarFilterSurfaceProps = Pick<Props, "trigger" | "open" | "onOpenChange"> & {
editor: React.ReactNode;
deletion: {
target: SavedTaskViewDeleteTarget | null;
anchorRef: RefObject<HTMLButtonElement | null>;
close: () => void;
};
};
export function SidebarFilterPopover({
trigger,
open,
@@ -39,11 +55,27 @@ export function SidebarFilterPopover({
const discard = useAppStore((s) => s.discardSidebarDraft);
const deleteView = useAppStore((s) => s.deleteSidebarView);
const renameView = useAppStore((s) => s.renameSidebarView);
const { usesDesktopWorkbench } = useResponsiveBreakpoint();
const { usesDesktopWorkbench, isFinePointer } = useResponsiveBreakpoint();
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(views);
const popoverContentRef = useRef<HTMLDivElement>(null);
const activeView: SidebarView | undefined = views.find((view) => view.id === activeViewId);
const current = createSidebarViewEditorCurrent(activeView, storedDraft);
const hasDraft = !!storedDraft && activeView?.id === storedDraft.baseViewId;
const usesInlineDeleteConfirmation = !usesDesktopWorkbench || !isFinePointer;
const inlineDeleteConfirmation =
usesInlineDeleteConfirmation && deletion.target ? (
<SavedTaskViewDeleteConfirmation
target={deletion.target}
presentation="inline"
open
anchorRef={deletion.anchorRef}
onOpenChange={(nextOpen) => {
if (!nextOpen) deletion.close();
}}
onConfirm={deleteView}
/>
) : undefined;
const headerProps: ComponentProps<typeof ViewHeaderRow> = {
activeView,
hasDraft,
@@ -52,7 +84,13 @@ export function SidebarFilterPopover({
onSaveAs: saveAs,
onRename: renameView,
onDiscard: discard,
onDelete: () => activeView && deleteView(activeView.id),
onDelete: () => {
if (!activeView) return;
deletion.request({ id: activeView.id, label: sidebarViewName(activeView, t) });
},
deleteAnchorRef: deletion.anchorRef,
deleteDensity: usesInlineDeleteConfirmation ? "touch" : "compact",
deleteConfirmation: inlineDeleteConfirmation,
renameRequestedViewId,
onRenameRequestHandled,
};
@@ -78,29 +116,111 @@ export function SidebarFilterPopover({
if (usesDesktopWorkbench) {
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className="max-h-[var(--radix-popper-available-height)] w-[calc(100vw-1rem)] max-w-[22rem] overflow-y-auto gap-0 p-0"
style={{ maxHeight: "var(--radix-popper-available-height, calc(100dvh - 1rem))" }}
align="end"
data-testid="sidebar-filter-popover"
>
{editor}
</PopoverContent>
</Popover>
<DesktopSidebarFilterSurface
trigger={trigger}
open={open}
onOpenChange={onOpenChange}
editor={editor}
deletion={deletion}
contentRef={popoverContentRef}
isFinePointer={isFinePointer}
onDelete={deleteView}
/>
);
}
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<MobileSidebarFilterSurface
trigger={trigger}
open={open}
onOpenChange={onOpenChange}
editor={editor}
deletion={deletion}
title={t("task:sidebarFilters")}
/>
);
}
function DesktopSidebarFilterSurface({
trigger,
open,
onOpenChange,
editor,
deletion,
contentRef,
isFinePointer,
onDelete,
}: SidebarFilterSurfaceProps & {
contentRef: RefObject<HTMLDivElement | null>;
isFinePointer: boolean;
onDelete: (id: string) => void;
}) {
return (
<>
<Popover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) deletion.close();
onOpenChange(nextOpen);
}}
>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
ref={contentRef}
className="max-h-[var(--radix-popper-available-height)] w-[calc(100vw-1rem)] max-w-[22rem] overflow-y-auto gap-0 p-0"
style={{ maxHeight: "var(--radix-popper-available-height, calc(100dvh - 1rem))" }}
align="end"
data-testid="sidebar-filter-popover"
onFocusOutside={(event) => {
if (deletion.target && isActionConfirmationTarget(event.target)) event.preventDefault();
}}
onInteractOutside={(event) => {
if (deletion.target && isActionConfirmationTarget(event.target)) event.preventDefault();
}}
>
{editor}
</PopoverContent>
</Popover>
{isFinePointer && deletion.target ? (
<SavedTaskViewDeleteConfirmation
target={deletion.target}
presentation="popover"
open
anchorRef={deletion.anchorRef}
focusBoundaryRef={contentRef}
onOpenChange={(nextOpen) => {
if (!nextOpen) deletion.close();
}}
onConfirm={onDelete}
/>
) : null}
</>
);
}
function MobileSidebarFilterSurface({
trigger,
open,
onOpenChange,
editor,
deletion,
title,
}: SidebarFilterSurfaceProps & { title: string }) {
return (
<Drawer
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) deletion.close();
onOpenChange(nextOpen);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent
data-testid="sidebar-filter-drawer"
className="h-[min(90dvh,48rem)] max-h-[calc(100dvh-1rem)] overflow-hidden rounded-t-xl"
>
<DrawerHeader className="shrink-0 border-b px-4 pb-3 pt-5 text-left">
<DrawerTitle>{t("task:sidebarFilters")}</DrawerTitle>
<DrawerTitle>{title}</DrawerTitle>
</DrawerHeader>
<div
data-testid="sidebar-filter-popover"
@@ -1,6 +1,13 @@
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
type RefObject,
} from "react";
import { Input } from "@kandev/ui/input";
import { Button } from "@kandev/ui/button";
import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types";
@@ -18,6 +25,9 @@ type HeaderProps = {
onRename: (id: string, name: string) => void;
onDiscard: () => void;
onDelete: () => void;
deleteAnchorRef?: RefObject<HTMLButtonElement | null>;
deleteDensity?: "compact" | "touch";
deleteConfirmation?: ReactNode;
renameRequestedViewId?: string | null;
onRenameRequestHandled?: (viewId: string) => void;
};
@@ -71,6 +81,10 @@ export function ViewHeaderRow(props: HeaderProps) {
setEditingViewId(null);
}, [activeViewId, editingViewId, mode]);
if (props.deleteConfirmation) {
return <div className="min-w-0 p-1">{props.deleteConfirmation}</div>;
}
return (
<div className="flex items-center justify-between gap-2">
<div className="flex flex-1 items-center gap-2 text-xs">
@@ -222,6 +236,8 @@ function ViewActions({
onRename,
onDiscard,
onDelete,
deleteAnchorRef,
deleteDensity = "compact",
}: {
activeView: SidebarView | undefined;
hasDraft: boolean;
@@ -231,6 +247,8 @@ function ViewActions({
onRename: () => void;
onDiscard: () => void;
onDelete: () => void;
deleteAnchorRef?: RefObject<HTMLButtonElement | null>;
deleteDensity?: "compact" | "touch";
}) {
const { t } = useTranslation();
const canOverwrite = hasDraft && !!activeView;
@@ -286,10 +304,11 @@ function ViewActions({
)}
{!hasDraft && activeView && canDelete && (
<Button
ref={deleteAnchorRef}
type="button"
size="sm"
variant="ghost"
className="h-6 cursor-pointer text-xs text-destructive"
className={`${deleteDensity === "touch" ? "min-h-11" : "h-6"} cursor-pointer text-xs text-destructive`}
onClick={onDelete}
data-testid="view-delete-button"
>
@@ -1,11 +1,13 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ThreadView, ThreadViewDraft } from "@/lib/state/slices/ui/thread-view-types";
import { ThreadsViewControls } from "./threads-view-controls";
const responsive = vi.hoisted(() => ({ usesDesktopWorkbench: true }));
const responsive = vi.hoisted(() => ({ usesDesktopWorkbench: true, isFinePointer: true }));
const EMPTY_CANDIDATES: never[] = [];
const VIEW_PICKER_TEST_ID = "threads-view-picker";
const DELETE_ACTION_TEST_ID = "threads-view-delete";
const MOBILE_DRAWER_TEST_ID = "threads-mobile-view-drawer";
const ALL_VIEW: ThreadView = {
id: "view-all-threads",
@@ -56,11 +58,73 @@ afterEach(() => {
cleanup();
vi.clearAllMocks();
responsive.usesDesktopWorkbench = true;
responsive.isFinePointer = true;
state.threadViews.draft = null;
state.threadViews.syncError = null;
});
describe("ThreadsViewControls", () => {
it("deletes the captured desktop view only after named confirmation", async () => {
render(
<ThreadsViewControls
candidates={EMPTY_CANDIDATES}
admittedCount={0}
matchingCount={0}
hiddenCount={0}
/>,
);
fireEvent.click(screen.getByTestId("threads-view-settings"));
fireEvent.click(screen.getByTestId(DELETE_ACTION_TEST_ID));
expect(state.deleteThreadView).not.toHaveBeenCalled();
const dialog = await screen.findByRole("dialog", { name: "Delete All threads?" });
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
expect(state.deleteThreadView).not.toHaveBeenCalled();
expect(screen.getByTestId("threads-view-settings-popover")).toBeTruthy();
fireEvent.click(screen.getByTestId(DELETE_ACTION_TEST_ID));
fireEvent.click(screen.getByRole("button", { name: "Delete All threads" }));
await waitFor(() => expect(state.deleteThreadView).toHaveBeenCalledWith(ALL_VIEW.id));
expect(state.deleteThreadView).toHaveBeenCalledOnce();
await waitFor(() => expect(screen.queryByTestId("threads-view-settings-popover")).toBeNull());
});
it("keeps mobile deletion inline until the user confirms", async () => {
responsive.usesDesktopWorkbench = false;
responsive.isFinePointer = false;
render(
<ThreadsViewControls
candidates={EMPTY_CANDIDATES}
admittedCount={0}
matchingCount={0}
hiddenCount={0}
/>,
);
fireEvent.click(screen.getByTestId("threads-mobile-view-trigger"));
fireEvent.click(await screen.findByTestId("threads-mobile-view-settings"));
fireEvent.click(await screen.findByTestId(DELETE_ACTION_TEST_ID));
expect(state.deleteThreadView).not.toHaveBeenCalled();
const confirmation = screen.getByRole("group", { name: "Delete All threads?" });
expect(within(confirmation).getByRole("button", { name: "Cancel" }).className).toContain(
"h-11",
);
fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" }));
expect(screen.getByTestId(MOBILE_DRAWER_TEST_ID).dataset.state).toBe("open");
fireEvent.click(screen.getByTestId(DELETE_ACTION_TEST_ID));
fireEvent.click(screen.getByRole("button", { name: "Delete All threads" }));
await waitFor(() => expect(state.deleteThreadView).toHaveBeenCalledWith(ALL_VIEW.id));
expect(state.deleteThreadView).toHaveBeenCalledOnce();
await waitFor(() =>
expect(screen.getByTestId(MOBILE_DRAWER_TEST_ID).dataset.state).toBe("closed"),
);
});
it("switches saved views from the compact selector and shows bounded counts", async () => {
render(
<ThreadsViewControls
@@ -99,7 +163,9 @@ describe("ThreadsViewControls", () => {
expect.objectContaining({ filters: expect.arrayContaining([expect.any(Object)]) }),
);
});
});
describe("ThreadsViewControls mobile composition", () => {
it("keeps the saved-view surface independent from sidebar view state", async () => {
render(
<ThreadsViewControls
@@ -133,7 +199,7 @@ describe("ThreadsViewControls", () => {
expect(screen.queryByTestId(VIEW_PICKER_TEST_ID)).toBeNull();
fireEvent.click(trigger);
expect(await screen.findByTestId("threads-mobile-view-drawer")).toBeTruthy();
expect(await screen.findByTestId(MOBILE_DRAWER_TEST_ID)).toBeTruthy();
fireEvent.click(await screen.findByTestId("threads-mobile-view-option-view-review"));
expect(state.setThreadActiveView).toHaveBeenCalledWith(REVIEW_VIEW.id);
@@ -165,6 +231,6 @@ describe("ThreadsViewControls", () => {
expect(screen.getByTestId("threads-task-picker")).toBeTruthy();
fireEvent.click(screen.getByTestId("threads-task-picker-back"));
expect(screen.getByTestId("threads-view-editor")).toBeTruthy();
expect(screen.getByTestId("threads-mobile-view-drawer")).toBeTruthy();
expect(screen.getByTestId(MOBILE_DRAWER_TEST_ID)).toBeTruthy();
});
});
@@ -20,6 +20,7 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/dra
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { useTranslation } from "react-i18next";
import { useAppStore } from "@/components/state-provider";
import { isActionConfirmationTarget } from "@/components/confirmation/action-confirm-popover";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import {
DEFAULT_THREAD_VIEW,
@@ -65,6 +66,7 @@ export function ThreadsViewControls({
const clearSyncError = useAppStore((state) => state.clearThreadViewSyncError);
const [settingsOpen, setSettingsOpen] = useState(false);
const openSettingsAfterPickerClose = useRef(false);
const settingsContentRef = useRef<HTMLDivElement>(null);
const activeView = useMemo(
() => views.find((view) => view.id === activeViewId) ?? DEFAULT_THREAD_VIEW,
[activeViewId, views],
@@ -105,7 +107,7 @@ export function ThreadsViewControls({
onSaveAs={saveAs}
onDiscard={discard}
onRename={(name) => renameView(activeView.id, name)}
onDelete={() => deleteView(activeView.id)}
onDelete={deleteView}
onDuplicate={() => duplicateView(activeView.id, "")}
onReapplySort={reapplySort}
onRetrySync={retrySync}
@@ -192,9 +194,16 @@ export function ThreadsViewControls({
</Button>
</PopoverTrigger>
<PopoverContent
ref={settingsContentRef}
align="start"
className="max-h-[calc(100dvh-1rem)] w-[min(42rem,calc(100vw-1rem))] overflow-y-auto border border-border/80 p-0 shadow-xl ring-1 ring-foreground/20"
data-testid="threads-view-settings-popover"
onFocusOutside={(event) => {
if (isActionConfirmationTarget(event.target)) event.preventDefault();
}}
onInteractOutside={(event) => {
if (isActionConfirmationTarget(event.target)) event.preventDefault();
}}
>
<ThreadsViewEditor
activeView={activeView}
@@ -208,8 +217,9 @@ export function ThreadsViewControls({
onSaveAs={saveAs}
onDiscard={discard}
onRename={(name) => renameView(activeView.id, name)}
onDelete={() => {
deleteView(activeView.id);
deleteFocusBoundaryRef={settingsContentRef}
onDelete={(viewId) => {
deleteView(viewId);
setSettingsOpen(false);
}}
onDuplicate={() => duplicateView(activeView.id, "")}
@@ -254,7 +264,7 @@ type MobileThreadsViewControlsProps = {
onSaveAs: (name: string) => void;
onDiscard: () => void;
onRename: (name: string) => void;
onDelete: () => void;
onDelete: (viewId: string) => void;
onDuplicate: () => void;
onReapplySort: () => void;
onRetrySync: () => void;
@@ -407,8 +417,8 @@ function MobileThreadsViewControls({
onSaveAs={onSaveAs}
onDiscard={onDiscard}
onRename={onRename}
onDelete={() => {
onDelete();
onDelete={(viewId) => {
onDelete(viewId);
closeDrawer();
}}
onDuplicate={onDuplicate}
@@ -0,0 +1,44 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorActions } from "./threads-view-editor-actions";
describe("EditorActions", () => {
afterEach(cleanup);
it("anchors delete requests and lets inline confirmation replace the action row", () => {
const onDelete = vi.fn();
const { rerender } = render(
<EditorActions
hasDraft={false}
canDelete
viewCount={2}
invalidDraft={false}
onSave={vi.fn()}
onSaveAs={vi.fn()}
onDiscard={vi.fn()}
onDelete={onDelete}
/>,
);
fireEvent.click(screen.getByTestId("threads-view-delete"));
expect(onDelete).toHaveBeenCalledOnce();
rerender(
<EditorActions
hasDraft={false}
canDelete
viewCount={2}
invalidDraft={false}
deleteConfirmation={<div data-testid="inline-delete-confirmation" />}
onSave={vi.fn()}
onSaveAs={vi.fn()}
onDiscard={vi.fn()}
onDelete={onDelete}
/>,
);
expect(screen.getByTestId("inline-delete-confirmation")).toBeTruthy();
expect(screen.queryByTestId("threads-view-delete")).toBeNull();
});
});
@@ -1,5 +1,6 @@
"use client";
import type { ReactNode, RefObject } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { MAX_THREAD_VIEWS } from "@/lib/state/slices/ui/thread-view-builtins";
@@ -13,6 +14,8 @@ export function EditorActions({
onSaveAs,
onDiscard,
onDelete,
deleteAnchorRef,
deleteConfirmation,
}: {
hasDraft: boolean;
canDelete: boolean;
@@ -22,8 +25,15 @@ export function EditorActions({
onSaveAs: () => void;
onDiscard: () => void;
onDelete: () => void;
deleteAnchorRef?: RefObject<HTMLButtonElement | null>;
deleteConfirmation?: ReactNode;
}) {
const { t } = useTranslation();
if (deleteConfirmation) {
return <div className="min-w-0 p-2">{deleteConfirmation}</div>;
}
return (
<div className="flex flex-wrap items-center justify-end gap-1 p-2">
{hasDraft && (
@@ -66,6 +76,7 @@ export function EditorActions({
)}
{!hasDraft && canDelete && (
<Button
ref={deleteAnchorRef}
type="button"
variant="ghost"
size="sm"
@@ -1,5 +1,6 @@
"use client";
import type { ReactNode, RefObject } from "react";
import { IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
@@ -52,6 +53,8 @@ export type EditorBodyProps = {
onRename: (name: string) => void;
onDiscard: () => void;
onDelete: () => void;
deleteAnchorRef?: RefObject<HTMLButtonElement | null>;
deleteConfirmation?: ReactNode;
onDuplicate: () => void;
onReapplySort: () => void;
onSetMaxColumns: (value: string, badInput?: boolean) => void;
@@ -82,6 +85,8 @@ export function EditorBody({
onRename,
onDiscard,
onDelete,
deleteAnchorRef,
deleteConfirmation,
onDuplicate,
onReapplySort,
onSetMaxColumns,
@@ -124,6 +129,8 @@ export function EditorBody({
onSave={onSave}
onDiscard={onDiscard}
onDelete={onDelete}
deleteAnchorRef={deleteAnchorRef}
deleteConfirmation={deleteConfirmation}
onNameChange={onNameChange}
onNameModeChange={onNameModeChange}
onReapplySort={onReapplySort}
@@ -151,6 +158,8 @@ type EditorSectionsProps = Pick<
| "onSave"
| "onDiscard"
| "onDelete"
| "deleteAnchorRef"
| "deleteConfirmation"
| "onNameChange"
| "onNameModeChange"
| "onReapplySort"
@@ -174,6 +183,8 @@ function EditorSections({
onSave,
onDiscard,
onDelete,
deleteAnchorRef,
deleteConfirmation,
onNameChange,
onNameModeChange,
onReapplySort,
@@ -232,6 +243,8 @@ function EditorSections({
}}
onDiscard={onDiscard}
onDelete={onDelete}
deleteAnchorRef={deleteAnchorRef}
deleteConfirmation={deleteConfirmation}
/>
</>
);
@@ -1,12 +1,19 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
import {
useSavedTaskViewDeleteConfirmation,
type SavedTaskViewDeleteTarget,
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
import type { ThreadCandidate } from "@/lib/threads/thread-view-query";
import type { ThreadView, ThreadViewDraft } from "@/lib/state/slices/ui/thread-view-types";
import type { Repository } from "@/lib/types/http";
import { ThreadsViewTaskPicker } from "./threads-view-task-picker";
import { EditorBody, type EditorBodyProps } from "./threads-view-editor-sections";
import { parseThreadMaxColumns } from "./threads-view-editor-utils";
import { threadViewName } from "@/lib/state/slices/ui/thread-view-builtins";
type EditorProps = {
activeView: ThreadView;
@@ -20,11 +27,12 @@ type EditorProps = {
onSaveAs: (name: string) => void;
onDiscard: () => void;
onRename: (name: string) => void;
onDelete: () => void;
onDelete: (viewId: string) => void;
onDuplicate: () => void;
onReapplySort: () => void;
showHeader?: boolean;
mobile?: boolean;
deleteFocusBoundaryRef?: RefObject<HTMLElement | null>;
};
export function ThreadsViewEditor({
@@ -44,7 +52,9 @@ export function ThreadsViewEditor({
onReapplySort,
showHeader = true,
mobile = false,
deleteFocusBoundaryRef,
}: EditorProps) {
const { t } = useTranslation();
const [pickerOpen, setPickerOpen] = useState(false);
const [nameMode, setNameMode] = useState<"rename" | "saveAs" | null>(null);
const [name, setName] = useState("");
@@ -53,17 +63,20 @@ export function ThreadsViewEditor({
formatMaxColumns(current.maxColumns),
);
const [maxColumnsInvalid, setMaxColumnsInvalid] = useState(false);
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>([activeView]);
const invalidSelectedScope =
current.taskScope.mode === "selected" && current.taskScope.taskIds.length === 0;
const hasDraft = !!draft && draft.baseViewId === activeView.id;
const repositoryNames = useMemo(
() =>
new Map<string, string>(
repositories.map((repository) => [String(repository.id), repository.name]),
),
[repositories],
);
const repositoryNames = useMemo(() => mapRepositoryNames(repositories), [repositories]);
const invalidDraft = invalidSelectedScope || maxColumnsInvalid;
const deleteConfirmation = deletion.target ? (
<ThreadViewDeleteConfirmation
deletion={deletion}
mobile={mobile}
focusBoundaryRef={deleteFocusBoundaryRef}
onConfirm={onDelete}
/>
) : null;
useEffect(() => {
setMaxColumnsInput(formatMaxColumns(current.maxColumns));
@@ -72,55 +85,120 @@ export function ThreadsViewEditor({
if (pickerOpen) {
return (
<div className="flex min-h-0 flex-col" data-testid="threads-view-editor">
<ThreadsViewTaskPicker
candidates={candidates}
selectedTaskIds={current.taskScope.mode === "selected" ? current.taskScope.taskIds : []}
onChange={(taskIds) => onUpdate({ taskScope: { mode: "selected", taskIds } })}
onBack={() => setPickerOpen(false)}
/>
</div>
<TaskPickerEditor
candidates={candidates}
current={current}
onUpdate={onUpdate}
onBack={() => setPickerOpen(false)}
/>
);
}
return (
<EditorBody
activeView={activeView}
current={current}
candidates={candidates}
repositoryNames={repositoryNames}
mobile={mobile}
showHeader={showHeader}
nameMode={nameMode}
name={name}
maxColumnsInput={maxColumnsInput}
maxColumnsInvalid={maxColumnsInvalid}
invalidSelectedScope={invalidSelectedScope}
invalidDraft={invalidDraft}
hasDraft={hasDraft}
viewCount={viewCount}
canDelete={canDelete}
onNameChange={setName}
onNameModeChange={setNameMode}
onUpdate={onUpdate}
onSave={onSave}
onSaveAs={onSaveAs}
onRename={onRename}
onDiscard={onDiscard}
onDelete={onDelete}
onDuplicate={onDuplicate}
onReapplySort={onReapplySort}
onSetMaxColumns={(value, badInput = false) => {
setMaxColumnsInput(value);
const parsed = parseThreadMaxColumns(value, badInput);
setMaxColumnsInvalid(parsed === undefined);
if (parsed !== undefined) onUpdate({ maxColumns: parsed });
<>
<EditorBody
activeView={activeView}
current={current}
candidates={candidates}
repositoryNames={repositoryNames}
mobile={mobile}
showHeader={showHeader}
nameMode={nameMode}
name={name}
maxColumnsInput={maxColumnsInput}
maxColumnsInvalid={maxColumnsInvalid}
invalidSelectedScope={invalidSelectedScope}
invalidDraft={invalidDraft}
hasDraft={hasDraft}
viewCount={viewCount}
canDelete={canDelete}
onNameChange={setName}
onNameModeChange={setNameMode}
onUpdate={onUpdate}
onSave={onSave}
onSaveAs={onSaveAs}
onRename={onRename}
onDiscard={onDiscard}
onDelete={() =>
deletion.request({ id: activeView.id, label: threadViewName(activeView, t) })
}
deleteAnchorRef={deletion.anchorRef}
deleteConfirmation={mobile ? deleteConfirmation : undefined}
onDuplicate={onDuplicate}
onReapplySort={onReapplySort}
onSetMaxColumns={(value, badInput = false) => {
setMaxColumnsInput(value);
const parsed = parseThreadMaxColumns(value, badInput);
setMaxColumnsInvalid(parsed === undefined);
if (parsed !== undefined) onUpdate({ maxColumns: parsed });
}}
onOpenPicker={() => setPickerOpen(true)}
/>
{!mobile ? deleteConfirmation : null}
</>
);
}
function ThreadViewDeleteConfirmation({
deletion,
mobile,
focusBoundaryRef,
onConfirm,
}: {
deletion: {
target: SavedTaskViewDeleteTarget | null;
anchorRef: RefObject<HTMLButtonElement | null>;
close: () => void;
};
mobile: boolean;
focusBoundaryRef?: RefObject<HTMLElement | null>;
onConfirm: (id: string) => void;
}) {
if (!deletion.target) return null;
return (
<SavedTaskViewDeleteConfirmation
target={deletion.target}
presentation={mobile ? "inline" : "popover"}
open
anchorRef={deletion.anchorRef}
focusBoundaryRef={focusBoundaryRef}
onOpenChange={(open) => {
if (!open) deletion.close();
}}
onOpenPicker={() => setPickerOpen(true)}
onConfirm={onConfirm}
/>
);
}
function TaskPickerEditor({
candidates,
current,
onUpdate,
onBack,
}: {
candidates: ThreadCandidate[];
current: ThreadView | ThreadViewDraft;
onUpdate: EditorBodyProps["onUpdate"];
onBack: () => void;
}) {
return (
<div className="flex min-h-0 flex-col" data-testid="threads-view-editor">
<ThreadsViewTaskPicker
candidates={candidates}
selectedTaskIds={current.taskScope.mode === "selected" ? current.taskScope.taskIds : []}
onChange={(taskIds) => onUpdate({ taskScope: { mode: "selected", taskIds } })}
onBack={onBack}
/>
</div>
);
}
function mapRepositoryNames(
repositories: ReadonlyArray<Pick<Repository, "id" | "name">>,
): Map<string, string> {
return new Map(repositories.map((repository) => [String(repository.id), repository.name]));
}
function formatMaxColumns(value: number | null): string {
return value === null ? "" : String(value);
}
+17 -1
View File
@@ -240,7 +240,23 @@ export class SidebarFilterPopoverPage {
await this.popover.getByTestId("view-discard-button").click();
}
async deleteActiveView(): Promise<void> {
get deleteConfirmation(): Locator {
return this.page.getByTestId("saved-task-view-delete-confirmation");
}
async beginDeleteActiveView(name: string): Promise<void> {
await this.popover.getByTestId("view-delete-button").click();
await expect(this.deleteConfirmation).toBeVisible();
await expect(this.deleteConfirmation).toHaveAccessibleName(`Delete ${name}?`);
}
async cancelDeleteActiveView(): Promise<void> {
await this.deleteConfirmation.getByRole("button", { name: "Cancel" }).click();
await expect(this.deleteConfirmation).toBeHidden();
}
async confirmDeleteActiveView(name: string): Promise<void> {
await this.deleteConfirmation.getByRole("button", { name: `Delete ${name}` }).click();
await expect(this.deleteConfirmation).toBeHidden();
}
}
@@ -167,6 +167,26 @@ test.describe("Desktop /github scope bar", () => {
await scopeBar.getByRole("button", { name: "Issues", exact: true }).click();
await expect(title).toContainText("Assigned");
await expect(repoFilter).toContainText("All repos");
await savedMenu.click();
await deleteIssueSavedQuery.click();
const deleteConfirmation = testPage.getByTestId("saved-task-view-delete-confirmation");
await expect(deleteConfirmation).toHaveAccessibleName(`Delete ${issueDefaultLabel}?`);
await expect(testPage.getByRole("menu")).toBeVisible();
await deleteConfirmation.getByRole("button", { name: "Cancel" }).click();
await expect(issueSavedQuery).toBeVisible();
await expect(testPage.getByRole("menu")).toBeVisible();
await deleteIssueSavedQuery.click();
const deleteResponse = testPage.waitForResponse(
(response) =>
response.ok() &&
response.request().method() === "PUT" &&
response.url().includes("/api/v1/github/workspace-settings"),
);
await deleteConfirmation.getByRole("button", { name: `Delete ${issueDefaultLabel}` }).click();
await deleteResponse;
await expect(issueSavedQuery).toHaveCount(0);
});
});
@@ -179,6 +179,7 @@ test.describe("Mobile /github sidebar", () => {
test("saved query defaults to its chosen repository and persists by touch", async ({
testPage,
apiClient,
prCapture,
}) => {
const savedQuery = "Mobile default repo issues";
await apiClient.mockGitHubReset();
@@ -264,5 +265,39 @@ test.describe("Mobile /github sidebar", () => {
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
await page.mobileMenuButton.tap();
const deleteAction = page.mobileSidebar.getByRole("button", {
name: `Delete ${savedQuery} saved query`,
});
const deleteBox = await deleteAction.boundingBox();
expect(deleteBox?.height ?? 0).toBeGreaterThanOrEqual(44);
expect(deleteBox?.width ?? 0).toBeGreaterThanOrEqual(44);
await deleteAction.tap();
const deleteConfirmation = page.mobileSidebar.getByTestId(
"saved-task-view-delete-confirmation",
);
await expect(deleteConfirmation).toHaveAccessibleName(`Delete ${savedQuery}?`);
await expect(testPage.locator('[role="dialog"]:visible')).toHaveCount(1);
await prCapture.screenshot("saved-query-delete-mobile", {
caption: "A saved integration query confirms inline without nesting another sheet.",
});
await deleteConfirmation.getByRole("button", { name: "Cancel" }).tap();
await expect(page.savedQueryByLabel(savedQuery)).toBeVisible();
await deleteAction.tap();
const deleteResponse = testPage.waitForResponse(
(response) =>
response.ok() &&
response.request().method() === "PUT" &&
response.url().includes("/api/v1/github/workspace-settings"),
);
await page.mobileSidebar
.getByTestId("saved-task-view-delete-confirmation")
.getByRole("button", { name: `Delete ${savedQuery}` })
.tap();
await deleteResponse;
await expect(page.savedQueryByLabel(savedQuery)).toHaveCount(0);
await expect(page.mobileSidebar).toBeVisible();
});
});
@@ -157,8 +157,19 @@ test.describe("GitLab issue milestone filter", () => {
// Deleting the selected saved query clears the milestone in the same update.
await savedMenu.click();
const deleteAction = testPage.getByRole("menuitem", {
name: "Delete Sprint 42 saved query",
});
await deleteAction.click();
const confirmation = testPage.getByTestId("saved-task-view-delete-confirmation");
await expect(confirmation).toHaveAccessibleName("Delete Sprint 42?");
await expect(testPage.getByRole("menu")).toBeVisible();
await confirmation.getByRole("button", { name: "Cancel" }).click();
await expect(testPage.getByRole("menuitem", { name: "Sprint 42", exact: true })).toBeVisible();
await deleteAction.click();
const deleted = waitForHttp(testPage, "GET", ISSUES_ENDPOINT);
await testPage.getByRole("menuitem", { name: "Delete Sprint 42 saved query" }).click();
await confirmation.getByRole("button", { name: "Delete Sprint 42" }).click();
await deleted;
await expect(milestoneInput).toHaveValue("");
await expect(gitlab.issueRow(602)).toBeVisible();
@@ -6,6 +6,65 @@ import { GitLabPage } from "../../pages/gitlab-page";
import { ISSUES_ENDPOINT, seededIssue } from "./gitlab-issue-milestone-filter-helpers";
test.describe("Mobile GitLab issue milestone filter", () => {
test("confirms a saved query inline with visible touch actions", async ({
testPage,
apiClient,
seedData,
}) => {
const savedLabel = "Mobile release MRs";
await apiClient.configureGitLab(seedData.workspaceId);
const seedResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
gitlab_saved_presets: [
{
id: "mobile-release-mrs",
kind: "mr",
label: savedLabel,
customQuery: "state=opened",
projectFilter: GITLAB_PROJECT,
milestone: "",
preset: "",
createdAt: "2026-09-07T00:00:00Z",
},
],
});
expect(seedResponse.ok).toBe(true);
const gitlab = new GitLabPage(testPage);
await gitlab.goto();
await gitlab.mobileFiltersButton.tap();
const deleteAction = gitlab.mobileSidebar.getByRole("button", {
name: `Delete ${savedLabel} saved query`,
exact: true,
});
await expect(deleteAction).toBeVisible();
const deleteBox = await deleteAction.boundingBox();
expect(deleteBox?.height ?? 0).toBeGreaterThanOrEqual(44);
expect(deleteBox?.width ?? 0).toBeGreaterThanOrEqual(44);
await deleteAction.tap();
const confirmation = gitlab.mobileSidebar.getByTestId("saved-task-view-delete-confirmation");
await expect(confirmation).toHaveAccessibleName(`Delete ${savedLabel}?`);
await expect(testPage.locator('[role="dialog"]:visible')).toHaveCount(1);
await confirmation.getByRole("button", { name: "Cancel" }).tap();
await expect(deleteAction).toBeVisible();
await deleteAction.tap();
const deleteResponse = testPage.waitForResponse(
(response) =>
response.ok() &&
response.request().method() === "PATCH" &&
response.url().includes("/api/v1/user/settings"),
);
await gitlab.mobileSidebar
.getByTestId("saved-task-view-delete-confirmation")
.getByRole("button", { name: `Delete ${savedLabel}` })
.tap();
await deleteResponse;
await expect(deleteAction).toHaveCount(0);
await expect(gitlab.mobileSidebar).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage, "mobile GitLab saved query deletion");
});
test("filters issues from the mobile sheet without clipping the milestone input", async ({
testPage,
apiClient,
@@ -195,6 +195,24 @@ test("connects and browses Azure work items, PRs, and feedback", async ({
"Connected as Ada Reviewer",
);
await testPage.getByTestId("azure-devops-save-button").click();
const savedViewsSeed = await testPage.request.put(
`/api/v1/azure-devops/views?workspace_id=${encodeURIComponent(seedData.workspaceId)}`,
{
data: {
views: [
{
id: "security-queue",
kind: "work_item",
label: "Security queue",
projectId: "project-1",
wiql: "SELECT [System.Id] FROM WorkItems",
createdAt: "2026-09-07T00:00:00Z",
},
],
},
},
);
expect(savedViewsSeed.ok()).toBe(true);
const defaultQueries = testPage
.getByRole("heading", { name: "Default queries" })
.locator("xpath=ancestor::section");
@@ -204,6 +222,30 @@ test("connects and browses Azure work items, PRs, and feedback", async ({
await testPage.goto("/azure-devops");
await expect(testPage.getByTestId("azure-devops-presets-scope-bar")).toContainText("Team queue");
const savedViewsMenu = testPage.getByTestId("azure-devops-saved-views-menu");
await savedViewsMenu.click();
const deleteSavedView = testPage.getByRole("menuitem", {
name: "Delete Security queue saved query",
});
await deleteSavedView.click();
const deleteConfirmation = testPage.getByTestId("saved-task-view-delete-confirmation");
await expect(deleteConfirmation).toHaveAccessibleName("Delete Security queue?");
await expect(testPage.getByRole("menu")).toBeVisible();
await deleteConfirmation.getByRole("button", { name: "Cancel" }).click();
await expect(deleteSavedView).toBeVisible();
await deleteSavedView.click();
const deleteSavedViewResponse = testPage.waitForResponse(
(response) =>
response.ok() &&
response.request().method() === "PUT" &&
response.url().includes("/api/v1/azure-devops/views"),
);
await deleteConfirmation.getByRole("button", { name: "Delete Security queue" }).click();
await deleteSavedViewResponse;
await expect(deleteSavedView).toHaveCount(0);
await testPage.keyboard.press("Escape");
await expect(testPage.getByRole("menu")).toBeHidden();
await expect(testPage.getByTestId("azure-devops-board")).toBeVisible();
await expect(testPage.getByText("Handle token rotation")).toBeVisible();
await testPage.getByTestId("azure-board-select").click();
@@ -77,4 +77,53 @@ test.describe("Jira status filter", () => {
await expect(testPage.getByText("CLIP-2")).toBeVisible();
await expect(testPage.getByText("CLIP-1")).toHaveCount(0);
});
test("confirms deletion of a custom view while preserving built-ins", async ({
testPage,
apiClient,
}) => {
const seedResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
jira_saved_views: [
{
id: "jira-sprint-bugs",
name: "Sprint bugs",
filters: {
projectKeys: [],
statuses: [],
assignee: "anyone",
searchText: "",
sort: "updated",
},
},
],
});
expect(seedResponse.ok).toBe(true);
await testPage.goto("/jira");
await testPage.getByRole("button", { name: "Assigned to me" }).click();
await expect(testPage.getByRole("button", { name: "Delete Assigned to me" })).toHaveCount(0);
const deleteAction = testPage.getByRole("button", { name: "Delete Sprint bugs" });
await deleteAction.click();
const confirmation = testPage.getByTestId("saved-task-view-delete-confirmation");
await expect(confirmation).toHaveAccessibleName("Delete Sprint bugs?");
await expect(testPage.getByText("Sprint bugs", { exact: true })).toBeVisible();
await confirmation.getByRole("button", { name: "Cancel" }).click();
const settingsAfterCancel = await apiClient.getUserSettings();
expect(
(settingsAfterCancel.settings.jira_saved_views as Array<{ id?: string }> | undefined)?.some(
(view) => view.id === "jira-sprint-bugs",
),
).toBe(true);
await deleteAction.click();
const deleteResponse = testPage.waitForResponse(
(response) =>
response.ok() &&
response.request().method() === "PATCH" &&
response.url().includes("/api/v1/user/settings"),
);
await confirmation.getByRole("button", { name: "Delete Sprint bugs" }).click();
await deleteResponse;
await expect(deleteAction).toHaveCount(0);
});
});
@@ -173,9 +173,64 @@ test("mobile board opens a focused column editor without horizontal overflow", a
organizationUrl: "https://dev.azure.com/acme",
pat: "azure-test-pat",
});
const savedViewsSeed = await testPage.request.put(
`/api/v1/azure-devops/views?workspace_id=${encodeURIComponent(seedData.workspaceId)}`,
{
data: {
views: [
{
id: "mobile-security-queue",
kind: "work_item",
label: "Mobile security queue",
projectId: "project-1",
wiql: "SELECT [System.Id] FROM WorkItems",
createdAt: "2026-09-07T00:00:00Z",
},
],
},
},
);
expect(savedViewsSeed.ok()).toBe(true);
await testPage.goto("/azure-devops");
await expect(testPage.getByTestId("azure-devops-board")).toBeVisible();
const savedViewsMenu = testPage.getByTestId("azure-devops-saved-views-menu");
await savedViewsMenu.tap();
const deleteSavedView = testPage.getByRole("menuitem", {
name: "Delete Mobile security queue saved query",
});
await expect
.poll(async () => (await deleteSavedView.boundingBox())?.height ?? 0)
.toBeGreaterThanOrEqual(44);
await expect
.poll(async () => (await deleteSavedView.boundingBox())?.width ?? 0)
.toBeGreaterThanOrEqual(44);
await deleteSavedView.tap();
const deleteConfirmation = testPage.getByTestId("saved-task-view-delete-confirmation");
await expect(deleteConfirmation).toHaveAccessibleName("Delete Mobile security queue?");
await expect(testPage.locator('[role="dialog"]:visible')).toHaveCount(0);
for (const action of await deleteConfirmation.getByRole("button").all()) {
const box = await action.boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
}
await deleteConfirmation.getByRole("button", { name: "Cancel" }).tap();
await expect(deleteSavedView).toBeVisible();
await deleteSavedView.tap();
const deleteSavedViewResponse = testPage.waitForResponse(
(response) =>
response.ok() &&
response.request().method() === "PUT" &&
response.url().includes("/api/v1/azure-devops/views"),
);
await testPage
.getByTestId("saved-task-view-delete-confirmation")
.getByRole("button", { name: "Delete Mobile security queue" })
.tap();
await deleteSavedViewResponse;
await expect(deleteSavedView).toHaveCount(0);
await testPage.keyboard.press("Escape");
await expect(testPage.getByRole("menu")).toBeHidden();
await expect(testPage.getByText("Handle token rotation")).toBeVisible();
await prCapture.screenshot("board-mobile", {
caption: "Azure DevOps focused mobile board column",
@@ -0,0 +1,73 @@
import { expect, test } from "../../fixtures/test-base";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
test.describe("Mobile Jira saved views", () => {
test.beforeEach(async ({ apiClient }) => {
await apiClient.mockJiraReset();
await apiClient.setJiraConfig({
siteUrl: "https://acme.atlassian.net",
email: "alice@example.com",
secret: "api-token-value",
});
await apiClient.waitForIntegrationAuthHealthy("jira");
});
test("keeps custom-view confirmation inline, touch-sized, and contained", async ({
testPage,
apiClient,
}) => {
const seedResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
jira_saved_views: [
{
id: "jira-mobile-long-view",
name: "Long mobile sprint bugs that need review",
filters: {
projectKeys: [],
statuses: [],
assignee: "anyone",
searchText: "",
sort: "updated",
},
},
],
});
expect(seedResponse.ok).toBe(true);
await testPage.goto("/jira");
await testPage.getByRole("button", { name: "Assigned to me" }).tap();
const deleteAction = testPage.getByRole("button", {
name: "Delete Long mobile sprint bugs that need review",
});
const deleteBox = await deleteAction.boundingBox();
expect(deleteBox?.height ?? 0).toBeGreaterThanOrEqual(44);
expect(deleteBox?.width ?? 0).toBeGreaterThanOrEqual(44);
await deleteAction.tap();
const confirmation = testPage.getByTestId("saved-task-view-delete-confirmation");
await expect(confirmation).toHaveAccessibleName(
"Delete Long mobile sprint bugs that need review?",
);
await expect(testPage.locator('[role="dialog"]:visible')).toHaveCount(1);
for (const action of await confirmation.getByRole("button").all()) {
const box = await action.boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
}
await assertNoDocumentHorizontalOverflow(testPage, "mobile Jira saved-view confirmation");
await confirmation.getByRole("button", { name: "Cancel" }).tap();
await expect(deleteAction).toBeFocused();
await deleteAction.tap();
const deleteResponse = testPage.waitForResponse(
(response) =>
response.ok() &&
response.request().method() === "PATCH" &&
response.url().includes("/api/v1/user/settings"),
);
await testPage
.getByTestId("saved-task-view-delete-confirmation")
.getByRole("button", { name: "Delete Long mobile sprint bugs that need review" })
.tap();
await deleteResponse;
await expect(deleteAction).toHaveCount(0);
});
});
@@ -173,6 +173,7 @@ test.describe("Mobile sidebar — view system", () => {
testPage,
apiClient,
seedData,
prCapture,
}) => {
const sheet = await seedAndOpenSheet(testPage, apiClient, seedData, ["Mobile New View Task"]);
const newView = sheet.getByTestId("sidebar-new-view");
@@ -235,6 +236,62 @@ test.describe("Mobile sidebar — view system", () => {
.getByTestId("sidebar-view-chip")
.filter({ hasText: "New view" }),
).toHaveAttribute("data-active", "true");
await reloadedSheet.getByTestId("sidebar-filter-gear").tap();
const deleteDrawer = testPage.getByTestId("sidebar-filter-drawer");
const deleteEditor = deleteDrawer.getByTestId("sidebar-filter-popover");
const deleteButton = deleteEditor.getByTestId("view-delete-button");
await deleteButton.scrollIntoViewIfNeeded();
const deleteButtonBox = await deleteButton.boundingBox();
expect(deleteButtonBox).not.toBeNull();
expect(deleteButtonBox!.height).toBeGreaterThanOrEqual(44);
expect(
await deleteButton.evaluate((element) => {
const bounds = element.getBoundingClientRect();
const hit = document.elementFromPoint(
bounds.left + bounds.width / 2,
bounds.top + bounds.height / 2,
);
return hit === element || (hit !== null && element.contains(hit));
}),
).toBe(true);
await deleteButton.tap();
const confirmation = deleteEditor.getByTestId("saved-task-view-delete-confirmation");
await expect(confirmation).toHaveAccessibleName("Delete New view?");
await expect(testPage.locator('[role="dialog"]:visible')).toHaveCount(2);
for (const action of await confirmation.getByRole("button").all()) {
const box = await action.boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
}
await prCapture.screenshot("saved-task-view-delete-mobile", {
caption: "Mobile confirmation stays inline in the existing task-filter drawer.",
});
await confirmation.getByRole("button", { name: "Cancel" }).tap();
await expect(deleteDrawer).toBeVisible();
await expect(deleteEditor.getByTestId("sidebar-filter-active-view-name")).toContainText(
"New view",
);
await deleteEditor.getByTestId("view-delete-button").tap();
const settingsWrite = testPage.waitForResponse(
(response) =>
response.url().includes("/api/v1/user/settings") &&
response.request().method() === "PATCH" &&
response.ok(),
);
await deleteEditor
.getByTestId("saved-task-view-delete-confirmation")
.getByRole("button", { name: "Delete New view" })
.tap();
await settingsWrite;
await expect(deleteEditor.getByTestId("sidebar-filter-active-view-name")).toContainText(
"All tasks",
);
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
});
test("editing filters in the mobile sheet narrows the task list live", async ({
@@ -269,4 +269,60 @@ test.describe("Mobile Threads view", () => {
await expect(geometryDrawer).toBeHidden();
await expect(reloadedTrigger).toBeFocused();
});
test("confirms deletion of a saved view inside the native drawer", async ({
testPage,
apiClient,
}) => {
const baseView = {
task_scope: { mode: "all", task_ids: [] },
filters: [],
sort: { key: "attention", direction: "asc" },
max_columns: null,
};
const seedResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
thread_views: [
{ ...baseView, id: "view-all-threads", name: "All threads" },
{ ...baseView, id: "view-release", name: "Release threads" },
],
thread_active_view_id: "view-release",
thread_view_draft: null,
});
expect(seedResponse.ok).toBe(true);
await testPage.goto("/threads");
const trigger = testPage.getByTestId("threads-mobile-view-trigger");
await expect(trigger).toContainText("Release threads");
await trigger.tap();
const drawer = testPage.getByTestId("threads-mobile-view-drawer");
await drawer.getByTestId("threads-mobile-view-settings").tap();
const editor = drawer.getByTestId("threads-view-editor");
await editor.getByTestId("threads-view-delete").tap();
const confirmation = editor.getByTestId("saved-task-view-delete-confirmation");
await expect(confirmation).toHaveAccessibleName("Delete Release threads?");
await expect(testPage.locator('[role="dialog"]:visible')).toHaveCount(1);
for (const action of await confirmation.getByRole("button").all()) {
const box = await action.boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
}
await confirmation.getByRole("button", { name: "Cancel" }).tap();
await expect(drawer).toBeVisible();
await expect(editor.getByTestId("threads-view-delete")).toBeFocused();
await editor.getByTestId("threads-view-delete").tap();
const deletedViewResponse = testPage.waitForResponse(
(response) =>
response.ok() &&
response.request().method() === "PATCH" &&
response.url().includes("/api/v1/user/settings"),
);
await editor
.getByTestId("saved-task-view-delete-confirmation")
.getByRole("button", { name: "Delete Release threads" })
.tap();
await deletedViewResponse;
await expect(drawer).toBeHidden();
await expect(trigger).toContainText("All threads");
await expect(trigger).toBeFocused();
});
});
+28 -2
View File
@@ -192,7 +192,8 @@ test.describe("Sidebar filter — view ordering", () => {
await filters.selectViewByName("Beta View");
await filters.open();
await filters.deleteActiveView();
await filters.beginDeleteActiveView("Beta View");
await filters.confirmDeleteActiveView("Beta View");
await filters.expectChipOrder(["All tasks", "Alpha View", "Gamma View"]);
// Deleting the active view falls back to the first remaining view.
await filters.expectActiveViewChip("All tasks");
@@ -629,6 +630,7 @@ test.describe("Sidebar filter — saved views CRUD", () => {
testPage,
apiClient,
seedData,
prCapture,
}) => {
const { filters } = await openWithSeed(testPage, apiClient, seedData, ["Delete View Task"]);
await filters.addFilterRow();
@@ -638,7 +640,31 @@ test.describe("Sidebar filter — saved views CRUD", () => {
await filters.expectActiveViewChip("Ephemeral");
await filters.open();
await filters.deleteActiveView();
await filters.beginDeleteActiveView("Ephemeral");
await expect(filters.popover).toBeVisible();
if (prCapture.capturing) {
await filters.deleteConfirmation.evaluate(async (element) => {
await Promise.all(
element.getAnimations().map((animation) => animation.finished.catch(() => undefined)),
);
});
}
await prCapture.screenshot("saved-task-view-delete-desktop", {
caption: "Desktop saved task view deletion names the target before removing filters.",
});
await filters.cancelDeleteActiveView();
await expect(filters.popover.getByTestId("sidebar-filter-active-view-name")).toContainText(
"Ephemeral",
);
const settingsAfterCancel = await apiClient.getUserSettings();
expect(
(settingsAfterCancel.settings.sidebar_views as Array<{ name?: string }> | undefined)?.some(
(view) => view.name === "Ephemeral",
),
).toBe(true);
await filters.beginDeleteActiveView("Ephemeral");
await filters.confirmDeleteActiveView("Ephemeral");
await filters.close();
await filters.openViewPicker();
await expect(
@@ -252,6 +252,52 @@ test.describe("Threads view", () => {
await expect(board.locator("[data-thread-column-id]")).toHaveCount(3);
});
test("confirms deletion of a saved Threads view before closing settings", async ({
testPage,
apiClient,
}) => {
const baseView = {
task_scope: { mode: "all", task_ids: [] },
filters: [],
sort: { key: "attention", direction: "asc" },
max_columns: null,
};
const seedResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
thread_views: [
{ ...baseView, id: "view-all-threads", name: "All threads" },
{ ...baseView, id: "view-release", name: "Release threads" },
],
thread_active_view_id: "view-release",
thread_view_draft: null,
});
expect(seedResponse.ok).toBe(true);
await testPage.goto("/threads");
await expect(testPage.getByTestId("threads-view-picker")).toContainText("Release threads");
await testPage.getByTestId("threads-view-settings").click();
const settings = testPage.getByTestId("threads-view-settings-popover");
await expect(settings).toBeVisible();
await settings.getByTestId("threads-view-delete").click();
const confirmation = testPage.getByTestId("saved-task-view-delete-confirmation");
await expect(confirmation).toHaveAccessibleName("Delete Release threads?");
await expect(settings).toBeVisible();
await confirmation.getByRole("button", { name: "Cancel" }).click();
await expect(settings).toBeVisible();
await expect(testPage.getByTestId("threads-view-picker")).toContainText("Release threads");
await settings.getByTestId("threads-view-delete").click();
const deletedViewResponse = testPage.waitForResponse(
(response) =>
response.ok() &&
response.request().method() === "PATCH" &&
response.url().includes("/api/v1/user/settings"),
);
await confirmation.getByRole("button", { name: "Delete Release threads" }).click();
await deletedViewResponse;
await expect(settings).toBeHidden();
await expect(testPage.getByTestId("threads-view-picker")).toContainText("All threads");
});
test("explains sorts and shows live task details in the task picker", async ({
testPage,
apiClient,
@@ -322,6 +368,20 @@ test.describe("Threads view", () => {
seedData,
}) => {
test.setTimeout(240_000);
const baseView = {
id: "view-all-threads",
name: "All threads",
task_scope: { mode: "all", task_ids: [] },
filters: [],
sort: { key: "attention", direction: "asc" },
max_columns: null,
};
const seedResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
thread_views: [baseView],
thread_active_view_id: baseView.id,
thread_view_draft: null,
});
expect(seedResponse.ok).toBe(true);
const first = await startAgentTask(testPage, apiClient, seedData, "threads-focus-a");
const second = await startAgentTask(testPage, apiClient, seedData, "threads-focus-b", {
title: SECOND_TITLE,
@@ -197,6 +197,12 @@ describe("convertMessage regional vocabulary", () => {
assert.equal(convertMessage("点击“启动智能体”", "zh-tw"), "點選「啟動代理程式」");
assert.equal(convertMessage("点击“启动智能体”", "zh-hk"), "點擊「啓動代理程式」");
});
it("keeps the adverb in copy that means only", () => {
for (const locale of TARGET_LOCALES) {
assert.equal(convertMessage("这只会删除", locale), "這只會刪除");
}
});
});
describe("convertMessage idempotence", () => {
@@ -25,6 +25,11 @@
"DELETE ALL"
],
"phrases": [
{
"from": "这只会",
"zh-tw": "這只會",
"zh-hk": "這只會"
},
{
"from": "個人訪問令牌",
"zh-tw": "個人存取權杖",
+8 -4
View File
@@ -8,8 +8,10 @@
"preventAutoStartAgentOnOpen": "開啟任務時不自動啟動代理程式",
"preventAutoStartAgentOnOpenHelp": "重新啟動後,或從工作流程最後一步開啟任務時,不會自動啟動代理程式,改為顯示「啟動代理程式」按鈕。"
},
"agents": {
"dynamicProfileSettings": "動態設定檔設定"
"common": {
"nativeFolderPickerUnavailable": "原生資料夾選擇器無法使用。請重新載入 Kandev 後再試。",
"sidebarWeeks_one": "{{count}}週",
"sidebarWeeks_other": "{{count}}週"
},
"github": {
"resolvedFromOneNamedHostAccount": "需要時從一個指定的主機帳戶解析。受管理任務會取得限定範圍的中介身分;除非在下方選取,否則不會繼承主機 Git。",
@@ -39,8 +41,10 @@
"preventAutoStartAgentOnOpen": "開啟任務時不自動啓動代理程式",
"preventAutoStartAgentOnOpenHelp": "重新啓動後,或從工作流程最後一步開啟任務時,不會自動啓動代理程式,改為顯示「啓動代理程式」按鈕。"
},
"agents": {
"dynamicProfileSettings": "動態設定檔設定"
"common": {
"nativeFolderPickerUnavailable": "原生資料夾選擇器無法使用。請重新載入 Kandev 後再試。",
"sidebarWeeks_one": "{{count}}週",
"sidebarWeeks_other": "{{count}}週"
},
"github": {
"resolvedFromOneNamedHostAccount": "需要時從一個指定的主機帳戶解析。受管理任務會取得限定範圍的中介身份;除非在下方選取,否則不會繼承主機 Git。",
+4
View File
@@ -144,6 +144,10 @@
"dShort": "{{diffDay}}d",
"default": "default",
"defaultBadge": "Default",
"delete": "Delete",
"deleteSavedTaskViewAction": "Delete {{name}}",
"deleteSavedTaskViewDescription": "This removes only the saved filters and view settings. It does not delete tasks or data in connected services.",
"deleteSavedTaskViewTitle": "Delete {{name}}?",
"deleteSession": "Delete session",
"describeABugYouHitOr": "Describe a bug you hit or a feature you'd like, and we'll create a task on your own agent to implement it in the kandev codebase.",
"destination": "Destination",
+6 -2
View File
@@ -144,6 +144,10 @@
"dShort": "{{diffDay}}ď",
"default": "ďēƒàũĺţ",
"defaultBadge": "Ďēƒàũĺţ",
"delete": "Ďēĺēţē",
"deleteSavedTaskViewAction": "Ďēĺēţē {{name}}",
"deleteSavedTaskViewDescription": "Ţĥĩś ŕēḿōvēś ōńĺŷ ţĥē śàvēď ƒĩĺţēŕś àńď vĩēŵ śēţţĩńĝś. Ĩţ ďōēś ńōţ ďēĺēţē ţàśķś ōŕ ďàţà ĩń ćōńńēćţēď śēŕvĩćēś.",
"deleteSavedTaskViewTitle": "Ďēĺēţē {{name}}?",
"deleteSession": "Ďēĺēţē śēśśĩōń",
"describeABugYouHitOr": "Ďēśćŕĩƀē à ƀũĝ ŷōũ ĥĩţ ōŕ à ƒēàţũŕē ŷōũ'ď ĺĩķē, àńď ŵē'ĺĺ ćŕēàţē à ţàśķ ōń ŷōũŕ ōŵń àĝēńţ ţō ĩḿƥĺēḿēńţ ĩţ ĩń ţĥē ķàńďēv ćōďēƀàśē.",
"destination": "Ďēśţĩńàţĩōń",
@@ -166,8 +170,8 @@
"failedToCreateFolder": "Ƒàĩĺēď ţō ćŕēàţē ƒōĺďēŕ",
"failedToCreateRepository": "Ƒàĩĺēď ţō ćŕēàţē ŕēƥōśĩţōŕŷ",
"failedToLoadDirectory": "Ƒàĩĺēď ţō ĺōàď ďĩŕēćţōŕŷ",
"nativeFolderPickerFailed": "Ţħē ńàţĩṽē ƒōĺďēŕ ƥĩćķēŕ ćōũĺď ńōţ ōƥēń.",
"nativeFolderPickerUnavailable": "Ţħē ńàţĩṽē ƒōĺďēŕ ƥĩćķēŕ ĩś ũńàṽàĩĺàƀĺē. Ŕēĺōàď Ķàńďēṽ àńď ţŕŷ àĝàĩń.",
"nativeFolderPickerFailed": "Ţĥē ńàţĩvē ƒōĺďēŕ ƥĩćķēŕ ćōũĺď ńōţ ōƥēń.",
"nativeFolderPickerUnavailable": "Ţĥē ńàţĩvē ƒōĺďēŕ ƥĩćķēŕ ĩś ũńàvàĩĺàƀĺē. Ŕēĺōàď Ķàńďēv àńď ţŕŷ àĝàĩń.",
"failedToLoadRoutine": "Ƒàĩĺēď ţō ĺōàď ŕōũţĩńē",
"failedToLoadSetup": "Ƒàĩĺēď ţō ĺōàď śēţũƥ",
"failedToRenderDiagram": "Ƒàĩĺēď ţō ŕēńďēŕ ďĩàĝŕàḿ",
+4
View File
@@ -143,6 +143,10 @@
"dShort": "{{diffDay}}d",
"default": "pré-definido",
"defaultBadge": "Pré-definido",
"delete": "Eliminar",
"deleteSavedTaskViewAction": "Eliminar {{name}}",
"deleteSavedTaskViewDescription": "Isto remove apenas os filtros guardados e as definições da vista. Não elimina tarefas nem dados em serviços ligados.",
"deleteSavedTaskViewTitle": "Eliminar {{name}}?",
"deleteSession": "Eliminar sessão",
"describeABugYouHitOr": "Descreva um erro que encontrou ou uma funcionalidade que gostaria de ter, e criamos uma tarefa no seu próprio agente para a implementar no código do kandev.",
"destination": "Destino",
+4
View File
@@ -144,6 +144,10 @@
"dShort": "{{diffDay}}天",
"default": "默认",
"defaultBadge": "默认",
"delete": "删除",
"deleteSavedTaskViewAction": "删除{{name}}",
"deleteSavedTaskViewDescription": "这只会删除已保存的筛选条件和视图设置,不会删除任务或关联服务中的数据。",
"deleteSavedTaskViewTitle": "删除{{name}}?",
"deleteSession": "删除会话",
"describeABugYouHitOr": "描述你遇到的缺陷或想要的功能,我们会在你自己的智能体上创建任务,并在 Kandev 代码库中实现。",
"destination": "目标位置",
+4
View File
@@ -144,6 +144,10 @@
"dShort": "{{diffDay}}天",
"default": "預設",
"defaultBadge": "預設",
"delete": "刪除",
"deleteSavedTaskViewAction": "刪除{{name}}",
"deleteSavedTaskViewDescription": "這只會刪除已儲存的篩選條件和視圖設定,不會刪除任務或關聯服務中的數據。",
"deleteSavedTaskViewTitle": "刪除{{name}}?",
"deleteSession": "刪除工作階段",
"describeABugYouHitOr": "描述你遇到的缺陷或想要的功能,我們會在你自己的代理程式上建立任務,並在 Kandev 程式碼庫中實現。",
"destination": "目標位置",
+4
View File
@@ -144,6 +144,10 @@
"dShort": "{{diffDay}}天",
"default": "預設",
"defaultBadge": "預設",
"delete": "刪除",
"deleteSavedTaskViewAction": "刪除{{name}}",
"deleteSavedTaskViewDescription": "這只會刪除已儲存的篩選條件和檢視設定,不會刪除任務或關聯服務中的資料。",
"deleteSavedTaskViewTitle": "刪除{{name}}?",
"deleteSession": "刪除工作階段",
"describeABugYouHitOr": "描述你遇到的缺陷或想要的功能,我們會在你自己的代理程式上建立任務,並在 Kandev 程式碼庫中實現。",
"destination": "目標位置",
@@ -0,0 +1,158 @@
---
created: 2026-09-06
status: complete
requirements:
- REQ-UI-SAVED-TASK-VIEW-DELETION-001
system_design:
- ../../specs/ui/system-design/saved-task-view-deletion-confirmation.md
legacy_specs: []
---
# Implementation Plan: Saved Task View Deletion Confirmation
## Overview
Add the shared localized confirmation shell first, connect the independent task
and integration surface adapters in parallel, then exercise their integrated
desktop and mobile behavior with production-build Playwright. This order gives
each surface one stable confirmation contract while leaving every existing
delete and persistence callback in place.
## Scope
### In scope
- Confirm deletion of task-sidebar views and Threads views.
- Confirm deletion of Jira saved views and GitHub, GitLab, and Azure DevOps
saved queries in every Kandev-owned desktop and mobile entry point.
- Name the target and explain that tasks and connected-service data are safe.
- Use an anchored fine-pointer confirmation and in-place coarse-pointer
confirmation with keyboard, focus, touch-target, safe-area, and overflow
coverage.
- Preserve existing eligibility, default-marker, fallback, persistence,
rollback, error, and parent-surface behavior.
- Add localized copy in all supported catalogs and pseudo-locale coverage.
### Out of scope
- Backend, API, user-settings schema, provider, or saved-view model changes.
- Confirmation for saved layouts, action presets, review watches, run filters,
tasks, or unrelated saved artifacts.
- Bespoke UI maintained only in an external plugin repository.
- A preference to skip confirmation or a redesign of the owning surfaces.
## Technical approach
### Shared confirmation contract
- Add
`apps/web/components/confirmation/saved-task-view-delete-confirmation.tsx`
as a presentation-only adapter around `ActionConfirmPopover` and
`InlineConfirmActions`.
- Pass a captured `{ id, label }` target, explicit `popover` or `inline`
presentation, anchor/focus-boundary refs, pending state, and the existing
delete callback. Keep mutation and parent-close behavior in callers.
- Reuse the existing `data-confirmation-boundary` contract and expose one
predicate for parent Radix menus/popovers to ignore interaction originating
inside the fine-pointer confirmation.
- Add shared `common` localization keys for the named title, consequence and
reassurance, and Delete action. Update `en`, `pt-pt`, and `zh-cn`, then
generate `zh-hk`, `zh-tw`, and `pseudo` with repository scripts.
### Task-owned surfaces
- In `SidebarFilterPopover` and `ViewHeaderRow`, replace immediate active-view
deletion with a captured candidate. Render an anchored confirmation from the
desktop popover and replace the drawer header action region on coarse
pointers. Continue calling `deleteSidebarView` only after confirmation.
- In `ThreadsViewControls`, carry the active candidate through
`ThreadsViewEditor` and `EditorActions`. Keep the current behavior that closes
the desktop settings popover or mobile drawer after confirmed deletion.
- Use `sidebarViewName` and `threadViewName` for confirmation labels so built-in
labels match the text shown to the user.
- Do not modify `sidebar-view-actions.ts`, `thread-view-actions.ts`, their
persistence queues, last-view guards, or optimistic rollback.
### Integration-owned surfaces
- In `presets-scope-bar-base.tsx`, add one candidate per `SavedMenu`; anchor a
fine-pointer confirmation to the selected delete item and replace that row
inline when the shared dropdown uses coarse-pointer presentation. Preserve
GitHub default-mutation disabling and prevent delete controls from selecting
the query or toggling its default marker.
- In the GitHub and GitLab mobile `PresetsSidebar` components, replace the
affected saved-query row with touch-density confirmation. Keep the GitHub
default action separate; make GitLab's delete action visible and at least
44px without hover.
- In Jira `ListToolbar`, add candidate state to `ViewsDropdown`; protect the
parent popover around fine-pointer confirmation and replace the custom saved
row inline for coarse pointers. Built-in rows remain nondeletable, and the
coarse delete trigger becomes visible and touch reachable.
- Continue invoking each wrapper's existing `onDeleteSaved` or `onDeleteView`
callback. Azure DevOps inherits the behavior through `IntegrationScopeBar`.
## Tests
| Acceptance criteria | Evidence |
| --- | --- |
| `AC-UI-SAVED-TASK-VIEW-DELETION-001.2`, `.3`, `.4`, `.6`, `.7`, `.8`, `.10` | New `saved-task-view-delete-confirmation.test.tsx` covers named localized content, fine/inline routing, cancel, Escape, disconnected anchors, focus, destructive confirmation, and a single captured-ID callback. Existing `action-confirm-popover.test.tsx` retains nested-boundary and anchor-lifecycle coverage. |
| `AC-UI-SAVED-TASK-VIEW-DELETION-001.1`, `.3`, `.4`, `.5`, `.8` for task surfaces | `view-manager.test.tsx`, `sidebar-filter-popover.test.tsx`, new `threads-view-editor-actions.test.tsx`, and `threads-view-controls.test.tsx` cover no mutation before confirmation, cancel, exact confirmation, last-view guards, active labels, and current parent-close behavior. |
| `AC-UI-SAVED-TASK-VIEW-DELETION-001.1`, `.3`, `.4`, `.5`, `.8` for integration surfaces | `presets-scope-bar-base.test.tsx`, GitHub `presets-sidebar.test.tsx`, new GitLab `presets-sidebar.test.tsx`, and new Jira `list-toolbar.test.tsx` cover each adapter, built-in/pending guards, target IDs, and isolation from select/default actions. |
| `AC-UI-SAVED-TASK-VIEW-DELETION-001.7`, `.9` | Responsive component assertions and mobile Playwright inspect visible 44px controls, parent surface retention, safe-area reachability, long-copy containment, and zero document overflow. |
## E2E tests
| Flow | Acceptance criteria | Playwright evidence |
| --- | --- | --- |
| Open task-sidebar and Threads delete confirmations, cancel without changing settings, then confirm and observe the existing fallback/reload behavior. | `.1`, `.2`, `.3`, `.4`, `.5`, `.6`, `.8` | `tests/task/sidebar-filter.spec.ts` and `tests/task/threads-view.spec.ts` on `chromium` |
| Confirm saved-query deletion without selecting the row or changing a GitHub default; exercise the shared GitLab and Azure adapters and Jira custom-view guard. | `.1`, `.2`, `.3`, `.4`, `.5`, `.6`, `.8` | `tests/github/github-scope-bar.spec.ts`, `tests/gitlab/gitlab-issue-milestone-filter.spec.ts`, `tests/integrations/azure-devops.spec.ts`, and `tests/integrations/jira-status-filter.spec.ts` on `chromium` |
| Repeat every distinct coarse-pointer composition inside its existing drawer, sheet, dropdown, or editor. Check touch targets, one overlay/scroll owner, safe area, long labels, and overflow before confirming. | `.1`, `.2`, `.3`, `.4`, `.7`, `.8`, `.9`, `.10` | `tests/task/mobile-sidebar-views.spec.ts`, `tests/task/mobile-threads-view.spec.ts`, `tests/github/mobile-github-sidebar.spec.ts`, `tests/gitlab/mobile-gitlab-issue-milestone-filter.spec.ts`, `tests/integrations/mobile-azure-devops.spec.ts`, and new `tests/integrations/mobile-jira-saved-view.spec.ts` on `mobile-chrome` |
## Work orders
Wave 1:
- [x] [Task 01: Shared confirmation shell](task-01-shared-confirmation-shell.md)
Wave 2:
- [x] [Task 02: Task saved-view surfaces](task-02-task-saved-view-surfaces.md)
- [x] [Task 03: Integration saved-query surfaces](task-03-integration-saved-query-surfaces.md)
Wave 3:
- [x] [Task 04: Cross-surface browser verification](task-04-cross-surface-browser-verification.md)
Tasks 02 and 03 are parallel-safe after Task 01 because their production and
unit-test files are disjoint. The wave is a dependency description only and
does not authorize subagent execution.
## Verification results
- Task 01: focused confirmation tests passed 15/15; locale generation,
`i18n:check`, and web typecheck passed.
- Task 02: task sidebar and Threads component tests passed 18/18; web typecheck
passed.
- Task 03: shared integration, GitHub, GitLab, and Jira component tests passed
15/15; the final combined focused suite passed 50/50 and web typecheck passed.
- Task 04: the desktop matrix passed 44/44 on `chromium`, and the mobile
matrix passed 24/24 on `mobile-chrome`. Fresh desktop and phone screenshots
were captured from those flows, inspected, and compressed for the PR.
- Final checks passed: changed-file ESLint, web typecheck, `i18n:check`,
`i18n:ratchet`, specification lint and lint tests, and `git diff --check`.
## Risks
- A nested Radix confirmation can dismiss its parent or misdirect focus unless
every fine-pointer owner applies the shared confirmation-boundary predicate.
- Optimistic deletion removes the anchor immediately after confirmation; the
shell must close first and invoke the callback with the captured ID.
- GitHub default mutation and delete state share one row. Confirm must remain
disabled while pending without trapping the user from cancelling.
- Hidden desktop controls can remain mounted during mobile tests, so locators
must stay scoped to the visible drawer or sheet.
- GitLab and Jira currently depend on hover disclosure. Enlarging their coarse
controls must not activate the row or create horizontal overflow.
- Longer localized names and confirmation copy can expand a row, especially in
the shared dropdown bottom-sheet presentation; the parent must remain the
only scroll owner.
@@ -0,0 +1,115 @@
---
id: "01-shared-confirmation-shell"
title: "Shared confirmation shell"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-SAVED-TASK-VIEW-DELETION-001
acceptance_criteria:
- AC-UI-SAVED-TASK-VIEW-DELETION-001.2
- AC-UI-SAVED-TASK-VIEW-DELETION-001.3
- AC-UI-SAVED-TASK-VIEW-DELETION-001.4
- AC-UI-SAVED-TASK-VIEW-DELETION-001.6
- AC-UI-SAVED-TASK-VIEW-DELETION-001.7
- AC-UI-SAVED-TASK-VIEW-DELETION-001.8
- AC-UI-SAVED-TASK-VIEW-DELETION-001.10
system_design:
- ../../specs/ui/system-design/saved-task-view-deletion-confirmation.md
---
# Task 01: Shared Confirmation Shell
## Summary
Implement one presentation-only confirmation shell for named saved task views.
It adapts the existing anchored and inline primitives, centralizes copy and
boundary handling, and invokes a captured target ID only after confirmation.
## In scope
- Add `SavedTaskViewDeleteConfirmation` and its target/prop contract.
- Support fine-pointer popover and touch-density inline presentations.
- Reuse the existing confirmation-boundary marker and provide a shared target
predicate for nested parent surfaces.
- Add localized title, consequence/reassurance, Cancel, Delete, and accessible
names in all supported catalogs.
- Test cancellation, Escape, focus, stale anchors, pending confirmation, and
exactly-once target dispatch.
## Out of scope
- Wiring any saved-view surface or calling a store/provider mutation.
- Changing `ActionConfirmPopover` or `InlineConfirmActions` behavior beyond the
small boundary adapter required by this contract.
- Adding persistence, retry, or error state.
## Acceptance
- Opening either presentation performs no mutation; Cancel, Escape, dismissal,
and a disconnected anchor preserve the target and restore focus when possible.
- The visible and accessible confirmation names the supplied label, uses a
destructive final action, and dispatches the captured ID once after close.
- Fine-pointer content exposes a nested-overlay boundary; inline content uses
44px touch actions and contains long localized text.
## Verification
```bash
cd apps
pnpm install --frozen-lockfile
pnpm --filter @kandev/web test -- \
components/confirmation/saved-task-view-delete-confirmation.test.tsx \
components/confirmation/action-confirm-popover.test.tsx
cd web
pnpm run i18n:zh-hant
pnpm run i18n:pseudo
pnpm run i18n:check
pnpm run typecheck
```
## Files likely touched
- `apps/web/components/confirmation/saved-task-view-delete-confirmation.tsx`
- `apps/web/components/confirmation/saved-task-view-delete-confirmation.test.tsx`
- `apps/web/components/confirmation/action-confirm-popover.tsx`
- `apps/web/components/confirmation/action-confirm-popover.test.tsx`
- `apps/web/src/locales/en/common.json`
- `apps/web/src/locales/pt-pt/common.json`
- `apps/web/src/locales/zh-cn/common.json`
- `apps/web/src/locales/zh-hk/common.json`
- `apps/web/src/locales/zh-tw/common.json`
- `apps/web/src/locales/pseudo/common.json`
## Dependencies
None.
## Risks
- A generic boundary predicate must not treat unrelated portal content as part
of the confirmation.
- `ActionConfirmPopover` and `InlineConfirmActions` close at different DOM
locations; the adapter must preserve both primitives' existing focus rules.
## Parallelism
`sequential`
## Inputs
- Requirement acceptance criteria `.2`, `.3`, `.4`, `.6`, `.7`, `.8`, and `.10`.
- System-design sections Shared confirmation shell, Data and contracts, Control
flow, and Responsive and accessibility behavior.
- Existing `WatcherDeleteAction`, `SavedLayoutDeleteConfirmation`,
`ActionConfirmPopover`, and `InlineConfirmActions` patterns.
## Results
- Installed the frozen pnpm workspace dependencies for this worktree.
- Added the shared fine-pointer/inline shell, captured-ID dispatch, and nested
confirmation-boundary predicate.
- Added synchronized `common` copy in all real locales and the pseudo locale;
`i18n:check` passed.
- Focused confirmation suites passed 15/15 and web typecheck passed.
@@ -0,0 +1,119 @@
---
id: "02-task-saved-view-surfaces"
title: "Task saved-view surfaces"
status: done
wave: 2
depends_on:
- "01-shared-confirmation-shell"
plan: "plan.md"
requirements:
- REQ-UI-SAVED-TASK-VIEW-DELETION-001
acceptance_criteria:
- AC-UI-SAVED-TASK-VIEW-DELETION-001.1
- AC-UI-SAVED-TASK-VIEW-DELETION-001.2
- AC-UI-SAVED-TASK-VIEW-DELETION-001.3
- AC-UI-SAVED-TASK-VIEW-DELETION-001.4
- AC-UI-SAVED-TASK-VIEW-DELETION-001.5
- AC-UI-SAVED-TASK-VIEW-DELETION-001.6
- AC-UI-SAVED-TASK-VIEW-DELETION-001.7
- AC-UI-SAVED-TASK-VIEW-DELETION-001.8
- AC-UI-SAVED-TASK-VIEW-DELETION-001.9
system_design:
- ../../specs/ui/system-design/saved-task-view-deletion-confirmation.md
---
# Task 02: Task Saved-View Surfaces
## Summary
Connect the shared shell to task-sidebar and Threads saved-view deletion. Keep
their existing guards, callbacks, fallback selection, persistence, rollback,
and post-confirm parent-close behavior unchanged.
## In scope
- Capture the active sidebar view and initiating delete element before opening
confirmation.
- Keep the fine-pointer sidebar editor popover mounted around its anchored
confirmation and use inline confirmation in the drawer header actions.
- Carry a captured Threads target through the editor/action components.
- Use an anchored confirmation in desktop Threads and inline confirmation in
the mobile drawer, closing settings only after explicit confirmation.
- Use the localized visible built-in name for each target.
- Add focused component tests for open, cancel, confirm, guards, target changes,
and parent-close behavior.
## Out of scope
- Sidebar or Threads store, settings payload, persistence queue, and rollback
changes.
- Saved-view creation, rename, duplicate, save, discard, query, sort, or column
behavior.
- Browser-level verification owned by Task 04.
## Acceptance
- Neither task surface invokes its delete action until the named confirmation's
destructive action is selected, and Cancel/Escape/target changes are no-ops.
- Fine-pointer confirmation preserves the editor and focus boundary; coarse
confirmation remains inside the existing drawer with 44px actions.
- Last-view guards and each surface's existing confirmed-delete fallback and
parent-close behavior remain intact.
## Verification
```bash
cd apps
pnpm --filter @kandev/web test -- \
components/task/sidebar-filter/view-manager.test.tsx \
components/task/sidebar-filter/sidebar-filter-popover.test.tsx \
components/threads/threads-view-editor-actions.test.tsx \
components/threads/threads-view-controls.test.tsx
pnpm --filter @kandev/web typecheck
```
## Files likely touched
- `apps/web/components/task/sidebar-filter/view-manager.tsx`
- `apps/web/components/task/sidebar-filter/view-manager.test.tsx`
- `apps/web/components/task/sidebar-filter/sidebar-filter-popover.tsx`
- `apps/web/components/task/sidebar-filter/sidebar-filter-popover.test.tsx`
- `apps/web/components/task/sidebar-filter/sidebar-view-editor.tsx`
- `apps/web/components/threads/threads-view-controls.tsx`
- `apps/web/components/threads/threads-view-controls.test.tsx`
- `apps/web/components/threads/threads-view-editor.tsx`
- `apps/web/components/threads/threads-view-editor-sections.tsx`
- `apps/web/components/threads/threads-view-editor-actions.tsx`
- `apps/web/components/threads/threads-view-editor-actions.test.tsx`
## Dependencies
Task 01 (`SavedTaskViewDeleteConfirmation` and shared copy/boundary contract).
## Risks
- The active view can change while confirmation is open; confirm must use the
captured target and stale candidate state must close safely.
- Threads intentionally closes its settings surface after delete while the
sidebar does not. Shared wiring must not erase that distinction.
## Parallelism
`parallel-safe` with Task 03 after Task 01 completes.
## Inputs
- Requirement acceptance criteria `.1` through `.9`.
- System-design sections Surface adapters, Control flow, and Responsive and
accessibility behavior.
- Existing `SidebarFilterPopover`, `ViewHeaderRow`, `ThreadsViewControls`,
`ThreadsViewEditor`, and optimistic saved-view actions.
## Results
- Added captured, named confirmation to task-sidebar and Threads deletion.
- Kept desktop editors mounted around anchored confirmation and used inline
touch actions inside the existing mobile drawers.
- Preserved sidebar and Threads store actions, guards, fallback selection, and
Threads parent-close behavior.
- Focused task-surface tests passed 18/18; web typecheck passed.
@@ -0,0 +1,119 @@
---
id: "03-integration-saved-query-surfaces"
title: "Integration saved-query surfaces"
status: done
wave: 2
depends_on:
- "01-shared-confirmation-shell"
plan: "plan.md"
requirements:
- REQ-UI-SAVED-TASK-VIEW-DELETION-001
acceptance_criteria:
- AC-UI-SAVED-TASK-VIEW-DELETION-001.1
- AC-UI-SAVED-TASK-VIEW-DELETION-001.2
- AC-UI-SAVED-TASK-VIEW-DELETION-001.3
- AC-UI-SAVED-TASK-VIEW-DELETION-001.4
- AC-UI-SAVED-TASK-VIEW-DELETION-001.5
- AC-UI-SAVED-TASK-VIEW-DELETION-001.6
- AC-UI-SAVED-TASK-VIEW-DELETION-001.7
- AC-UI-SAVED-TASK-VIEW-DELETION-001.8
- AC-UI-SAVED-TASK-VIEW-DELETION-001.9
system_design:
- ../../specs/ui/system-design/saved-task-view-deletion-confirmation.md
---
# Task 03: Integration Saved-Query Surfaces
## Summary
Connect the shared confirmation shell to the shared integration scope bar,
GitHub and GitLab mobile sidebars, and Jira saved views. Preserve every
surface's existing callback, default-marker, built-in, and failure semantics.
## In scope
- Add captured target and nested-boundary handling to `SavedMenu` so GitHub,
GitLab, and Azure DevOps inherit fine/coarse confirmation.
- Keep GitHub default actions and pending state isolated from delete; keep
Cancel available if pending state begins after confirmation opens.
- Replace the target row in GitHub and GitLab mobile sheets with touch-density
inline confirmation.
- Make GitLab and Jira coarse-pointer delete triggers visible, accessible, and
at least 44px without changing row selection.
- Add Jira custom-view confirmation while leaving built-ins nondeletable.
- Add focused tests for every adapter, cancellation path, exact ID, pending
guard, built-in guard, and event isolation.
## Out of scope
- Integration settings models, saved-query hooks, workspace/user persistence,
provider requests, and rollback behavior.
- GitHub default-view semantics or the shared scope bar's selection behavior.
- External-plugin-only UI and browser-level verification owned by Task 04.
## Acceptance
- Every in-tree integration delete trigger opens a named confirmation and calls
its original callback exactly once only after explicit confirmation.
- Delete actions never select a saved query, toggle GitHub's default marker, or
become available for Jira built-ins or during an existing pending mutation.
- Fine-pointer parents remain mounted around the anchored confirmation; coarse
rows expose visible 44px controls and inline content without overflow.
## Verification
```bash
cd apps
pnpm --filter @kandev/web test -- \
components/integrations/presets-scope-bar-base.test.tsx \
components/github/my-github/presets-sidebar.test.tsx \
components/gitlab/my-gitlab/presets-sidebar.test.tsx \
components/jira/my-jira/list-toolbar.test.tsx
pnpm --filter @kandev/web typecheck
```
## Files likely touched
- `apps/web/components/integrations/presets-scope-bar-base.tsx`
- `apps/web/components/integrations/presets-scope-bar-base.test.tsx`
- `apps/web/components/github/my-github/presets-sidebar.tsx`
- `apps/web/components/github/my-github/presets-sidebar.test.tsx`
- `apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx`
- `apps/web/components/gitlab/my-gitlab/presets-sidebar.test.tsx`
- `apps/web/components/jira/my-jira/list-toolbar.tsx`
- `apps/web/components/jira/my-jira/list-toolbar.test.tsx`
## Dependencies
Task 01 (`SavedTaskViewDeleteConfirmation` and shared copy/boundary contract).
## Risks
- The shared scope bar runs inside Radix dropdown behavior that changes to a
bottom sheet on coarse pointers; event prevention must work in both modes.
- GitHub rows have three independent actions. Replacing or anchoring delete
must not alter saved-query selection or default mutation state.
- Jira uses one responsive popover rather than a dedicated mobile drawer, so
inline replacement must fit its current scroll and width constraints.
## Parallelism
`parallel-safe` with Task 02 after Task 01 completes.
## Inputs
- Requirement acceptance criteria `.1` through `.9`.
- System-design sections Surface inventory, Surface adapters, Control flow, and
Responsive and accessibility behavior.
- Existing shared scope-bar test, GitHub sidebar test, saved-layout nested
confirmation pattern, and each integration's saved-query hook.
## Results
- Added captured confirmation to the shared GitHub, GitLab, and Azure DevOps
scope menu while preserving menu selection and GitHub default mutation state.
- Added inline, touch-sized confirmation to GitHub and GitLab mobile sidebars.
- Added responsive Jira confirmation with built-in view protection and visible
coarse-pointer deletion controls.
- Focused integration-surface tests passed 15/15; the combined confirmation
suite passed 50/50 and web typecheck passed.
@@ -0,0 +1,133 @@
---
id: "04-cross-surface-browser-verification"
title: "Cross-surface browser verification"
status: done
wave: 3
depends_on:
- "02-task-saved-view-surfaces"
- "03-integration-saved-query-surfaces"
plan: "plan.md"
requirements:
- REQ-UI-SAVED-TASK-VIEW-DELETION-001
acceptance_criteria:
- AC-UI-SAVED-TASK-VIEW-DELETION-001.1
- AC-UI-SAVED-TASK-VIEW-DELETION-001.2
- AC-UI-SAVED-TASK-VIEW-DELETION-001.3
- AC-UI-SAVED-TASK-VIEW-DELETION-001.4
- AC-UI-SAVED-TASK-VIEW-DELETION-001.5
- AC-UI-SAVED-TASK-VIEW-DELETION-001.6
- AC-UI-SAVED-TASK-VIEW-DELETION-001.7
- AC-UI-SAVED-TASK-VIEW-DELETION-001.8
- AC-UI-SAVED-TASK-VIEW-DELETION-001.9
- AC-UI-SAVED-TASK-VIEW-DELETION-001.10
system_design:
- ../../specs/ui/system-design/saved-task-view-deletion-confirmation.md
---
# Task 04: Cross-Surface Browser Verification
## Summary
Exercise the real desktop Radix nesting and every distinct mobile composition
after the shared shell and all adapters are integrated. Prove cancellation is a
no-op, explicit confirmation preserves existing deletion outcomes, and phone
surfaces remain touch reachable and contained.
## In scope
- Update the sidebar filter page object so tests can begin, cancel, and confirm
deletion explicitly.
- Extend desktop task-sidebar, Threads, GitHub, GitLab, Azure DevOps, and Jira
journeys with named confirmation and no-mutation-before-confirm assertions.
- Extend mobile task-sidebar, Threads, GitHub, GitLab, and Azure DevOps journeys
and add a Jira saved-view journey.
- Assert 44px controls, parent surface retention, one overlay/scroll owner,
safe-area reachability, focus/selection isolation, long-name containment, and
zero document-level horizontal overflow.
- Record exact focused runner results in this work order and the plan.
## Out of scope
- New production behavior, general responsive redesign, or broad E2E cleanup.
- Full-suite verification or unrelated provider journeys.
## Acceptance
- Every listed desktop and mobile surface proves that trigger and cancellation
do not delete, while final confirmation invokes the existing outcome once.
- Desktop tests prove anchored confirmation, parent-overlay retention, target
naming, focus/event isolation, and nondeletable guards.
- Mobile tests prove in-place confirmation with visible 44px actions, no nested
drawer/modal, one scroll owner, safe-area reachability, and zero overflow.
## Verification
```bash
cd apps/web
pnpm e2e:run --project chromium \
tests/task/sidebar-filter.spec.ts \
tests/task/threads-view.spec.ts \
tests/github/github-scope-bar.spec.ts \
tests/gitlab/gitlab-issue-milestone-filter.spec.ts \
tests/integrations/azure-devops.spec.ts \
tests/integrations/jira-status-filter.spec.ts
pnpm e2e:run --project mobile-chrome \
tests/task/mobile-sidebar-views.spec.ts \
tests/task/mobile-threads-view.spec.ts \
tests/github/mobile-github-sidebar.spec.ts \
tests/gitlab/mobile-gitlab-issue-milestone-filter.spec.ts \
tests/integrations/mobile-azure-devops.spec.ts \
tests/integrations/mobile-jira-saved-view.spec.ts
```
## Files likely touched
- `apps/web/e2e/pages/sidebar-filter-popover.ts`
- `apps/web/e2e/tests/task/sidebar-filter.spec.ts`
- `apps/web/e2e/tests/task/threads-view.spec.ts`
- `apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts`
- `apps/web/e2e/tests/task/mobile-threads-view.spec.ts`
- `apps/web/e2e/tests/github/github-scope-bar.spec.ts`
- `apps/web/e2e/tests/github/mobile-github-sidebar.spec.ts`
- `apps/web/e2e/tests/gitlab/gitlab-issue-milestone-filter.spec.ts`
- `apps/web/e2e/tests/gitlab/mobile-gitlab-issue-milestone-filter.spec.ts`
- `apps/web/e2e/tests/integrations/azure-devops.spec.ts`
- `apps/web/e2e/tests/integrations/mobile-azure-devops.spec.ts`
- `apps/web/e2e/tests/integrations/jira-status-filter.spec.ts`
- `apps/web/e2e/tests/integrations/mobile-jira-saved-view.spec.ts`
## Dependencies
Tasks 02 and 03, including their focused component tests.
## Risks
- Focused files still share server and seed state; scenarios must use existing
fixture isolation and must not assume execution order.
- Mobile pages can keep hidden desktop variants mounted, so geometry and action
locators must be scoped to the visible parent surface.
- The managed E2E runner must run sequentially by project to respect the
repository's memory and worker guardrails.
## Parallelism
`sequential`
## Inputs
- All requirement acceptance criteria and the system design's Observability and
verification section.
- Existing E2E fixture-state, cleanup, responsive geometry, and page-object
conventions.
- Completed unit-tested adapters from Tasks 02 and 03.
## Results
- Updated the task-sidebar page object and all six desktop plus six mobile
surface journeys to separate trigger, cancellation, and final confirmation.
- The exact desktop command passed 44/44 on `chromium`, covering anchored
confirmation and each existing deletion outcome.
- The exact mobile command passed 24/24 on `mobile-chrome`, covering inline
confirmation, touch geometry, overlay containment, long names, and overflow.
- Captured and inspected fresh desktop and phone confirmation screenshots from
the task-sidebar journeys, then compressed both assets for PR publication.
+2
View File
@@ -94,6 +94,7 @@ UI owns responsive behavior; other systems own behavior/state.
- [Review File Status Cues](requirements/review-file-status.md)
- [Review Markdown Preview](requirements/review-markdown-preview.md)
- [Search/filter dropdown scroll reset](requirements/search-filter-scroll-reset.md)
- [Saved Task View Deletion Confirmation](requirements/saved-task-view-deletion-confirmation.md)
- [Selected option prominence](requirements/selected-option-picker-prominence.md)
- [Session](requirements/session-start-composer-readiness.md)
- [Session tab delete feedback](requirements/session-tab-delete-feedback.md)
@@ -180,6 +181,7 @@ UI owns responsive behavior; other systems own behavior/state.
- [Responsive Plan Formatting](system-design/responsive-plan-formatting.md)
- [Confirmations](system-design/confirmation-warning-hierarchy.md)
- [Resizable Markdown Table Columns](system-design/resizable-markdown-tables.md)
- [Saved Task View Deletion Confirmation](system-design/saved-task-view-deletion-confirmation.md)
- [Session](system-design/session-start-composer-readiness.md)
- [Task Layout Profiles](system-design/task-layout-profiles.md)
- [Task surface](system-design/task-surface-render-isolation.md)
@@ -0,0 +1,94 @@
---
status: active
system: ui
created: 2026-09-06
owners:
- kandev
---
# Saved Task View Deletion Confirmation Requirements
## Overview
Saved task views and saved integration queries are reusable user preferences,
but their current delete actions remove them immediately. A mistaken click can
therefore discard a useful filter definition without giving the user a chance
to verify the target.
The UI system owns this independent, reusable confirmation contract. Each task
surface continues to own its saved-view collection, persistence, active-view
fallback, and failure recovery.
## Terminology
- **Saved task view:** A named Kandev-owned task filter, view, or integration
query that a user can select again.
- **Owning surface:** The task sidebar, Threads, Jira, GitHub, GitLab, or Azure
DevOps UI that owns a saved task view and its delete action.
- **Fine-pointer confirmation:** A compact confirmation anchored to the delete
action that opened it.
- **Coarse-pointer confirmation:** An in-place confirmation inside the current
drawer, sheet, menu, or editor.
## Requirements
### REQ-UI-SAVED-TASK-VIEW-DELETION-001: Confirm saved task view deletion
**Intent:** Prevent accidental deletion of reusable task filters while keeping
each surface's existing delete behavior intact after explicit confirmation.
**User story:** As a user with saved task views, I want to verify the named view
before deleting it, so that an accidental action does not discard my filters.
#### Acceptance criteria
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.1:** When a user invokes any in-tree
delete action for a task-sidebar view, Threads view, Jira saved view, or
GitHub, GitLab, or Azure DevOps saved query, the UI shall show confirmation
before invoking the owning surface's delete operation.
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.2:** The confirmation shall identify the
saved task view by its current visible name and explain that only its saved
filters and view settings are removed; tasks and connected-service data
remain unchanged.
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.3:** Cancel, Escape, dismissal of the
owning surface, or disappearance of the confirmation target shall preserve
the saved task view and shall not invoke its delete operation. When the
initiating surface remains open, focus shall return to the delete action.
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.4:** Explicit confirmation shall use the
semantic destructive action treatment and invoke the existing delete
operation exactly once. Existing active-view fallback, persistence, sync
error, and optimistic rollback behavior shall remain unchanged.
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.5:** A delete action that is unavailable
because a view is built in, is the protected last view, or is temporarily
disabled by an existing mutation shall remain unavailable and shall not open
confirmation.
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.6:** With a fine pointer, confirmation
shall be anchored to the initiating delete action, stay within the viewport,
and keep its owning menu, popover, or editor available until the user decides.
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.7:** With a coarse pointer, confirmation
shall replace the relevant row or action region inside the existing surface.
It shall not stack another drawer, sheet, or modal, and its actions shall be
visible without hover and at least 44 by 44 CSS pixels.
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.8:** The delete trigger and confirmation
shall be keyboard and screen-reader operable. The target name shall be
programmatically available, Cancel shall receive initial confirmation focus,
and using delete controls shall not also select the saved view or toggle an
adjacent default marker.
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.9:** At phone widths and with long saved
task view names or translated copy, the owning surface shall retain one
vertical scroll owner, keep actions reachable above the safe area, and create
no document-level horizontal overflow.
- **AC-UI-SAVED-TASK-VIEW-DELETION-001.10:** Confirmation copy and accessible
names shall use the active locale in every supported Kandev locale.
## Out of scope
- Changing saved-view models, limits, active-view selection, persistence,
optimistic updates, rollback, or provider APIs.
- Deleting tasks, task data, provider records, or connected-service data.
- Confirming deletion of saved layouts, action presets, review watches,
run-status filters, or other saved artifacts that are not task views.
- Adding confirmation to bespoke UI implemented only in an external plugin
repository. Plugins that render Kandev's shared host controls inherit the
host confirmation behavior.
- Adding a preference that bypasses or changes this confirmation.
@@ -0,0 +1,223 @@
---
status: current
system: ui
requirements:
- REQ-UI-SAVED-TASK-VIEW-DELETION-001
---
# Saved Task View Deletion Confirmation System Design
## Purpose and boundaries
This design adds one reusable confirmation presentation contract to all
Kandev-owned saved task view and saved integration query delete actions. The UI
system owns the pointer-responsive surface, copy hierarchy, accessibility, and
overlay containment. Surface-specific stores and hooks remain authoritative for
whether a view can be deleted and what happens after deletion.
No backend endpoint, settings schema, saved-view model, provider operation, or
persistence queue changes. Confirmation state is ephemeral and never becomes a
second saved-view source of truth.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-UI-SAVED-TASK-VIEW-DELETION-001` | [Shared confirmation shell](#shared-confirmation-shell), [Surface adapters](#surface-adapters), [Control flow](#control-flow), and [Responsive and accessibility behavior](#responsive-and-accessibility-behavior) |
## Surface inventory
| Owning surface | Existing delete entry | Existing mutation boundary |
| --- | --- | --- |
| Task sidebar | Active-view actions in `ViewHeaderRow` | `deleteSidebarView` |
| Threads | `EditorActions` in the desktop editor or mobile drawer | `deleteThreadView` |
| GitHub, GitLab, and Azure DevOps shared scope bar | Saved-query row in `IntegrationScopeBar` | Each wrapper's `onDeleteSaved` callback |
| GitHub mobile filter sheet | Saved-query row in `PresetsSidebar` | GitHub `onDeleteSaved` callback |
| GitLab mobile filter sheet | Saved-query row in `PresetsSidebar` | GitLab `onDeleteSaved` callback |
| Jira ticket list | Custom row in `ViewsDropdown` | `useSavedViews.removeView` through `onDeleteView` |
The shared scope bar is a Kandev host component. A plugin that uses that host
component receives the confirmation without defining another contract. UI that
exists only inside an external plugin repository is outside this design.
## Components and responsibilities
### Shared confirmation shell
`apps/web/components/confirmation/saved-task-view-delete-confirmation.tsx`
adds a small presentation-only adapter over `ActionConfirmPopover` and
`InlineConfirmActions`. Its stable input is the target visible to the user, not
the surface's complete saved-view model:
```ts
type SavedTaskViewDeleteTarget = {
id: string;
label: string;
};
type SavedTaskViewDeleteConfirmationProps = {
target: SavedTaskViewDeleteTarget;
presentation: "popover" | "inline";
open: boolean;
anchorRef: RefObject<HTMLElement | null>;
focusBoundaryRef?: RefObject<HTMLElement | null>;
confirmDisabled?: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (id: string) => void | Promise<void>;
};
```
The component owns localized title, consequence, Cancel, and destructive Delete
content. It passes the target ID back only after explicit confirmation. It does
not decide eligibility, select a view, mutate a store, close a parent surface,
show a persistence error, or retry a failed write.
For `popover`, the component enables `ActionConfirmPopover`'s confirmation
boundary and accepts the owning surface as its focus boundary. A shared target
predicate recognizes that boundary so nested Radix menu and popover parents can
ignore focus/interact-outside events originating inside the confirmation. This
follows the existing saved-layout deletion pattern instead of adding another
overlay primitive.
For `inline`, the caller mounts the component where the saved row or editor
actions normally render. `InlineConfirmActions` uses touch density, focuses
Cancel on mount, stops row activation events, handles Escape locally, and
closes before queueing the delete callback.
### Surface adapters
Each owning surface keeps at most one local delete candidate plus the initiating
element reference. The candidate contains the stable ID and the exact localized
or user-authored label already rendered by that surface.
- `SidebarFilterPopover` coordinates the active sidebar view candidate. The
fine-pointer popover is anchored to `view-delete-button`; the drawer branch
replaces the view-header action region with inline confirmation.
- `ThreadsViewControls` coordinates the active Threads view candidate and
carries it through `ThreadsViewEditor` to `EditorActions`. Confirm retains the
current desktop/mobile behavior of closing the settings surface after the
existing delete callback is invoked.
- `SavedMenu` in `presets-scope-bar-base.tsx` coordinates saved-query rows for
GitHub, GitLab, and Azure DevOps. The shared dropdown stays mounted around a
fine-pointer confirmation; its coarse-pointer row becomes the inline surface.
- GitHub and GitLab `PresetsSidebar` components keep their mobile-sheet
candidate local and replace only that saved-query row. Their delete actions
remain separate from row selection and GitHub's default marker.
- Jira `ViewsDropdown` coordinates custom saved-view rows. Built-in rows never
create a candidate. Its parent popover uses the same confirmation-boundary
handling as the shared scope-bar menu.
The task sidebar, Threads, integration scope bar, and Jira adapters read pointer
mode from `useResponsiveBreakpoint`. GitHub and GitLab mobile sidebars always
use the coarse-pointer inline presentation because their Kandev-owned mount is
the phone filter sheet.
## Data and contracts
The confirmation target is transient component state. Its `id` remains opaque;
its `label` is display content only. The shell never serializes either value,
sends a network request, or derives deletion eligibility.
Shared copy lives in the `common` namespace with a `{{name}}` placeholder for
the target title, a consequence/reassurance description, and a generic Delete
action. English, Portuguese, Simplified Chinese, both Traditional Chinese
catalogs, and the generated pseudo locale change together. The title and
destructive action accessible name include the same visible target label.
Existing trigger test IDs remain stable. The shared shell adds stable IDs for
the confirmation container and final destructive action; surface tests scope
those IDs to the owning row or overlay when hidden responsive variants are also
mounted.
## Control flow
1. The owning surface evaluates its existing eligibility and pending rules.
2. Activating an available delete trigger records the target and anchor. It
prevents row selection or default-toggle events and performs no mutation.
3. A fine pointer opens the anchored confirmation. A coarse pointer replaces
the relevant row or action region inside its existing parent surface.
4. Cancel, Escape, parent dismissal, target disappearance, view switching, or
unmount clears the candidate without invoking the delete callback.
5. Explicit Delete first closes the confirmation, then invokes the owning
callback once with the captured target ID.
6. The existing surface action applies its current fallback, persistence,
optimistic update, rollback, error, and parent-close behavior.
Capturing the target ID prevents a later active-view change from redirecting a
confirmation to another view. The connected-anchor guard already provided by
`ActionConfirmPopover` prevents a stale fine-pointer target from being deleted.
The inline branch disappears with its keyed row and therefore also fails closed.
## Responsive and accessibility behavior
Fine-pointer triggers retain their compact row geometry. The confirmation is a
viewport-contained non-modal popover anchored to the trigger. Its parent
dropdown or popover stays mounted while focus moves between the two surfaces,
and Cancel restores focus to the connected trigger.
Coarse-pointer triggers and both confirmation actions use a minimum 44-pixel
hit area and are visible without hover. Inline content wraps long translated
copy and names without widening its parent. It remains inside the current
drawer, sheet, dropdown bottom sheet, or editor, so no second modal navigation
layer is introduced. The existing parent remains the sole vertical scroll
owner and keeps its current safe-area padding.
The confirmation is exposed as a named group or dialog. Cancel receives initial
focus. Escape is a cancellation path, the destructive action is not an implicit
Enter default, and pointer/key events within the confirmation do not activate
the saved-view row beneath it.
The GitLab and Jira coarse-pointer delete triggers become explicitly visible
and touch reachable; their fine-pointer variants may keep hover disclosure but
must also reveal on keyboard focus.
## Failure and recovery
Closing or cancelling a confirmation is always a local no-op. If live data
removes the target or disconnects its anchor, the shell closes without calling
the stale callback.
After confirmation, each owner keeps its current failure contract. Backend
user-settings failures continue to use existing optimistic rollback and sync
feedback. Integration settings failures continue to use their existing local
or provider-specific feedback. The confirmation shell neither suppresses nor
duplicates those messages and adds no new retry state.
When an adjacent GitHub default mutation is pending, the existing disabled
delete rule remains authoritative. If pending state begins while confirmation
is open, the destructive action is disabled while Cancel remains available.
## Persistence
Confirmation open state, target, and anchor are never persisted. Sidebar and
Threads views continue through backend user settings. Integration saved queries
continue through their existing workspace or portable settings owners. There
is no browser-storage fallback and no schema migration.
## Security
The shell can act only on a delete callback already provided by an authorized
owning surface. It does not fetch by target ID, expand provider permissions, or
log target names. Confirmation copy makes clear that the operation does not
delete tasks or connected-service records.
## Observability and verification
No production log or metric is added for local confirmation state. Component
tests verify that opening and every cancellation path perform no mutation, the
named destructive action calls the captured ID once, disconnected targets fail
closed, and focus/event isolation is preserved.
Surface tests cover each adapter, including built-in/last-view guards, GitHub
default-mutation disabling, and delete versus row-selection isolation.
Playwright covers the real Radix nesting on desktop and the task-sidebar,
Threads, GitHub, GitLab, Azure DevOps, and Jira phone compositions. Mobile
checks inspect 44-pixel actions, one overlay/scroll owner, reachable safe-area
actions, long-name containment, and zero document-level horizontal overflow.
## Related decisions
- [Backend-owned Portable User Settings](../../../decisions/0041-backend-owned-portable-user-settings.md)
- [Surface-owned Saved Task Views](../../../decisions/2026-08-31-surface-owned-saved-task-views.md)
- [Task Confirmation Surface Requirements](../requirements/confirmation-warning-hierarchy.md)
- [Threads Saved Views](threads-saved-views.md)