feat(ui): use native mobile confirmation sheets (#3621)

This commit is contained in:
José Almeida
2026-09-12 14:58:49 +02:00
committed by GitHub
parent e598742110
commit 3f33ce0451
171 changed files with 7570 additions and 1428 deletions
+8 -1
View File
@@ -40,6 +40,7 @@ function CommandDialog({
className,
showCloseButton = false,
overlayClassName,
contentProps,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
@@ -47,15 +48,21 @@ function CommandDialog({
className?: string;
showCloseButton?: boolean;
overlayClassName?: string;
/** Optional overrides for the owning dialog, such as an application-local confirmation step. */
contentProps?: React.ComponentProps<typeof DialogContent>;
}) {
return (
<Dialog {...props}>
<DialogContent
{...contentProps}
className={cn("rounded-xl! p-0 overflow-hidden", className)}
showCloseButton={showCloseButton}
overlayClassName={overlayClassName}
>
<DialogHeader className="sr-only">
<DialogHeader
className="sr-only"
aria-hidden={contentProps?.["aria-labelledby"] ? true : undefined}
>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
+1 -1
View File
@@ -30,7 +30,7 @@ import { Dialog } from "@kandev/ui/dialog";
- CSS visibility classes can leave the other viewport tree mounted. Gate live effects such as polling, subscriptions, timers, and measurements with the same responsive or pointer condition, and test both initialization orders when readiness can precede or follow hydration.
- Use `useTouchDrawer` when a hover/popover disclosure needs a coarse-pointer `Drawer` alternative. Width-based phone composition and pointer-based disclosure behavior are related but not interchangeable. Apply the 44px minimum to coarse-pointer hit areas, touch rows, and mobile controls only; keep fine-pointer desktop controls at the surrounding design-system density, using 28px for ordinary buttons, inputs, and selectors and 24px only for deliberate compact inline controls, and do not reuse a touch-sized `h-11` class as the shared visual button size. See the [sizing guide](../../.agents/skills/mobile-parity/references/control-sizing.md) for exceptions.
- Below 640px, shared Radix DropdownMenu/ContextMenu use inset, safe-area-aware bottom sheets in `app/globals.css`. Open `mobile-menu-root` content owns one decorative positioner backdrop matching Drawer dimming and blur; fade it with the sheet's exit motion. Never mark submenus; keep the backdrop outside scrolling content and pointer-transparent so Radix owns dismissal and non-modal interaction. Reuse these primitives and cover long/nested menus instead of adding parallel mobile menus.
- Mobile capability parity does not require desktop layout parity. Load `/mobile-parity` for the Kandev surface decision guide, mobile design contract, and verification requirements.
- Mobile capability parity does not require desktop layout parity. Load `/mobile-parity` for the Kandev surface decision guide, mobile design contract, and verification requirements; read [confirmation guidance](components/confirmation/AGENTS.md) when adopting phone confirmation surfaces.
- Phone listing chrome uses `KanbanHeaderMobile` and `MobileListingContext` across Kanban, List, and Threads. Threads supplies its saved-view control and inline pagination in the title slot. Phone search, tools, and plugin actions live in `MobileListingMenuActions`; keep activity/connection cues on the persistent menu button and restore the actual opener unless focus is moving into a launched surface. Tablet/desktop composition stays separate.
## Data Flow Pattern (Critical)
+17 -20
View File
@@ -5,7 +5,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { IconBrandGitlab, IconMenu2 } from "@tabler/icons-react";
import { Alert, AlertDescription } from "@kandev/ui/alert";
import { Button } from "@kandev/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { PageShell } from "@/components/page-shell";
import { fetchGitLabStatus } from "@/lib/api/domains/gitlab-api";
import type { GitLabStatus, Issue, MR } from "@/lib/types/gitlab";
@@ -31,6 +30,7 @@ import {
import { toGitLabTaskPreset } from "@/components/gitlab/my-gitlab/task-presets";
import { useAppStore } from "@/components/state-provider";
import { Trans, useTranslation } from "react-i18next";
import { IntegrationFiltersSheet } from "@/components/integrations/integration-filters-sheet";
type GitLabPageClientProps = {
workspaceId?: string;
@@ -298,25 +298,22 @@ function GitLabPageOverlays({
};
return (
<>
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
<SheetContent
side="right"
className="w-full sm:max-w-sm overflow-y-auto p-0"
data-testid="gitlab-mobile-sidebar"
>
<SheetHeader className="px-4 pt-4 pb-2">
<SheetTitle>{t("gitlab:filters")}</SheetTitle>
</SheetHeader>
<PresetsSidebar
selected={state.selection}
onSelect={onMobileSidebarSelect}
savedPresets={state.savedPresets}
onDeleteSaved={state.onDeleteSaved}
canSaveCurrent={state.canSaveCurrent}
onSaveCurrent={onMobileSaveCurrent}
/>
</SheetContent>
</Sheet>
<IntegrationFiltersSheet
key={workspaceId}
open={mobileSidebarOpen}
onOpenChange={setMobileSidebarOpen}
title={t("gitlab:filters")}
testId="gitlab-mobile-sidebar"
>
<PresetsSidebar
selected={state.selection}
onSelect={onMobileSidebarSelect}
savedPresets={state.savedPresets}
onDeleteSaved={state.onDeleteSaved}
canSaveCurrent={state.canSaveCurrent}
onSaveCurrent={onMobileSaveCurrent}
/>
</IntegrationFiltersSheet>
<SavePresetDialog
open={state.saveDialogOpen}
onOpenChange={state.setSaveDialogOpen}
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
}));
let finePointer = true;
let isMobile = false;
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: mocks.toast }),
@@ -29,7 +30,7 @@ vi.mock("@/hooks/domains/azure-devops/use-azure-devops-projects", () => ({
}),
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isFinePointer: finePointer }),
useResponsiveBreakpoint: () => ({ isFinePointer: finePointer, isMobile }),
}));
vi.mock("@/lib/api/domains/azure-devops-api", () => ({
deleteAzureDevOpsConfig: mocks.deleteConfig,
@@ -86,6 +87,7 @@ const config: AzureDevOpsConfig = {
beforeEach(() => {
vi.clearAllMocks();
finePointer = true;
isMobile = false;
mocks.getConfig.mockResolvedValue(config);
mocks.setConfig.mockResolvedValue({
...config,
@@ -205,6 +207,23 @@ describe("AzureDevOpsConnectionSection", () => {
});
describe("AzureDevOpsConnectionSection removal", () => {
it("keeps phone removal in a sheet and restores the same button on Cancel", async () => {
isMobile = true;
finePointer = false;
render(
<SettingsSaveProvider>
<AzureDevOpsConnectionSection workspaceId={WORKSPACE_ID} />
</SettingsSaveProvider>,
);
const trigger = await screen.findByTestId(DELETE_BUTTON_TEST_ID);
fireEvent.click(trigger);
const sheet = screen.getByRole("dialog");
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(trigger.isConnected).toBe(true);
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trigger));
expect(mocks.deleteConfig).not.toHaveBeenCalled();
});
it("uses local fine-pointer confirmation and calls removal once after confirmation", async () => {
const nativeConfirm = vi.fn(() => false);
vi.stubGlobal("confirm", nativeConfirm);
@@ -21,6 +21,7 @@ import { Separator } from "@kandev/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
import {
IntegrationAuthStatusBanner,
type IntegrationAuthHealth,
@@ -441,10 +442,11 @@ function saveButtonLabel(t: (key: string) => string, state: SettingsState): stri
function ConnectionActions({ state, disabled }: { state: SettingsState; disabled: boolean }) {
const { t } = useTranslation();
const { isFinePointer } = useResponsiveBreakpoint();
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
const [confirmingRemove, setConfirmingRemove] = useState(false);
const removeAnchorRef = useRef<HTMLButtonElement>(null);
const removeConfirmation = t("azuredevops:removeConfigurationConfirm");
const removeLabel = t("azuredevops:remove");
useEffect(() => {
if (!state.config && confirmingRemove) setConfirmingRemove(false);
@@ -473,7 +475,7 @@ function ConnectionActions({ state, disabled }: { state: SettingsState; disabled
<IconDeviceFloppy className="h-4 w-4" />
{saveButtonLabel(t, state)}
</Button>
{state.config && (isFinePointer || !confirmingRemove) && (
{state.config && (isMobile || isFinePointer || !confirmingRemove) && (
<Button
ref={removeAnchorRef}
type="button"
@@ -483,39 +485,53 @@ function ConnectionActions({ state, disabled }: { state: SettingsState; disabled
data-testid="azure-devops-delete-button"
>
<IconTrash className="h-4 w-4" />
{t("azuredevops:remove")}
{removeLabel}
</Button>
)}
{state.config && !isFinePointer && confirmingRemove ? (
<InlineConfirmActions
density="touch"
testId="azure-devops-remove-inline-confirmation"
ariaLabel={removeConfirmation}
description={removeConfirmation}
cancelLabel={t("common:cancel")}
confirmLabel={t("azuredevops:remove")}
confirmAriaLabel={removeConfirmation}
confirmTestId="azure-devops-remove-confirm"
onCancel={() => setConfirmingRemove(false)}
onClose={() => setConfirmingRemove(false)}
onConfirm={() => void state.remove()}
/>
) : null}
{state.config && isFinePointer ? (
<ActionConfirmPopover
open={confirmingRemove}
anchorRef={removeAnchorRef}
title={removeConfirmation}
cancelLabel={t("common:cancel")}
confirmLabel={t("azuredevops:remove")}
confirmAriaLabel={removeConfirmation}
confirmTestId="azure-devops-remove-confirm"
testId="azure-devops-remove-confirm-popover"
onOpenChange={setConfirmingRemove}
onCancel={() => setConfirmingRemove(false)}
onConfirm={() => void state.remove()}
/>
) : null}
<MobileActionConfirmation
open={!!state.config && confirmingRemove}
targetKey={`${state.config?.workspaceId}:${state.config?.organizationUrl}`}
title={removeConfirmation}
subject={state.config?.organizationUrl}
cancelLabel={t("common:cancel")}
confirmLabel={removeLabel}
confirmAriaLabel={removeConfirmation}
confirmTestId="azure-devops-remove-confirm"
onOpenChange={setConfirmingRemove}
focusReturnRef={removeAnchorRef}
onConfirm={() => void state.remove()}
fallback={
!isFinePointer ? (
<InlineConfirmActions
density="touch"
testId="azure-devops-remove-inline-confirmation"
ariaLabel={removeConfirmation}
description={removeConfirmation}
cancelLabel={t("common:cancel")}
confirmLabel={removeLabel}
confirmAriaLabel={removeConfirmation}
confirmTestId="azure-devops-remove-confirm"
onCancel={() => setConfirmingRemove(false)}
onClose={() => setConfirmingRemove(false)}
onConfirm={() => void state.remove()}
/>
) : (
<ActionConfirmPopover
open={confirmingRemove}
anchorRef={removeAnchorRef}
title={removeConfirmation}
cancelLabel={t("common:cancel")}
confirmLabel={removeLabel}
confirmAriaLabel={removeConfirmation}
confirmTestId="azure-devops-remove-confirm"
testId="azure-devops-remove-confirm-popover"
onOpenChange={setConfirmingRemove}
onCancel={() => setConfirmingRemove(false)}
onConfirm={() => void state.remove()}
/>
)
}
/>
</div>
);
}
@@ -182,6 +182,8 @@ function WatchActions({
<IconRefresh className="h-4 w-4" /> {t("common:reset")}
</Button>
<WatcherDeleteAction
targetKey={`${watch.workspaceId}:${kind}:${watch.id}`}
subject={"wiql" in watch ? watch.wiql : watch.projectId}
title={t("azuredevops:deleteWatchConfirm")}
cancelLabel={t("common:cancel")}
confirmLabel={t("azuredevops:delete")}
@@ -0,0 +1,57 @@
import { useRef, useState } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { CommandPanelDialog } from "./command-panel-dialog";
import { MobileActionConfirmation } from "./confirmation/mobile-action-confirmation";
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: true }),
}));
afterEach(cleanup);
it("keeps the command dialog and its query while presenting one explicit decision step", () => {
const onConfirm = vi.fn();
const onKeyDown = vi.fn();
function Panel() {
const [open, setOpen] = useState(false);
const trigger = useRef<HTMLButtonElement>(null);
return (
<CommandPanelDialog open>
<div onKeyDown={onKeyDown}>
<input aria-label="Query" defaultValue="archive" />
<button ref={trigger} onClick={() => setOpen(true)}>
Archive command
</button>
<MobileActionConfirmation
open={open}
onOpenChange={setOpen}
targetKey="task"
title="Archive task?"
subject="Selected task"
cancelLabel="Cancel"
confirmLabel="Archive"
variant="default"
onConfirm={onConfirm}
focusReturnRef={trigger}
/>
</div>
</CommandPanelDialog>
);
}
render(<Panel />);
const dialog = screen.getByRole("dialog");
const query = screen.getByRole("textbox");
fireEvent.change(query, { target: { value: "my query" } });
fireEvent.click(screen.getByRole("button", { name: "Archive command" }));
expect(screen.getByRole("dialog", { name: "Archive task?" })).toBe(dialog);
expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1);
expect(screen.getAllByRole("heading")).toHaveLength(1);
expect(screen.queryByRole("textbox")).toBeNull();
fireEvent.keyDown(screen.getByRole("group"), { key: "Enter" });
expect(onConfirm).not.toHaveBeenCalled();
expect(onKeyDown).not.toHaveBeenCalled();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.getByRole("textbox")).toBe(query);
expect((query as HTMLInputElement).value).toBe("my query");
expect(screen.getByRole("dialog")).toBe(dialog);
});
@@ -0,0 +1,23 @@
import { CommandDialog } from "@kandev/ui/command";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
import {
MobileConfirmationHost,
MobileConfirmationHostBody,
} from "./confirmation/mobile-confirmation-host";
export function CommandPanelDialog(props: ComponentProps<typeof CommandDialog>) {
return (
<MobileConfirmationHost open={Boolean(props.open)}>
{({ active, contentProps }) => (
<CommandDialog
{...props}
className={cn(props.className, active && "flex max-h-[calc(100dvh-2rem)] flex-col")}
contentProps={{ ...contentProps, enterConfirms: !active }}
>
<MobileConfirmationHostBody>{props.children}</MobileConfirmationHostBody>
</CommandDialog>
)}
</MobileConfirmationHost>
);
}
+4 -9
View File
@@ -3,13 +3,8 @@
import { useEffect, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import {
Command,
CommandDialog,
CommandEmpty,
CommandInput,
CommandList,
} from "@kandev/ui/command";
import { Command, CommandEmpty, CommandInput, CommandList } from "@kandev/ui/command";
import { CommandPanelDialog } from "./command-panel-dialog";
import { Kbd, KbdGroup } from "@kandev/ui/kbd";
import type { CommandPanelMode, CommandItem as CommandItemType } from "@/lib/commands/types";
import type { Task } from "@/lib/types/http";
@@ -322,7 +317,7 @@ export function CommandPanelView(props: CommandPanelViewProps) {
};
return (
<CommandDialog
<CommandPanelDialog
open={open}
onOpenChange={handleOpenChange}
overlayClassName="supports-backdrop-filter:backdrop-blur-none!"
@@ -351,6 +346,6 @@ export function CommandPanelView(props: CommandPanelViewProps) {
<CommandPanelResultList {...renderedProps} />
<CommandPanelFooter mode={renderedProps.mode} />
</Command>
</CommandDialog>
</CommandPanelDialog>
);
}
@@ -0,0 +1,23 @@
# Confirmation surfaces
Phone confirmation adapters use `mobile-action-confirmation.tsx` with an explicit
localized title/target and a stable owner outside transient menus. Keep the
responsive adapter mounted with its non-phone `fallback` so boundary changes
cancel pending decisions.
Opt existing drawers into `MobileConfirmationHost`/`MobileConfirmationHostBody`
(pickers: `confirmationHost`); use `surface="drawer"` and spread its
`contentProps` on the owning surface. Active drawer steps fit the confirmation
content; the hidden origin keeps its measured dimensions outside layout so
Cancel can restore its normal size and scroll. Do not override the host's
active sizing with a fixed-height style. Centered dialog hosts omit `surface`.
Drawer roots use `overflow: clip` so focus cannot scroll the outer shell; only
the inner list or confirmation body owns scrolling. Initial Cancel focus uses
`preventScroll` to keep the title and actions in view.
A hosted step keeps the origin mounted/inert and opens no second modal. Form
dialog hosts also disable `enterConfirms` while the step is active.
Domain callbacks still own transport and error behavior. Ordinary actions close
before dispatch. Retryable form owners explicitly use
`completionPolicy="await-with-retry"` and reject on failure; this keeps their
confirmation busy during the request and available for retry afterward.
@@ -0,0 +1,171 @@
import { useRef, useState } from "react";
import { cleanup, fireEvent, render, screen, waitFor, act } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { MobileActionConfirmation } from "./mobile-action-confirmation";
const viewport = vi.hoisted(() => ({ isMobile: true }));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({ useResponsiveBreakpoint: () => viewport }));
afterEach(() => {
cleanup();
viewport.isMobile = true;
});
function Harness({
onConfirm = vi.fn(),
completionPolicy,
targetKey = "A",
}: {
onConfirm?: () => void | Promise<void>;
completionPolicy?: "await-with-retry";
targetKey?: string;
}) {
const [open, setOpen] = useState(false);
const trigger = useRef<HTMLButtonElement>(null);
return (
<>
<button ref={trigger} onClick={() => setOpen(true)}>
Remove A
</button>
<MobileActionConfirmation
open={open}
onOpenChange={setOpen}
targetKey={targetKey}
completionPolicy={completionPolicy}
title="Delete item?"
subject="A"
description="This cannot be undone."
cancelLabel="Cancel"
confirmLabel="Delete"
onConfirm={onConfirm}
focusReturnRef={trigger}
fallback={open ? <div>Desktop confirmation</div> : null}
/>
</>
);
}
it("uses a standalone drawer and returns focus on cancellation", async () => {
render(<Harness />);
const trigger = screen.getByRole("button", { name: "Remove A" });
fireEvent.click(trigger);
expect(screen.getByRole("dialog", { name: "Delete item?" }).getAttribute("data-slot")).toBe(
"drawer-content",
);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Cancel" })),
);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trigger));
});
it("closes before dispatch, prevents duplicate activation, and does not reopen on rejection", async () => {
let reject!: (reason: Error) => void;
let closedAtDispatch = false;
const onConfirm = vi.fn(() => {
closedAtDispatch = screen.queryByRole("group") === null;
return new Promise<void>((_, fail) => {
reject = fail;
});
});
render(<Harness onConfirm={onConfirm} />);
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
const button = screen.getByRole("button", { name: "Delete" });
act(() => {
fireEvent.click(button);
fireEvent.click(button);
});
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
expect(closedAtDispatch).toBe(true);
await act(async () => {
reject(new Error("delete failed"));
});
expect(screen.queryByRole("group")).toBeNull();
});
it("cancels crossing the phone boundary and leaves the non-phone fallback unchanged", () => {
const onConfirm = vi.fn();
const { rerender } = render(<Harness onConfirm={onConfirm} />);
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
expect(screen.getByRole("group")).toBeTruthy();
viewport.isMobile = false;
rerender(<Harness onConfirm={onConfirm} />);
expect(screen.queryByRole("group")).toBeNull();
expect(screen.queryByText("Desktop confirmation")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
expect(screen.getByText("Desktop confirmation")).toBeTruthy();
expect(onConfirm).not.toHaveBeenCalled();
});
it("allows a newly opened request to supply its target after the idle state", () => {
function SelectedTarget() {
const [target, setTarget] = useState<string | null>(null);
return (
<>
<button onClick={() => setTarget("A")}>Choose A</button>
<MobileActionConfirmation
open={target !== null}
targetKey={target ?? ""}
onOpenChange={(open) => {
if (!open) setTarget(null);
}}
title="Delete item?"
subject={target}
cancelLabel="Cancel"
confirmLabel="Delete"
onConfirm={vi.fn()}
/>
</>
);
}
render(<SelectedTarget />);
fireEvent.click(screen.getByRole("button", { name: "Choose A" }));
expect(screen.getByRole("dialog", { name: "Delete item?" })).toBeTruthy();
});
it("keeps an opted-in request busy and retries rejection without duplicate dispatch", async () => {
let reject!: () => void;
const onConfirm = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<void>((_, fail) => {
reject = fail;
}),
)
.mockResolvedValue(undefined);
render(<Harness onConfirm={onConfirm} completionPolicy="await-with-retry" />);
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
const button = screen.getByRole("button", { name: "Delete" }) as HTMLButtonElement;
fireEvent.click(button);
fireEvent.click(button);
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
expect(screen.getByRole("dialog", { name: "Delete item?" })).toBeTruthy();
expect(button.disabled).toBe(true);
await act(async () => reject());
await waitFor(() => expect(button.disabled).toBe(false));
fireEvent.click(button);
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(2));
expect(screen.queryByRole("group")).toBeNull();
});
it("does not let an old awaited rejection replace a newer target", async () => {
let reject!: () => void;
const onConfirm = vi.fn(
() =>
new Promise<void>((_, fail) => {
reject = fail;
}),
);
const { rerender } = render(
<Harness onConfirm={onConfirm} completionPolicy="await-with-retry" />,
);
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
rerender(<Harness onConfirm={onConfirm} completionPolicy="await-with-retry" targetKey="B" />);
expect(screen.queryByRole("group")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
await act(async () => reject());
expect(screen.getAllByRole("group")).toHaveLength(1);
expect(onConfirm).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,163 @@
"use client";
import { useId, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Drawer, DrawerContent, DrawerTitle, DrawerDescription } from "@kandev/ui/drawer";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import {
MobileConfirmationContent,
type MobileConfirmationContentProps,
} from "./mobile-confirmation-content";
import { useMobileConfirmationHost } from "./mobile-confirmation-host";
import {
useMobileConfirmationSubmit,
type ConfirmationCompletionPolicy,
} from "./use-mobile-confirmation-submit";
export type MobileActionConfirmationProps = Omit<
MobileConfirmationContentProps,
"onConfirm" | "onCancel"
> & {
open: boolean;
targetKey: string;
onOpenChange: (open: boolean) => void;
onCancel?: () => void;
/** Local owner dismissal before dispatch, when it differs from cancellation. */
onClose?: () => void;
onConfirm: () => void | Promise<void>;
completionPolicy?: ConfirmationCompletionPolicy;
focusReturnRef?: RefObject<HTMLElement | null>;
fallback?: ReactNode;
};
/** Keep this adapter mounted across responsive branches so a boundary change cancels the request. */
export function MobileActionConfirmation(props: MobileActionConfirmationProps) {
const { isMobile, changed } = useConfirmationBoundary(
props.open,
props.targetKey,
props.onOpenChange,
);
if (changed || !props.open) return null;
if (!isMobile) return props.fallback ?? null;
return <OpenMobileConfirmation {...props} />;
}
export function useConfirmationBoundary(
open: boolean,
targetKey: string,
onOpenChange: (open: boolean) => void,
) {
const viewport = useResponsiveBreakpoint();
const { isMobile } = viewport;
const previous = useRef({ isMobile, targetKey, open });
const changed =
previous.current.open &&
open &&
(previous.current.isMobile !== isMobile || previous.current.targetKey !== targetKey);
useLayoutEffect(() => {
previous.current = { isMobile, targetKey, open };
if (changed && open) onOpenChange(false);
});
return { ...viewport, changed };
}
function OpenMobileConfirmation({
onOpenChange,
onCancel,
onClose,
onConfirm,
completionPolicy = "close-before-dispatch",
focusReturnRef,
fallback: _fallback,
open: _open,
targetKey: _targetKey,
...content
}: MobileActionConfirmationProps) {
const host = useMobileConfirmationHost();
const id = useId();
const token = useRef(Symbol());
const [closed, setClosed] = useState(false);
const { submit, submitted, pending } = useMobileConfirmationSubmit({
closed,
disabled: !!(content.disabled || content.confirmDisabled),
onConfirm,
completionPolicy,
close: () => {
setClosed(true);
if (onClose) onClose();
else onOpenChange(false);
},
});
const initialFocus = useRef(document.activeElement as HTMLElement | null);
const latest = useRef({ onOpenChange, onCancel, disabled: content.disabled });
useLayoutEffect(() => {
latest.current = { onOpenChange, onCancel, disabled: content.disabled };
});
const titleId = `${id}-title`;
const descriptionId = `${id}-description`;
const cancel = useRef((invalidated = false) => {
if (!invalidated && (submitted.current || latest.current.disabled)) return;
setClosed(true);
latest.current.onCancel?.();
latest.current.onOpenChange(false);
queueMicrotask(() => {
const target = focusReturnRef?.current ?? initialFocus.current;
if (host) host.restoreFocus(target);
else if (target?.isConnected && !target.closest("[inert]"))
target.focus({ preventScroll: true });
});
}).current;
const register = host?.register;
const release = host?.release;
useLayoutEffect(() => {
if (!register || !release || closed) return;
const requestToken = token.current;
register({ token: requestToken, titleId, descriptionId, cancel });
return () => release(requestToken);
}, [register, release, closed, titleId, descriptionId, cancel]);
const body = closed ? null : (
<MobileConfirmationContent
{...content}
disabled={content.disabled || pending}
titleId={titleId}
descriptionId={descriptionId}
onBack={host ? () => cancel() : undefined}
onCancel={() => cancel()}
onConfirm={submit}
/>
);
if (host)
return host.outlet && host.request?.token === token.current
? createPortal(body, host.outlet)
: null;
return (
<Drawer
open={!closed}
onOpenChange={(next) => {
if (!next) cancel();
}}
>
<DrawerContent
aria-labelledby={titleId}
aria-describedby={descriptionId}
className="data-[vaul-drawer-direction=bottom]:max-h-[calc(100dvh-1rem)] data-[vaul-drawer-direction=bottom]:mt-0 overflow-hidden"
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={(event) => {
event.preventDefault();
cancel();
}}
>
<DrawerTitle asChild>
<span hidden>{content.title}</span>
</DrawerTitle>
<DrawerDescription asChild>
<span hidden>{content.subject ?? content.title}</span>
</DrawerDescription>
{body}
</DrawerContent>
</Drawer>
);
}
@@ -0,0 +1,98 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import i18n from "i18next";
import { MobileConfirmationContent } from "./mobile-confirmation-content";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("isolates confirmation mouse events from row selection owners", () => {
const onMouseDown = vi.fn();
render(
<div onMouseDown={onMouseDown}>
<MobileConfirmationContent
title="Delete file?"
cancelLabel="Cancel"
confirmLabel="Delete"
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>
</div>,
);
fireEvent.mouseDown(screen.getByRole("button", { name: "Cancel" }));
expect(onMouseDown).not.toHaveBeenCalled();
});
it("updates Back with the active locale without reopening", async () => {
render(
<MobileConfirmationContent
title="Delete file?"
cancelLabel="Cancel"
confirmLabel="Delete"
onBack={vi.fn()}
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>,
);
const back = screen.getByRole("button", { name: "Back" });
try {
await act(async () => {
await i18n.changeLanguage("pt-pt");
});
expect(screen.getByRole("button", { name: "Voltar" })).toBe(back);
} finally {
await act(async () => {
await i18n.changeLanguage("en");
});
}
});
it("names the target, focuses Cancel, and requires explicit action activation", () => {
const onConfirm = vi.fn();
const focus = vi.spyOn(HTMLElement.prototype, "focus");
render(
<MobileConfirmationContent
title="Delete file?"
subject="notes.txt"
description="This file will be deleted."
cancelLabel="Cancel"
confirmLabel="Delete"
onCancel={vi.fn()}
onConfirm={onConfirm}
/>,
);
const group = screen.getByRole("group", { name: "Delete file?" });
expect(screen.getByText("notes.txt")).toBeTruthy();
expect(group.getAttribute("aria-describedby")).toBeTruthy();
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Cancel" }));
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
fireEvent.keyDown(group, { key: "Enter" });
expect(onConfirm).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(onConfirm).toHaveBeenCalledOnce();
});
it("keeps cancellation available when the action is disabled", () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<MobileConfirmationContent
title="Archive task?"
subject="Task A"
cancelLabel="Cancel"
confirmLabel="Archive"
variant="default"
confirmDisabled
onCancel={onCancel}
onConfirm={onConfirm}
/>,
);
const confirm = screen.getByRole("button", { name: "Archive" });
expect(confirm.getAttribute("data-variant")).toBe("default");
fireEvent.click(confirm);
expect(onConfirm).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledOnce();
});
@@ -0,0 +1,131 @@
"use client";
import { useId, useLayoutEffect, useRef, type ReactNode, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { IconArrowLeft } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
export type MobileConfirmationContentProps = {
title: ReactNode;
subject?: ReactNode;
description?: ReactNode;
children?: ReactNode;
titleId?: string;
descriptionId?: string;
cancelLabel: ReactNode;
confirmLabel: ReactNode;
confirmAriaLabel?: string;
confirmTestId?: string;
testId?: string;
variant?: "default" | "destructive";
disabled?: boolean;
confirmDisabled?: boolean;
onBack?: () => void;
onCancel: () => void;
onConfirm: () => void;
};
function cancelOnEscape(event: KeyboardEvent, disabled: boolean | undefined, cancel: () => void) {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
if (!disabled) cancel();
}
export function MobileConfirmationContent({
title,
subject,
description,
children,
titleId,
descriptionId,
cancelLabel,
confirmLabel,
confirmAriaLabel,
confirmTestId,
testId,
variant = "destructive",
disabled,
confirmDisabled,
onBack,
onCancel,
onConfirm,
}: MobileConfirmationContentProps) {
const { t } = useTranslation();
const id = useId();
const headingId = titleId ?? `${id}-title`;
const bodyId = descriptionId ?? `${id}-description`;
const cancelRef = useRef<HTMLButtonElement>(null);
useLayoutEffect(() => {
cancelRef.current?.focus({ preventScroll: true });
}, []);
return (
<section
role="group"
aria-labelledby={headingId}
aria-describedby={bodyId}
data-testid={testId ?? "mobile-action-confirmation"}
data-confirmation-boundary=""
className="flex min-h-0 min-w-0 flex-1 flex-col text-left text-sm text-foreground"
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") event.stopPropagation();
}}
onKeyDownCapture={(event) => cancelOnEscape(event, disabled, onCancel)}
>
<header className="shrink-0 px-4 pb-3 pt-2">
{onBack && (
<Button
type="button"
variant="ghost"
disabled={disabled}
onClick={onBack}
className="-ml-3 mb-1 min-h-11 cursor-pointer px-3 text-sm"
>
<IconArrowLeft className="size-4" aria-hidden="true" />
{t("common:back")}
</Button>
)}
<h2 id={headingId} className="text-lg font-semibold [overflow-wrap:anywhere]">
{title}
</h2>
</header>
<div
data-testid="mobile-confirmation-body"
className="min-h-0 min-w-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-4 text-sm leading-6 [overflow-wrap:anywhere]"
>
<div id={bodyId} className="space-y-3">
{subject && <p className="font-medium">{subject}</p>}
{description && <div>{description}</div>}
</div>
{children}
</div>
<footer className="flex shrink-0 flex-col gap-2 px-4 pt-5 pb-[max(1rem,env(safe-area-inset-bottom))]">
<Button
type="button"
variant={variant}
disabled={disabled || confirmDisabled}
aria-label={confirmAriaLabel}
data-testid={confirmTestId}
onClick={onConfirm}
className="h-auto min-h-12 w-full cursor-pointer whitespace-normal px-4 py-3 text-sm"
>
{confirmLabel}
</Button>
<Button
ref={cancelRef}
type="button"
variant="outline"
disabled={disabled}
onClick={onCancel}
className="h-auto min-h-12 w-full cursor-pointer whitespace-normal px-4 py-3 text-sm"
>
{cancelLabel}
</Button>
</footer>
</section>
);
}
@@ -0,0 +1,211 @@
import { useRef, useState } from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { MobileConfirmationHost, MobileConfirmationHostBody } from "./mobile-confirmation-host";
import { MobileActionConfirmation } from "./mobile-action-confirmation";
import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet";
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: true }),
}));
afterEach(cleanup);
function Harness({
showSource = true,
showTrigger = true,
parentOpen = true,
target = "A",
onConfirm = vi.fn(),
disabled = false,
onCancel = vi.fn(),
surface = "dialog" as "drawer" | "dialog",
}) {
const [open, setOpen] = useState(false);
const trigger = useRef<HTMLButtonElement>(null);
return (
<MobileConfirmationHost open={parentOpen} surface={surface}>
{({ contentProps }) => (
<div role="dialog" {...contentProps}>
<MobileConfirmationHostBody>
<input aria-label="Filter" defaultValue="unfinished draft" />
<div data-testid="scroll-owner" style={{ overflowY: "auto", height: 100 }}>
{showTrigger && (
<button ref={trigger} onClick={() => setOpen(true)}>
Remove {target}
</button>
)}
</div>
{showSource && (
<MobileActionConfirmation
open={open}
disabled={disabled}
onCancel={onCancel}
onOpenChange={setOpen}
targetKey={target}
title="Delete item?"
subject={target}
description="Deletes the selected item."
cancelLabel="Cancel"
confirmLabel="Delete"
onConfirm={onConfirm}
focusReturnRef={trigger}
/>
)}
</MobileConfirmationHostBody>
</div>
)}
</MobileConfirmationHost>
);
}
it.each(["drawer", "dialog"] as const)(
"%s restores the mounted draft, scroll and initiating focus",
async (surface) => {
render(<Harness surface={surface} />);
const input = screen.getByRole("textbox") as HTMLInputElement;
const scroll = screen.getByTestId("scroll-owner");
scroll.scrollTop = 48;
fireEvent.change(input, { target: { value: "my filter" } });
const trigger = screen.getByRole("button", { name: "Remove A" });
fireEvent.click(trigger);
expect(screen.getAllByRole("dialog")).toHaveLength(1);
expect(screen.getByRole("dialog").getAttribute("aria-labelledby")).toBeTruthy();
expect(screen.getByRole("group", { name: "Delete item?" }).closest("[inert]")).toBeNull();
expect(screen.queryByRole("textbox")).toBeNull();
expect(input.isConnected).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Back" }));
expect(screen.getByRole("textbox")).toBe(input);
expect(input.value).toBe("my filter");
expect(scroll.scrollTop).toBe(48);
await waitFor(() => expect(document.activeElement).toBe(trigger));
},
);
it("Escape cancels only the step without invoking the action", () => {
const outsideKey = vi.fn();
const onConfirm = vi.fn();
render(
<div onKeyDown={outsideKey}>
<Harness onConfirm={onConfirm} />
</div>,
);
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
fireEvent.keyDown(screen.getByRole("button", { name: "Cancel" }), { key: "Escape" });
expect(screen.queryByRole("group")).toBeNull();
expect(screen.getByRole("textbox")).toBeTruthy();
expect(outsideKey).not.toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled();
});
it("returns focus to a visible host control if its initiating control disappears", async () => {
const { rerender } = render(<Harness />);
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
rerender(<Harness showTrigger={false} />);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(document.activeElement).toBe(screen.getByRole("textbox")));
});
it("invalidates the decision when its source unmounts", () => {
const { rerender } = render(<Harness />);
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
expect(screen.getByRole("group")).toBeTruthy();
rerender(<Harness showSource={false} />);
expect(screen.queryByRole("group")).toBeNull();
});
it("does not resurrect a decision after closing and reopening its parent", () => {
const { rerender } = render(<Harness />);
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
rerender(<Harness parentOpen={false} />);
rerender(<Harness parentOpen />);
expect(screen.queryByRole("group")).toBeNull();
});
it("invalidates even a disabled decision when its parent is dismissed", () => {
const onCancel = vi.fn();
const { rerender } = render(<Harness disabled onCancel={onCancel} />);
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
rerender(<Harness disabled parentOpen={false} onCancel={onCancel} />);
expect(onCancel).toHaveBeenCalledOnce();
rerender(<Harness disabled onCancel={onCancel} />);
expect(screen.queryByRole("group")).toBeNull();
});
it("does not redirect a pending decision to a replacement target", () => {
const onConfirm = vi.fn();
const { rerender } = render(<Harness onConfirm={onConfirm} />);
fireEvent.click(screen.getByRole("button", { name: "Remove A" }));
expect(screen.getByRole("group")).toBeTruthy();
rerender(<Harness target="B" onConfirm={onConfirm} />);
expect(screen.queryByRole("group")).toBeNull();
expect(onConfirm).not.toHaveBeenCalled();
});
it("lets a picker opt into one hosted step without changing its normal title or list", () => {
function Picker() {
const [open, setOpen] = useState(false);
return (
<MobilePickerSheet open onOpenChange={vi.fn()} title="Terminals" confirmationHost>
<button onClick={() => setOpen(true)}>Close shell</button>
<MobileActionConfirmation
open={open}
onOpenChange={setOpen}
targetKey="shell"
title="Close terminal?"
subject="Shell"
cancelLabel="Cancel"
confirmLabel="Close"
onConfirm={vi.fn()}
/>
</MobilePickerSheet>
);
}
render(<Picker />);
const picker = screen.getByRole("dialog", { name: "Terminals" });
fireEvent.click(screen.getByRole("button", { name: "Close shell" }));
expect(screen.getAllByRole("dialog")).toHaveLength(1);
expect(screen.getByRole("dialog", { name: "Close terminal?" })).toBe(picker);
expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1);
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.getByRole("dialog", { name: "Terminals" })).toBeTruthy();
expect(screen.queryByRole("group")).toBeNull();
});
it("an older request's cleanup cannot remove the replacement request", () => {
function Requests({ second }: { second: boolean }) {
const [first, setFirst] = useState(true);
return (
<MobileConfirmationHost open>
{({ contentProps }) => (
<div role="dialog" {...contentProps}>
<MobileConfirmationHostBody>
<MobileActionConfirmation
open={first}
onOpenChange={setFirst}
targetKey="first"
title="First?"
cancelLabel="Cancel"
confirmLabel="Delete"
onConfirm={vi.fn()}
/>
<MobileActionConfirmation
open={second}
onOpenChange={vi.fn()}
targetKey="second"
title="Second?"
cancelLabel="Cancel"
confirmLabel="Delete"
onConfirm={vi.fn()}
/>
</MobileConfirmationHostBody>
</div>
)}
</MobileConfirmationHost>
);
}
const { rerender } = render(<Requests second={false} />);
expect(screen.getByRole("group", { name: "First?" })).toBeTruthy();
rerender(<Requests second />);
expect(screen.getByRole("group", { name: "Second?" })).toBeTruthy();
expect(screen.queryByRole("group", { name: "First?" })).toBeNull();
});
@@ -0,0 +1,163 @@
"use client";
import {
createContext,
useCallback,
useContext,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
type RefObject,
} from "react";
type Request = {
token: symbol;
titleId: string;
descriptionId: string;
cancel: (invalidated?: boolean) => void;
};
type HostContext = {
request: Request | null;
outlet: HTMLDivElement | null;
setOutlet: (node: HTMLDivElement | null) => void;
register: (request: Request) => void;
release: (token: symbol) => void;
originRef: RefObject<HTMLDivElement | null>;
originSize: { width: number; height: number } | null;
restoreFocus: (preferred: HTMLElement | null) => void;
};
const Context = createContext<HostContext | null>(null);
export const useMobileConfirmationHost = () => useContext(Context);
type HostState = {
active: boolean;
contentProps: {
"aria-labelledby"?: string;
"aria-describedby"?: string;
onEscapeKeyDown?: (event: KeyboardEvent) => void;
style?: CSSProperties;
};
};
/** Owns one step, not another modal. The actual Drawer/Dialog receives contentProps. */
export function MobileConfirmationHost({
open,
surface = "dialog",
children,
}: {
open: boolean;
surface?: "drawer" | "dialog";
children: (state: HostState) => ReactNode;
}) {
const current = useRef<Request | null>(null);
const originRef = useRef<HTMLDivElement>(null);
const [request, setRequest] = useState<Request | null>(null);
const [outlet, setOutlet] = useState<HTMLDivElement | null>(null);
const [originSize, setOriginSize] = useState<HostContext["originSize"]>(null);
const restoreFocus = useCallback((preferred: HTMLElement | null) => {
if (current.current || !originRef.current?.isConnected) return;
if (preferred && originRef.current.contains(preferred)) {
preferred.focus({ preventScroll: true });
if (document.activeElement === preferred) return;
}
const controls = originRef.current.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), a[href], [tabindex="0"]',
);
const fallback = Array.from(controls).find(
(control) => !control.closest('[hidden], [inert], [aria-hidden="true"]'),
);
fallback?.focus({ preventScroll: true });
}, []);
const release = useCallback((token: symbol) => {
if (current.current?.token !== token) return;
current.current = null;
setRequest(null);
setOriginSize(null);
}, []);
const register = useCallback(
(next: Request) => {
if (current.current?.token === next.token) return;
if (surface === "drawer" && originRef.current) {
const { width, height } = originRef.current.getBoundingClientRect();
setOriginSize({ width, height });
}
const previous = current.current;
current.current = next;
previous?.cancel(true);
setRequest(next);
},
[surface],
);
useLayoutEffect(() => {
if (open || !current.current) return;
const previous = current.current;
release(previous.token);
previous.cancel(true);
}, [open, request, release]);
const context = useMemo(
() => ({ request, outlet, setOutlet, register, release, originRef, originSize, restoreFocus }),
[request, outlet, register, release, originSize, restoreFocus],
);
// Only the inner list/body scrolls; focus must not scroll Vaul's outer shell.
const style: CSSProperties | undefined =
surface === "drawer"
? {
overflow: "clip",
...(request ? { height: "auto", maxHeight: "calc(100dvh - 1rem)", marginTop: 0 } : {}),
}
: undefined;
const contentProps: HostState["contentProps"] = request
? {
"aria-labelledby": request.titleId,
"aria-describedby": request.descriptionId,
style,
onEscapeKeyDown: (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
request.cancel();
},
}
: { style };
return (
<Context.Provider value={context}>
{children({ active: request !== null, contentProps })}
</Context.Provider>
);
}
/** The hidden origin retains its scroll geometry without sizing an active drawer step. */
export function MobileConfirmationHostBody({ children }: { children: ReactNode }) {
const host = useMobileConfirmationHost();
if (!host) return children;
const active = host.request !== null;
const originStyle: CSSProperties = {};
if (active) {
originStyle.visibility = "hidden";
if (host.originSize) {
Object.assign(originStyle, host.originSize, { position: "absolute", top: 0, left: 0 });
}
}
return (
<div className="relative grid min-h-0 min-w-0 flex-1 grid-cols-1 grid-rows-1">
<div
ref={host.originRef}
aria-hidden={active || undefined}
inert={active || undefined}
style={originStyle}
className="col-start-1 row-start-1 flex min-h-0 min-w-0 flex-col"
>
{children}
</div>
<div
ref={host.setOutlet}
className="col-start-1 row-start-1 flex min-h-0 min-w-0 flex-col"
hidden={!active}
/>
</div>
);
}
@@ -67,7 +67,23 @@ function ReplacingHarness() {
}
describe("SavedTaskViewDeleteConfirmation", () => {
afterEach(cleanup);
afterEach(() => {
cleanup();
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
});
it.each(["inline", "popover"] as const)(
"uses a phone sheet before the %s presentation hint",
async (presentation) => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
const onConfirm = vi.fn();
render(<Harness presentation={presentation} onConfirm={onConfirm} />);
const dialog = screen.getByRole("dialog", { name: TARGET_TITLE });
expect(dialog.getAttribute("data-slot")).toBe("drawer-content");
fireEvent.click(within(dialog).getByRole("button", { name: "Delete Needs review" }));
await waitFor(() => expect(onConfirm).toHaveBeenCalledExactlyOnceWith(TARGET.id));
},
);
it("names the target and dispatches its captured id only after fine-pointer confirmation", async () => {
const onConfirm = vi.fn();
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { ActionConfirmPopover } from "./action-confirm-popover";
import { InlineConfirmActions } from "./inline-confirm-actions";
import { MobileActionConfirmation } from "./mobile-action-confirmation";
import type { SavedTaskViewDeleteTarget } from "./use-saved-task-view-delete-confirmation";
export type { SavedTaskViewDeleteTarget } from "./use-saved-task-view-delete-confirmation";
@@ -38,53 +39,56 @@ export function SavedTaskViewDeleteConfirmation({
const title = t("common:deleteSavedTaskViewTitle", { name: target.label });
const description = t("common:deleteSavedTaskViewDescription");
if (presentation === "popover") {
return (
const actions = {
description,
cancelLabel: t("common:cancel"),
confirmLabel: t("common:delete"),
confirmAriaLabel: t("common:deleteSavedTaskViewAction", { name: target.label }),
confirmDisabled,
testId,
confirmTestId,
onConfirm: () => onConfirm(target.id),
};
const fallback =
presentation === "popover" ? (
<ActionConfirmPopover
{...actions}
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)}
/>
) : (
<InlineConfirmActions
{...actions}
density="touch"
ariaLabel={title}
description={
<>
<span className="block font-medium text-foreground">{title}</span>
<span className="mt-1 block">{description}</span>
</>
}
onCancel={() => {
onOpenChange(false);
queueMicrotask(() => {
if (anchorRef.current?.isConnected) anchorRef.current.focus();
});
}}
onClose={() => onOpenChange(false)}
/>
);
}
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)}
<MobileActionConfirmation
{...actions}
open={open}
title={title}
targetKey={target.id}
focusReturnRef={anchorRef}
onOpenChange={onOpenChange}
fallback={fallback}
/>
);
}
@@ -0,0 +1,58 @@
"use client";
import { useLayoutEffect, useRef, useState } from "react";
export type ConfirmationCompletionPolicy = "close-before-dispatch" | "await-with-retry";
/** Only owners with retryable forms opt into waiting; ordinary actions dismiss before dispatch. */
export function useMobileConfirmationSubmit({
closed,
disabled,
close,
onConfirm,
completionPolicy,
}: {
closed: boolean;
disabled: boolean;
close: () => void;
onConfirm: () => void | Promise<void>;
completionPolicy: ConfirmationCompletionPolicy;
}) {
const submitted = useRef(false);
const live = useRef(true);
const [pending, setPending] = useState(false);
useLayoutEffect(() => {
live.current = !closed;
return () => {
live.current = false;
};
}, [closed]);
const submit = () => {
if (submitted.current || closed || disabled) return;
submitted.current = true;
if (completionPolicy === "close-before-dispatch") {
close();
queueMicrotask(() => {
void Promise.resolve()
.then(onConfirm)
.catch(() => undefined);
});
return;
}
setPending(true);
void Promise.resolve()
.then(onConfirm)
.then(
() => {
if (live.current) close();
},
() => {
if (!live.current) return;
submitted.current = false;
setPending(false);
},
);
};
return { submit, submitted, pending };
}
@@ -1,12 +1,40 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useConfirmationBoundary } from "./mobile-action-confirmation";
export type SavedTaskViewDeleteTarget = {
id: string;
label: string;
};
/** Transient menus close before a phone sheet takes focus. */
export function useSavedTaskViewDeleteMenu<T extends HTMLElement = HTMLElement>(
availableTargets: ReadonlyArray<{ id: string }>,
isMobile: boolean,
) {
const deletion = useSavedTaskViewDeleteConfirmation<T>(availableTargets);
const [open, setOpen] = useState(false);
const pendingTarget = useRef<SavedTaskViewDeleteTarget | null>(null);
const onOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) deletion.close();
};
const request = (target: SavedTaskViewDeleteTarget) => {
if (!isMobile) return deletion.request(target);
pendingTarget.current = target;
setOpen(false);
};
const onCloseAutoFocus = (event: Event) => {
const target = pendingTarget.current;
pendingTarget.current = null;
if (!target || !isMobile) return;
event.preventDefault();
deletion.request(target);
};
return { ...deletion, request, open, onOpenChange, onCloseAutoFocus };
}
export function useSavedTaskViewDeleteConfirmation<T extends HTMLElement = HTMLElement>(
availableTargets: ReadonlyArray<{ id: string }>,
) {
@@ -18,6 +46,7 @@ export function useSavedTaskViewDeleteConfirmation<T extends HTMLElement = HTMLE
requestedTarget && availableTargets.some((item) => item.id === requestedTarget.id)
? requestedTarget
: null;
useConfirmationBoundary(Boolean(target), target?.id ?? "", () => setRequestedTarget(null));
useEffect(() => {
if (requestedTarget && !target) setRequestedTarget(null);
@@ -11,6 +11,10 @@ import {
DrawerTrigger,
} from "@kandev/ui/drawer";
import { PresetsSidebar, type PresetsSidebarProps } from "./presets-sidebar";
import {
MobileConfirmationHost,
MobileConfirmationHostBody,
} from "@/components/confirmation/mobile-confirmation-host";
export function MobileViewsPicker({
title,
@@ -30,51 +34,58 @@ export function MobileViewsPicker({
[],
);
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button
variant="outline"
className="h-11 min-w-0 w-full justify-between gap-2 text-sm font-normal"
data-testid="github-mobile-menu-button"
aria-label={t("github:viewPicker", { view: title })}
>
<span className="min-w-0 truncate">
<span className="text-muted-foreground">{t("github:views")}:</span>{" "}
<span className="font-medium" data-testid="github-list-toolbar-title">
{title}
</span>
</span>
<IconChevronDown className="h-4 w-4 shrink-0" />
</Button>
</DrawerTrigger>
<DrawerContent
className="h-[88dvh] max-h-[88dvh] overflow-hidden pb-[max(0.5rem,env(safe-area-inset-bottom))]"
data-testid="github-mobile-sidebar"
aria-describedby={undefined}
onCloseAutoFocus={() => {
if (!saveAfterClose.current) return;
saveAfterClose.current = false;
saveFrame.current = requestAnimationFrame(onSaveCurrent);
}}
>
<DrawerHeader className="flex-row shrink-0 items-center justify-between px-3 py-2">
<DrawerTitle>{t("github:views")}</DrawerTitle>
<DrawerClose asChild>
<Button variant="ghost">{t("task:done")}</Button>
</DrawerClose>
</DrawerHeader>
<PresetsSidebar
{...props}
onSelect={(selection) => {
onSelect(selection);
if (selection.source !== "kind-switch") setOpen(false);
}}
onSaveCurrent={() => {
saveAfterClose.current = true;
setOpen(false);
}}
/>
</DrawerContent>
</Drawer>
<MobileConfirmationHost open={open} surface="drawer">
{({ contentProps }) => (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button
variant="outline"
className="h-11 min-w-0 w-full justify-between gap-2 text-sm font-normal"
data-testid="github-mobile-menu-button"
aria-label={t("github:viewPicker", { view: title })}
>
<span className="min-w-0 truncate">
<span className="text-muted-foreground">{t("github:views")}:</span>{" "}
<span className="font-medium" data-testid="github-list-toolbar-title">
{title}
</span>
</span>
<IconChevronDown className="h-4 w-4 shrink-0" />
</Button>
</DrawerTrigger>
<DrawerContent
className="h-[88dvh] max-h-[88dvh] overflow-hidden pb-[max(0.5rem,env(safe-area-inset-bottom))]"
data-testid="github-mobile-sidebar"
aria-describedby={undefined}
{...contentProps}
onCloseAutoFocus={() => {
if (!saveAfterClose.current) return;
saveAfterClose.current = false;
saveFrame.current = requestAnimationFrame(onSaveCurrent);
}}
>
<MobileConfirmationHostBody>
<DrawerHeader className="flex-row shrink-0 items-center justify-between px-3 py-2">
<DrawerTitle>{t("github:views")}</DrawerTitle>
<DrawerClose asChild>
<Button variant="ghost">{t("task:done")}</Button>
</DrawerClose>
</DrawerHeader>
<PresetsSidebar
{...props}
onSelect={(selection) => {
onSelect(selection);
if (selection.source !== "kind-switch") setOpen(false);
}}
onSaveCurrent={() => {
saveAfterClose.current = true;
setOpen(false);
}}
/>
</MobileConfirmationHostBody>
</DrawerContent>
</Drawer>
)}
</MobileConfirmationHost>
);
}
@@ -3,7 +3,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { SavedPreset } from "./saved-preset-model";
import { PresetsSidebar } from "./presets-sidebar";
afterEach(cleanup);
afterEach(() => {
cleanup();
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
});
const savedPreset: SavedPreset = {
id: "saved-pr",
@@ -15,7 +18,8 @@ const savedPreset: SavedPreset = {
isDefault: false,
};
async function expectDeleteDoesNotSelect() {
async function expectDeleteDoesNotSelect(phone = false) {
if (phone) Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
const onSelect = vi.fn();
const onDeleteSaved = vi.fn();
render(
@@ -33,7 +37,14 @@ async function expectDeleteDoesNotSelect() {
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Delete Kandev PRs saved query" }));
const trigger = screen.getByRole("button", { name: "Delete Kandev PRs saved query" });
fireEvent.click(trigger);
if (phone) {
expect(trigger.isConnected).toBe(true);
expect(
screen.getByRole("dialog", { name: "Delete Kandev PRs?" }).getAttribute("data-slot"),
).toBe("drawer-content");
}
expect(onDeleteSaved).not.toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
@@ -121,5 +132,7 @@ describe("PresetsSidebar saved defaults", () => {
expect(onSelect).not.toHaveBeenCalled();
});
it("deletes a saved query without selecting it", expectDeleteDoesNotSelect);
it("deletes a saved query without selecting it", () => expectDeleteDoesNotSelect());
it("keeps a phone saved query row mounted during confirmation", () =>
expectDeleteDoesNotSelect(true));
});
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
import { PR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./search-bar";
import type { SavedPreset } from "./saved-preset-model";
import { useTranslation } from "react-i18next";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { SavedQueryDefaultButton } from "@/components/integrations/saved-query-default-button";
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
import {
@@ -165,6 +166,7 @@ function SavedSection({
}) {
const { t } = useTranslation();
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(saved);
const { isMobile } = useResponsiveBreakpoint();
const defaultMutationPending = defaultMutationPendingId !== null;
return (
<>
@@ -188,6 +190,19 @@ function SavedSection({
onToggleDefault={() => void onToggleSavedDefault(preset)}
/>
))}
{isMobile && deletion.target && (
<SavedTaskViewDeleteConfirmation
target={deletion.target}
presentation="inline"
open
anchorRef={deletion.anchorRef}
confirmDisabled={defaultMutationPending}
onOpenChange={(open) => {
if (!open) deletion.close();
}}
onConfirm={onDelete}
/>
)}
</>
);
}
@@ -220,7 +235,8 @@ function GitHubSavedPresetEntry({
onToggleDefault: () => void;
}) {
const { t } = useTranslation();
if (deletion.target?.id === preset.id) {
const { isMobile } = useResponsiveBreakpoint();
if (!isMobile && deletion.target?.id === preset.id) {
return (
<div className="mx-1 min-w-0 p-1">
<SavedTaskViewDeleteConfirmation
@@ -16,39 +16,52 @@ const SAVED: SavedPreset = {
};
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();
afterEach(() => {
cleanup();
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
});
it.each([false, true])(
"uses visible controls and confirms without selecting (phone: %s)",
async (phone) => {
if (phone) Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
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);
if (phone) {
expect(deleteButton.isConnected).toBe(true);
expect(
screen.getByRole("dialog", { name: "Delete Ready MRs?" }).getAttribute("data-slot"),
).toBe("drawer-content");
}
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,7 @@ 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 { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
import { useSavedTaskViewDeleteConfirmation } from "@/components/confirmation/use-saved-task-view-delete-confirmation";
@@ -174,6 +175,7 @@ function SavedSection({
}) {
const { t } = useTranslation();
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(saved);
const { isMobile } = useResponsiveBreakpoint();
return (
<>
<SectionHeader id="saved" title={t("gitlab:saved")} />
@@ -183,7 +185,7 @@ function SavedSection({
</div>
)}
{saved.map((s) => {
if (deletion.target?.id === s.id) {
if (!isMobile && deletion.target?.id === s.id) {
return (
<div key={s.id} className="mx-1 min-w-0 p-1">
<SavedTaskViewDeleteConfirmation
@@ -228,26 +230,45 @@ function SavedSection({
/>
);
})}
<button
type="button"
onClick={onSaveCurrent}
disabled={!canSaveCurrent}
className={cn(
"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",
)}
title={canSaveCurrent ? t("gitlab:saveCurrentQuery") : t("gitlab:typeACustomQueryFirst")}
data-testid="gitlab-save-current-query"
>
<IconDeviceFloppy className="h-4 w-4 shrink-0" />
<span>{t("gitlab:saveCurrentQuery")}</span>
</button>
{isMobile && deletion.target && (
<SavedTaskViewDeleteConfirmation
target={deletion.target}
presentation="inline"
open
anchorRef={deletion.anchorRef}
onOpenChange={(open) => {
if (!open) deletion.close();
}}
onConfirm={onDelete}
/>
)}
<SaveQueryButton enabled={canSaveCurrent} onClick={onSaveCurrent} />
</>
);
}
function SaveQueryButton({ enabled, onClick }: { enabled: boolean; onClick: () => void }) {
const { t } = useTranslation();
return (
<button
type="button"
onClick={onClick}
disabled={!enabled}
className={cn(
"mx-1 mt-1 flex min-h-11 items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors",
enabled
? "text-muted-foreground hover:bg-muted/50 hover:text-foreground cursor-pointer"
: "text-muted-foreground/50 cursor-not-allowed",
)}
title={enabled ? t("gitlab:saveCurrentQuery") : t("gitlab:typeACustomQueryFirst")}
data-testid="gitlab-save-current-query"
>
<IconDeviceFloppy className="h-4 w-4 shrink-0" />
<span>{t("gitlab:saveCurrentQuery")}</span>
</button>
);
}
export function PresetsSidebar({
selected,
onSelect,
@@ -0,0 +1,75 @@
"use client";
import type { ReactNode } from "react";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import {
MobileConfirmationHost,
MobileConfirmationHostBody,
} from "@/components/confirmation/mobile-confirmation-host";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
/** Provider state stays with the caller; this shell owns only responsive presentation. */
export function IntegrationFiltersSheet({
open,
onOpenChange,
title,
testId,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
testId: string;
children: ReactNode;
}) {
const { isMobile } = useResponsiveBreakpoint();
return (
<MobileConfirmationHost open={open} surface={isMobile ? "drawer" : "dialog"}>
{({ active, contentProps }) =>
isMobile ? (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent
className="overflow-hidden data-[vaul-drawer-direction=bottom]:max-h-[80dvh]"
data-testid={testId}
aria-describedby={undefined}
{...contentProps}
>
<MobileConfirmationHostBody>
<DrawerHeader className="shrink-0 text-left pb-2">
<DrawerTitle>{title}</DrawerTitle>
</DrawerHeader>
<div
data-testid="integration-filters-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[calc(1rem+env(safe-area-inset-bottom))]"
>
{children}
</div>
</MobileConfirmationHostBody>
</DrawerContent>
</Drawer>
) : (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-full sm:max-w-sm overflow-hidden p-0"
data-testid={testId}
showCloseButton={!active}
aria-describedby={undefined}
{...contentProps}
>
<MobileConfirmationHostBody>
<div className="min-h-0 flex-1 overflow-y-auto">
<SheetHeader className="px-4 pt-4 pb-2">
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
{children}
</div>
</MobileConfirmationHostBody>
</SheetContent>
</Sheet>
)
}
</MobileConfirmationHost>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState, type RefObject, type ReactNode } from "react";
import { useCallback, useEffect, useRef, type RefObject, type ReactNode } from "react";
import { IconBookmark, IconChevronDown, IconDeviceFloppy, IconX } from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import {
@@ -18,7 +18,7 @@ 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,
useSavedTaskViewDeleteMenu,
type SavedTaskViewDeleteTarget,
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
@@ -124,15 +124,18 @@ function SavedMenuTrigger({
testId,
active,
label,
triggerRef,
}: {
testId: string;
active: boolean;
label: string | null;
triggerRef: RefObject<HTMLButtonElement | null>;
}) {
const { t } = useTranslation();
return (
<DropdownMenuTrigger asChild>
<button
ref={triggerRef}
type="button"
data-testid={testId}
className={cn(PILL_BASE, active ? PILL_ACTIVE : PILL_IDLE)}
@@ -167,6 +170,10 @@ function useSavedMenuSave(onSaveCurrent: () => void) {
};
}
function preserveConfirmationInteraction(event: Event) {
if (isActionConfirmationTarget(event.target)) event.preventDefault();
}
function SavedMenu<K extends string>({
testId,
selected,
@@ -191,34 +198,32 @@ function SavedMenu<K extends string>({
savedStatus?: ReactNode;
}) {
const { t } = useTranslation();
const { isFinePointer } = useResponsiveBreakpoint();
const [menuOpen, setMenuOpen] = useState(false);
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
const deletion = useSavedTaskViewDeleteMenu(saved, isMobile);
const save = useSavedMenuSave(onSaveCurrent);
const deletion = useSavedTaskViewDeleteConfirmation(saved);
const menuContentRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const defaultMutationPending = defaultMutationPendingId !== null;
const activeSaved = selected.source === "saved";
const activeLabel = activeSaved ? (saved.find((s) => s.id === selected.id)?.label ?? null) : null;
return (
<DropdownMenu
open={menuOpen}
onOpenChange={(open) => {
setMenuOpen(open);
if (!open) deletion.close();
}}
>
<SavedMenuTrigger testId={testId} active={activeSaved} label={activeLabel} />
<DropdownMenu open={deletion.open} onOpenChange={deletion.onOpenChange}>
<SavedMenuTrigger
testId={testId}
active={activeSaved}
label={activeLabel}
triggerRef={triggerRef}
/>
<DropdownMenuContent
ref={menuContentRef}
align="end"
className="w-56"
onCloseAutoFocus={save.onCloseAutoFocus}
onFocusOutside={(event) => {
if (isActionConfirmationTarget(event.target)) event.preventDefault();
}}
onInteractOutside={(event) => {
if (isActionConfirmationTarget(event.target)) event.preventDefault();
onCloseAutoFocus={(event) => {
deletion.onCloseAutoFocus(event);
save.onCloseAutoFocus();
}}
onFocusOutside={preserveConfirmationInteraction}
onInteractOutside={preserveConfirmationInteraction}
>
{savedStatus}
{saved.length === 0
@@ -229,7 +234,7 @@ function SavedMenu<K extends string>({
<SavedMenuEntry
key={preset.id}
preset={preset}
isFinePointer={isFinePointer}
isFinePointer={!isMobile && isFinePointer}
deletion={deletion}
defaultMutationPending={defaultMutationPending}
defaultMutationPendingForPreset={defaultMutationPendingId === preset.id}
@@ -250,12 +255,12 @@ function SavedMenu<K extends string>({
<span>{t("integrations:saveCurrentQuery")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
{isFinePointer && deletion.target ? (
{(isMobile || isFinePointer) && deletion.target ? (
<SavedTaskViewDeleteConfirmation
target={deletion.target}
presentation="popover"
open
anchorRef={deletion.anchorRef}
anchorRef={isMobile ? triggerRef : deletion.anchorRef}
focusBoundaryRef={menuContentRef}
confirmDisabled={defaultMutationPending}
onOpenChange={(open) => {
@@ -0,0 +1,50 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { IconInbox } from "@tabler/icons-react";
import { IntegrationScopeBar } from "./presets-scope-bar-base";
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: true, isFinePointer: false }),
}));
afterEach(cleanup);
it("hands the real phone saved-view menu off without selecting or changing its default", async () => {
const onSelect = vi.fn();
const onDelete = vi.fn();
const onDefault = vi.fn();
render(
<TooltipProvider>
<IntegrationScopeBar
testId="scope"
savedMenuTestId="saved-menu"
kinds={[{ value: "issue", label: "Issues" }]}
selected={{ kind: "issue", source: "preset", id: "inbox" }}
onSelect={onSelect}
presetsByKind={() => [{ value: "inbox", label: "Inbox", icon: IconInbox, group: "inbox" }]}
savedPresets={[{ id: "saved", kind: "issue", label: "My work" }]}
onDeleteSaved={onDelete}
onToggleSavedDefault={onDefault}
defaultMutationPendingId={null}
canSaveCurrent={false}
onSaveCurrent={vi.fn()}
/>
</TooltipProvider>,
);
const trigger = screen.getByTestId("saved-menu");
fireEvent.pointerDown(trigger);
fireEvent.click(screen.getByRole("menuitem", { name: "Delete My work saved query" }));
const sheet = await screen.findByRole("dialog", { name: "Delete My work?" });
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(screen.queryByRole("menu")).toBeNull();
expect(document.querySelector('[data-slot="dropdown-menu-content"]')).toBeNull();
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trigger));
expect(onSelect).not.toHaveBeenCalled();
expect(onDefault).not.toHaveBeenCalled();
expect(onDelete).not.toHaveBeenCalled();
fireEvent.pointerDown(trigger);
fireEvent.click(screen.getByRole("menuitem", { name: "Delete My work saved query" }));
fireEvent.click(await screen.findByRole("button", { name: "Delete My work" }));
await waitFor(() => expect(onDelete).toHaveBeenCalledExactlyOnceWith("saved"));
});
@@ -0,0 +1,36 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { JiraActionBar } from "./jira-action-bar";
afterEach(() => {
cleanup();
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
});
it("keeps Jira removal in a phone sheet without testing or removing on Cancel", async () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
const onDelete = vi.fn();
const onTest = vi.fn();
render(
<JiraActionBar
workspaceId="workspace-a"
testing={false}
loading={false}
hasConfig
disableTest={false}
onTest={onTest}
onDelete={onDelete}
/>,
);
const trigger = screen.getByTestId("jira-delete-button");
fireEvent.click(trigger);
const sheet = screen.getByRole("dialog");
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(trigger.isConnected).toBe(true);
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trigger));
expect(onDelete).not.toHaveBeenCalled();
expect(onTest).not.toHaveBeenCalled();
fireEvent.click(trigger);
fireEvent.click(screen.getByTestId("jira-remove-confirm"));
await waitFor(() => expect(onDelete).toHaveBeenCalledTimes(1));
});
+50 -33
View File
@@ -6,9 +6,11 @@ import { useTranslation } from "react-i18next";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
type JiraActionBarProps = {
workspaceId: string;
testing: boolean;
loading: boolean;
hasConfig: boolean;
@@ -18,6 +20,7 @@ type JiraActionBarProps = {
};
export function JiraActionBar({
workspaceId,
testing,
loading,
hasConfig,
@@ -26,10 +29,11 @@ export function JiraActionBar({
onDelete,
}: JiraActionBarProps) {
const { t } = useTranslation();
const { isFinePointer } = useResponsiveBreakpoint();
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
const [confirmingDelete, setConfirmingDelete] = useState(false);
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
const removeConfirmation = t("jira:removeJiraConfiguration");
const removeLabel = t("jira:removeConfiguration");
useEffect(() => {
if (!hasConfig && confirmingDelete) setConfirmingDelete(false);
@@ -48,7 +52,7 @@ export function JiraActionBar({
>
{testing ? t("jira:testingConnection") : t("jira:testConnection")}
</Button>
{hasConfig && (isFinePointer || !confirmingDelete) && (
{hasConfig && (isMobile || isFinePointer || !confirmingDelete) && (
<Button
ref={deleteAnchorRef}
type="button"
@@ -57,39 +61,52 @@ export function JiraActionBar({
className="ml-auto cursor-pointer"
data-testid="jira-delete-button"
>
{t("jira:removeConfiguration")}
{removeLabel}
</Button>
)}
{hasConfig && !isFinePointer && confirmingDelete ? (
<InlineConfirmActions
density="touch"
testId="jira-remove-inline-confirmation"
ariaLabel={removeConfirmation}
description={removeConfirmation}
cancelLabel={t("common:cancel")}
confirmLabel={t("jira:removeConfiguration")}
confirmAriaLabel={removeConfirmation}
confirmTestId="jira-remove-confirm"
onCancel={() => setConfirmingDelete(false)}
onClose={() => setConfirmingDelete(false)}
onConfirm={onDelete}
/>
) : null}
{hasConfig && isFinePointer ? (
<ActionConfirmPopover
open={confirmingDelete}
anchorRef={deleteAnchorRef}
title={removeConfirmation}
cancelLabel={t("common:cancel")}
confirmLabel={t("jira:removeConfiguration")}
confirmAriaLabel={removeConfirmation}
confirmTestId="jira-remove-confirm"
testId="jira-remove-confirm-popover"
onOpenChange={setConfirmingDelete}
onCancel={() => setConfirmingDelete(false)}
onConfirm={onDelete}
/>
) : null}
<MobileActionConfirmation
open={hasConfig && confirmingDelete}
targetKey={workspaceId}
title={removeConfirmation}
cancelLabel={t("common:cancel")}
confirmLabel={removeLabel}
confirmAriaLabel={removeConfirmation}
confirmTestId="jira-remove-confirm"
onOpenChange={setConfirmingDelete}
focusReturnRef={deleteAnchorRef}
onConfirm={onDelete}
fallback={
!isFinePointer ? (
<InlineConfirmActions
density="touch"
testId="jira-remove-inline-confirmation"
ariaLabel={removeConfirmation}
description={removeConfirmation}
cancelLabel={t("common:cancel")}
confirmLabel={removeLabel}
confirmAriaLabel={removeConfirmation}
confirmTestId="jira-remove-confirm"
onCancel={() => setConfirmingDelete(false)}
onClose={() => setConfirmingDelete(false)}
onConfirm={onDelete}
/>
) : (
<ActionConfirmPopover
open={confirmingDelete}
anchorRef={deleteAnchorRef}
title={removeConfirmation}
cancelLabel={t("common:cancel")}
confirmLabel={removeLabel}
confirmAriaLabel={removeConfirmation}
confirmTestId="jira-remove-confirm"
testId="jira-remove-confirm-popover"
onOpenChange={setConfirmingDelete}
onCancel={() => setConfirmingDelete(false)}
onConfirm={onDelete}
/>
)
}
/>
</div>
);
}
@@ -123,6 +123,8 @@ function WatchActions({
<TooltipContent>{t("common:reset")}</TooltipContent>
</Tooltip>
<WatcherDeleteAction
targetKey={`${watch.workspaceId}:${watch.id}`}
subject={watch.jql}
title={t("jira:deleteThisJiraWatcher")}
cancelLabel={t("common:cancel")}
confirmLabel={t("jira:delete")}
@@ -521,6 +521,7 @@ export function JiraConnectionSection({ workspaceId }: { workspaceId: string })
<TestResultAlert result={s.testResult} />
<Separator />
<JiraActionBar
workspaceId={workspaceId}
testing={s.testing}
loading={s.loading}
hasConfig={!!s.config}
@@ -5,7 +5,9 @@ import { DEFAULT_FILTERS } from "./filter-model";
import type { SavedView } from "./use-saved-views";
import { ListToolbar } from "./list-toolbar";
const responsive = vi.hoisted(() => ({ isFinePointer: false }));
const DELETE_VIEW_TITLE = "Delete view";
const responsive = vi.hoisted(() => ({ isFinePointer: false, isMobile: false }));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => responsive,
@@ -49,14 +51,35 @@ function renderToolbar(onDeleteView = vi.fn()) {
}
describe("Jira ListToolbar saved views", () => {
afterEach(cleanup);
afterEach(() => {
cleanup();
responsive.isMobile = false;
});
it("hands a phone saved view to a named sheet outside the closed picker", async () => {
responsive.isMobile = true;
const { onDeleteView } = renderToolbar();
const trigger = screen.getByRole("button", { name: "Sprint bugs" });
fireEvent.click(trigger);
fireEvent.click(screen.getByTitle(DELETE_VIEW_TITLE));
const sheet = await screen.findByRole("dialog", { name: "Delete Sprint bugs?" });
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(document.querySelector('[data-slot="popover-content"]')).toBeNull();
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trigger));
expect(onDeleteView).not.toHaveBeenCalled();
fireEvent.click(trigger);
fireEvent.click(screen.getByTitle(DELETE_VIEW_TITLE));
fireEvent.click(await screen.findByRole("button", { name: "Delete Sprint bugs" }));
await waitFor(() => expect(onDeleteView).toHaveBeenCalledExactlyOnceWith(CUSTOM.id));
});
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(screen.getAllByTitle(DELETE_VIEW_TITLE)).toHaveLength(1);
fireEvent.click(screen.getByTitle(DELETE_VIEW_TITLE));
expect(onDeleteView).not.toHaveBeenCalled();
const confirmation = screen.getByRole("group", { name: "Delete Sprint bugs?" });
@@ -66,7 +89,7 @@ describe("Jira ListToolbar saved views", () => {
fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" }));
expect(onDeleteView).not.toHaveBeenCalled();
fireEvent.click(screen.getByTitle("Delete view"));
fireEvent.click(screen.getByTitle(DELETE_VIEW_TITLE));
fireEvent.click(screen.getByRole("button", { name: "Delete Sprint bugs" }));
await waitFor(() => expect(onDeleteView).toHaveBeenCalledWith(CUSTOM.id));
@@ -28,7 +28,7 @@ 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,
useSavedTaskViewDeleteMenu,
type SavedTaskViewDeleteTarget,
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
@@ -179,15 +179,15 @@ function ViewsDropdown({
activeName: string | undefined;
}) {
const { t } = useTranslation();
const { isFinePointer } = useResponsiveBreakpoint();
const [open, setOpen] = useState(false);
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
const contentRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const builtin = views.filter((v) => v.builtin);
const custom = views.filter((v) => !v.builtin);
const deletion = useSavedTaskViewDeleteConfirmation<HTMLButtonElement>(custom);
const deletion = useSavedTaskViewDeleteMenu<HTMLButtonElement>(custom, isMobile);
const deleteProps: ViewDeletionProps = {
isFinePointer,
isFinePointer: !isMobile && isFinePointer,
deleteTarget: deletion.target,
deleteAnchorRef: deletion.anchorRef,
onDeleteOpenChange: (nextOpen) => {
@@ -199,15 +199,9 @@ function ViewsDropdown({
};
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) deletion.close();
}}
>
<Popover open={deletion.open} onOpenChange={deletion.onOpenChange}>
<PopoverTrigger asChild>
<Button variant="outline" className="cursor-pointer text-xs gap-1.5">
<Button ref={triggerRef} variant="outline" className="cursor-pointer text-xs gap-1.5">
<IconBookmark className="h-3.5 w-3.5" />
{activeName ?? t("jira:noView")}
</Button>
@@ -216,6 +210,7 @@ function ViewsDropdown({
ref={contentRef}
align="start"
className="w-60 max-w-[calc(100vw-1rem)] overflow-x-hidden p-0"
onCloseAutoFocus={deletion.onCloseAutoFocus}
onFocusOutside={(event) => {
if (isActionConfirmationTarget(event.target)) event.preventDefault();
}}
@@ -229,7 +224,7 @@ function ViewsDropdown({
activeViewId={activeViewId}
onSelect={(id) => {
onSelect(id);
setOpen(false);
deletion.onOpenChange(false);
}}
{...deleteProps}
/>
@@ -242,19 +237,19 @@ function ViewsDropdown({
activeViewId={activeViewId}
onSelect={(id) => {
onSelect(id);
setOpen(false);
deletion.onOpenChange(false);
}}
{...deleteProps}
/>
</>
)}
</PopoverContent>
{isFinePointer && deletion.target ? (
{(isMobile || isFinePointer) && deletion.target ? (
<SavedTaskViewDeleteConfirmation
target={deletion.target}
presentation="popover"
open
anchorRef={deletion.anchorRef}
anchorRef={isMobile ? triggerRef : deletion.anchorRef}
focusBoundaryRef={contentRef}
onOpenChange={(nextOpen) => {
if (!nextOpen) deletion.close();
+1
View File
@@ -472,6 +472,7 @@ function KanbanCardFrame({
</KanbanCardContextMenu>
</div>
<TaskDetachConfirmationSurface
taskId={task.id}
open={menu.showDetachConfirm}
anchorRef={menu.detachAnchorRef}
focusReturnRef={menu.detachFocusReturnRef}
@@ -142,6 +142,8 @@ function WatchActions({
<TooltipContent>{t("common:reset")}</TooltipContent>
</Tooltip>
<WatcherDeleteAction
targetKey={`${watch.workspaceId}:${watch.id}`}
subject={summarizeFilter(watch.filter)}
title={t("linear:deleteThisLinearWatcher")}
cancelLabel={t("common:cancel")}
confirmLabel={t("linear:delete")}
@@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { LinearConfig } from "@/lib/types/linear";
import { SettingsSaveProvider } from "@/components/settings/settings-save-provider";
const REMOVE_CONFIRM_TEST_ID = "linear-remove-confirm";
const mocks = vi.hoisted(() => ({
deleteConfig: vi.fn(),
getConfig: vi.fn(),
@@ -13,6 +15,7 @@ const mocks = vi.hoisted(() => ({
}));
let finePointer = true;
let isMobile = false;
const DELETE_BUTTON_TEST_ID = "linear-delete-button";
const CONFIRM_POPOVER_TEST_ID = "linear-remove-confirm-popover";
@@ -23,7 +26,7 @@ vi.mock("@/hooks/domains/integrations/use-integration-availability", () => ({
INTEGRATION_STATUS_REFRESH_MS: 100_000,
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isFinePointer: finePointer }),
useResponsiveBreakpoint: () => ({ isFinePointer: finePointer, isMobile }),
}));
vi.mock("@/components/linear/linear-enabled-control", () => ({
LinearEnabledControl: () => null,
@@ -60,6 +63,7 @@ beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
finePointer = true;
isMobile = false;
mocks.getConfig.mockResolvedValue(config);
mocks.listTeams.mockResolvedValue({ teams: [] });
mocks.deleteConfig.mockResolvedValue(undefined);
@@ -72,6 +76,24 @@ afterEach(() => {
});
describe("LinearConnectionSection removal", () => {
it("keeps the phone form controls behind a sheet and cancels locally", async () => {
isMobile = true;
finePointer = false;
renderSection();
const trigger = await screen.findByTestId(DELETE_BUTTON_TEST_ID);
fireEvent.click(trigger);
const sheet = screen.getByRole("dialog");
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(trigger.isConnected).toBe(true);
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trigger));
expect(mocks.deleteConfig).not.toHaveBeenCalled();
fireEvent.click(trigger);
fireEvent.click(screen.getByTestId(REMOVE_CONFIRM_TEST_ID));
await waitFor(() =>
expect(mocks.deleteConfig).toHaveBeenCalledExactlyOnceWith({ workspaceId: "workspace-a" }),
);
});
it("uses local fine-pointer confirmation and invokes deletion once", async () => {
const nativeConfirm = vi.fn(() => false);
vi.stubGlobal("confirm", nativeConfirm);
@@ -88,7 +110,7 @@ describe("LinearConnectionSection removal", () => {
fireEvent.click(removeButton);
fireEvent.click(
within(screen.getByTestId(CONFIRM_POPOVER_TEST_ID)).getByTestId("linear-remove-confirm"),
within(screen.getByTestId(CONFIRM_POPOVER_TEST_ID)).getByTestId(REMOVE_CONFIRM_TEST_ID),
);
await waitFor(() => expect(mocks.deleteConfig).toHaveBeenCalledTimes(1));
expect(mocks.deleteConfig).toHaveBeenCalledWith({ workspaceId: "workspace-a" });
@@ -102,14 +124,14 @@ describe("LinearConnectionSection removal", () => {
fireEvent.click(removeButton);
const inline = screen.getByTestId("linear-remove-inline-confirmation");
expect(screen.queryByTestId(CONFIRM_POPOVER_TEST_ID)).toBeNull();
expect(within(inline).getByTestId("linear-remove-confirm").className).toContain("h-11");
expect(within(inline).getByTestId(REMOVE_CONFIRM_TEST_ID).className).toContain("h-11");
fireEvent.click(within(inline).getByRole("button", { name: "Cancel" }));
expect(mocks.deleteConfig).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId(DELETE_BUTTON_TEST_ID));
fireEvent.click(
within(screen.getByTestId("linear-remove-inline-confirmation")).getByTestId(
"linear-remove-confirm",
REMOVE_CONFIRM_TEST_ID,
),
);
await waitFor(() => expect(mocks.deleteConfig).toHaveBeenCalledTimes(1));
+59 -34
View File
@@ -19,6 +19,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { useToast } from "@/components/toast-provider";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
import { SettingsSection } from "@/components/settings/settings-section";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -191,6 +192,7 @@ function configToHealth(config: LinearConfig | null): IntegrationAuthHealth | nu
}
type ActionBarProps = {
workspaceId: string;
testing: boolean;
loading: boolean;
hasConfig: boolean;
@@ -199,12 +201,21 @@ type ActionBarProps = {
onDelete: () => void;
};
function ActionBar({ testing, loading, hasConfig, disableTest, onTest, onDelete }: ActionBarProps) {
function ActionBar({
workspaceId,
testing,
loading,
hasConfig,
disableTest,
onTest,
onDelete,
}: ActionBarProps) {
const { t } = useTranslation();
const { isFinePointer } = useResponsiveBreakpoint();
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
const [confirmingDelete, setConfirmingDelete] = useState(false);
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
const removeConfirmation = t("linear:removeLinearConfigurationConfirm");
const removeLabel = t("linear:removeConfiguration");
useEffect(() => {
if (!hasConfig && confirmingDelete) setConfirmingDelete(false);
@@ -223,7 +234,7 @@ function ActionBar({ testing, loading, hasConfig, disableTest, onTest, onDelete
>
{testing ? t("linear:testing") : t("linear:testConnection")}
</Button>
{hasConfig && (isFinePointer || !confirmingDelete) && (
{hasConfig && (isMobile || isFinePointer || !confirmingDelete) && (
<Button
ref={deleteAnchorRef}
type="button"
@@ -232,39 +243,52 @@ function ActionBar({ testing, loading, hasConfig, disableTest, onTest, onDelete
className="ml-auto cursor-pointer"
data-testid="linear-delete-button"
>
{t("linear:removeConfiguration")}
{removeLabel}
</Button>
)}
{hasConfig && !isFinePointer && confirmingDelete ? (
<InlineConfirmActions
density="touch"
testId="linear-remove-inline-confirmation"
ariaLabel={removeConfirmation}
description={removeConfirmation}
cancelLabel={t("common:cancel")}
confirmLabel={t("linear:removeConfiguration")}
confirmAriaLabel={removeConfirmation}
confirmTestId="linear-remove-confirm"
onCancel={() => setConfirmingDelete(false)}
onClose={() => setConfirmingDelete(false)}
onConfirm={onDelete}
/>
) : null}
{hasConfig && isFinePointer ? (
<ActionConfirmPopover
open={confirmingDelete}
anchorRef={deleteAnchorRef}
title={removeConfirmation}
cancelLabel={t("common:cancel")}
confirmLabel={t("linear:removeConfiguration")}
confirmAriaLabel={removeConfirmation}
confirmTestId="linear-remove-confirm"
testId="linear-remove-confirm-popover"
onOpenChange={setConfirmingDelete}
onCancel={() => setConfirmingDelete(false)}
onConfirm={onDelete}
/>
) : null}
<MobileActionConfirmation
open={hasConfig && confirmingDelete}
targetKey={workspaceId}
title={removeConfirmation}
cancelLabel={t("common:cancel")}
confirmLabel={removeLabel}
confirmAriaLabel={removeConfirmation}
confirmTestId="linear-remove-confirm"
onOpenChange={setConfirmingDelete}
focusReturnRef={deleteAnchorRef}
onConfirm={onDelete}
fallback={
!isFinePointer ? (
<InlineConfirmActions
density="touch"
testId="linear-remove-inline-confirmation"
ariaLabel={removeConfirmation}
description={removeConfirmation}
cancelLabel={t("common:cancel")}
confirmLabel={removeLabel}
confirmAriaLabel={removeConfirmation}
confirmTestId="linear-remove-confirm"
onCancel={() => setConfirmingDelete(false)}
onClose={() => setConfirmingDelete(false)}
onConfirm={onDelete}
/>
) : (
<ActionConfirmPopover
open={confirmingDelete}
anchorRef={deleteAnchorRef}
title={removeConfirmation}
cancelLabel={t("common:cancel")}
confirmLabel={removeLabel}
confirmAriaLabel={removeConfirmation}
confirmTestId="linear-remove-confirm"
testId="linear-remove-confirm-popover"
onOpenChange={setConfirmingDelete}
onCancel={() => setConfirmingDelete(false)}
onConfirm={onDelete}
/>
)
}
/>
</div>
);
}
@@ -514,6 +538,7 @@ export function LinearConnectionSection({ workspaceId }: { workspaceId: string }
<TestResultAlert result={s.testResult} />
<Separator />
<ActionBar
workspaceId={workspaceId}
testing={s.testing}
loading={s.loading}
hasConfig={!!s.config}
@@ -1,7 +1,9 @@
import { useCallback, useState } from "react";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, within, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useClarificationEscapeGuard } from "@/hooks/use-clarification-escape-guard";
import { ResetContextButton } from "@/components/task/chat/reset-context-button";
import { TooltipProvider } from "@kandev/ui/tooltip";
const setQuickChatInitialPrompt = vi.fn();
const quickChatSessionItems: Record<string, { state: string; task_id: string }> = {};
@@ -18,7 +20,7 @@ const WORKSPACE_ID = "ws-1";
const TERMINAL_TAB_ID = "terminal-1";
const FIRE_ESCAPE_TESTID = "fire-escape";
const TOGGLE_ESCAPE_GUARD_TESTID = "toggle-escape-guard";
const responsiveMock = vi.hoisted(() => ({ isFinePointer: true }));
const responsiveMock = vi.hoisted(() => ({ isFinePointer: true, isMobile: false }));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => responsiveMock,
@@ -34,6 +36,7 @@ vi.mock("@/components/state-provider", () => ({
) =>
selector({
setQuickChatInitialPrompt,
clearContextWindow: vi.fn(),
taskSessions: { items: quickChatSessionItems },
prepareProgress: { bySessionId: quickChatPrepareProgress },
}),
@@ -126,6 +129,9 @@ function EscapeGuardProbe() {
useClarificationEscapeGuard(predicate);
return (
<div data-testid="quick-chat-session-view">
<TooltipProvider>
<ResetContextButton sessionId="chat-1" presentation="mobile" />
</TooltipProvider>
<button
type="button"
data-testid={TOGGLE_ESCAPE_GUARD_TESTID}
@@ -148,6 +154,24 @@ vi.mock("./use-quick-chat-modal", () => ({
useQuickChatModal: () => useQuickChatModalMock(),
}));
it("hosts phone context reset inside Quick Chat without another modal", async () => {
responsiveMock.isMobile = true;
useQuickChatModalMock.mockReturnValue({
...defaultQuickChatModalState,
activeKind: "conversation",
activeSessionNeedsAgent: false,
});
render(<QuickChatModal workspaceId={WORKSPACE_ID} />);
const trigger = screen.getByTestId("reset-context-button");
fireEvent.click(trigger);
const confirmation = await screen.findByTestId("mobile-action-confirmation");
expect(document.querySelector('[data-slot="drawer-content"]')).toBeNull();
expect(trigger.closest('[aria-hidden="true"][inert]')).toBeTruthy();
fireEvent.click(within(confirmation).getByRole("button", { name: "Back" }));
await waitFor(() => expect(document.activeElement).toBe(trigger));
responsiveMock.isMobile = false;
});
const defaultQuickChatModalState = {
isOpen: true,
sessions: [{ sessionId: "chat-1", workspaceId: WORKSPACE_ID, kind: "chat", name: CHAT_NAME }],
@@ -201,6 +225,7 @@ const defaultQuickChatModalState = {
import { QuickChatModal } from "./quick-chat-modal";
afterEach(() => {
responsiveMock.isMobile = false;
cleanup();
for (const key of Object.keys(quickChatSessionItems)) delete quickChatSessionItems[key];
for (const key of Object.keys(quickChatPrepareProgress)) delete quickChatPrepareProgress[key];
@@ -19,6 +19,10 @@ import {
} from "@/hooks/use-clarification-escape-guard";
import { ConfigChatSetup } from "@/components/config-chat/config-chat-setup";
import { useConfigChat } from "@/components/config-chat/use-config-chat";
import {
MobileConfirmationHost,
MobileConfirmationHostBody,
} from "@/components/confirmation/mobile-confirmation-host";
const QuickTerminalTabView = dynamic(
() => import("./quick-terminal-tab-view").then((module) => module.QuickTerminalTabView),
@@ -174,41 +178,52 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic
);
return (
<ClarificationEscapeGuardProvider value={escapeGuardRegistry}>
<Dialog open={quickChat.isOpen} onOpenChange={quickChat.handleOpenChange}>
<DialogContent
className="!left-0 !top-0 !h-dvh !max-h-dvh !w-screen !max-w-none !translate-x-0 !translate-y-0 flex flex-col gap-0 p-0 pt-safe pb-safe shadow-2xl sm:!left-1/2 sm:!top-1/2 sm:!h-[85vh] sm:!max-h-[85vh] sm:!w-[var(--quick-chat-width)] sm:!max-w-[calc(100vw-2rem)] sm:!-translate-x-1/2 sm:!-translate-y-1/2"
style={{ "--quick-chat-width": `${width}px` } as CSSProperties}
showCloseButton={false}
overlayClassName="bg-black/20 sm:bg-black/40 sm:backdrop-blur-sm"
onEscapeKeyDown={(event) => {
// A pending, expanded clarification (or an open suggestion popup,
// or the reverse-search overlay) handles this Escape itself and
// the modal stays open -- but only when that widget's own guard
// predicate reports it will actually act on this exact keydown
// (matching its enabled/scope/modifier state), so Escape never
// goes silently swallowed with nothing left to handle it. Once no
// guard claims it (e.g. a second, now-unguarded Escape), the modal
// closes, matching the main task chat panel's two-stage Escape
// after #2729.
for (const test of escapeGuardsRef.current.values()) {
if (test(event)) {
event.preventDefault();
break;
}
}
}}
>
<DialogTitle className="sr-only">{t("common:commandQuickChat")}</DialogTitle>
<QuickChatResizeHandle edge="left" {...leftResizeHandleProps} />
<QuickChatResizeHandle edge="right" {...rightResizeHandleProps} />
<QuickChatContent
workspaceId={workspaceId}
configChat={configChat}
quickChat={quickChat}
setQuickChatInitialPrompt={setQuickChatInitialPrompt}
/>
</DialogContent>
</Dialog>
<MobileConfirmationHost open={quickChat.isOpen} key={workspaceId}>
{({ contentProps }) => (
<Dialog open={quickChat.isOpen} onOpenChange={quickChat.handleOpenChange}>
<DialogContent
{...contentProps}
className="!left-0 !top-0 !h-dvh !max-h-dvh !w-screen !max-w-none !translate-x-0 !translate-y-0 flex flex-col gap-0 p-0 pt-safe pb-safe shadow-2xl sm:!left-1/2 sm:!top-1/2 sm:!h-[85vh] sm:!max-h-[85vh] sm:!w-[var(--quick-chat-width)] sm:!max-w-[calc(100vw-2rem)] sm:!-translate-x-1/2 sm:!-translate-y-1/2"
style={{ "--quick-chat-width": `${width}px` } as CSSProperties}
showCloseButton={false}
overlayClassName="bg-black/20 sm:bg-black/40 sm:backdrop-blur-sm"
onEscapeKeyDown={(event) => {
if (contentProps.onEscapeKeyDown) {
contentProps.onEscapeKeyDown(event);
return;
}
// A pending, expanded clarification (or an open suggestion popup,
// or the reverse-search overlay) handles this Escape itself and
// the modal stays open -- but only when that widget's own guard
// predicate reports it will actually act on this exact keydown
// (matching its enabled/scope/modifier state), so Escape never
// goes silently swallowed with nothing left to handle it. Once no
// guard claims it (e.g. a second, now-unguarded Escape), the modal
// closes, matching the main task chat panel's two-stage Escape
// after #2729.
for (const test of escapeGuardsRef.current.values()) {
if (test(event)) {
event.preventDefault();
break;
}
}
}}
>
<MobileConfirmationHostBody>
<DialogTitle className="sr-only">{t("common:commandQuickChat")}</DialogTitle>
<QuickChatResizeHandle edge="left" {...leftResizeHandleProps} />
<QuickChatResizeHandle edge="right" {...rightResizeHandleProps} />
<QuickChatContent
workspaceId={workspaceId}
configChat={configChat}
quickChat={quickChat}
setQuickChatInitialPrompt={setQuickChatInitialPrompt}
/>
</MobileConfirmationHostBody>
</DialogContent>
</Dialog>
)}
</MobileConfirmationHost>
<QuickChatDeleteDialog
sessionToDelete={quickChat.sessionToClose}
@@ -0,0 +1,48 @@
import type { RefObject } from "react";
import { useTranslation } from "react-i18next";
import type { TaskWalkthrough } from "@/lib/types/http";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
export function WalkthroughDiscardConfirmation({
taskId,
walkthrough,
open,
disabled,
anchorRef,
onOpenChange,
onConfirm,
}: {
taskId: string;
walkthrough: TaskWalkthrough;
open: boolean;
disabled: boolean;
anchorRef: RefObject<HTMLButtonElement | null>;
onOpenChange: (open: boolean) => void;
onConfirm: () => Promise<void>;
}) {
const { t } = useTranslation();
const { isFinePointer } = useResponsiveBreakpoint();
const actions = {
open,
onOpenChange,
onConfirm,
title: t("review:discardWalkthroughTitle"),
description: t("review:discardWalkthroughDescription"),
cancelLabel: t("common:cancel"),
confirmLabel: t("review:discardWalkthrough"),
confirmTestId: "walkthrough-discard-confirm",
testId: "walkthrough-discard-confirmation",
};
return (
<MobileActionConfirmation
{...actions}
targetKey={`${taskId}:${walkthrough.id}:${walkthrough.updated_at}`}
subject={walkthrough.title}
focusReturnRef={anchorRef}
disabled={disabled}
fallback={isFinePointer ? <ActionConfirmPopover {...actions} anchorRef={anchorRef} /> : null}
/>
);
}
@@ -264,7 +264,7 @@ describe("WalkthroughOverlay fine-pointer discard confirmation", () => {
describe("WalkthroughOverlay coarse-pointer discard confirmation", () => {
beforeEach(resetOverlayMocks);
it("morphs coarse-pointer discard into local touch actions", async () => {
it("shows a named phone sheet without replacing the walkthrough launcher", async () => {
vi.mocked(useResponsiveBreakpoint).mockReturnValue(responsive(false));
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
renderOverlay();
@@ -274,11 +274,14 @@ describe("WalkthroughOverlay coarse-pointer discard confirmation", () => {
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
const confirmation = await screen.findByTestId(DISCARD_CONFIRMATION_TEST_ID);
expect(screen.getByRole("dialog").getAttribute("data-slot")).toBe("drawer-content");
expect(confirmation.textContent).toContain("Walkthrough");
expect(screen.getByTestId(DISCARD_BUTTON_TEST_ID).isConnected).toBe(true);
expect(confirmation.getAttribute("role")).toBe("group");
expect(screen.queryByRole(ALERT_DIALOG_ROLE)).toBeNull();
expect(
within(confirmation).getByRole("button", { name: DISCARD_BUTTON_NAME }).className,
).toContain("h-11");
).toContain("min-h-12");
});
it("restores focus to the coarse-pointer discard button after cancel", async () => {
@@ -301,7 +304,9 @@ describe("WalkthroughOverlay coarse-pointer discard confirmation", () => {
);
act(() => runAnimationFrame?.(0));
expect(document.activeElement).toBe(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByTestId(DISCARD_BUTTON_TEST_ID)),
);
});
});
@@ -10,8 +10,8 @@ import { clearOpenWalkthroughTaskId, setOpenWalkthroughTaskId } from "@/lib/walk
import { cn } from "@kandev/ui/lib/utils";
import type { TaskWalkthrough } from "@/lib/types/http";
import { useTranslation } from "react-i18next";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { WalkthroughDiscardConfirmation } from "./walkthrough-discard-confirmation";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
type WalkthroughOverlayProps = {
@@ -29,6 +29,7 @@ type WalkthroughLauncherProps = {
discardDisabled: boolean;
hasUnseen: boolean;
isFinePointer: boolean;
isMobile: boolean;
isOpen: boolean;
onDiscardClick: () => void;
onDiscardCancel: () => void;
@@ -45,6 +46,7 @@ function WalkthroughLauncher({
discardDisabled,
hasUnseen,
isFinePointer,
isMobile,
isOpen,
onDiscardClick,
onDiscardCancel,
@@ -90,7 +92,7 @@ function WalkthroughLauncher({
{activeStep + 1}/{stepCount}
</span>
</button>
{isFinePointer || !confirmDiscardOpen ? (
{isMobile || isFinePointer || !confirmDiscardOpen ? (
<button
ref={discardAnchorRef}
type="button"
@@ -178,8 +180,7 @@ function useWalkthroughBackfill(params: {
*/
export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayProps) {
const { t } = useTranslation();
const discardLabel = t("review:discardWalkthrough");
const { isFinePointer } = useResponsiveBreakpoint();
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
const walkthrough = useAppStore((s) => (taskId ? s.walkthroughs.byTaskId[taskId] : null));
const connectionStatus = useAppStore((s) => s.connection.status);
const activeStep = useAppStore((s) =>
@@ -251,7 +252,8 @@ export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayP
discardAnchorRef={discardAnchorRef}
discardDisabled={discarding}
hasUnseen={hasUnseen}
isFinePointer={isFinePointer}
isFinePointer={!isMobile && isFinePointer}
isMobile={isMobile}
isOpen={open}
onDiscardClick={() => setDiscardTargetTaskId(taskId)}
onDiscardCancel={() => {
@@ -263,20 +265,15 @@ export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayP
onToggle={() => (open ? closeTour() : openTour())}
stepCount={walkthrough.steps.length}
/>
{isFinePointer ? (
<ActionConfirmPopover
open={confirmDiscardOpen}
anchorRef={discardAnchorRef}
title={t("review:discardWalkthroughTitle")}
description={t("review:discardWalkthroughDescription")}
cancelLabel={t("common:cancel")}
confirmLabel={discardLabel}
confirmTestId="walkthrough-discard-confirm"
testId="walkthrough-discard-confirmation"
onOpenChange={(nextOpen) => setDiscardTargetTaskId(nextOpen ? taskId : null)}
onConfirm={() => discardWalkthrough(discardTargetTaskId)}
/>
) : null}
<WalkthroughDiscardConfirmation
taskId={taskId}
walkthrough={walkthrough}
open={confirmDiscardOpen}
anchorRef={discardAnchorRef}
disabled={discarding}
onOpenChange={(nextOpen) => setDiscardTargetTaskId(nextOpen ? taskId : null)}
onConfirm={() => discardWalkthrough(discardTargetTaskId)}
/>
</>
);
}
@@ -17,9 +17,33 @@ function instance(id: string, name: string): SentryConfig {
};
}
afterEach(() => cleanup());
afterEach(() => {
cleanup();
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
});
describe("SentryInstanceCard", () => {
it("names the phone instance in a sheet and keeps its delete button mounted", () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
const onCancel = vi.fn();
render(
<SentryInstanceCard
instance={instance("production", "Production")}
onEdit={vi.fn()}
onDelete={vi.fn()}
isFinePointer={false}
confirmingDelete
onDeleteCancel={onCancel}
onDeleteConfirm={vi.fn()}
/>,
);
const sheet = screen.getByRole("dialog");
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(sheet.textContent).toContain("Production");
expect(screen.getByTestId("sentry-instance-delete-button").isConnected).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledExactlyOnceWith("production");
});
it("gives each instance action a distinct accessible name", () => {
render(
<>
@@ -9,6 +9,8 @@ import {
} from "@/components/integrations/auth-status-banner";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type { SentryConfig } from "@/lib/types/sentry";
import { useTranslation } from "react-i18next";
@@ -48,7 +50,16 @@ export function SentryInstanceCard({
const { t } = useTranslation();
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
const confirmationTitle = t("sentry:removeInstanceConfirm", { name: instance.name });
const { isMobile } = useResponsiveBreakpoint();
const cancelDelete = () => onDeleteCancel(instance.id);
const deleteLabel = t("sentry:delete");
const actions = {
cancelLabel: t("common:cancel"),
confirmLabel: deleteLabel,
confirmAriaLabel: confirmationTitle,
confirmTestId: "sentry-remove-confirm",
onConfirm: onDeleteConfirm,
};
return (
<div className="space-y-3 rounded-md border p-4" data-testid="sentry-instance-card">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
@@ -70,7 +81,7 @@ export function SentryInstanceCard({
<IconPencil className="h-3.5 w-3.5" />
{t("sentry:edit")}
</Button>
{(isFinePointer || !confirmingDelete) && (
{(isMobile || isFinePointer || !confirmingDelete) && (
<Button
ref={deleteAnchorRef}
type="button"
@@ -81,43 +92,46 @@ export function SentryInstanceCard({
data-testid="sentry-instance-delete-button"
>
<IconTrash className="h-3.5 w-3.5" />
{t("sentry:delete")}
{deleteLabel}
</Button>
)}
</div>
</div>
{!isFinePointer && confirmingDelete ? (
<InlineConfirmActions
density="touch"
testId="sentry-remove-inline-confirmation"
ariaLabel={confirmationTitle}
description={confirmationTitle}
cancelLabel={t("common:cancel")}
confirmLabel={t("sentry:delete")}
confirmAriaLabel={confirmationTitle}
confirmTestId="sentry-remove-confirm"
onCancel={cancelDelete}
onClose={cancelDelete}
onConfirm={onDeleteConfirm}
/>
) : null}
{isFinePointer ? (
<ActionConfirmPopover
open={confirmingDelete}
anchorRef={deleteAnchorRef}
title={confirmationTitle}
cancelLabel={t("common:cancel")}
confirmLabel={t("sentry:delete")}
confirmAriaLabel={confirmationTitle}
confirmTestId="sentry-remove-confirm"
testId="sentry-remove-confirm-popover"
onOpenChange={(open) => {
if (!open) cancelDelete();
}}
onCancel={cancelDelete}
onConfirm={onDeleteConfirm}
/>
) : null}
<MobileActionConfirmation
open={confirmingDelete}
targetKey={`${instance.workspaceId}:${instance.id}`}
title={confirmationTitle}
{...actions}
onOpenChange={(open) => {
if (!open) cancelDelete();
}}
focusReturnRef={deleteAnchorRef}
fallback={
!isFinePointer ? (
<InlineConfirmActions
density="touch"
testId="sentry-remove-inline-confirmation"
ariaLabel={confirmationTitle}
description={confirmationTitle}
{...actions}
onCancel={cancelDelete}
onClose={cancelDelete}
/>
) : (
<ActionConfirmPopover
open={confirmingDelete}
anchorRef={deleteAnchorRef}
title={confirmationTitle}
{...actions}
testId="sentry-remove-confirm-popover"
onOpenChange={(open) => {
if (!open) cancelDelete();
}}
onCancel={cancelDelete}
/>
)
}
/>
<IntegrationAuthStatusBanner health={configToHealth(instance)} />
</div>
);
@@ -150,6 +150,8 @@ function WatchActions({
<TooltipContent>{t("common:reset")}</TooltipContent>
</Tooltip>
<WatcherDeleteAction
targetKey={`${watch.workspaceId}:${watch.id}`}
subject={summarizeFilter(watch.filter)}
title={t("sentry:deleteThisSentryWatcher")}
cancelLabel={t("common:cancel")}
confirmLabel={t("sentry:delete")}
@@ -9,7 +9,10 @@ import {
} from "./agent-profile-delete-dialog";
import type { AgentProfileDeleteConflict } from "./agent-profile-delete-dialog";
afterEach(cleanup);
afterEach(() => {
cleanup();
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
});
function renderLocalConfirmation(
isFinePointer: boolean,
@@ -25,6 +28,8 @@ function renderLocalConfirmation(
Delete
</button>
<AgentProfileDeleteConfirmation
profileId="profile-1"
profileName="Code reviewer"
open={open}
isFinePointer={isFinePointer}
anchorRef={anchorRef}
@@ -40,6 +45,19 @@ function renderLocalConfirmation(
}
describe("AgentProfileDeleteConfirmation", () => {
it("names the phone target and restores the original trigger on Cancel", async () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
const onConfirm = vi.fn();
renderLocalConfirmation(false, onConfirm);
const sheet = screen.getByRole("dialog", { name: "Delete agent profile?" });
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(within(sheet).getByText("Code reviewer", { selector: "p" })).toBeTruthy();
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByTestId("profile-delete-trigger")),
);
expect(onConfirm).not.toHaveBeenCalled();
});
it("uses touch-sized inline actions on coarse pointers and cancels without mutating", async () => {
const onConfirm = vi.fn();
renderLocalConfirmation(false, onConfirm);
@@ -15,6 +15,7 @@ import {
} from "@kandev/ui/alert-dialog";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
import { useAppStore } from "@/components/state-provider";
import type {
ActiveSessionInfo,
@@ -39,6 +40,8 @@ const DELETE_PROFILE_DESCRIPTION_KEY = "agents:deleteAgentProfileDescription";
const CANCEL_LABEL_KEY = "common:cancel";
type AgentProfileDeleteConfirmationProps = {
profileId: string;
profileName: string;
open: boolean;
isFinePointer: boolean;
anchorRef: RefObject<HTMLElement | null>;
@@ -52,6 +55,8 @@ type AgentProfileDeleteConfirmationProps = {
* AgentProfileDeleteConflictDialog because its dependency lists need a modal.
*/
export function AgentProfileDeleteConfirmation({
profileId,
profileName,
open,
isFinePointer,
anchorRef,
@@ -60,26 +65,21 @@ export function AgentProfileDeleteConfirmation({
onConfirm,
}: AgentProfileDeleteConfirmationProps) {
const { t } = useTranslation();
if (isFinePointer) {
return (
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
title={t(DELETE_PROFILE_TITLE_KEY)}
description={t(DELETE_PROFILE_DESCRIPTION_KEY)}
cancelLabel={t(CANCEL_LABEL_KEY)}
confirmLabel={t("agents:delete")}
confirmTestId="agent-profile-delete-confirm"
testId="agent-profile-delete-confirm-popover"
onOpenChange={onOpenChange}
onCancel={onCancel}
onConfirm={onConfirm}
/>
);
}
if (!open) return null;
return (
const fallback = isFinePointer ? (
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
title={t(DELETE_PROFILE_TITLE_KEY)}
description={t(DELETE_PROFILE_DESCRIPTION_KEY)}
cancelLabel={t(CANCEL_LABEL_KEY)}
confirmLabel={t("agents:delete")}
confirmTestId="agent-profile-delete-confirm"
testId="agent-profile-delete-confirm-popover"
onOpenChange={onOpenChange}
onCancel={onCancel}
onConfirm={onConfirm}
/>
) : (
<InlineConfirmActions
density="touch"
testId="agent-profile-delete-inline-confirmation"
@@ -93,6 +93,23 @@ export function AgentProfileDeleteConfirmation({
onConfirm={onConfirm}
/>
);
return (
<MobileActionConfirmation
open={open}
targetKey={profileId}
title={t(DELETE_PROFILE_TITLE_KEY)}
subject={profileName}
description={t(DELETE_PROFILE_DESCRIPTION_KEY)}
cancelLabel={t(CANCEL_LABEL_KEY)}
confirmLabel={t("agents:delete")}
confirmTestId="agent-profile-delete-confirm"
focusReturnRef={anchorRef}
onOpenChange={onOpenChange}
onCancel={onCancel}
onConfirm={onConfirm}
fallback={fallback}
/>
);
}
export function AgentProfileDisableConflictDialog({
@@ -65,6 +65,7 @@ import type { AgentProfileMcpConfig } from "@/lib/types/http";
import { useAgentProfileSettings } from "@/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings";
import { agentProfileDiscoveryTarget } from "@/lib/settings-discovery/dynamic-targets";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useConfirmationBoundary } from "@/components/confirmation/mobile-action-confirmation";
import { DynamicAgentProfileEditor } from "@/components/settings/dynamic-agent-profile-editor";
import { isHandledApiError } from "@/lib/api/client";
@@ -137,15 +138,23 @@ function ProfileEditorHeader({
}
type DeleteProfileCardProps = {
profile: AgentProfile;
onDelete: () => void;
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void | Promise<void>;
};
function DeleteProfileCard({ onDelete, open, onOpenChange, onConfirm }: DeleteProfileCardProps) {
function DeleteProfileCard({
profile,
onDelete,
open,
onOpenChange,
onConfirm,
}: DeleteProfileCardProps) {
const { t } = useTranslation();
const { isFinePointer } = useResponsiveBreakpoint();
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
useConfirmationBoundary(open, profile.id, onOpenChange);
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
const closeDeleteConfirmation = () => {
onOpenChange(false);
@@ -161,7 +170,7 @@ function DeleteProfileCard({ onDelete, open, onOpenChange, onConfirm }: DeletePr
<p className="text-sm font-medium">{t("agents:removeThisProfile")}</p>
<p className="text-xs text-muted-foreground">{t("agents:actionCannotBeUndone")}</p>
</div>
{!open || isFinePointer ? (
{!open || isFinePointer || isMobile ? (
<Button
ref={deleteAnchorRef}
variant="destructive"
@@ -173,9 +182,11 @@ function DeleteProfileCard({ onDelete, open, onOpenChange, onConfirm }: DeletePr
{t("agents:delete")}
</Button>
) : null}
{!isFinePointer && open ? (
{!isMobile && !isFinePointer && open ? (
<div className="basis-full min-w-0">
<AgentProfileDeleteConfirmation
profileId={profile.id}
profileName={profile.name}
open={open}
isFinePointer={false}
anchorRef={deleteAnchorRef}
@@ -186,8 +197,10 @@ function DeleteProfileCard({ onDelete, open, onOpenChange, onConfirm }: DeletePr
</div>
) : null}
</CardContent>
{isFinePointer ? (
{isMobile || isFinePointer ? (
<AgentProfileDeleteConfirmation
profileId={profile.id}
profileName={profile.name}
open={open}
isFinePointer
anchorRef={deleteAnchorRef}
@@ -517,6 +530,7 @@ function ProfileEditor({
/>
<DeleteProfileCard
profile={savedProfile}
onDelete={deleteState.requestDelete}
open={deleteState.showDeleteConfirm}
onOpenChange={deleteState.setShowDeleteConfirm}
@@ -21,6 +21,7 @@ import { deleteAgentProfileAction } from "@/app/actions/agents";
import { useProfileDuplicate } from "@/hooks/domains/settings/use-profile-duplicate";
import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useConfirmationBoundary } from "@/components/confirmation/mobile-action-confirmation";
import { useRouter } from "@/lib/routing/client-router";
import { toAgentProfileOption } from "@/lib/state/slices/settings/types";
import type { Agent, AgentProfile } from "@/lib/types/http";
@@ -78,6 +79,8 @@ function ProfileRowActions({
onConfirmDelete: () => void;
}) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const pendingDelete = useRef(false);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -92,7 +95,15 @@ function ProfileRowActions({
<IconDotsVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => {
if (!pendingDelete.current) return;
pendingDelete.current = false;
event.preventDefault();
onConfirmDelete();
}}
>
{profile.kind !== "dynamic" && (
<DropdownMenuItem
className="cursor-pointer"
@@ -106,7 +117,10 @@ function ProfileRowActions({
<DropdownMenuItem
className="cursor-pointer text-destructive focus:text-destructive"
data-testid={`delete-profile-${profile.id}`}
onSelect={onConfirmDelete}
onSelect={() => {
if (isMobile) pendingDelete.current = true;
else onConfirmDelete();
}}
>
<IconTrash className="h-4 w-4 mr-2" />
{t("agents:delete")}
@@ -168,6 +182,8 @@ function ProfileRowInlineActions({
}
type ProfileRowDeleteConfirmationProps = {
profileId: string;
profileName: string;
open: boolean;
isFinePointer: boolean;
anchorRef: RefObject<HTMLButtonElement | null>;
@@ -179,6 +195,8 @@ type ProfileRowDeleteConfirmationProps = {
type ProfileRowDeleteConfirmationBaseProps = Omit<ProfileRowDeleteConfirmationProps, "placement">;
function ProfileRowDeleteConfirmation({
profileId,
profileName,
open,
isFinePointer,
anchorRef,
@@ -187,11 +205,14 @@ function ProfileRowDeleteConfirmation({
onConfirm,
placement,
}: ProfileRowDeleteConfirmationProps) {
if (placement === "inline" && (isFinePointer || !open)) return null;
if (placement === "popover" && !isFinePointer) return null;
const { isMobile } = useResponsiveBreakpoint();
if (placement === "inline" && (isMobile || isFinePointer || !open)) return null;
if (placement === "popover" && !isMobile && !isFinePointer) return null;
const confirmation = (
<AgentProfileDeleteConfirmation
profileId={profileId}
profileName={profileName}
open={open}
isFinePointer={isFinePointer}
anchorRef={anchorRef}
@@ -232,6 +253,7 @@ function ProfileRowCard({
onConfirmDelete,
confirmationProps,
}: ProfileRowCardProps) {
const { isMobile } = useResponsiveBreakpoint();
return (
<Card
// Same surface treatment as the workspace section tiles.
@@ -261,7 +283,7 @@ function ProfileRowCard({
</div>
<div className="relative z-10 flex shrink-0 items-center gap-1">
{canManage &&
!(confirmOpen && !isFinePointer) &&
(isMobile || !(confirmOpen && !isFinePointer)) &&
(isFullDesktop ? (
<ProfileRowInlineActions
profile={profile}
@@ -294,6 +316,7 @@ export function ProfileRow({ agent, profile }: { agent: Agent; profile: AgentPro
const { isFinePointer, isFullDesktop } = useResponsiveBreakpoint();
const handleDuplicate = useProfileDuplicate();
const [confirmOpen, setConfirmOpen] = useState(false);
useConfirmationBoundary(confirmOpen, profile.id, setConfirmOpen);
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
const store = useAppStoreApi();
const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
@@ -343,6 +366,8 @@ export function ProfileRow({ agent, profile }: { agent: Agent; profile: AgentPro
closeDeleteConfirmation();
};
const confirmationProps = {
profileId: profile.id,
profileName: profile.name,
open: confirmOpen,
isFinePointer,
anchorRef: deleteAnchorRef,
@@ -0,0 +1,51 @@
import { useRef, useState } from "react";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { getBuiltInLayoutProfile } from "@/lib/layout/layout-profiles";
import { LayoutProfileDeleteConfirmation } from "./layout-profile-delete-confirmation";
afterEach(() => {
cleanup();
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
});
it("keeps the default-layout warning and stable trigger in the phone sheet", async () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
const onConfirm = vi.fn();
function Harness() {
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLButtonElement>(null);
return (
<TooltipProvider>
<LayoutProfileDeleteConfirmation
profile={{
id: "layout-1",
name: "Review",
is_default: true,
layout: getBuiltInLayoutProfile("default").layout,
created_at: "2026-09-10T00:00:00Z",
}}
isFinePointer={false}
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
onConfirm={onConfirm}
/>
</TooltipProvider>
);
}
render(<Harness />);
const trigger = screen.getByTestId("layout-profile-delete");
fireEvent.click(trigger);
const sheet = screen.getByRole("dialog");
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(within(sheet).getByText(/built-in default layout/i)).toBeTruthy();
expect(trigger.isConnected).toBe(true);
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trigger));
expect(onConfirm).not.toHaveBeenCalled();
fireEvent.click(trigger);
fireEvent.click(screen.getByTestId("layout-profile-delete-confirm"));
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
});
@@ -8,6 +8,8 @@ import { useTranslation } from "react-i18next";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { settingsActionClassName } from "@/components/settings/settings-control";
import type { SavedLayout } from "@/lib/types/http";
@@ -29,6 +31,7 @@ export function LayoutProfileDeleteConfirmation({
onConfirm,
}: LayoutProfileDeleteConfirmationProps) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const title = t("settings:deleteLayoutProfileNamed", { name: profile.name });
const description = profile.is_default
? t("settings:theBuiltInDefaultLayoutWill")
@@ -38,41 +41,53 @@ export function LayoutProfileDeleteConfirmation({
queueMicrotask(() => anchorRef.current?.focus());
};
if (!isFinePointer) {
if (!open)
return <DeleteProfileButton anchorRef={anchorRef} onClick={() => onOpenChange(true)} />;
return (
<InlineConfirmActions
density="touch"
testId="layout-profile-delete-inline-confirmation"
ariaLabel={title}
description={description}
cancelLabel={t("settings:cancel")}
confirmLabel={t("settings:delete")}
confirmAriaLabel={title}
confirmTestId="layout-profile-delete-confirm"
onCancel={cancelInlineDelete}
onClose={() => onOpenChange(false)}
onConfirm={onConfirm}
/>
);
}
const fallback = !isFinePointer ? (
<InlineConfirmActions
density="touch"
testId="layout-profile-delete-inline-confirmation"
ariaLabel={title}
description={description}
cancelLabel={t("settings:cancel")}
confirmLabel={t("settings:delete")}
confirmAriaLabel={title}
confirmTestId="layout-profile-delete-confirm"
onCancel={cancelInlineDelete}
onClose={() => onOpenChange(false)}
onConfirm={onConfirm}
/>
) : (
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
title={title}
description={description}
cancelLabel={t("settings:cancel")}
confirmLabel={t("settings:delete")}
confirmAriaLabel={title}
confirmTestId="layout-profile-delete-confirm"
testId="layout-profile-delete-confirm-popover"
onOpenChange={onOpenChange}
onConfirm={onConfirm}
/>
);
return (
<>
<DeleteProfileButton anchorRef={anchorRef} onClick={() => onOpenChange(true)} />
<ActionConfirmPopover
{(isMobile || isFinePointer || !open) && (
<DeleteProfileButton anchorRef={anchorRef} onClick={() => onOpenChange(true)} />
)}
<MobileActionConfirmation
open={open}
anchorRef={anchorRef}
targetKey={profile.id}
title={title}
description={description}
cancelLabel={t("settings:cancel")}
confirmLabel={t("settings:delete")}
confirmAriaLabel={title}
confirmTestId="layout-profile-delete-confirm"
testId="layout-profile-delete-confirm-popover"
focusReturnRef={anchorRef}
onOpenChange={onOpenChange}
onConfirm={onConfirm}
fallback={fallback}
/>
</>
);
@@ -236,6 +236,7 @@ function PluginDangerZone({
onUninstall,
}: PluginDangerZoneProps) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const busy = actions.busyId === plugin.id || actions.uninstallBusy;
const canEnable =
plugin.status === "disabled" || plugin.status === "registered" || plugin.status === "error";
@@ -271,7 +272,7 @@ function PluginDangerZone({
{t("plugins:disable")}
</Button>
)}
{(isFinePointer || !confirmingUninstall) && (
{(isMobile || isFinePointer || !confirmingUninstall) && (
<Button
ref={uninstallAnchorRef}
variant="ghost"
@@ -16,6 +16,7 @@ import { PluginRepoLink } from "./plugin-repo-link";
import { PluginStatusBadge } from "./plugin-status-badge";
import { PluginErrorDiagnostic } from "./plugin-error-diagnostic";
import { PluginUninstallConfirmation } from "./uninstall-plugin-dialog";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type { MarketplaceEntry, PluginRecord } from "@/lib/types/plugins";
import { SETTINGS_TYPOGRAPHY } from "@/components/settings/settings-typography";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
@@ -489,31 +490,18 @@ function PluginRowActions({
onUpdate,
}: PluginRowActionsProps) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const updateEntry = update?.hasUpdate ? update.latest : undefined;
return (
<div className="relative z-10 flex flex-wrap items-center gap-2 shrink-0">
{updateEntry && onUpdate && (
<Button
variant="default"
size="sm"
data-testid={`plugin-update-${plugin.id}`}
className={controlSizingClassName(
"compact",
"cursor-pointer gap-1 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11",
)}
aria-busy={update?.busy ? "true" : undefined}
<PluginUpdateButton
pluginId={plugin.id}
entry={updateEntry}
updating={!!update?.busy}
disabled={busy}
onClick={() => onUpdate(updateEntry)}
>
{update?.busy ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconArrowUpCircle className="h-4 w-4" />
)}
{update?.busy
? t("plugins:updating")
: t("plugins:updateToVersion", { version: updateEntry.version })}
</Button>
onUpdate={onUpdate}
/>
)}
{canEnable && (
<Button
@@ -543,7 +531,7 @@ function PluginRowActions({
{t("plugins:disable")}
</Button>
)}
{(isFinePointer || !confirmingUninstall) && (
{(isMobile || isFinePointer || !confirmingUninstall) && (
<Button
ref={uninstallAnchorRef}
variant="ghost"
@@ -573,3 +561,40 @@ function PluginRowActions({
</div>
);
}
function PluginUpdateButton({
pluginId,
entry,
updating,
disabled,
onUpdate,
}: {
pluginId: string;
entry: MarketplaceEntry;
updating: boolean;
disabled: boolean;
onUpdate: (entry: MarketplaceEntry) => void;
}) {
const { t } = useTranslation();
return (
<Button
variant="default"
size="sm"
data-testid={`plugin-update-${pluginId}`}
className={controlSizingClassName(
"compact",
"cursor-pointer gap-1 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11",
)}
aria-busy={updating ? "true" : undefined}
disabled={disabled}
onClick={() => onUpdate(entry)}
>
{updating ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconArrowUpCircle className="h-4 w-4" />
)}
{updating ? t("plugins:updating") : t("plugins:updateToVersion", { version: entry.version })}
</Button>
);
}
@@ -5,7 +5,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { PluginUninstallConfirmation } from "./uninstall-plugin-dialog";
import type { PluginRecord } from "@/lib/types/plugins";
afterEach(cleanup);
afterEach(() => {
cleanup();
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
});
function plugin(): PluginRecord {
return {
@@ -48,6 +51,14 @@ function Confirmation({ isFinePointer }: { isFinePointer: boolean }) {
}
describe("plugin uninstall confirmation", () => {
it("shows the named phone uninstall warning in a sheet", () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
render(<Confirmation isFinePointer={false} />);
const sheet = screen.getByRole("dialog");
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(sheet.textContent).toContain("Acme Tools");
expect(sheet.textContent).toContain("revoke its API key");
});
it("uses an anchored non-modal confirmation on fine pointers", () => {
render(<Confirmation isFinePointer />);
@@ -5,6 +5,7 @@ import { Trans, useTranslation } from "react-i18next";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
import type { PluginRecord } from "@/lib/types/plugins";
type UninstallPluginConfirmationProps = {
@@ -40,33 +41,28 @@ export function PluginUninstallConfirmation({
);
const confirmAriaLabel = t("plugins:confirmUninstall");
if (!isFinePointer) {
if (!open) return null;
return (
<InlineConfirmActions
density="touch"
testId="plugin-uninstall-inline-confirmation"
ariaLabel={t("plugins:uninstallPlugin")}
description={description}
cancelLabel={t("plugins:cancel")}
confirmLabel={t("plugins:confirmUninstall")}
confirmAriaLabel={confirmAriaLabel}
confirmTestId="plugin-uninstall-confirm"
onCancel={onCancel}
onClose={() => onOpenChange(false)}
onConfirm={onConfirm}
/>
);
}
return (
const fallback = !isFinePointer ? (
<InlineConfirmActions
density="touch"
testId="plugin-uninstall-inline-confirmation"
ariaLabel={t("plugins:uninstallPlugin")}
description={description}
cancelLabel={t("plugins:cancel")}
confirmLabel={confirmAriaLabel}
confirmAriaLabel={confirmAriaLabel}
confirmTestId="plugin-uninstall-confirm"
onCancel={onCancel}
onClose={() => onOpenChange(false)}
onConfirm={onConfirm}
/>
) : (
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
title={t("plugins:uninstallPlugin")}
description={description}
cancelLabel={t("plugins:cancel")}
confirmLabel={t("plugins:confirmUninstall")}
confirmLabel={confirmAriaLabel}
confirmAriaLabel={confirmAriaLabel}
confirmTestId="plugin-uninstall-confirm"
testId="plugin-uninstall-confirm-popover"
@@ -75,4 +71,20 @@ export function PluginUninstallConfirmation({
onConfirm={onConfirm}
/>
);
return (
<MobileActionConfirmation
open={open}
targetKey={target.id}
title={t("plugins:uninstallPlugin")}
description={description}
cancelLabel={t("plugins:cancel")}
confirmLabel={confirmAriaLabel}
confirmAriaLabel={confirmAriaLabel}
confirmTestId="plugin-uninstall-confirm"
onOpenChange={onOpenChange}
onConfirm={onConfirm}
focusReturnRef={anchorRef}
fallback={fallback}
/>
);
}
@@ -5,8 +5,10 @@ import { Trans, useTranslation } from "react-i18next";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
type PromptDeleteConfirmationProps = {
promptId: string;
promptName: string;
open: boolean;
isFinePointer: boolean;
@@ -28,6 +30,7 @@ function PromptDeleteDescription({ promptName }: { promptName: string }) {
}
export function PromptDeleteConfirmation({
promptId,
promptName,
open,
isFinePointer,
@@ -53,26 +56,22 @@ export function PromptDeleteConfirmation({
anchorRef.current?.focus();
}, [anchorRef, isFinePointer, open]);
if (!isFinePointer) {
return open ? (
<InlineConfirmActions
density="touch"
testId="prompt-delete-inline-confirmation"
ariaLabel={promptDeleteLabel}
description={description}
cancelLabel={cancelLabel}
confirmLabel={promptDeleteLabel}
confirmAriaLabel={promptDeleteLabel}
confirmTestId="prompt-delete-confirm"
confirmDisabled={isBusy}
onCancel={handleCancel}
onClose={onClose}
onConfirm={onConfirm}
/>
) : null;
}
return (
const fallback = !isFinePointer ? (
<InlineConfirmActions
density="touch"
testId="prompt-delete-inline-confirmation"
ariaLabel={promptDeleteLabel}
description={description}
cancelLabel={cancelLabel}
confirmLabel={promptDeleteLabel}
confirmAriaLabel={promptDeleteLabel}
confirmTestId="prompt-delete-confirm"
confirmDisabled={isBusy}
onCancel={handleCancel}
onClose={onClose}
onConfirm={onConfirm}
/>
) : (
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
@@ -91,4 +90,26 @@ export function PromptDeleteConfirmation({
onConfirm={onConfirm}
/>
);
return (
<MobileActionConfirmation
open={open}
targetKey={promptId}
title={promptDeleteLabel}
subject={`@${promptName}`}
description={description}
cancelLabel={cancelLabel}
confirmLabel={promptDeleteLabel}
confirmAriaLabel={promptDeleteLabel}
confirmTestId="prompt-delete-confirm"
confirmDisabled={isBusy}
focusReturnRef={anchorRef}
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose();
}}
onCancel={onCancel}
onClose={onClose}
onConfirm={onConfirm}
fallback={fallback}
/>
);
}
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { PromptDeleteConfirmation } from "@/components/settings/prompt-delete-confirmation";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type { CustomPrompt } from "@/lib/types/http";
type PromptRowActionsProps = {
@@ -13,9 +13,6 @@ type PromptRowActionsProps = {
deleteAnchorRef: RefObject<HTMLButtonElement | null>;
onStartEditing: (prompt: CustomPrompt) => void;
onOpenDelete: (prompt: CustomPrompt) => void;
onDeleteClose: () => void;
onDeleteCancel: () => void;
onDeleteConfirm: () => void;
isBusy: boolean;
showCreate: boolean;
isFinePointer: boolean;
@@ -27,18 +24,16 @@ export function PromptRowActions({
deleteAnchorRef,
onStartEditing,
onOpenDelete,
onDeleteClose,
onDeleteCancel,
onDeleteConfirm,
isBusy,
showCreate,
isFinePointer,
isDeleteTarget,
}: PromptRowActionsProps) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
return (
<div className="flex items-center gap-2">
{isFinePointer || !isDeleteTarget ? (
{isMobile || isFinePointer || !isDeleteTarget ? (
<>
<Button
variant="ghost"
@@ -65,18 +60,6 @@ export function PromptRowActions({
</Button>
</>
) : null}
{isFinePointer ? (
<PromptDeleteConfirmation
promptName={prompt.name}
open={isDeleteTarget}
isFinePointer={isFinePointer}
anchorRef={deleteAnchorRef}
isBusy={isBusy}
onClose={onDeleteClose}
onCancel={onDeleteCancel}
onConfirm={onDeleteConfirm}
/>
) : null}
</div>
);
}
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
toast: vi.fn(),
prompts: [] as CustomPrompt[],
finePointer: true,
isMobile: false,
promptEditor: vi.fn(),
}));
@@ -26,7 +27,7 @@ vi.mock("@/components/state-provider", () => ({
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isFinePointer: mocks.finePointer }),
useResponsiveBreakpoint: () => ({ isFinePointer: mocks.finePointer, isMobile: mocks.isMobile }),
}));
vi.mock("@/components/toast-provider", () => ({
@@ -69,6 +70,7 @@ beforeEach(() => {
vi.clearAllMocks();
mocks.prompts = [];
mocks.finePointer = true;
mocks.isMobile = false;
mocks.setPrompts.mockImplementation((next: CustomPrompt[]) => {
mocks.prompts = next;
});
@@ -101,6 +103,29 @@ function renderPromptSettings() {
}
describe("PromptsSettings deletion confirmation", () => {
it("keeps the phone editor draft and delete trigger behind a confirmation sheet", async () => {
mocks.isMobile = true;
mocks.finePointer = false;
renderPromptSettings();
const row = screen.getByTestId(promptRowTestId);
fireEvent.click(within(row).getByTestId("prompt-edit-button"));
const editor = within(row).getByTestId(promptContentInputTestId) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Keep the mobile draft" } });
const trigger = within(row).getByTestId(promptDeleteButtonTestId);
fireEvent.click(trigger);
const sheet = screen.getByRole("dialog", { name: "Delete prompt" });
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(trigger.isConnected).toBe(true);
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trigger));
expect(editor.value).toBe("Keep the mobile draft");
expect(mocks.deletePrompt).not.toHaveBeenCalled();
fireEvent.click(trigger);
fireEvent.click(screen.getByTestId(promptDeleteConfirmTestId));
await waitFor(() =>
expect(mocks.deletePrompt).toHaveBeenCalledExactlyOnceWith(promptId, { cache: "no-store" }),
);
});
it("cancels an anchored delete without losing the prompt editor draft", async () => {
renderPromptSettings();
@@ -158,27 +158,23 @@ function PromptListItem({
deleteAnchorRef={deleteAnchorRef}
onStartEditing={onStartEditing}
onOpenDelete={onOpenDelete}
onDeleteClose={onDeleteCancel}
onDeleteCancel={onDeleteCancel}
onDeleteConfirm={onDeleteConfirm}
isBusy={isBusy}
showCreate={showCreate}
isFinePointer={isFinePointer}
isDeleteTarget={isDeleteTarget}
/>
</div>
{!isFinePointer ? (
<PromptDeleteConfirmation
promptName={prompt.name}
open={isDeleteTarget}
isFinePointer={isFinePointer}
anchorRef={deleteAnchorRef}
isBusy={isBusy}
onClose={onDeleteCancel}
onCancel={onDeleteCancel}
onConfirm={onDeleteConfirm}
/>
) : null}
<PromptDeleteConfirmation
promptId={prompt.id}
promptName={prompt.name}
open={isDeleteTarget}
isFinePointer={isFinePointer}
anchorRef={deleteAnchorRef}
isBusy={isBusy}
onClose={onDeleteCancel}
onCancel={onDeleteCancel}
onConfirm={onDeleteConfirm}
/>
{isEditing ? (
<div className="space-y-3">
<Input
@@ -16,6 +16,7 @@ import {
} from "@kandev/ui/alert-dialog";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
import type { SecretListItem, SecretReference } from "@/lib/types/http-secrets";
import { settingsActionClassName } from "./settings-control";
import { secretReferenceLabel } from "./secret-delete-error";
@@ -42,31 +43,41 @@ export function SecretDeleteConfirmation({
}: SecretDeleteConfirmationProps) {
const { t } = useTranslation();
const description = loading ? (
t("settings:checkingSecretReferences")
) : (
<Trans i18nKey="settings:thisWillPermanentlyRemoveSecret" values={{ name: secret.name }}>
This will permanently remove{" "}
<span className="font-medium text-foreground">{secret.name}</span>. This action cannot be
undone.
</Trans>
);
const actions = {
open,
description,
cancelLabel: t("settings:cancel"),
confirmLabel: t("settings:deleteSecret"),
confirmAriaLabel: t("settings:deleteSecretNamed", { name: secret.name }),
confirmTestId: "secret-delete-confirm",
confirmDisabled: loading,
onOpenChange,
onConfirm,
};
return (
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
title={t("settings:deleteSecret")}
description={
loading ? (
t("settings:checkingSecretReferences")
) : (
<Trans i18nKey="settings:thisWillPermanentlyRemoveSecret" values={{ name: secret.name }}>
This will permanently remove{" "}
<span className="font-medium text-foreground">{secret.name}</span>. This action cannot
be undone.
</Trans>
)
<MobileActionConfirmation
{...actions}
targetKey={secret.id}
title={t("settings:deleteSecretNamed", { name: secret.name })}
focusReturnRef={anchorRef}
fallback={
<ActionConfirmPopover
{...actions}
anchorRef={anchorRef}
title={t("settings:deleteSecret")}
testId="secret-delete-confirm-popover"
onCancel={onCancel}
/>
}
cancelLabel={t("settings:cancel")}
confirmLabel={t("settings:deleteSecret")}
confirmAriaLabel={t("settings:deleteSecretNamed", { name: secret.name })}
confirmTestId="secret-delete-confirm"
confirmDisabled={loading}
testId="secret-delete-confirm-popover"
onOpenChange={onOpenChange}
onCancel={onCancel}
onConfirm={onConfirm}
/>
);
}
@@ -4,9 +4,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { SecretListItem } from "@/lib/types/http-secrets";
let finePointer = true;
let isMobile = false;
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isFinePointer: finePointer }),
useResponsiveBreakpoint: () => ({ isFinePointer: finePointer, isMobile }),
}));
import { SecretListItemRow } from "./secrets-list-item-row";
@@ -47,6 +48,7 @@ function renderRow(overrides: Partial<Parameters<typeof SecretListItemRow>[0]> =
afterEach(() => {
finePointer = true;
isMobile = false;
cleanup();
});
@@ -61,6 +63,18 @@ function copyMoveDisabled(): boolean {
}
describe("SecretListItemRow", () => {
it("uses a named phone sheet while keeping the original row controls mounted", async () => {
isMobile = true;
finePointer = false;
const { onDeleteCancel } = renderRow({ isDeleteConfirming: true });
const sheet = screen.getByRole("dialog", { name: "Delete secret API Key" });
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(screen.getByRole("button", { name: COPY_MOVE_BUTTON, hidden: true }).isConnected).toBe(
true,
);
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
expect(onDeleteCancel).toHaveBeenCalledTimes(1);
});
it("keeps delete trigger target-aware and touch-sized", () => {
const { onDelete } = renderRow();
const deleteButton = screen.getByRole("button", { name: DELETE_BUTTON });
@@ -10,6 +10,7 @@ import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { revealSecret } from "@/lib/api/domains/secrets-api";
import type { SecretListItem } from "@/lib/types/http-secrets";
import { SecretDeleteConfirmation } from "./secrets-delete-dialog";
import { useConfirmationBoundary } from "@/components/confirmation/mobile-action-confirmation";
type SecretListItemRowProps = {
secret: SecretListItem;
@@ -77,7 +78,10 @@ export function SecretListItemRow({
isDeleteBlocked = false,
}: SecretListItemRowProps) {
const { t } = useTranslation();
const { isFinePointer } = useResponsiveBreakpoint();
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
useConfirmationBoundary(isDeleteConfirming, `${workspaceId}:${secret.id}`, () =>
onDeleteCancel(),
);
const [revealed, setRevealed] = useState(false);
const [revealedValue, setRevealedValue] = useState<string | null>(null);
const [revealing, setRevealing] = useState(false);
@@ -134,7 +138,7 @@ export function SecretListItemRow({
isDeleteConfirming={isDeleteConfirming}
isDeleteLoading={isDeleteLoading}
isDeleteBlocked={isDeleteBlocked}
isFinePointer={isFinePointer}
isFinePointer={isMobile || isFinePointer}
revealed={revealed}
revealing={revealing}
deleteAnchorRef={deleteAnchorRef}
@@ -107,7 +107,7 @@ describe("UsersTable confirmation presentation", () => {
expect(screen.queryByTestId(POPOVER_TEST_ID)).toBeNull();
});
it("morphs a phone row action into visible touch-sized status confirmation", async () => {
it("opens a phone status change in a named sheet", async () => {
mocks.responsive.isFinePointer = false;
mocks.responsive.isMobile = true;
await renderUsersTable();
@@ -117,7 +117,8 @@ describe("UsersTable confirmation presentation", () => {
expect(within(row).getByTestId("users-table-email").textContent).toContain(TARGET.email);
fireEvent.click(within(row).getByTestId(STATUS_TOGGLE_TEST_ID));
const confirmation = await screen.findByTestId("users-table-inline-confirmation");
const confirmation = await screen.findByRole("dialog");
expect(confirmation.getAttribute("data-slot")).toBe("drawer-content");
expect(confirmation.textContent).toContain(
`Disable ${TARGET_EMAIL}? They will be signed out everywhere.`,
);
@@ -149,7 +150,7 @@ describe("UsersTable confirmation presentation", () => {
expect(screen.queryByTestId("users-table")).toBeNull();
fireEvent.click(within(targetRow()).getByTestId(ROLE_TOGGLE_TEST_ID));
expect(await screen.findByTestId(POPOVER_TEST_ID)).toBeTruthy();
expect((await screen.findByRole("dialog")).getAttribute("data-slot")).toBe("drawer-content");
});
});
@@ -11,6 +11,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { IconMailForward, IconUserPlus, IconUsers } from "@tabler/icons-react";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import {
MobileActionConfirmation,
useConfirmationBoundary,
} from "@/components/confirmation/mobile-action-confirmation";
import { useToast } from "@/components/toast-provider";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { ApiError } from "@/lib/api/client";
@@ -171,8 +175,10 @@ function UserActionRegion({
const statusAnchorRef = useRef<HTMLButtonElement>(null);
const action = pending?.user.id === user.id ? pending : null;
const anchorRef = action?.next.role !== undefined ? roleAnchorRef : statusAnchorRef;
const targetKey = `${user.id}:${JSON.stringify(action?.next)}`;
const { isMobile } = useConfirmationBoundary(!!action, targetKey, () => onCancel());
if (!isFinePointer && action) {
if (!isMobile && !isFinePointer && action) {
return (
<InlineConfirmActions
density="touch"
@@ -195,14 +201,14 @@ function UserActionRegion({
<UserActionButtons
user={user}
isLastActiveAdmin={isLastActiveAdmin}
isFinePointer={isFinePointer}
isFinePointer={!isMobile && isFinePointer}
roleAnchorRef={roleAnchorRef}
statusAnchorRef={statusAnchorRef}
onToggleRole={onToggleRole}
onToggleStatus={onToggleStatus}
isMutating={isMutating}
/>
{isFinePointer && action ? (
{(isMobile || isFinePointer) && action ? (
<UserConfirmPopover
action={action}
anchorRef={anchorRef}
@@ -285,19 +291,32 @@ function UserConfirmPopover({
onConfirm: (action: PendingAction) => void;
}) {
const { t } = useTranslation();
const actions = {
title: action.label,
description: t("system:usersTakesEffectImmediately", { email: action.user.email }),
cancelLabel: t("common:cancel"),
confirmLabel: t("system:usersConfirm"),
confirmAriaLabel: action.label,
confirmTestId: "users-table-confirm",
onOpenChange: (open: boolean) => {
if (!open) onCancel();
},
onConfirm: () => onConfirm(action),
};
return (
<ActionConfirmPopover
<MobileActionConfirmation
{...actions}
open
anchorRef={anchorRef}
title={action.label}
description={t("system:usersTakesEffectImmediately", { email: action.user.email })}
cancelLabel={t("common:cancel")}
confirmLabel={t("system:usersConfirm")}
confirmAriaLabel={action.label}
confirmTestId="users-table-confirm"
testId="users-table-confirm-popover"
onOpenChange={(open) => !open && onCancel()}
onConfirm={() => onConfirm(action)}
targetKey={`${action.user.id}:${JSON.stringify(action.next)}`}
focusReturnRef={anchorRef}
fallback={
<ActionConfirmPopover
{...actions}
open
anchorRef={anchorRef}
testId="users-table-confirm-popover"
/>
}
/>
);
}
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor, act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type {
WorkflowSyncController,
@@ -7,7 +7,10 @@ import type {
import type { WorkflowSyncConfig } from "@/lib/types/workflow-sync";
import { WorkflowSyncDialog } from "./workflow-sync-dialog";
afterEach(cleanup);
afterEach(() => {
cleanup();
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
});
const WORKFLOWS_DIR = ".kandev/workflows";
const REMOVE_TEST_ID = "workflow-sync-remove";
@@ -77,7 +80,82 @@ function controller(overrides: Partial<WorkflowSyncController> = {}): WorkflowSy
};
}
describe("WorkflowSyncDialog removal confirmation", () => {
describe("WorkflowSyncDialog phone removal confirmation", () => {
it("does not dismiss a replacement dialog when an unmounted removal completes", async () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
let finish!: (result: boolean) => void;
const sync = controller({
handleDelete: vi.fn(
() =>
new Promise<boolean>((resolve) => {
finish = resolve;
}),
),
});
const onOpenChange = vi.fn();
const { rerender } = render(
<WorkflowSyncDialog key="original" open onOpenChange={onOpenChange} sync={sync} />,
);
fireEvent.click(screen.getByTestId(REMOVE_TEST_ID));
fireEvent.click(screen.getByTestId(REMOVE_CONFIRM_TEST_ID));
await waitFor(() => expect(sync.handleDelete).toHaveBeenCalledTimes(1));
rerender(
<WorkflowSyncDialog key="replacement" open onOpenChange={onOpenChange} sync={controller()} />,
);
await act(async () => finish(true));
expect(onOpenChange).not.toHaveBeenCalled();
expect(screen.getByTestId(REMOVE_TEST_ID)).toBeTruthy();
});
it("uses one phone dialog, preserves its draft, and retries a failed removal", async () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
let finish!: (result: boolean) => void;
const sync = controller({
handleDelete: vi
.fn()
.mockImplementationOnce(
() =>
new Promise<boolean>((resolve) => {
finish = resolve;
}),
)
.mockResolvedValue(true),
});
const onOpenChange = vi.fn();
render(<WorkflowSyncDialog open onOpenChange={onOpenChange} sync={sync} />);
const dialog = screen.getByTestId("workflow-sync-dialog");
const originalId = dialog.id;
const input = screen.getByTestId("workflow-sync-branch-input");
const trigger = screen.getByTestId(REMOVE_TEST_ID);
fireEvent.click(trigger);
expect(screen.getByRole("button", { name: "Back" })).toBeTruthy();
expect(screen.getAllByRole("dialog")).toHaveLength(1);
expect(dialog.id).toBe(originalId);
expect(input.closest("[inert]")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Back" }));
await waitFor(() => expect(document.activeElement).toBe(trigger));
expect(screen.getByTestId("workflow-sync-branch-input")).toBe(input);
expect(sync.handleDelete).not.toHaveBeenCalled();
fireEvent.click(trigger);
const confirm = screen.getByTestId(REMOVE_CONFIRM_TEST_ID);
fireEvent.click(confirm);
fireEvent.click(confirm);
await waitFor(() => expect(sync.handleDelete).toHaveBeenCalledTimes(1));
expect((confirm as HTMLButtonElement).disabled).toBe(true);
await act(async () => finish(false));
await waitFor(() =>
expect((screen.getByTestId(REMOVE_CONFIRM_TEST_ID) as HTMLButtonElement).disabled).toBe(
false,
),
);
expect(onOpenChange).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId(REMOVE_CONFIRM_TEST_ID));
await waitFor(() => expect(sync.handleDelete).toHaveBeenCalledTimes(2));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
describe("WorkflowSyncDialog wider removal confirmation", () => {
it("keeps removal inline in the existing dialog without a second overlay", () => {
const sync = controller();
render(<WorkflowSyncDialog open onOpenChange={vi.fn()} sync={sync} />);
@@ -1,6 +1,13 @@
"use client";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type Dispatch,
type SetStateAction,
} from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
@@ -22,6 +29,12 @@ import type {
import type { WorkflowSyncProvider } from "@/lib/types/workflow-sync";
import { RemoteRepoProviderTabs } from "@/components/task-create-dialog-remote-repo-provider-tabs";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
import {
MobileConfirmationHost,
MobileConfirmationHostBody,
} from "@/components/confirmation/mobile-confirmation-host";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type { RemoteRepositoryProvider } from "@/hooks/domains/integrations/use-remote-repositories";
const WORKFLOW_EXPORT_FORMAT = "kandev_workflow";
@@ -230,6 +243,8 @@ function isSaveDisabled(sync: WorkflowSyncController, intervalInvalid: boolean):
}
type WorkflowSyncRemovalActionsProps = {
targetKey: string;
subject: string;
hasConfig: boolean;
confirming: boolean;
saving: boolean;
@@ -239,6 +254,8 @@ type WorkflowSyncRemovalActionsProps = {
};
function WorkflowSyncRemovalActions({
targetKey,
subject,
hasConfig,
confirming,
saving,
@@ -247,37 +264,61 @@ function WorkflowSyncRemovalActions({
onConfirm,
}: WorkflowSyncRemovalActionsProps) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const anchorRef = useRef<HTMLButtonElement>(null);
if (!hasConfig) return null;
const removeLabel = t("workflows:remove");
if (confirming) {
return (
<InlineConfirmActions
density="touch"
testId={REMOVE_CONFIRMATION_TEST_ID}
ariaLabel={removeLabel}
const fallback = (
<InlineConfirmActions
density="touch"
testId={REMOVE_CONFIRMATION_TEST_ID}
ariaLabel={removeLabel}
description={t("workflows:removeSyncConfirm")}
cancelLabel={t("common:cancel")}
confirmLabel={removeLabel}
confirmAriaLabel={removeLabel}
confirmTestId={REMOVE_CONFIRM_TEST_ID}
onCancel={onCancel}
onConfirm={onConfirm}
/>
);
return (
<>
{(isMobile || !confirming) && (
<Button
ref={anchorRef}
type="button"
variant="destructive"
onClick={onRequest}
disabled={saving}
className="sm:mr-auto cursor-pointer"
data-testid={REMOVE_TEST_ID}
>
{removeLabel}
</Button>
)}
<MobileActionConfirmation
open={confirming}
targetKey={targetKey}
title={removeLabel}
subject={subject}
description={t("workflows:removeSyncConfirm")}
cancelLabel={t("common:cancel")}
confirmLabel={removeLabel}
confirmAriaLabel={removeLabel}
confirmTestId={REMOVE_CONFIRM_TEST_ID}
onCancel={onCancel}
testId={REMOVE_CONFIRMATION_TEST_ID}
onOpenChange={(open) => {
if (!open) onCancel();
}}
onConfirm={onConfirm}
completionPolicy="await-with-retry"
focusReturnRef={anchorRef}
fallback={fallback}
/>
);
}
return (
<Button
type="button"
variant="destructive"
onClick={onRequest}
disabled={saving}
className="sm:mr-auto cursor-pointer"
data-testid={REMOVE_TEST_ID}
>
{removeLabel}
</Button>
</>
);
}
@@ -316,6 +357,36 @@ type WorkflowSyncDialogProps = {
sync: WorkflowSyncController;
};
function WorkflowSyncFields({ sync }: { sync: WorkflowSyncController }) {
const { t } = useTranslation();
const switchingProvider = sync.config && sync.config.provider !== sync.form.provider;
return (
<div className="space-y-4">
<ProviderField provider={sync.form.provider} onChange={sync.setProvider} />
{switchingProvider && (
<p className="text-xs text-destructive" data-testid="workflow-sync-provider-switch-warning">
{t("workflows:switchProviderWarning")}
</p>
)}
<RepoUrlField
provider={sync.form.provider}
url={sync.url}
invalid={sync.urlInvalid}
resolved={resolvedTarget(t, sync.form)}
onChange={sync.setUrlInput}
/>
<BranchDirectoryFields form={sync.form} update={sync.update} />
<PollFields form={sync.form} update={sync.update} />
<p className="text-xs text-muted-foreground">
{t("workflows:syncDirectoryHelp", {
format: WORKFLOW_EXPORT_FORMAT,
extensions: WORKFLOW_EXPORT_EXTENSIONS,
})}
</p>
</div>
);
}
// WorkflowSyncDialog holds the workflow sync configuration form. It closes
// itself after a successful save or removal; failures keep it open with the
// error surfaced via toast.
@@ -323,12 +394,18 @@ export function WorkflowSyncDialog({ open, onOpenChange, sync }: WorkflowSyncDia
const { t } = useTranslation();
const [removeConfirming, setRemoveConfirming] = useState(false);
const hasConfig = !!sync.config;
const switchingProvider = hasConfig && sync.config?.provider !== sync.form.provider;
const intervalInvalid =
sync.form.poll_enabled &&
(!Number.isInteger(sync.form.interval_seconds) || sync.form.interval_seconds < 60);
const disableSave = isSaveDisabled(sync, intervalInvalid);
const resolved = resolvedTarget(t, sync.form);
const targetKey = workflowSyncConfirmationTarget(sync);
const generation = useRef(0);
useLayoutEffect(() => {
generation.current += 1;
return () => {
generation.current += 1;
};
}, [open, targetKey]);
useClearWorkflowSyncRemovalConfirmation(open, sync, setRemoveConfirming);
@@ -336,13 +413,14 @@ export function WorkflowSyncDialog({ open, onOpenChange, sync }: WorkflowSyncDia
if (await sync.handleSave()) onOpenChange(false);
};
const handleRemove = async () => {
if (await sync.handleDelete()) {
const requestGeneration = generation.current;
const removed = await sync.handleDelete();
if (generation.current !== requestGeneration) return;
if (removed) {
onOpenChange(false);
return;
}
// InlineConfirmActions restores its own shell when this callback rejects.
// Keep parent confirmation state mounted so the existing failure toast and
// retry path remain inside this dialog.
// The confirmation owns retry state; the controller owns failure feedback.
return Promise.reject();
};
const handleDialogOpenChange = (nextOpen: boolean) => {
@@ -351,71 +429,72 @@ export function WorkflowSyncDialog({ open, onOpenChange, sync }: WorkflowSyncDia
};
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent
className="sm:max-w-lg"
data-testid="workflow-sync-dialog"
onEscapeKeyDown={(event) => {
if (!removeConfirming) return;
event.preventDefault();
setRemoveConfirming(false);
}}
>
<DialogHeader>
<DialogTitle>{t("workflows:syncTitle")}</DialogTitle>
<DialogDescription>{t("workflows:syncDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<ProviderField provider={sync.form.provider} onChange={sync.setProvider} />
{switchingProvider && (
<p
className="text-xs text-destructive"
data-testid="workflow-sync-provider-switch-warning"
>
{t("workflows:switchProviderWarning")}
</p>
)}
<RepoUrlField
provider={sync.form.provider}
url={sync.url}
invalid={sync.urlInvalid}
resolved={resolved}
onChange={sync.setUrlInput}
/>
<BranchDirectoryFields form={sync.form} update={sync.update} />
<PollFields form={sync.form} update={sync.update} />
<p className="text-xs text-muted-foreground">
{t("workflows:syncDirectoryHelp", {
format: WORKFLOW_EXPORT_FORMAT,
extensions: WORKFLOW_EXPORT_EXTENSIONS,
})}
</p>
</div>
<DialogFooter>
<WorkflowSyncRemovalActions
hasConfig={hasConfig}
confirming={removeConfirming}
saving={sync.saving}
onRequest={() => setRemoveConfirming(true)}
onCancel={() => setRemoveConfirming(false)}
onConfirm={handleRemove}
/>
<Button
type="button"
onClick={handleSave}
disabled={disableSave || removeConfirming}
className="cursor-pointer"
data-testid="workflow-sync-save"
data-dialog-default-action
<MobileConfirmationHost open={open}>
{({ active, contentProps }) => (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent
className="sm:max-w-lg max-h-[calc(100dvh-2rem)] flex flex-col overflow-hidden"
data-testid="workflow-sync-dialog"
enterConfirms={!removeConfirming}
showCloseButton={!active}
onEscapeKeyDown={(event) => {
if (!removeConfirming) return;
event.preventDefault();
setRemoveConfirming(false);
}}
{...contentProps}
>
{saveLabel(t, sync.saving, hasConfig)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<MobileConfirmationHostBody>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
<DialogHeader>
<DialogTitle>{t("workflows:syncTitle")}</DialogTitle>
<DialogDescription>{t("workflows:syncDescription")}</DialogDescription>
</DialogHeader>
<WorkflowSyncFields sync={sync} />
<DialogFooter>
<WorkflowSyncRemovalActions
targetKey={targetKey}
subject={resolvedTarget(t, sync.config ?? sync.form)}
hasConfig={hasConfig}
confirming={removeConfirming}
saving={sync.saving}
onRequest={() => setRemoveConfirming(true)}
onCancel={() => setRemoveConfirming(false)}
onConfirm={handleRemove}
/>
<Button
type="button"
onClick={handleSave}
disabled={disableSave || removeConfirming}
className="cursor-pointer"
data-testid="workflow-sync-save"
data-dialog-default-action
>
{saveLabel(t, sync.saving, hasConfig)}
</Button>
</DialogFooter>
</div>
</MobileConfirmationHostBody>
</DialogContent>
</Dialog>
)}
</MobileConfirmationHost>
);
}
function workflowSyncConfirmationTarget(sync: WorkflowSyncController) {
const fields = (config: WorkflowSyncFormState | null) =>
config && [
config.provider,
config.repo_owner,
config.repo_name,
config.project_path,
config.branch,
config.path,
];
return JSON.stringify([sync.config?.workspace_id, fields(sync.config), fields(sync.form)]);
}
function saveLabel(t: TFunction, saving: boolean, hasConfig: boolean): string {
if (saving) return t("workflows:saving");
return hasConfig ? t("workflows:update") : t("workflows:save");
@@ -62,7 +62,12 @@ export function WorkflowSyncSection({
<Separator />
</div>
)}
<WorkflowSyncDialog open={dialogOpen} onOpenChange={onDialogOpenChange} sync={sync} />
<WorkflowSyncDialog
key={workspaceId}
open={dialogOpen}
onOpenChange={onDialogOpenChange}
sync={sync}
/>
</>
);
}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { IconAt } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { ModelSelector } from "@/components/task/model-selector";
@@ -195,7 +196,9 @@ function MobileLeftActions(props: MobileLeftActionsProps) {
}
export function MobileChatInputToolbar(props: MobileToolbarProps) {
const [resetConfirmationOpen, setResetConfirmationOpen] = useState(false);
const [resetRequested, setResetConfirmationOpen] = useState(false);
const { isMobile } = useResponsiveBreakpoint();
const resetConfirmationOpen = resetRequested && !isMobile;
const presentation = props.presentation ?? "mobile";
return (
@@ -3,6 +3,8 @@ import { render, screen, fireEvent, act, cleanup } from "@testing-library/react"
import { StateProvider } from "@/components/state-provider";
import type { TaskSession } from "@/lib/types/http";
const MODEL_SELECTOR_TEST_ID = "mock-model-selector";
const responsiveMock = vi.hoisted(() => ({
breakpoint: "desktop" as "mobile" | "tablet" | "compactDesktop" | "desktop",
}));
@@ -97,8 +99,19 @@ vi.mock("./implement-plan-button", () => ({
}));
vi.mock("./reset-context-button", () => ({
ResetContextButton: ({ presentation = "desktop" }: { presentation?: "desktop" | "mobile" }) => (
<button type="button" data-testid="reset-context-button" data-reset-presentation={presentation}>
ResetContextButton: ({
presentation = "desktop",
onConfirmationOpenChange,
}: {
presentation?: "desktop" | "mobile";
onConfirmationOpenChange?: (open: boolean) => void;
}) => (
<button
type="button"
data-testid="reset-context-button"
data-reset-presentation={presentation}
onClick={() => onConfirmationOpenChange?.(true)}
>
Reset context
</button>
),
@@ -208,6 +221,16 @@ function makeRunningSession(cancellationPending: boolean): TaskSession {
} as TaskSession;
}
it("keeps phone composer controls mounted while a reset sheet is open", () => {
responsiveMock.breakpoint = "mobile";
renderFullToolbar();
const model = screen.getByTestId(MODEL_SELECTOR_TEST_ID);
const submit = screen.getByTestId(SUBMIT_MESSAGE_BUTTON_TEST_ID);
fireEvent.click(screen.getByTestId("reset-context-button"));
expect(model.isConnected).toBe(true);
expect(submit.isConnected).toBe(true);
});
describe("ChatInputToolbar backend cancellation state", () => {
it("renders backend-owned pending state after store hydration", () => {
render(
@@ -477,9 +500,9 @@ describe("ChatInputToolbar responsive wrapper", () => {
);
expect(screen.getByTestId("toolbar-item-enhance")).toBeTruthy();
expect(screen.getByTestId("mock-mode-selector").className).toContain("max-w-[46vw]");
expect(screen.getByTestId("mock-model-selector").className).toContain("max-w-[56vw]");
expect(screen.getByTestId("mock-model-selector").className).toContain("min-w-0");
expect(screen.getByTestId("mock-model-selector").className).toContain("overflow-hidden");
expect(screen.getByTestId(MODEL_SELECTOR_TEST_ID).className).toContain("max-w-[56vw]");
expect(screen.getByTestId(MODEL_SELECTOR_TEST_ID).className).toContain("min-w-0");
expect(screen.getByTestId(MODEL_SELECTOR_TEST_ID).className).toContain("overflow-hidden");
});
it("keeps the compact sessions control on tablet layouts", () => {
@@ -41,6 +41,7 @@ function renderResetButton(
afterEach(() => {
cleanup();
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
vi.clearAllMocks();
});
@@ -117,6 +118,25 @@ describe("ResetContextButton context-window invalidation", () => {
});
describe("ResetContextButton mobile presentation", () => {
it("does not leave a phone tooltip floating over a hosted confirmation", () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
renderResetButton("mobile");
act(() => screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID).focus());
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("uses a phone sheet while keeping the reset trigger mounted", async () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
renderResetButton("mobile");
const trigger = screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID);
fireEvent.click(trigger);
const sheet = await screen.findByRole("dialog", { name: "Reset agent context?" });
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(trigger.isConnected).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trigger));
expect(mocks.request).not.toHaveBeenCalled();
});
beforeEach(() => {
mocks.request.mockResolvedValue({ success: true });
});
@@ -8,6 +8,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { getWebSocketClient } from "@/lib/ws/connection";
import { useTranslation } from "react-i18next";
@@ -32,8 +34,10 @@ function ResetContextTrigger({
tooltip,
onOpen,
}: ResetContextTriggerProps) {
const { isMobile } = useResponsiveBreakpoint();
const [tooltipOpen, setTooltipOpen] = useState(false);
return (
<Tooltip>
<Tooltip open={!isMobile && tooltipOpen} onOpenChange={setTooltipOpen}>
<TooltipTrigger asChild>
<Button
ref={actionRef}
@@ -64,11 +68,11 @@ export function ResetContextButton({
onConfirmationOpenChange,
}: ResetContextButtonProps) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const [confirmOpen, setConfirmOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const { isResetting, handleReset } = useContextResetRequest(sessionId);
const actionRef = useRef<HTMLButtonElement>(null);
const restoreFocusAfterCancelRef = useRef(false);
const clearContextWindow = useAppStore((state) => state.clearContextWindow);
const resetContextLabel = t("task:resetContext");
const resetContextAriaLabel = t("task:resetAgentContext");
const resetContextTooltip = t("task:resetAgentContextClearsConversationHistory");
@@ -94,20 +98,6 @@ export function ResetContextButton({
actionRef.current?.focus();
}, [confirmOpen]);
const handleReset = useCallback(async () => {
setIsResetting(true);
try {
const client = getWebSocketClient();
if (!client) return;
await client.request("session.reset_context", { session_id: sessionId }, 30000);
clearContextWindow(sessionId);
} catch (error) {
console.error("Failed to reset agent context:", error);
} finally {
setIsResetting(false);
}
}, [clearContextWindow, sessionId]);
const handleMobileCancel = () => {
restoreFocusAfterCancelRef.current = true;
updateConfirmationOpen(false);
@@ -123,42 +113,66 @@ export function ResetContextButton({
/>
);
if (presentation === "mobile") {
return confirmOpen ? (
const confirmationProps = {
description: t("task:thisWillClearTheAgentS"),
cancelLabel: t("common:cancel"),
confirmLabel: resetContextLabel,
confirmAriaLabel: resetContextLabel,
confirmTestId: "reset-context-confirm",
onConfirm: handleReset,
};
const fallback =
presentation === "mobile" ? (
<InlineConfirmActions
{...confirmationProps}
density="touch"
testId="reset-context-inline-confirm"
ariaLabel={resetContextAriaLabel}
description={t("task:thisWillClearTheAgentS")}
cancelLabel={t("common:cancel")}
confirmLabel={resetContextLabel}
confirmAriaLabel={resetContextLabel}
confirmTestId="reset-context-confirm"
onCancel={handleMobileCancel}
onClose={() => updateConfirmationOpen(false)}
onConfirm={handleReset}
/>
) : (
resetContextTrigger
);
}
return (
<>
{resetContextTrigger}
<ActionConfirmPopover
{...confirmationProps}
open={confirmOpen}
anchorRef={actionRef}
title={resetContextAriaLabel}
description={t("task:thisWillClearTheAgentS")}
cancelLabel={t("common:cancel")}
confirmLabel={resetContextLabel}
confirmAriaLabel={resetContextLabel}
confirmTestId="reset-context-confirm"
testId="reset-context-confirm-popover"
onOpenChange={updateConfirmationOpen}
onConfirm={handleReset}
/>
);
return (
<>
{isMobile || presentation !== "mobile" || !confirmOpen ? resetContextTrigger : null}
<MobileActionConfirmation
{...confirmationProps}
open={confirmOpen}
targetKey={sessionId}
title={resetContextAriaLabel}
focusReturnRef={actionRef}
onOpenChange={updateConfirmationOpen}
fallback={fallback}
/>
</>
);
}
function useContextResetRequest(sessionId: string) {
const [isResetting, setIsResetting] = useState(false);
const clearContextWindow = useAppStore((state) => state.clearContextWindow);
const handleReset = useCallback(async () => {
setIsResetting(true);
try {
const client = getWebSocketClient();
if (!client) return;
await client.request("session.reset_context", { session_id: sessionId }, 30000);
clearContextWindow(sessionId);
} catch (error) {
console.error("Failed to reset agent context:", error);
} finally {
setIsResetting(false);
}
}, [clearContextWindow, sessionId]);
return { isResetting, handleReset };
}
@@ -21,6 +21,7 @@ import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { FileIcon } from "@/components/ui/file-icon";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type { FileTreeNode } from "@/lib/types/backend";
import type { FileInfo } from "@/lib/state/store";
import type { FileBrowserRow } from "./file-browser-hooks";
@@ -134,11 +135,13 @@ export function FileTreeNodeTouchActions({
}) {
const { t } = useTranslation();
const deleteAction = useFileDeleteAction();
const { isMobile } = useResponsiveBreakpoint();
const deletePendingRef = React.useRef(false);
if (!showTouchActions || (!onAddToChatContext && !deleteAction)) return null;
const stopRowInteraction = (event: React.SyntheticEvent) => event.stopPropagation();
if (deleteAction?.confirming && !deleteAction.isBulk) {
if (deleteAction?.confirming && !deleteAction.isBulk && !isMobile) {
return (
<InlineConfirmActions
density="touch"
@@ -159,6 +162,7 @@ export function FileTreeNodeTouchActions({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
ref={deleteAction?.triggerRef}
type="button"
data-testid="file-tree-node-actions"
data-path={node.path}
@@ -176,6 +180,12 @@ export function FileTreeNodeTouchActions({
align="end"
data-testid="file-tree-touch-menu"
onClick={stopRowInteraction}
onCloseAutoFocus={(event) => {
if (!deletePendingRef.current) return;
event.preventDefault();
deletePendingRef.current = false;
deleteAction?.onDelete();
}}
>
{onAddToChatContext && (
<DropdownMenuItem
@@ -191,7 +201,10 @@ export function FileTreeNodeTouchActions({
data-testid="file-tree-touch-delete"
variant="destructive"
className="min-h-11 cursor-pointer"
onSelect={deleteAction.onDelete}
onSelect={() => {
if (isMobile && !deleteAction.isBulk) deletePendingRef.current = true;
else deleteAction.onDelete();
}}
>
<IconTrash className="h-3.5 w-3.5" />
{deleteAction.isBulk
@@ -4,6 +4,8 @@ import type * as FileBrowserParts from "./file-browser-parts";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const NODE_ACTIONS_TEST_ID = "file-tree-node-actions";
const { responsive, node } = vi.hoisted(() => ({
responsive: { isMobile: false, isFinePointer: true },
node: { name: "README.md", path: "README.md", is_dir: false, size: 0 },
@@ -111,11 +113,19 @@ afterEach(() => {
});
describe("FileBrowser responsive touch action wiring", () => {
it("releases row-owned confirmations when the file session changes", () => {
responsive.isMobile = true;
const { rerender } = render(<FileBrowser sessionId="session-1" onOpenFile={vi.fn()} />);
const trigger = screen.getByTestId(NODE_ACTIONS_TEST_ID);
rerender(<FileBrowser sessionId="session-2" onOpenFile={vi.fn()} />);
expect(trigger.isConnected).toBe(false);
expect(screen.getByTestId(NODE_ACTIONS_TEST_ID)).not.toBe(trigger);
});
it("renders the 44px action for a coarse-pointer desktop workbench", () => {
responsive.isFinePointer = false;
renderFileBrowser();
const trigger = screen.getByTestId("file-tree-node-actions");
const trigger = screen.getByTestId(NODE_ACTIONS_TEST_ID);
expect(trigger.className).toContain("min-h-11");
expect(trigger.className).toContain("min-w-11");
});
@@ -123,13 +133,13 @@ describe("FileBrowser responsive touch action wiring", () => {
it("hides the touch action only for fine-pointer desktop", () => {
renderFileBrowser();
expect(screen.queryByTestId("file-tree-node-actions")).toBeNull();
expect(screen.queryByTestId(NODE_ACTIONS_TEST_ID)).toBeNull();
});
it("renders the touch action for mobile", () => {
responsive.isMobile = true;
renderFileBrowser();
expect(screen.getByTestId("file-tree-node-actions")).toBeTruthy();
expect(screen.getByTestId(NODE_ACTIONS_TEST_ID)).toBeTruthy();
});
});
@@ -622,6 +622,7 @@ export function FileBrowser({
addSourcesDisabledReason={addSourcesDisabledReason}
/>
<FileBrowserTreeContent
key={`${sessionId}:${environmentId ?? ""}`}
scrollAreaRef={scrollAreaRef}
data={data}
handlers={handlers}
@@ -3,7 +3,10 @@ import { render, screen, cleanup, fireEvent, waitFor, within } from "@testing-li
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FileTreeNode } from "@/lib/types/backend";
const responsive = vi.hoisted(() => ({ isFinePointer: true }));
const DELETE_INLINE_CONFIRM_ID = "file-delete-inline-confirmation";
const DELETE_CONFIRM_ID = "file-delete-confirm";
const responsive = vi.hoisted(() => ({ isFinePointer: true, isMobile: false }));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => responsive,
@@ -40,9 +43,46 @@ const BULK_TREE: FileTreeNode = {
afterEach(() => {
cleanup();
responsive.isFinePointer = true;
responsive.isMobile = false;
vi.useRealTimers();
});
it("hands phone file deletion from the touch menu to a named sheet without replacing its trigger", async () => {
responsive.isMobile = true;
responsive.isFinePointer = false;
const onDeleteFile = vi.fn().mockResolvedValue(true);
render(
<FileContextMenu
node={FILE_NODE}
tree={FILE_NODE}
setTree={vi.fn()}
onDeleteFile={onDeleteFile}
onStartRename={vi.fn()}
>
<div data-testid="phone-file-row">
<span>{FILE_NODE.name}</span>
<FileTreeNodeTouchActions node={FILE_NODE} showTouchActions />
</div>
</FileContextMenu>,
);
const trigger = screen.getByTestId("file-tree-node-actions");
fireEvent.pointerDown(trigger);
fireEvent.click(screen.getByTestId("file-tree-touch-delete"));
const sheet = await screen.findByRole("dialog", { name: "Delete README.md" });
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(within(sheet).queryByText("README.md", { selector: "p" })).toBeNull();
expect(trigger.isConnected).toBe(true);
expect(screen.queryByRole("menu")).toBeNull();
expect(screen.queryByTestId(DELETE_INLINE_CONFIRM_ID)).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trigger));
expect(onDeleteFile).not.toHaveBeenCalled();
fireEvent.pointerDown(trigger);
fireEvent.click(screen.getByTestId("file-tree-touch-delete"));
fireEvent.click(await screen.findByTestId(DELETE_CONFIRM_ID));
await waitFor(() => expect(onDeleteFile).toHaveBeenCalledExactlyOnceWith(FILE_NODE.path));
});
function openMenu(triggerTestId: string) {
const trigger = screen.getByTestId(triggerTestId);
fireEvent.contextMenu(trigger);
@@ -369,8 +409,8 @@ describe("FileContextMenu bulk deletion", () => {
openMenu("single-delete-row");
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => expect(screen.getByTestId("file-delete-confirm")).toBeTruthy());
fireEvent.click(screen.getByTestId("file-delete-confirm"));
await waitFor(() => expect(screen.getByTestId(DELETE_CONFIRM_ID)).toBeTruthy());
fireEvent.click(screen.getByTestId(DELETE_CONFIRM_ID));
await waitFor(() => expect(onDeleteFile).toHaveBeenCalledWith(node.path));
});
@@ -421,17 +461,13 @@ describe("FileContextMenu touch actions", () => {
fireEvent.pointerDown(screen.getByTestId("file-tree-node-actions"));
fireEvent.click(screen.getByTestId("file-tree-touch-delete"));
await waitFor(() => expect(screen.getByTestId("file-delete-inline-confirmation")).toBeTruthy());
await waitFor(() => expect(screen.getByTestId(DELETE_INLINE_CONFIRM_ID)).toBeTruthy());
expect(screen.queryByTestId(DELETE_CONFIRM_POPOVER_ID)).toBeNull();
expect(onDeleteFile).not.toHaveBeenCalled();
expect(
screen.getByTestId("file-delete-inline-confirmation").querySelectorAll("button"),
).toHaveLength(2);
expect(screen.getByTestId(DELETE_INLINE_CONFIRM_ID).querySelectorAll("button")).toHaveLength(2);
fireEvent.click(
within(screen.getByTestId("file-delete-inline-confirmation")).getByTestId(
"file-delete-confirm",
),
within(screen.getByTestId(DELETE_INLINE_CONFIRM_ID)).getByTestId(DELETE_CONFIRM_ID),
);
await waitFor(() => expect(onDeleteFile).toHaveBeenCalledWith(FILE_NODE.path));
});
+73 -13
View File
@@ -14,6 +14,7 @@ import {
import { cn } from "@/lib/utils";
import { useToast } from "@/components/toast-provider";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useTranslation } from "react-i18next";
import type { FileTreeNode } from "@/lib/types/backend";
@@ -229,7 +230,10 @@ function FileContextMenuSurface({
onConfirmDelete,
onDelete,
}: FileContextMenuSurfaceProps) {
const { isMobile } = useResponsiveBreakpoint();
const renamePendingRef = useRef(false);
const deletePendingRef = useRef(false);
const touchTriggerRef = useRef<HTMLButtonElement>(null);
const handleStartRename = useCallback(() => {
renamePendingRef.current = true;
@@ -237,16 +241,24 @@ function FileContextMenuSurface({
const handleCloseAutoFocus = useCallback(
(event: Event) => {
if (deletePendingRef.current) {
event.preventDefault();
deletePendingRef.current = false;
setDeleteConfirmationOpen(true);
return;
}
if (!renamePendingRef.current) return;
event.preventDefault();
renamePendingRef.current = false;
onStartRename();
},
[onStartRename],
[onStartRename, setDeleteConfirmationOpen],
);
return (
<FileDeleteActionContext.Provider value={deleteAction}>
<FileDeleteActionContext.Provider
value={deleteAction ? { ...deleteAction, triggerRef: touchTriggerRef } : null}
>
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent onCloseAutoFocus={handleCloseAutoFocus}>
@@ -265,7 +277,10 @@ function FileContextMenuSurface({
onDownloadFile={onDownloadFile}
onUploadFilesHere={onUploadFilesHere}
onStartRename={handleStartRename}
onDelete={onDelete}
onDelete={(event) => {
if (isMobile && !isBulk) deletePendingRef.current = true;
else onDelete(event);
}}
/>
</ContextMenuContent>
</ContextMenu>
@@ -274,20 +289,16 @@ function FileContextMenuSurface({
<DeleteConfirmDialog selectedCount={selectedCount} onConfirm={onConfirmDelete} />
</AlertDialog>
)}
{!isBulk && isFinePointer && deleteAction && (
<ActionConfirmPopover
{!isBulk && deleteAction && (
<FileDeleteConfirmation
action={deleteAction}
open={deleteConfirmationOpen}
path={node.path}
isFinePointer={isFinePointer}
anchorRef={anchorRef}
focusBoundaryRef={focusBoundaryRef}
title={deleteAction.title}
description={deleteAction.description}
cancelLabel={deleteAction.cancelLabel}
confirmLabel={deleteAction.label}
confirmAriaLabel={deleteAction.title}
confirmTestId="file-delete-confirm"
testId="file-delete-confirm-popover"
focusReturnRef={touchTriggerRef.current ? touchTriggerRef : anchorRef}
onOpenChange={setDeleteConfirmationOpen}
onCancel={() => setDeleteConfirmationOpen(false)}
onConfirm={onConfirmDelete}
/>
)}
@@ -295,6 +306,55 @@ function FileContextMenuSurface({
);
}
function FileDeleteConfirmation({
action,
path,
isFinePointer,
anchorRef,
focusReturnRef,
focusBoundaryRef,
...props
}: {
action: FileDeleteAction;
path: string;
isFinePointer: boolean;
anchorRef: React.RefObject<HTMLElement | null>;
focusReturnRef: React.RefObject<HTMLElement | null>;
focusBoundaryRef: React.RefObject<HTMLElement | null>;
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}) {
const actions = {
...props,
title: action.title,
description: action.description,
cancelLabel: action.cancelLabel,
confirmLabel: action.label,
confirmAriaLabel: action.title,
confirmTestId: "file-delete-confirm",
};
return (
<MobileActionConfirmation
{...actions}
targetKey={path}
subject={path.includes("/") ? path : undefined}
focusReturnRef={focusReturnRef}
fallback={
isFinePointer ? (
<ActionConfirmPopover
{...actions}
anchorRef={anchorRef}
focusBoundaryRef={focusBoundaryRef}
testId="file-delete-confirm-popover"
onCancel={() => props.onOpenChange(false)}
/>
) : null
}
/>
);
}
/** Context menu for file nodes with Download, Rename, and Delete options */
export function FileContextMenu({
children,
@@ -12,6 +12,7 @@ export type FileDeleteAction = {
label: string;
cancelLabel: string;
description: React.ReactNode;
triggerRef?: React.RefObject<HTMLButtonElement | null>;
onDelete: () => void;
onCancel: () => void;
onConfirm: () => void;
@@ -5,6 +5,7 @@ import { getBuiltInLayoutProfile } from "@/lib/layout/layout-profiles";
const mocks = vi.hoisted(() => ({
finePointer: true,
isMobile: false,
savedLayouts: [] as Array<Record<string, unknown>>,
updateUserSettings: vi.fn(),
setUserSettings: vi.fn(),
@@ -31,7 +32,7 @@ vi.mock("@/lib/i18n", () => ({
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isFinePointer: mocks.finePointer }),
useResponsiveBreakpoint: () => ({ isFinePointer: mocks.finePointer, isMobile: mocks.isMobile }),
}));
vi.mock("@/lib/toast/sonner", () => ({
@@ -143,6 +144,7 @@ function deferred<T>() {
beforeEach(() => {
mocks.finePointer = true;
mocks.isMobile = false;
mocks.savedLayouts = [savedLayout()];
mocks.updateUserSettings.mockReset();
mocks.setUserSettings.mockReset();
@@ -166,6 +168,26 @@ afterEach(() => {
});
describe("LayoutPresetSelector saved-layout deletion", () => {
it("hands the phone menu off to a sheet without applying the layout", async () => {
mocks.isMobile = true;
mocks.finePointer = false;
render(<LayoutPresetSelector />);
const trigger = screen.getByTestId("layout-preset-trigger");
await openSavedLayouts();
fireEvent.click(screen.getByTestId(SAVED_DELETE_TEST_ID));
const sheet = await screen.findByRole("dialog", { name: "Delete Saved layout?" });
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(document.querySelector('[data-slot="dropdown-menu-content"]')).toBeNull();
fireEvent.click(within(sheet).getByRole("button", { name: "common:cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trigger));
expect(mocks.applyCustomLayout).not.toHaveBeenCalled();
expect(mocks.updateUserSettings).not.toHaveBeenCalled();
await openSavedLayouts();
fireEvent.click(screen.getByTestId(SAVED_DELETE_TEST_ID));
fireEvent.click(await screen.findByTestId(SAVED_DELETE_CONFIRM_TEST_ID));
await waitFor(() => expect(mocks.updateUserSettings).toHaveBeenCalledTimes(1));
expect(mocks.savedLayouts).toEqual([]);
});
it("anchors desktop confirmation to the menu action and keeps cancel local", async () => {
render(<LayoutPresetSelector />);
await openSavedLayouts();
@@ -50,6 +50,7 @@ import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import type { SavedLayout } from "@/lib/types/http";
import { useTaskSessions } from "@/hooks/use-task-sessions";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useConfirmationBoundary } from "@/components/confirmation/mobile-action-confirmation";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { resolveLayoutApplySessionIds } from "./layout-preset-selector-session-ids";
import { SavedLayoutDeleteConfirmation } from "./saved-layout-delete-confirmation";
@@ -236,7 +237,7 @@ function SavedLayoutItems({
<SavedLayoutLabel layout={layout} />
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-11 min-w-11 shrink-0 cursor-pointer justify-center px-2 text-destructive/60 focus:text-destructive sm:min-h-7 sm:min-w-7"
className="min-h-11 min-w-11 shrink-0 cursor-pointer justify-center px-2 text-destructive/60 focus:text-destructive md:min-h-7 md:min-w-7"
aria-label={t("task:delete2", { name: layout.name })}
data-testid="layout-saved-delete"
data-layout-id={layout.id}
@@ -341,6 +342,8 @@ function useApplyBuiltInLayout(
}
type PresetDropdownProps = {
triggerRef: RefObject<HTMLButtonElement | null>;
onCloseAutoFocus: (event: Event) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
deleteMenuRef: RefObject<HTMLDivElement | null>;
@@ -360,6 +363,8 @@ type PresetDropdownProps = {
};
function PresetDropdown({
triggerRef,
onCloseAutoFocus,
open,
onOpenChange,
deleteMenuRef,
@@ -384,6 +389,7 @@ function PresetDropdown({
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
ref={triggerRef}
size="sm"
variant="outline"
className="cursor-pointer px-2"
@@ -397,6 +403,7 @@ function PresetDropdown({
<TooltipContent side="bottom">{t("task:layoutPresets")}</TooltipContent>
</Tooltip>
<DropdownMenuContent
onCloseAutoFocus={onCloseAutoFocus}
ref={deleteMenuRef}
align="end"
className="w-60"
@@ -490,12 +497,15 @@ function usePresetDropdownVisibility(onClose: () => void) {
export function LayoutPresetSelector() {
const { t } = useTranslation();
const { isFinePointer } = useResponsiveBreakpoint();
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [deleteCandidate, setDeleteCandidate] = useState<SavedLayout | null>(null);
const cancelDelete = useCallback(() => setDeleteCandidate(null), []);
useConfirmationBoundary(deleteCandidate !== null, deleteCandidate?.id ?? "", cancelDelete);
const deleteAnchorRef = useRef<HTMLElement>(null);
const deleteMenuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const pendingTarget = useRef<SavedLayout | null>(null);
const resetLayout = useDockviewStore((s) => s.resetLayout);
const savedLayouts = useAppStore((s) => s.userSettings.savedLayouts);
const handleApplyCustom = useApplySavedLayout();
@@ -503,10 +513,18 @@ export function LayoutPresetSelector() {
const { saveLayout, deleteLayout } = useSavedLayoutMutations();
const { dropdownOpen, tooltipOpen, handleDropdownOpenChange, handleTooltipOpenChange } =
usePresetDropdownVisibility(cancelDelete);
const beginDelete = useCallback((layout: SavedLayout, anchor: HTMLElement) => {
deleteAnchorRef.current = anchor;
setDeleteCandidate(layout);
}, []);
const beginDelete = useCallback(
(layout: SavedLayout, anchor: HTMLElement) => {
if (isMobile) {
pendingTarget.current = layout;
handleDropdownOpenChange(false);
return;
}
deleteAnchorRef.current = anchor;
setDeleteCandidate(layout);
},
[isMobile, handleDropdownOpenChange],
);
const closeDelete = useCallback(() => {
setDeleteCandidate(null);
@@ -528,14 +546,22 @@ export function LayoutPresetSelector() {
return (
<>
<PresetDropdown
triggerRef={triggerRef}
onCloseAutoFocus={(event) => {
const target = pendingTarget.current;
pendingTarget.current = null;
if (!target) return;
event.preventDefault();
if (savedLayouts.some((layout) => layout.id === target.id)) setDeleteCandidate(target);
}}
open={dropdownOpen}
onOpenChange={handleDropdownOpenChange}
deleteMenuRef={deleteMenuRef}
tooltipOpen={tooltipOpen}
tooltipOpen={!isMobile && tooltipOpen}
onTooltipOpenChange={handleTooltipOpenChange}
resetLayout={resetLayout}
savedLayouts={savedLayouts}
isFinePointer={isFinePointer}
isFinePointer={isMobile || isFinePointer}
onApplyCustom={handleApplyCustom}
onApplyBuiltIn={handleApplyBuiltIn}
confirmingDeleteId={deleteCandidate?.id ?? null}
@@ -552,8 +578,8 @@ export function LayoutPresetSelector() {
/>
<SavedLayoutDeleteConfirmation
layout={deleteCandidate}
open={isFinePointer && deleteCandidate !== null}
anchorRef={deleteAnchorRef}
open={(isMobile || isFinePointer) && deleteCandidate !== null}
anchorRef={isMobile ? triggerRef : deleteAnchorRef}
focusBoundaryRef={deleteMenuRef}
onOpenChange={(open) => {
if (!open) cancelDelete();
@@ -1,6 +1,10 @@
"use client";
import { type ReactNode } from "react";
import {
MobileConfirmationHost,
MobileConfirmationHostBody,
} from "@/components/confirmation/mobile-confirmation-host";
import {
Drawer,
DrawerContent,
@@ -18,6 +22,8 @@ type MobilePickerSheetProps = {
contentTestId?: string;
/** Optional trailing element rendered to the right of the title (e.g. a "+" CTA). */
headerAction?: ReactNode;
/** Replace the picker content with a confirmation step in this same drawer. */
confirmationHost?: boolean;
onCloseAutoFocus?: (event: Event) => void;
/** Fixed content above the single scrolling picker region. */
fixedContent?: ReactNode;
@@ -36,28 +42,45 @@ export function MobilePickerSheet({
description,
contentTestId,
headerAction,
confirmationHost = false,
onCloseAutoFocus,
fixedContent,
children,
}: MobilePickerSheetProps) {
const content = (
<>
<DrawerHeader className="text-left pb-2">
<div className="flex items-center justify-between gap-2">
<DrawerTitle className="text-sm">{title}</DrawerTitle>
{headerAction}
</div>
{description && <DrawerDescription>{description}</DrawerDescription>}
</DrawerHeader>
{fixedContent}
<div
className="flex-1 min-h-0 max-h-[70dvh] overflow-y-auto px-2 pb-[calc(1rem+env(safe-area-inset-bottom))]"
data-testid={contentTestId}
>
{children}
</div>
</>
);
if (confirmationHost)
return (
<MobileConfirmationHost open={open} surface="drawer">
{({ contentProps }) => (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent onCloseAutoFocus={onCloseAutoFocus} {...contentProps}>
<MobileConfirmationHostBody>{content}</MobileConfirmationHostBody>
</DrawerContent>
</Drawer>
)}
</MobileConfirmationHost>
);
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent onCloseAutoFocus={onCloseAutoFocus}>
<DrawerHeader className="text-left pb-2">
<div className="flex items-center justify-between gap-2">
<DrawerTitle className="text-sm">{title}</DrawerTitle>
{headerAction}
</div>
{description && <DrawerDescription>{description}</DrawerDescription>}
</DrawerHeader>
{fixedContent}
<div
className="flex-1 min-h-0 max-h-[70dvh] overflow-y-auto px-2 pb-[calc(1rem+env(safe-area-inset-bottom))]"
data-testid={contentTestId}
>
{children}
</div>
</DrawerContent>
<DrawerContent onCloseAutoFocus={onCloseAutoFocus}>{content}</DrawerContent>
</Drawer>
);
}
@@ -0,0 +1,65 @@
"use client";
import type { RefObject } from "react";
import { useTranslation } from "react-i18next";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
import { SessionDeleteDescription } from "../session-delete-description";
export function MobileSessionDeleteConfirmation({
open,
targetKey,
isPrimary,
isOnlySession,
targetName,
focusReturnRef,
onCancel,
onClose,
onConfirm,
}: {
open: boolean;
targetKey: string;
isPrimary: boolean;
isOnlySession: boolean;
targetName: string;
focusReturnRef: RefObject<HTMLElement | null>;
onCancel: () => void;
onClose: () => void;
onConfirm: () => void | Promise<void>;
}) {
const { t } = useTranslation();
const description = (
<SessionDeleteDescription isPrimary={isPrimary} isOnlySession={isOnlySession} />
);
const actions = {
testId: "mobile-session-delete-confirmation",
description,
cancelLabel: t("common:cancel"),
confirmLabel: t("task:delete"),
confirmAriaLabel: t("task:delete2", { name: targetName }),
confirmTestId: "mobile-session-delete-confirm",
onClose,
onConfirm,
};
return (
<MobileActionConfirmation
{...actions}
open={open}
targetKey={targetKey}
title={t("task:deleteSession")}
subject={targetName}
focusReturnRef={focusReturnRef}
onOpenChange={(next) => {
if (!next) onCancel();
}}
fallback={
<InlineConfirmActions
{...actions}
density="touch"
ariaLabel={t("task:deleteSession")}
onCancel={onCancel}
/>
}
/>
);
}
@@ -406,6 +406,33 @@ describe("MobileSessionsPicker pending lifecycle", () => {
});
describe("MobileSessionsPicker session delete confirmation", () => {
it("uses the same modal for the named delete step and restores the picker on Back", async () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
try {
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_A} fullWidth />);
fireEvent.click(screen.getByTestId(PILL_TESTID));
const picker = screen.getByRole("dialog", { name: "Sessions" });
const row = screen.getByTestId(`mobile-session-row-${SESSION_A}`);
fireEvent.pointerDown(within(row).getByRole("button", { name: SESSION_ACTIONS_LABEL }));
fireEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
expect(await screen.findByRole("dialog", { name: "Delete session?" })).toBe(picker);
expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1);
expect(screen.getByRole("group").textContent).toContain("Alpha");
expect(screen.queryByRole("button", { name: SESSION_ACTIONS_LABEL })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Back" }));
expect(screen.getByRole("dialog", { name: "Sessions" })).toBe(picker);
expect(row.isConnected).toBe(true);
await vi.waitFor(() =>
expect(document.activeElement).toBe(
within(row).getByRole("button", { name: SESSION_ACTIONS_LABEL }),
),
);
expect(mocks.removeSession).not.toHaveBeenCalled();
} finally {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
}
});
it("morphs the target row into local touch-sized confirmation actions", () => {
mocks.sessions = [session(SESSION_A, "profile-a", START_TIME, { state: "COMPLETED" })];
render(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_A} fullWidth />);
@@ -1,6 +1,6 @@
"use client";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { IconDotsVertical, IconPlus, IconStar } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
@@ -11,7 +11,7 @@ import {
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { AgentLogo } from "@/components/agent-logo";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useAppStore } from "@/components/state-provider";
import { useTaskSessions } from "@/hooks/use-task-sessions";
import {
@@ -32,7 +32,7 @@ import type { ForegroundActivity, TaskSession, TaskSessionState } from "@/lib/ty
import type { AgentProfileOption } from "@/lib/state/slices";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
import { SessionDeleteDescription } from "../session-delete-description";
import { MobileSessionDeleteConfirmation } from "./mobile-session-delete-confirmation";
type SessionRow = {
id: string;
@@ -144,6 +144,7 @@ function StateBadge({
}
function SessionActionsMenu({
triggerRef,
taskId,
state,
isPrimary,
@@ -153,6 +154,7 @@ function SessionActionsMenu({
onAskDelete,
onHandoffProfile,
}: {
triggerRef: RefObject<HTMLButtonElement | null>;
taskId: string;
state: TaskSessionState | null;
isPrimary: boolean;
@@ -163,6 +165,8 @@ function SessionActionsMenu({
onHandoffProfile: (profileId: string) => void;
}) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const pendingDelete = useRef(false);
const hasLifecycleAction =
!!state &&
(isSessionStoppable(state) || isSessionResumable(state) || isSessionDeletable(state));
@@ -171,16 +175,26 @@ function SessionActionsMenu({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
ref={triggerRef}
variant="ghost"
size="icon-sm"
className="cursor-pointer h-7 w-7"
className="cursor-pointer h-11 w-11 md:h-7 md:w-7"
onClick={(e) => e.stopPropagation()}
aria-label={t("task:sessionActions")}
>
<IconDotsVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuContent
align="end"
onClick={(e) => e.stopPropagation()}
onCloseAutoFocus={(event) => {
if (!pendingDelete.current) return;
event.preventDefault();
pendingDelete.current = false;
onAskDelete();
}}
>
<DropdownMenuItem
className="cursor-pointer"
onSelect={onSetPrimary}
@@ -202,7 +216,10 @@ function SessionActionsMenu({
{state && isSessionDeletable(state) && (
<DropdownMenuItem
className="cursor-pointer text-destructive focus:text-destructive"
onSelect={onAskDelete}
onSelect={() => {
if (isMobile) pendingDelete.current = true;
else onAskDelete();
}}
>
{t("task:delete")}
</DropdownMenuItem>
@@ -214,39 +231,6 @@ function SessionActionsMenu({
);
}
function SessionDeleteInlineConfirmation({
isPrimary,
isOnlySession,
targetName,
onCancel,
onClose,
onConfirm,
}: {
isPrimary: boolean;
isOnlySession: boolean;
targetName: string;
onCancel: () => void;
onClose: () => void;
onConfirm: () => void | Promise<void>;
}) {
const { t } = useTranslation();
return (
<InlineConfirmActions
density="touch"
testId="mobile-session-delete-confirmation"
ariaLabel={t("task:deleteSession")}
description={<SessionDeleteDescription isPrimary={isPrimary} isOnlySession={isOnlySession} />}
cancelLabel={t("common:cancel")}
confirmLabel={t("task:delete")}
confirmAriaLabel={t("task:delete2", { name: targetName })}
confirmTestId="mobile-session-delete-confirm"
onCancel={onCancel}
onClose={onClose}
onConfirm={onConfirm}
/>
);
}
function SessionIdentity({ row }: { row: SessionRow }) {
return (
<div className="flex min-w-0 flex-1 flex-col">
@@ -282,9 +266,14 @@ function SessionRowTrailingActions({
onHandoffProfile: (profileId: string) => void;
actions: ReturnType<typeof useSessionActions>;
}) {
if (isConfirming) {
return (
<SessionDeleteInlineConfirmation
const { isMobile } = useResponsiveBreakpoint();
const triggerRef = useRef<HTMLButtonElement>(null);
return (
<>
<MobileSessionDeleteConfirmation
focusReturnRef={triggerRef}
open={isConfirming}
targetKey={`${taskId}:${row.id}`}
isPrimary={row.isPrimary}
isOnlySession={totalSessions === 1}
targetName={row.agentLabel}
@@ -292,19 +281,20 @@ function SessionRowTrailingActions({
onClose={onCancelDelete}
onConfirm={() => void actions.remove()}
/>
);
}
return (
<SessionActionsMenu
taskId={taskId}
state={row.state}
isPrimary={row.isPrimary}
onSetPrimary={() => void actions.setPrimary()}
onStop={() => void actions.stop()}
onResume={() => void actions.resume()}
onAskDelete={onAskDelete}
onHandoffProfile={onHandoffProfile}
/>
{(!isConfirming || isMobile) && (
<SessionActionsMenu
triggerRef={triggerRef}
taskId={taskId}
state={row.state}
isPrimary={row.isPrimary}
onSetPrimary={() => void actions.setPrimary()}
onStop={() => void actions.stop()}
onResume={() => void actions.resume()}
onAskDelete={onAskDelete}
onHandoffProfile={onHandoffProfile}
/>
)}
</>
);
}
@@ -596,7 +586,13 @@ export const MobileSessionsPicker = memo(function MobileSessionsPicker({
data-testid="mobile-sessions-pill"
ariaLabel={ariaLabel}
/>
<MobilePickerSheet open={open} onOpenChange={setOpen} title={t("task:sessions")}>
<MobilePickerSheet
key={taskId}
open={open}
onOpenChange={setOpen}
title={t("task:sessions")}
confirmationHost
>
<MobileSessionsList
taskId={taskId}
activeSessionId={effectiveSessionId}
@@ -3,6 +3,8 @@
import { IconTerminal2, IconX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { useTranslation } from "react-i18next";
import { useRef } from "react";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type { Terminal } from "@/hooks/domains/session/use-terminals";
import { TerminalCloseInlineConfirmation } from "../terminal-close-inline-confirmation";
@@ -31,6 +33,8 @@ export function MobileTerminalRow({
onConfirmClose,
}: MobileTerminalRowProps) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const closeRef = useRef<HTMLButtonElement>(null);
return (
<div
data-testid={`mobile-terminal-row-${terminal.id}`}
@@ -45,32 +49,39 @@ export function MobileTerminalRow({
>
<IconTerminal2 className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm truncate flex-1">{terminal.label}</span>
{isRunning && !isConfirming && (
{isRunning && (!isConfirming || isMobile) && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 leading-none">
{t("task:running")}
</span>
)}
</button>
{terminal.closable &&
(isConfirming ? (
{terminal.closable && (
<>
<TerminalCloseInlineConfirmation
open={isConfirming}
terminalId={terminal.id}
terminalLabel={terminal.label}
focusReturnRef={closeRef}
density="touch"
testId="mobile-terminal-close-confirmation"
onCancel={onCancelClose}
onClose={onClose}
onConfirm={onConfirmClose}
/>
) : (
<Button
variant="ghost"
size="icon-sm"
aria-label={t("task:close2", { label: terminal.label })}
className="h-11 min-h-11 w-11 min-w-11 transition-[color,background-color,transform] duration-100 active:scale-[0.96]"
onClick={() => onAskClose(terminal)}
>
<IconX className="h-4 w-4" aria-hidden="true" />
</Button>
))}
{(!isConfirming || isMobile) && (
<Button
ref={closeRef}
variant="ghost"
size="icon-sm"
aria-label={t("task:close2", { label: terminal.label })}
className="h-11 min-h-11 w-11 min-w-11 transition-[color,background-color,transform] duration-100 active:scale-[0.96]"
onClick={() => onAskClose(terminal)}
>
<IconX className="h-4 w-4" aria-hidden="true" />
</Button>
)}
</>
)}
</div>
);
}
@@ -184,7 +184,13 @@ export const MobileTerminalsPicker = memo(function MobileTerminalsPicker({
data-testid="mobile-terminals-pill"
ariaLabel={t("task:activeTerminalTapToSwitch", { label })}
/>
<MobilePickerSheet open={open} onOpenChange={setOpen} title={t("task:terminals")}>
<MobilePickerSheet
key={sessionId}
open={open}
onOpenChange={setOpen}
title={t("task:terminals")}
confirmationHost
>
<MobileTerminalsList sessionId={sessionId} onClose={() => setOpen(false)} />
</MobilePickerSheet>
</>
@@ -10,6 +10,10 @@ import type { MobileTaskListProps } from "@/components/task/mobile/session-task-
import { selectTaskFromSheet } from "@/components/task/mobile/session-task-switcher-sheet-selection";
import type { TaskSwitcherItem } from "@/components/task/task-switcher";
import type { TaskSession } from "@/lib/types/http";
import { useState } from "react";
import { DrawerTitle } from "@kandev/ui/drawer";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
import { TaskSwitcherDrawer } from "./task-switcher-drawer";
const mocks = vi.hoisted(() => ({
toggleSidebarGroupCollapsed: vi.fn(),
@@ -77,6 +81,45 @@ function task(id: string): TaskSwitcherItem {
};
}
it("keeps the Tasks drawer as the sole modal during confirmation and restores its list", () => {
const previousWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
function Tasks() {
const [confirm, setConfirm] = useState(false);
return (
<TaskSwitcherDrawer open onOpenChange={vi.fn()}>
<DrawerTitle>Tasks</DrawerTitle>
<input aria-label="Task filter" defaultValue="Work in progress" />
<button onClick={() => setConfirm(true)}>Archive target</button>
<MobileActionConfirmation
open={confirm}
onOpenChange={setConfirm}
targetKey="task"
title="Archive task?"
subject="Target"
cancelLabel="Cancel"
confirmLabel="Archive"
onConfirm={vi.fn()}
/>
</TaskSwitcherDrawer>
);
}
const view = render(<Tasks />);
try {
const drawer = screen.getByRole("dialog", { name: "Tasks" });
const input = screen.getByRole("textbox", { name: "Task filter" });
fireEvent.click(screen.getByRole("button", { name: "Archive target" }));
expect(screen.getByRole("dialog", { name: "Archive task?" })).toBe(drawer);
expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1);
expect(screen.queryByRole("textbox", { name: "Task filter" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.getByRole("textbox", { name: "Task filter" })).toBe(input);
} finally {
view.unmount();
Object.defineProperty(window, "innerWidth", { configurable: true, value: previousWidth });
}
});
describe("MobileTaskList", () => {
beforeEach(() => {
mocks.toggleSidebarGroupCollapsed.mockClear();
@@ -4,7 +4,8 @@ import { useCallback, useEffect, useMemo, useState, memo } from "react";
import { useTranslation } from "react-i18next";
import { IconCheck, IconNetwork, IconPlus } from "@tabler/icons-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
import { DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
import { TaskSwitcherDrawer } from "./task-switcher-drawer";
import { Button } from "@kandev/ui/button";
import { QuickChatSheetButton } from "./quick-chat-sheet-button";
import { TaskSwitcher } from "../task-switcher";
@@ -475,14 +476,14 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
const surface =
presentation === "drawer" ? (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerContent
onCloseAutoFocus={onCloseAutoFocus}
className="h-[88dvh] max-h-[88dvh] overflow-hidden pb-[max(0.5rem,env(safe-area-inset-bottom))]"
>
{surfaceContent}
</DrawerContent>
</Drawer>
<TaskSwitcherDrawer
key={workspaceId}
open={open}
onOpenChange={handleOpenChange}
onCloseAutoFocus={onCloseAutoFocus}
>
{surfaceContent}
</TaskSwitcherDrawer>
) : (
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
import { Drawer, DrawerContent } from "@kandev/ui/drawer";
import {
MobileConfirmationHost,
MobileConfirmationHostBody,
} from "@/components/confirmation/mobile-confirmation-host";
export function TaskSwitcherDrawer({
open,
onOpenChange,
onCloseAutoFocus,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onCloseAutoFocus?: (event: Event) => void;
children: ReactNode;
}) {
return (
<MobileConfirmationHost open={open} surface="drawer">
{({ contentProps }) => (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent
onCloseAutoFocus={onCloseAutoFocus}
{...contentProps}
className="h-[88dvh] max-h-[88dvh] overflow-hidden pb-[max(0.5rem,env(safe-area-inset-bottom))]"
>
<MobileConfirmationHostBody>{children}</MobileConfirmationHostBody>
</DrawerContent>
</Drawer>
)}
</MobileConfirmationHost>
);
}
@@ -116,6 +116,8 @@ function ParkedTerminalRow({
className="w-full px-1"
>
<TerminalCloseInlineConfirmation
terminalId={terminal.id}
terminalLabel={terminal.label}
onCancel={onCancelDestroy}
onClose={onCloseDestroy}
onConfirm={onConfirmDestroy}
@@ -4,6 +4,10 @@ import type { RefObject } from "react";
import { useTranslation } from "react-i18next";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import {
MobileActionConfirmation,
useConfirmationBoundary,
} from "@/components/confirmation/mobile-action-confirmation";
import type { SavedLayout } from "@/lib/types/http";
type SavedLayoutDeleteConfirmationProps = {
@@ -24,6 +28,8 @@ export function SavedLayoutDeleteConfirmation({
onConfirm,
}: SavedLayoutDeleteConfirmationProps) {
const { t } = useTranslation();
const { changed } = useConfirmationBoundary(open, layout?.id ?? "", onOpenChange);
if (changed) return null;
if (!layout) return null;
const title = t("task:deleteLayoutConfirm", { name: layout.name });
@@ -31,21 +37,31 @@ export function SavedLayoutDeleteConfirmation({
? t("task:theBuiltInDefaultLayoutWill")
: t("task:thisSavedLayoutWillBePermanently");
const actions = {
open,
title,
description,
cancelLabel: t("common:cancel"),
confirmLabel: t("task:delete"),
confirmAriaLabel: t("task:delete2", { name: layout.name }),
confirmTestId: "layout-saved-delete-confirm",
onOpenChange,
onConfirm: () => onConfirm(layout.id),
};
return (
<ActionConfirmPopover
open={open}
anchorRef={anchorRef}
focusBoundaryRef={focusBoundaryRef}
title={title}
description={description}
cancelLabel={t("common:cancel")}
confirmLabel={t("task:delete")}
confirmAriaLabel={t("task:delete2", { name: layout.name })}
confirmTestId="layout-saved-delete-confirm"
testId="layout-saved-delete-confirm-popover"
confirmationBoundary
onOpenChange={onOpenChange}
onConfirm={() => onConfirm(layout.id)}
<MobileActionConfirmation
{...actions}
targetKey={layout.id}
focusReturnRef={anchorRef}
fallback={
<ActionConfirmPopover
{...actions}
anchorRef={anchorRef}
focusBoundaryRef={focusBoundaryRef}
testId="layout-saved-delete-confirm-popover"
confirmationBoundary
/>
}
/>
);
}
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } 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 { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types";
import { SidebarFilterPopover } from "./sidebar-filter-popover";
@@ -6,6 +6,7 @@ import { SidebarFilterPopover } from "./sidebar-filter-popover";
const responsive = vi.hoisted(() => ({
usesDesktopWorkbench: true,
isFinePointer: true,
isMobile: false,
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
@@ -66,6 +67,7 @@ afterEach(() => {
state.sidebarViews.activeViewId = VIEW.id;
responsive.usesDesktopWorkbench = true;
responsive.isFinePointer = true;
responsive.isMobile = false;
});
describe("SidebarFilterPopover task-row editor", () => {
@@ -95,6 +97,7 @@ describe("SidebarFilterPopover task-row editor", () => {
});
it("keeps deletion touch-reachable in a phone drawer with a fine pointer", async () => {
responsive.isMobile = true;
responsive.usesDesktopWorkbench = false;
responsive.isFinePointer = true;
state.sidebarViews.views = [VIEW, SECOND_VIEW];
@@ -107,11 +110,19 @@ describe("SidebarFilterPopover task-row editor", () => {
);
const deleteButton = screen.getByTestId("view-delete-button");
const drawer = screen.getByTestId("sidebar-filter-drawer");
const dialogId = drawer.id;
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();
expect(screen.getByRole("dialog", { name: "Delete All tasks?" }).id).toBe(dialogId);
expect(document.querySelectorAll('[data-slot="drawer-content"]')).toHaveLength(1);
expect(deleteButton.isConnected).toBe(true);
fireEvent.click(within(drawer).getByRole("button", { name: "Back" }));
await waitFor(() => expect(document.activeElement).toBe(deleteButton));
expect(state.deleteSidebarView).not.toHaveBeenCalled();
});
it("keeps view settings collapsed until the user opens them", () => {
@@ -20,6 +20,10 @@ import {
type SavedTaskViewDeleteTarget,
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
import { sidebarViewName } from "@/lib/state/slices/ui/sidebar-view-builtins";
import {
MobileConfirmationHost,
MobileConfirmationHostBody,
} from "@/components/confirmation/mobile-confirmation-host";
type Props = {
trigger: React.ReactNode;
@@ -207,28 +211,36 @@ function MobileSidebarFilterSurface({
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>{title}</DrawerTitle>
</DrawerHeader>
<div
data-testid="sidebar-filter-popover"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[calc(1rem+env(safe-area-inset-bottom))]"
<MobileConfirmationHost open={open} surface="drawer">
{({ contentProps }) => (
<Drawer
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) deletion.close();
onOpenChange(nextOpen);
}}
>
{editor}
</div>
</DrawerContent>
</Drawer>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent
aria-describedby={undefined}
{...contentProps}
data-testid="sidebar-filter-drawer"
className="h-[min(90dvh,48rem)] max-h-[calc(100dvh-1rem)] overflow-hidden rounded-t-xl"
>
<MobileConfirmationHostBody>
<DrawerHeader className="shrink-0 border-b px-4 pb-3 pt-5 text-left">
<DrawerTitle>{title}</DrawerTitle>
</DrawerHeader>
<div
data-testid="sidebar-filter-popover"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[calc(1rem+env(safe-area-inset-bottom))]"
>
{editor}
</div>
</MobileConfirmationHostBody>
</DrawerContent>
</Drawer>
)}
</MobileConfirmationHost>
);
}
@@ -13,6 +13,7 @@ import { Button } from "@kandev/ui/button";
import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types";
import { useTranslation } from "react-i18next";
import { sidebarViewName } from "@/lib/state/slices/ui/sidebar-view-builtins";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
type HeaderMode = "view" | "rename" | "saveAs";
@@ -33,6 +34,7 @@ type HeaderProps = {
};
export function ViewHeaderRow(props: HeaderProps) {
const { isMobile } = useResponsiveBreakpoint();
const { t } = useTranslation();
const [mode, setMode] = useState<HeaderMode>("view");
const [nameDraft, setNameDraft] = useState("");
@@ -81,41 +83,44 @@ export function ViewHeaderRow(props: HeaderProps) {
setEditingViewId(null);
}, [activeViewId, editingViewId, mode]);
if (props.deleteConfirmation) {
if (props.deleteConfirmation && !isMobile) {
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">
<span className="text-muted-foreground">
{mode === "saveAs" ? t("task:saveAs") : t("task:view")}
</span>
{isEditing ? (
<NameInput
mode={mode}
value={nameDraft}
onChange={setNameDraft}
onSubmit={submit}
onCancel={exit}
/>
) : (
<NameDisplay activeView={props.activeView} hasDraft={props.hasDraft} />
)}
<>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-1 items-center gap-2 text-xs">
<span className="text-muted-foreground">
{mode === "saveAs" ? t("task:saveAs") : t("task:view")}
</span>
{isEditing ? (
<NameInput
mode={mode}
value={nameDraft}
onChange={setNameDraft}
onSubmit={submit}
onCancel={exit}
/>
) : (
<NameDisplay activeView={props.activeView} hasDraft={props.hasDraft} />
)}
</div>
<div className="flex items-center gap-1">
{isEditing ? (
<EditingActions
mode={mode}
canSubmit={!!nameDraft.trim()}
onSubmit={submit}
onCancel={exit}
/>
) : (
<ViewActions {...props} onRename={enterRename} onSaveAs={enterSaveAs} />
)}
</div>
</div>
<div className="flex items-center gap-1">
{isEditing ? (
<EditingActions
mode={mode}
canSubmit={!!nameDraft.trim()}
onSubmit={submit}
onCancel={exit}
/>
) : (
<ViewActions {...props} onRename={enterRename} onSaveAs={enterSaveAs} />
)}
</div>
</div>
{isMobile ? props.deleteConfirmation : null}
</>
);
}
@@ -190,6 +190,7 @@ export function ParentPicker({ task }: ParentPickerProps) {
testId="parent-picker-trigger"
/>
<TaskDetachConfirmationSurface
taskId={task.id}
open={detachConfirmation.open}
anchorRef={detachAnchorRef}
taskTitle={task.title}
@@ -140,6 +140,13 @@ export function TaskActionsMenuDialogs({
const editBoardRow = useLastResolvedBoardRow(taskId, boardRow);
if (!taskId) return null;
const linkDialogTask = buildLinkDialogTask(taskId, taskTitle, boardRow);
const confirmationTarget = {
taskId,
taskTitle,
anchorRef: menu.triggerRef,
focusReturnRef: menu.triggerRef,
restoreFocusOnConfirm: true,
};
return (
<>
@@ -155,12 +162,8 @@ export function TaskActionsMenuDialogs({
/>
)}
<TaskArchiveConfirmation
{...confirmationTarget}
open={menu.showArchiveConfirm}
anchorRef={menu.triggerRef}
focusReturnRef={menu.triggerRef}
restoreFocusOnConfirm
taskTitle={taskTitle}
taskId={taskId}
executorType={boardRow?.primaryExecutorType ?? subjectExecutorType}
isArchiving={isArchiving}
onOpenChange={menu.setShowArchiveConfirm}
@@ -177,11 +180,8 @@ export function TaskActionsMenuDialogs({
focusReturnRef={menu.triggerRef}
/>
<TaskDetachConfirmationSurface
{...confirmationTarget}
open={menu.showDetachConfirm}
anchorRef={menu.triggerRef}
focusReturnRef={menu.triggerRef}
restoreFocusOnConfirm
taskTitle={taskTitle}
sharesParentWorkspace={boardRow?.workspaceMode === "inherit_parent"}
onOpenChange={menu.setShowDetachConfirm}
onConfirm={menu.handleDetachConfirm}
@@ -28,6 +28,7 @@ import {
stopDialogPropagation,
} from "./task-confirm-dialog-shared";
import { useTranslation } from "react-i18next";
import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation";
type TaskArchiveConfirmDialogProps = {
open: boolean;
@@ -49,7 +50,7 @@ type TaskArchiveConfirmDialogProps = {
executorType?: string | null;
/** Executor types of the tasks being archived (bulk). */
executorTypes?: Array<string | null | undefined>;
onConfirm: (opts: { cascade: boolean }) => void;
onConfirm: (opts: { cascade: boolean }) => void | Promise<void>;
confirmTestId?: string;
/** Preflight result supplied by the local confirmation adapter. */
subtaskClassification?: SubtaskCountResult;
@@ -110,6 +111,55 @@ function isArchiveActionDisabled(
);
}
function archiveCleanup({
isBulkOperation,
executorTypes,
executorType,
}: Pick<TaskArchiveConfirmDialogProps, "isBulkOperation" | "executorTypes" | "executorType">) {
return isBulkOperation
? getBulkCleanupSummary(executorTypes ?? [])
: getCleanupSummary(executorType);
}
function ArchiveOptions({
inFlight,
bulkCount,
subtaskCount,
cascade,
setCascade,
disabled,
}: {
inFlight: boolean;
bulkCount?: number;
subtaskCount: number;
cascade: boolean;
setCascade: (value: boolean) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
return (
<>
{inFlight && <StillWorkingWarning count={bulkCount} />}
{subtaskCount > 0 && (
<label className="flex min-h-11 cursor-pointer items-start gap-2 text-sm md:min-h-0">
<Checkbox
checked={cascade}
onCheckedChange={(v) => setCascade(v === true)}
disabled={disabled}
data-testid="archive-cascade-checkbox"
/>
<span>
{t("task:alsoArchiveSubtasks", { count: subtaskCount })}
<span className="block text-sm text-muted-foreground">
{t("task:subtasksStayActiveUnlessYouTick")}
</span>
</span>
</label>
)}
</>
);
}
// The legacy cascade dialog intentionally keeps its state, preference bypass,
// and cleanup copy in one boundary.
// eslint-disable-next-line max-lines-per-function
@@ -140,9 +190,7 @@ export function TaskArchiveConfirmDialog({
const firstLine = isBulkOperation
? t("task:archiveTasksConfirm", { count: safeCount })
: t("task:archiveTaskConfirm", { taskTitle });
const cleanup = isBulkOperation
? getBulkCleanupSummary(executorTypes ?? [])
: getCleanupSummary(executorType);
const cleanup = archiveCleanup({ isBulkOperation, executorTypes, executorType });
const [cascade, setCascade] = useState(false);
const confirmedRef = useRef(false);
@@ -179,7 +227,23 @@ export function TaskArchiveConfirmDialog({
if (!requiresConfirmation) return null;
return (
const description = (
<div className="space-y-3">
<p data-testid="task-confirmation-outcome">{firstLine}</p>
<TaskCleanupConsequences summary={cleanup} />
</div>
);
const options = (
<ArchiveOptions
inFlight={taskIsInFlight}
bulkCount={isBulkOperation ? safeCount : undefined}
subtaskCount={subtaskCount}
cascade={cascade}
setCascade={setCascade}
disabled={isArchiving}
/>
);
const desktop = (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogContent
size="lg"
@@ -196,30 +260,9 @@ export function TaskArchiveConfirmDialog({
</AlertDialogHeader>
<div data-testid="task-confirmation-body" className={TASK_CONFIRM_BODY_CLASS}>
<AlertDialogDescription asChild className="text-left text-sm leading-6">
<div className="space-y-3">
<p data-testid="task-confirmation-outcome">{firstLine}</p>
<TaskCleanupConsequences summary={cleanup} />
</div>
{description}
</AlertDialogDescription>
{taskIsInFlight && (
<StillWorkingWarning count={isBulkOperation ? safeCount : undefined} />
)}
{subtaskCount > 0 && (
<label className="flex cursor-pointer items-start gap-2 text-sm">
<Checkbox
checked={cascade}
onCheckedChange={(v) => setCascade(v === true)}
disabled={isArchiving}
data-testid="archive-cascade-checkbox"
/>
<span>
{t("task:alsoArchiveSubtasks", { count: subtaskCount })}
<span className="block text-sm text-muted-foreground">
{t("task:subtasksStayActiveUnlessYouTick")}
</span>
</span>
</label>
)}
{options}
</div>
<AlertDialogFooter className={TASK_CONFIRM_FOOTER_CLASS}>
<AlertDialogCancel className={TASK_CONFIRM_ACTION_CLASS}>
@@ -244,4 +287,24 @@ export function TaskArchiveConfirmDialog({
</AlertDialogContent>
</AlertDialog>
);
return (
<MobileActionConfirmation
open={open}
targetKey={taskId ?? taskIds?.join(",") ?? "archive"}
onOpenChange={handleOpenChange}
focusReturnRef={focusReturnRef}
title={title}
description={description}
variant="default"
cancelLabel={t("common:cancel")}
confirmLabel={t("task:archive")}
confirmTestId={confirmTestId}
confirmDisabled={archiveDisabled}
onConfirm={() => onConfirm({ cascade })}
fallback={desktop}
>
{classification.status === "loading" && <p role="status">{t("common:loading")}</p>}
{options}
</MobileActionConfirmation>
);
}
@@ -4,7 +4,7 @@ import { useCallback, useRef, useState, type ReactNode } from "react";
import { StateProvider } from "@/components/state-provider";
const getSubtaskCountMock = vi.hoisted(() => vi.fn());
const pointerState = vi.hoisted(() => ({ isFinePointer: false }));
const pointerState = vi.hoisted(() => ({ isFinePointer: false, isMobile: false }));
const CONFIRM_TEST_ID = "archive-task-confirm";
const INLINE_CONFIRMATION_TEST_ID = "task-archive-inline-confirmation";
const CLEANUP_EFFECTS_TEST_ID = "task-cleanup-effects";
@@ -22,10 +22,48 @@ import { TaskArchiveConfirmation } from "./task-archive-confirmation";
afterEach(cleanup);
beforeEach(() => {
pointerState.isMobile = false;
pointerState.isFinePointer = false;
getSubtaskCountMock.mockReset();
});
describe("TaskArchiveConfirmation phone surfaces", () => {
it.each([false, true])("uses a stable phone sheet even with a fine pointer: %s", async (fine) => {
pointerState.isMobile = true;
pointerState.isFinePointer = fine;
const count = deferredSubtaskCount();
getSubtaskCountMock.mockReturnValue(count.promise);
const onConfirm = vi.fn();
renderConfirmation(onConfirm);
const sheet = screen.getByRole("dialog", { name: /Archive task/ });
expect(screen.getByTestId(CONFIRM_TEST_ID).hasAttribute("disabled")).toBe(true);
expect(screen.queryByTestId(INLINE_CONFIRMATION_TEST_ID)).toBeNull();
await act(async () => count.resolve({ count: 2 }));
expect(screen.getByRole("dialog", { name: /Archive task/ })).toBe(sheet);
const cascade = screen.getByRole("checkbox");
expect(cascade.getAttribute("data-state")).toBe("unchecked");
fireEvent.click(cascade);
fireEvent.click(screen.getByTestId(CONFIRM_TEST_ID));
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith({ cascade: true }));
expect(onConfirm).toHaveBeenCalledOnce();
});
it("keeps forced phone archive in a bottom sheet and cancels delayed classification", async () => {
pointerState.isMobile = true;
const count = deferredSubtaskCount();
getSubtaskCountMock.mockReturnValue(count.promise);
const onConfirm = vi.fn();
const onOpenChange = vi.fn();
renderConfirmation(onConfirm, onOpenChange, true);
expect(screen.getByRole("dialog").getAttribute("data-slot")).toBe("drawer-content");
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await act(async () => count.resolve({ count: 3 }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(onConfirm).not.toHaveBeenCalled();
});
});
function ConfirmationHarness({
onConfirm,
onOpenChange,
@@ -38,17 +76,26 @@ function ConfirmationHarness({
renderInline?: (content: ReactNode) => ReactNode;
}) {
const anchorRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(true);
return (
<>
<button ref={anchorRef} type="button" data-testid="archive-anchor">
<button
ref={anchorRef}
type="button"
data-testid="archive-anchor"
onClick={() => setOpen(true)}
>
Archive source
</button>
<button type="button" data-testid="outside-action">
Outside action
</button>
<TaskArchiveConfirmation
open
onOpenChange={onOpenChange}
open={open}
onOpenChange={(next) => {
setOpen(next);
onOpenChange(next);
}}
anchorRef={anchorRef}
taskId="task-1"
taskTitle="Task One"
@@ -260,7 +307,11 @@ describe("TaskArchiveConfirmation classification", () => {
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onOpenChange).toHaveBeenCalledWith(false);
fireEvent.click(archive);
fireEvent.click(screen.getByTestId("archive-anchor"));
await waitFor(() =>
expect(screen.getByTestId(CONFIRM_TEST_ID).hasAttribute("disabled")).toBe(false),
);
fireEvent.click(screen.getByTestId(CONFIRM_TEST_ID));
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith({ cascade: false }));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { useAppStore } from "@/components/state-provider";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useConfirmationBoundary } from "@/components/confirmation/mobile-action-confirmation";
import { useSubtaskCountState, type SubtaskCountResult } from "@/hooks/use-subtask-count";
import { useTaskInFlight } from "@/hooks/use-task-in-flight";
import { getCleanupSummary, type CleanupSummary } from "./task-cleanup-summary";
@@ -227,6 +227,7 @@ type ArchiveConfirmationContentProps = ArchiveDialogProps & {
confirmTaskArchive: boolean;
forceDialog: boolean;
isFinePointer: boolean;
isMobile: boolean;
taskIsInFlight: boolean;
anchorRef: RefObject<HTMLElement | null>;
focusReturnRef?: RefObject<HTMLElement | null>;
@@ -239,6 +240,7 @@ function ArchiveConfirmationContent({
confirmTaskArchive,
forceDialog,
isFinePointer,
isMobile,
taskIsInFlight,
anchorRef,
focusReturnRef,
@@ -250,6 +252,7 @@ function ArchiveConfirmationContent({
}: ArchiveConfirmationContentProps) {
const { t } = useTranslation();
const shouldUseDialog =
isMobile ||
forceDialog ||
!confirmTaskArchive ||
dialogProps.isBulkOperation ||
@@ -340,19 +343,24 @@ export function TaskArchiveConfirmation({
renderInline,
forceDialog = false,
}: TaskArchiveConfirmationProps) {
const { isFinePointer } = useResponsiveBreakpoint();
const { isFinePointer, isMobile, changed } = useConfirmationBoundary(
open,
taskId ?? taskIds?.join(",") ?? "archive",
onOpenChange,
);
const confirmTaskArchive = useAppStore((state) => state.userSettings?.confirmTaskArchive ?? true);
const classification = useSubtaskCountState(open && confirmTaskArchive, taskId, taskIds);
const storeInFlight = useTaskInFlight(taskId, taskIds, open && confirmTaskArchive);
const taskIsInFlight = Boolean(isInFlight) || storeInFlight;
if (!open) return null;
if (!open || changed) return null;
return (
<ArchiveConfirmationContent
confirmTaskArchive={confirmTaskArchive}
forceDialog={forceDialog}
isFinePointer={isFinePointer}
isMobile={isMobile}
taskIsInFlight={taskIsInFlight}
anchorRef={anchorRef}
focusReturnRef={focusReturnRef}
@@ -6,9 +6,43 @@ import {
TaskDetachConfirmPopover,
TaskDetachInlineConfirmation,
TaskDetachTargetConfirmDialog,
TaskDetachConfirmationSurface,
} from "./task-detach-confirm-dialog";
afterEach(cleanup);
afterEach(() => {
cleanup();
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
});
it("routes phone detach to a sheet with the named hierarchy-only consequence", async () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
const onConfirm = vi.fn();
function Phone() {
const [open, setOpen] = useState(true);
const anchorRef = useRef<HTMLButtonElement>(null);
return (
<>
<button ref={anchorRef}>Child task</button>
<TaskDetachConfirmationSurface
taskId="child"
open={open}
anchorRef={anchorRef}
taskTitle="Child task"
sharesParentWorkspace
onOpenChange={setOpen}
onConfirm={onConfirm}
/>
</>
);
}
render(<Phone />);
const sheet = await screen.findByRole("dialog", { name: "Detach task from parent?" });
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
expect(sheet.textContent).toContain("Child task");
expect(sheet.textContent).toContain("shares its parent's workspace");
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onConfirm).not.toHaveBeenCalled();
});
function PopoverHarness({ onConfirm = vi.fn() }: { onConfirm?: () => void | Promise<void> }) {
const [open, setOpen] = useState(true);

Some files were not shown because too many files have changed in this diff Show More