From 3f33ce0451a0d5ce946e2cbc40a8bf234c1b2f15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:58:49 +0200 Subject: [PATCH] feat(ui): use native mobile confirmation sheets (#3621) --- apps/packages/ui/src/command.tsx | 9 +- apps/web/AGENTS.md | 2 +- apps/web/app/gitlab/gitlab-page-client.tsx | 37 +-- .../azure-devops-settings.test.tsx | 21 +- .../azure-devops/azure-devops-settings.tsx | 82 +++-- .../azure-devops-watch-settings.tsx | 2 + .../command-panel-confirmation.test.tsx | 57 ++++ apps/web/components/command-panel-dialog.tsx | 23 ++ apps/web/components/command-panel-footer.tsx | 13 +- apps/web/components/confirmation/AGENTS.md | 23 ++ .../mobile-action-confirmation.test.tsx | 171 ++++++++++ .../mobile-action-confirmation.tsx | 163 +++++++++ .../mobile-confirmation-content.test.tsx | 98 ++++++ .../mobile-confirmation-content.tsx | 131 ++++++++ .../mobile-confirmation-host.test.tsx | 211 ++++++++++++ .../confirmation/mobile-confirmation-host.tsx | 163 +++++++++ ...ved-task-view-delete-confirmation.test.tsx | 18 +- .../saved-task-view-delete-confirmation.tsx | 78 ++--- .../use-mobile-confirmation-submit.ts | 58 ++++ ...use-saved-task-view-delete-confirmation.ts | 29 ++ .../github/my-github/mobile-views-picker.tsx | 103 +++--- .../github/my-github/presets-sidebar.test.tsx | 21 +- .../github/my-github/presets-sidebar.tsx | 18 +- .../gitlab/my-gitlab/presets-sidebar.test.tsx | 81 +++-- .../gitlab/my-gitlab/presets-sidebar.tsx | 55 +++- .../integration-filters-sheet.tsx | 75 +++++ .../integrations/presets-scope-bar-base.tsx | 49 +-- .../presets-scope-bar-mobile.test.tsx | 50 +++ .../components/jira/jira-action-bar.test.tsx | 36 ++ apps/web/components/jira/jira-action-bar.tsx | 83 +++-- .../jira/jira-issue-watch-table.tsx | 2 + apps/web/components/jira/jira-settings.tsx | 1 + .../jira/my-jira/list-toolbar.test.tsx | 33 +- .../components/jira/my-jira/list-toolbar.tsx | 29 +- apps/web/components/kanban-card.tsx | 1 + .../linear/linear-issue-watch-table.tsx | 2 + .../linear/linear-settings.test.tsx | 30 +- .../web/components/linear/linear-settings.tsx | 93 ++++-- .../quick-chat/quick-chat-modal.test.tsx | 29 +- .../quick-chat/quick-chat-modal.tsx | 85 +++-- .../walkthrough-discard-confirmation.tsx | 48 +++ .../review/walkthrough-overlay.test.tsx | 11 +- .../components/review/walkthrough-overlay.tsx | 35 +- .../sentry/sentry-instance-card.test.tsx | 26 +- .../sentry/sentry-instance-card.tsx | 82 +++-- .../sentry/sentry-issue-watch-table.tsx | 2 + .../agent-profile-delete-dialog.test.tsx | 20 +- .../settings/agent-profile-delete-dialog.tsx | 57 ++-- .../settings/agent-profile-page.tsx | 24 +- .../agents/agent-profiles-section.tsx | 35 +- ...ayout-profile-delete-confirmation.test.tsx | 51 +++ .../layout-profile-delete-confirmation.tsx | 63 ++-- .../settings/plugins/plugin-detail.tsx | 3 +- .../settings/plugins/plugin-row.tsx | 67 ++-- .../plugins/uninstall-plugin-dialog.test.tsx | 13 +- .../plugins/uninstall-plugin-dialog.tsx | 54 +-- .../settings/prompt-delete-confirmation.tsx | 61 ++-- .../settings/prompt-row-actions.tsx | 23 +- .../settings/prompts-settings.test.tsx | 27 +- .../components/settings/prompts-settings.tsx | 26 +- .../settings/secrets-delete-dialog.tsx | 57 ++-- .../settings/secrets-list-item-row.test.tsx | 16 +- .../settings/secrets-list-item-row.tsx | 8 +- .../settings/system/users-table.test.tsx | 7 +- .../settings/system/users-table.tsx | 47 ++- .../settings/workflow-sync-dialog.test.tsx | 84 ++++- .../settings/workflow-sync-dialog.tsx | 257 ++++++++++----- .../settings/workflow-sync-section.tsx | 7 +- .../task/chat/chat-input-toolbar-mobile.tsx | 5 +- .../task/chat/chat-input-toolbar.test.tsx | 33 +- .../task/chat/reset-context-button.test.tsx | 20 ++ .../task/chat/reset-context-button.tsx | 90 ++--- .../components/task/file-browser-parts.tsx | 17 +- .../task/file-browser-responsive.test.tsx | 16 +- apps/web/components/task/file-browser.tsx | 1 + .../task/file-context-menu.test.tsx | 56 +++- .../web/components/task/file-context-menu.tsx | 86 ++++- .../components/task/file-delete-action.tsx | 1 + .../task/layout-preset-selector.test.tsx | 24 +- .../task/layout-preset-selector.tsx | 46 ++- .../task/mobile/mobile-picker-sheet.tsx | 55 +++- .../mobile-session-delete-confirmation.tsx | 65 ++++ .../mobile/mobile-sessions-section.test.tsx | 27 ++ .../task/mobile/mobile-sessions-section.tsx | 108 +++--- .../task/mobile/mobile-terminal-row.tsx | 39 ++- .../task/mobile/mobile-terminals-section.tsx | 8 +- .../session-task-switcher-sheet.test.tsx | 43 +++ .../mobile/session-task-switcher-sheet.tsx | 19 +- .../task/mobile/task-switcher-drawer.tsx | 34 ++ .../components/task/parked-terminals-menu.tsx | 2 + .../task/saved-layout-delete-confirmation.tsx | 44 ++- .../sidebar-filter-popover.test.tsx | 13 +- .../sidebar-filter/sidebar-filter-popover.tsx | 56 ++-- .../task/sidebar-filter/view-manager.tsx | 65 ++-- .../task/simple/components/parent-picker.tsx | 1 + .../task/task-actions-menu-dialogs.tsx | 18 +- .../task/task-archive-confirm-dialog.tsx | 119 +++++-- .../task/task-archive-confirmation.test.tsx | 61 +++- .../task/task-archive-confirmation.tsx | 14 +- .../task/task-detach-confirm-dialog.test.tsx | 36 +- .../task/task-detach-confirm-dialog.tsx | 71 ++-- ...ask-plan-revision-restore-actions.test.tsx | 80 +++++ .../task-plan-revision-restore-actions.tsx | 42 ++- .../components/task/task-plan-revisions.tsx | 94 ++++-- .../task-switcher-archive-confirmation.tsx | 21 +- .../task/task-switcher-context-menu.test.tsx | 18 +- .../task/task-switcher-context-menu.tsx | 7 +- ...erminal-close-inline-confirmation.test.tsx | 78 +++++ .../terminal-close-inline-confirmation.tsx | 30 +- .../threads/threads-view-controls.test.tsx | 19 +- .../threads/threads-view-controls.tsx | 188 ++++++----- .../threads/threads-view-editor.tsx | 9 +- .../watches/watcher-delete-action.test.tsx | 34 ++ .../watches/watcher-delete-action.tsx | 98 +++--- apps/web/e2e/helpers/mobile-confirmations.ts | 28 ++ .../auth/mobile-users-self-actions.spec.ts | 14 +- .../mobile-reset-context-confirmation.spec.ts | 13 +- .../github/mobile-github-sidebar.spec.ts | 11 +- .../tests/gitlab/mobile-gitlab-parity.spec.ts | 102 ++++++ .../integrations/mobile-azure-devops.spec.ts | 7 +- ...e-integration-remove-confirmations.spec.ts | 35 +- .../mobile-jira-saved-view.spec.ts | 13 +- ...obile-watcher-delete-confirmations.spec.ts | 23 +- .../mobile-card-archive-confirmation.spec.ts | 28 +- .../mobile-plugin-settings-row.spec.ts | 33 +- .../pr/mobile-ci-automation-options.spec.ts | 7 +- .../tests/review/mobile-walkthrough.spec.ts | 3 +- .../tests/session/copyfiles-progress.spec.ts | 13 +- .../session/mobile-session-deletion.spec.ts | 21 +- .../mobile-agent-profile-delete.spec.ts | 15 +- .../settings/mobile-layout-profiles.spec.ts | 9 +- .../mobile-management-confirmations.spec.ts | 124 +++++++ .../settings/mobile-prompts-settings.spec.ts | 9 +- .../settings/mobile-secrets-delete.spec.ts | 14 +- .../task/confirmation-text-hierarchy.spec.ts | 29 ++ .../e2e/tests/task/file-tree-search.spec.ts | 7 +- .../task/mobile-action-confirmations.spec.ts | 268 +++++++++++++++ .../task/mobile-archive-task-redirect.spec.ts | 2 +- ...mobile-confirmation-text-hierarchy.spec.ts | 13 +- .../task/mobile-content-confirmations.spec.ts | 34 ++ .../task/mobile-file-confirmations.spec.ts | 29 +- .../tests/task/mobile-plan-restore.spec.ts | 16 +- .../task/mobile-sidebar-task-actions.spec.ts | 47 ++- .../tests/task/mobile-sidebar-views.spec.ts | 22 +- .../task/mobile-subtask-detachment.spec.ts | 10 +- .../tests/task/mobile-threads-view.spec.ts | 65 +++- .../terminal/mobile-terminal-close.spec.ts | 23 +- ...2026-09-10-mobile-confirmation-surfaces.md | 47 +++ docs/decisions/INDEX.md | 1 + .../plans/mobile-action-confirmations/plan.md | 210 ++++++++++++ .../task-01-shared-mobile-surfaces.md | 119 +++++++ .../task-02-archive-surfaces.md | 144 ++++++++ .../task-03-picker-confirmations.md | 117 +++++++ .../task-04-content-confirmations.md | 129 ++++++++ .../task-05-saved-artifact-confirmations.md | 129 ++++++++ .../task-06-management-confirmations.md | 164 ++++++++++ .../mobile-confirmation-sheet-sizing/plan.md | 278 ++++++++++++++++ .../task-01-compact-drawer-hosts.md | 167 ++++++++++ .../task-02-bottom-filter-hosts.md | 150 +++++++++ docs/public/mobile-remote-access.md | 37 +++ docs/public/tasks-and-workflows.md | 4 + .../system-design/archive-confirmation.md | 9 +- docs/specs/ui/README.md | 2 + .../confirmation-warning-hierarchy.md | 56 ++-- .../mobile-action-confirmations.md | 172 ++++++++++ .../saved-task-view-deletion-confirmation.md | 20 +- .../session-tab-delete-feedback.md | 18 +- .../requirements/terminal-close-feedback.md | 36 +- .../confirmation-warning-hierarchy.md | 79 +++-- .../mobile-action-confirmations.md | 309 ++++++++++++++++++ .../saved-task-view-deletion-confirmation.md | 27 +- 171 files changed, 7570 insertions(+), 1428 deletions(-) create mode 100644 apps/web/components/command-panel-confirmation.test.tsx create mode 100644 apps/web/components/command-panel-dialog.tsx create mode 100644 apps/web/components/confirmation/AGENTS.md create mode 100644 apps/web/components/confirmation/mobile-action-confirmation.test.tsx create mode 100644 apps/web/components/confirmation/mobile-action-confirmation.tsx create mode 100644 apps/web/components/confirmation/mobile-confirmation-content.test.tsx create mode 100644 apps/web/components/confirmation/mobile-confirmation-content.tsx create mode 100644 apps/web/components/confirmation/mobile-confirmation-host.test.tsx create mode 100644 apps/web/components/confirmation/mobile-confirmation-host.tsx create mode 100644 apps/web/components/confirmation/use-mobile-confirmation-submit.ts create mode 100644 apps/web/components/integrations/integration-filters-sheet.tsx create mode 100644 apps/web/components/integrations/presets-scope-bar-mobile.test.tsx create mode 100644 apps/web/components/jira/jira-action-bar.test.tsx create mode 100644 apps/web/components/review/walkthrough-discard-confirmation.tsx create mode 100644 apps/web/components/settings/layouts/layout-profile-delete-confirmation.test.tsx create mode 100644 apps/web/components/task/mobile/mobile-session-delete-confirmation.tsx create mode 100644 apps/web/components/task/mobile/task-switcher-drawer.tsx create mode 100644 apps/web/components/task/task-plan-revision-restore-actions.test.tsx create mode 100644 apps/web/components/task/terminal-close-inline-confirmation.test.tsx create mode 100644 apps/web/components/watches/watcher-delete-action.test.tsx create mode 100644 apps/web/e2e/helpers/mobile-confirmations.ts create mode 100644 apps/web/e2e/tests/settings/mobile-management-confirmations.spec.ts create mode 100644 apps/web/e2e/tests/task/mobile-action-confirmations.spec.ts create mode 100644 apps/web/e2e/tests/task/mobile-content-confirmations.spec.ts create mode 100644 docs/decisions/2026-09-10-mobile-confirmation-surfaces.md create mode 100644 docs/plans/mobile-action-confirmations/plan.md create mode 100644 docs/plans/mobile-action-confirmations/task-01-shared-mobile-surfaces.md create mode 100644 docs/plans/mobile-action-confirmations/task-02-archive-surfaces.md create mode 100644 docs/plans/mobile-action-confirmations/task-03-picker-confirmations.md create mode 100644 docs/plans/mobile-action-confirmations/task-04-content-confirmations.md create mode 100644 docs/plans/mobile-action-confirmations/task-05-saved-artifact-confirmations.md create mode 100644 docs/plans/mobile-action-confirmations/task-06-management-confirmations.md create mode 100644 docs/plans/mobile-confirmation-sheet-sizing/plan.md create mode 100644 docs/plans/mobile-confirmation-sheet-sizing/task-01-compact-drawer-hosts.md create mode 100644 docs/plans/mobile-confirmation-sheet-sizing/task-02-bottom-filter-hosts.md create mode 100644 docs/specs/ui/requirements/mobile-action-confirmations.md create mode 100644 docs/specs/ui/system-design/mobile-action-confirmations.md diff --git a/apps/packages/ui/src/command.tsx b/apps/packages/ui/src/command.tsx index 3b44ae1f54..b91ee96202 100644 --- a/apps/packages/ui/src/command.tsx +++ b/apps/packages/ui/src/command.tsx @@ -40,6 +40,7 @@ function CommandDialog({ className, showCloseButton = false, overlayClassName, + contentProps, ...props }: React.ComponentProps & { 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; }) { return ( - + {title} {description} diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 600e934b49..6df9c00856 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -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) diff --git a/apps/web/app/gitlab/gitlab-page-client.tsx b/apps/web/app/gitlab/gitlab-page-client.tsx index 3232085bc3..48840ab201 100644 --- a/apps/web/app/gitlab/gitlab-page-client.tsx +++ b/apps/web/app/gitlab/gitlab-page-client.tsx @@ -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 ( <> - - - - {t("gitlab:filters")} - - - - + + + ({ })); 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( + + + , + ); + 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); diff --git a/apps/web/components/azure-devops/azure-devops-settings.tsx b/apps/web/components/azure-devops/azure-devops-settings.tsx index 20c139c3da..b7b05cd64c 100644 --- a/apps/web/components/azure-devops/azure-devops-settings.tsx +++ b/apps/web/components/azure-devops/azure-devops-settings.tsx @@ -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(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 {saveButtonLabel(t, state)} - {state.config && (isFinePointer || !confirmingRemove) && ( + {state.config && (isMobile || isFinePointer || !confirmingRemove) && ( )} - {state.config && !isFinePointer && confirmingRemove ? ( - setConfirmingRemove(false)} - onClose={() => setConfirmingRemove(false)} - onConfirm={() => void state.remove()} - /> - ) : null} - {state.config && isFinePointer ? ( - setConfirmingRemove(false)} - onConfirm={() => void state.remove()} - /> - ) : null} + void state.remove()} + fallback={ + !isFinePointer ? ( + setConfirmingRemove(false)} + onClose={() => setConfirmingRemove(false)} + onConfirm={() => void state.remove()} + /> + ) : ( + setConfirmingRemove(false)} + onConfirm={() => void state.remove()} + /> + ) + } + /> ); } diff --git a/apps/web/components/azure-devops/azure-devops-watch-settings.tsx b/apps/web/components/azure-devops/azure-devops-watch-settings.tsx index 5ec9cfde36..96c2a31956 100644 --- a/apps/web/components/azure-devops/azure-devops-watch-settings.tsx +++ b/apps/web/components/azure-devops/azure-devops-watch-settings.tsx @@ -182,6 +182,8 @@ function WatchActions({ {t("common:reset")} ({ + 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(null); + return ( + +
+ + + +
+
+ ); + } + render(); + 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); +}); diff --git a/apps/web/components/command-panel-dialog.tsx b/apps/web/components/command-panel-dialog.tsx new file mode 100644 index 0000000000..76e2f13979 --- /dev/null +++ b/apps/web/components/command-panel-dialog.tsx @@ -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) { + return ( + + {({ active, contentProps }) => ( + + {props.children} + + )} + + ); +} diff --git a/apps/web/components/command-panel-footer.tsx b/apps/web/components/command-panel-footer.tsx index a54208b18c..bfe81200d0 100644 --- a/apps/web/components/command-panel-footer.tsx +++ b/apps/web/components/command-panel-footer.tsx @@ -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 ( - - + ); } diff --git a/apps/web/components/confirmation/AGENTS.md b/apps/web/components/confirmation/AGENTS.md new file mode 100644 index 0000000000..ba7b2581a9 --- /dev/null +++ b/apps/web/components/confirmation/AGENTS.md @@ -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. diff --git a/apps/web/components/confirmation/mobile-action-confirmation.test.tsx b/apps/web/components/confirmation/mobile-action-confirmation.test.tsx new file mode 100644 index 0000000000..f9dbf4b823 --- /dev/null +++ b/apps/web/components/confirmation/mobile-action-confirmation.test.tsx @@ -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; + completionPolicy?: "await-with-retry"; + targetKey?: string; +}) { + const [open, setOpen] = useState(false); + const trigger = useRef(null); + return ( + <> + + Desktop confirmation : null} + /> + + ); +} + +it("uses a standalone drawer and returns focus on cancellation", async () => { + render(); + 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((_, fail) => { + reject = fail; + }); + }); + render(); + 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(); + fireEvent.click(screen.getByRole("button", { name: "Remove A" })); + expect(screen.getByRole("group")).toBeTruthy(); + viewport.isMobile = false; + rerender(); + 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(null); + return ( + <> + + { + if (!open) setTarget(null); + }} + title="Delete item?" + subject={target} + cancelLabel="Cancel" + confirmLabel="Delete" + onConfirm={vi.fn()} + /> + + ); + } + render(); + 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((_, fail) => { + reject = fail; + }), + ) + .mockResolvedValue(undefined); + render(); + 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((_, fail) => { + reject = fail; + }), + ); + const { rerender } = render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Remove A" })); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce()); + rerender(); + 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); +}); diff --git a/apps/web/components/confirmation/mobile-action-confirmation.tsx b/apps/web/components/confirmation/mobile-action-confirmation.tsx new file mode 100644 index 0000000000..3d2fbcada8 --- /dev/null +++ b/apps/web/components/confirmation/mobile-action-confirmation.tsx @@ -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; + completionPolicy?: ConfirmationCompletionPolicy; + focusReturnRef?: RefObject; + 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 ; +} + +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 : ( + cancel() : undefined} + onCancel={() => cancel()} + onConfirm={submit} + /> + ); + if (host) + return host.outlet && host.request?.token === token.current + ? createPortal(body, host.outlet) + : null; + return ( + { + if (!next) cancel(); + }} + > + event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + onEscapeKeyDown={(event) => { + event.preventDefault(); + cancel(); + }} + > + + + + + + + {body} + + + ); +} diff --git a/apps/web/components/confirmation/mobile-confirmation-content.test.tsx b/apps/web/components/confirmation/mobile-confirmation-content.test.tsx new file mode 100644 index 0000000000..6a9f6fa5e3 --- /dev/null +++ b/apps/web/components/confirmation/mobile-confirmation-content.test.tsx @@ -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( +
+ +
, + ); + fireEvent.mouseDown(screen.getByRole("button", { name: "Cancel" })); + expect(onMouseDown).not.toHaveBeenCalled(); +}); + +it("updates Back with the active locale without reopening", async () => { + render( + , + ); + 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( + , + ); + 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( + , + ); + 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(); +}); diff --git a/apps/web/components/confirmation/mobile-confirmation-content.tsx b/apps/web/components/confirmation/mobile-confirmation-content.tsx new file mode 100644 index 0000000000..8f1a439580 --- /dev/null +++ b/apps/web/components/confirmation/mobile-confirmation-content.tsx @@ -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(null); + useLayoutEffect(() => { + cancelRef.current?.focus({ preventScroll: true }); + }, []); + + return ( +
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)} + > +
+ {onBack && ( + + )} +

+ {title} +

+
+
+
+ {subject &&

{subject}

} + {description &&
{description}
} +
+ {children} +
+
+ + +
+
+ ); +} diff --git a/apps/web/components/confirmation/mobile-confirmation-host.test.tsx b/apps/web/components/confirmation/mobile-confirmation-host.test.tsx new file mode 100644 index 0000000000..67bb318871 --- /dev/null +++ b/apps/web/components/confirmation/mobile-confirmation-host.test.tsx @@ -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(null); + return ( + + {({ contentProps }) => ( +
+ + +
+ {showTrigger && ( + + )} +
+ {showSource && ( + + )} +
+
+ )} +
+ ); +} + +it.each(["drawer", "dialog"] as const)( + "%s restores the mounted draft, scroll and initiating focus", + async (surface) => { + render(); + 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( +
+ +
, + ); + 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(); + fireEvent.click(screen.getByRole("button", { name: "Remove A" })); + rerender(); + 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(); + fireEvent.click(screen.getByRole("button", { name: "Remove A" })); + expect(screen.getByRole("group")).toBeTruthy(); + rerender(); + expect(screen.queryByRole("group")).toBeNull(); +}); + +it("does not resurrect a decision after closing and reopening its parent", () => { + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Remove A" })); + rerender(); + rerender(); + expect(screen.queryByRole("group")).toBeNull(); +}); + +it("invalidates even a disabled decision when its parent is dismissed", () => { + const onCancel = vi.fn(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Remove A" })); + rerender(); + expect(onCancel).toHaveBeenCalledOnce(); + rerender(); + expect(screen.queryByRole("group")).toBeNull(); +}); + +it("does not redirect a pending decision to a replacement target", () => { + const onConfirm = vi.fn(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Remove A" })); + expect(screen.getByRole("group")).toBeTruthy(); + rerender(); + 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 ( + + + + + ); + } + render(); + 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 ( + + {({ contentProps }) => ( +
+ + + + +
+ )} +
+ ); + } + const { rerender } = render(); + expect(screen.getByRole("group", { name: "First?" })).toBeTruthy(); + rerender(); + expect(screen.getByRole("group", { name: "Second?" })).toBeTruthy(); + expect(screen.queryByRole("group", { name: "First?" })).toBeNull(); +}); diff --git a/apps/web/components/confirmation/mobile-confirmation-host.tsx b/apps/web/components/confirmation/mobile-confirmation-host.tsx new file mode 100644 index 0000000000..b0d898b9d8 --- /dev/null +++ b/apps/web/components/confirmation/mobile-confirmation-host.tsx @@ -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; + originSize: { width: number; height: number } | null; + restoreFocus: (preferred: HTMLElement | null) => void; +}; + +const Context = createContext(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(null); + const originRef = useRef(null); + const [request, setRequest] = useState(null); + const [outlet, setOutlet] = useState(null); + const [originSize, setOriginSize] = useState(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( + '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 ( + + {children({ active: request !== null, contentProps })} + + ); +} + +/** 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 ( +
+
+ {children} +
+ + ); +} diff --git a/apps/web/components/confirmation/saved-task-view-delete-confirmation.test.tsx b/apps/web/components/confirmation/saved-task-view-delete-confirmation.test.tsx index 21655bcc05..82f68ca3b3 100644 --- a/apps/web/components/confirmation/saved-task-view-delete-confirmation.test.tsx +++ b/apps/web/components/confirmation/saved-task-view-delete-confirmation.test.tsx @@ -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(); + 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(); diff --git a/apps/web/components/confirmation/saved-task-view-delete-confirmation.tsx b/apps/web/components/confirmation/saved-task-view-delete-confirmation.tsx index 267029df86..552cfd67a3 100644 --- a/apps/web/components/confirmation/saved-task-view-delete-confirmation.tsx +++ b/apps/web/components/confirmation/saved-task-view-delete-confirmation.tsx @@ -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" ? ( onConfirm(target.id)} + /> + ) : ( + + {title} + {description} + + } + onCancel={() => { + onOpenChange(false); + queueMicrotask(() => { + if (anchorRef.current?.isConnected) anchorRef.current.focus(); + }); + }} + onClose={() => onOpenChange(false)} /> ); - } - - if (!open) return null; - return ( - - {title} - {description} - - } - 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)} + ); } diff --git a/apps/web/components/confirmation/use-mobile-confirmation-submit.ts b/apps/web/components/confirmation/use-mobile-confirmation-submit.ts new file mode 100644 index 0000000000..99cd4b7f23 --- /dev/null +++ b/apps/web/components/confirmation/use-mobile-confirmation-submit.ts @@ -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; + 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 }; +} diff --git a/apps/web/components/confirmation/use-saved-task-view-delete-confirmation.ts b/apps/web/components/confirmation/use-saved-task-view-delete-confirmation.ts index c751716d4c..5e38d3abad 100644 --- a/apps/web/components/confirmation/use-saved-task-view-delete-confirmation.ts +++ b/apps/web/components/confirmation/use-saved-task-view-delete-confirmation.ts @@ -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( + availableTargets: ReadonlyArray<{ id: string }>, + isMobile: boolean, +) { + const deletion = useSavedTaskViewDeleteConfirmation(availableTargets); + const [open, setOpen] = useState(false); + const pendingTarget = useRef(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( availableTargets: ReadonlyArray<{ id: string }>, ) { @@ -18,6 +46,7 @@ export function useSavedTaskViewDeleteConfirmation item.id === requestedTarget.id) ? requestedTarget : null; + useConfirmationBoundary(Boolean(target), target?.id ?? "", () => setRequestedTarget(null)); useEffect(() => { if (requestedTarget && !target) setRequestedTarget(null); diff --git a/apps/web/components/github/my-github/mobile-views-picker.tsx b/apps/web/components/github/my-github/mobile-views-picker.tsx index f5062614d8..1539e080c8 100644 --- a/apps/web/components/github/my-github/mobile-views-picker.tsx +++ b/apps/web/components/github/my-github/mobile-views-picker.tsx @@ -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 ( - - - - - { - if (!saveAfterClose.current) return; - saveAfterClose.current = false; - saveFrame.current = requestAnimationFrame(onSaveCurrent); - }} - > - - {t("github:views")} - - - - - { - onSelect(selection); - if (selection.source !== "kind-switch") setOpen(false); - }} - onSaveCurrent={() => { - saveAfterClose.current = true; - setOpen(false); - }} - /> - - + + {({ contentProps }) => ( + + + + + { + if (!saveAfterClose.current) return; + saveAfterClose.current = false; + saveFrame.current = requestAnimationFrame(onSaveCurrent); + }} + > + + + {t("github:views")} + + + + + { + onSelect(selection); + if (selection.source !== "kind-switch") setOpen(false); + }} + onSaveCurrent={() => { + saveAfterClose.current = true; + setOpen(false); + }} + /> + + + + )} + ); } diff --git a/apps/web/components/github/my-github/presets-sidebar.test.tsx b/apps/web/components/github/my-github/presets-sidebar.test.tsx index c974533d5c..8bd96686ba 100644 --- a/apps/web/components/github/my-github/presets-sidebar.test.tsx +++ b/apps/web/components/github/my-github/presets-sidebar.test.tsx @@ -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)); }); diff --git a/apps/web/components/github/my-github/presets-sidebar.tsx b/apps/web/components/github/my-github/presets-sidebar.tsx index 9a0b5f140a..557d381e89 100644 --- a/apps/web/components/github/my-github/presets-sidebar.tsx +++ b/apps/web/components/github/my-github/presets-sidebar.tsx @@ -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(saved); + const { isMobile } = useResponsiveBreakpoint(); const defaultMutationPending = defaultMutationPendingId !== null; return ( <> @@ -188,6 +190,19 @@ function SavedSection({ onToggleDefault={() => void onToggleSavedDefault(preset)} /> ))} + {isMobile && deletion.target && ( + { + 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 (
{ - afterEach(cleanup); - - it("uses visible touch controls and deletes without selecting only after confirmation", async () => { - const onDeleteSaved = vi.fn(); - const onSelect = vi.fn(); - render( - , - ); - - 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( + , + ); + + 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(); + }, + ); }); diff --git a/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx b/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx index 3695ccb9b7..376a2144ba 100644 --- a/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx +++ b/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx @@ -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(saved); + const { isMobile } = useResponsiveBreakpoint(); return ( <> @@ -183,7 +185,7 @@ function SavedSection({
)} {saved.map((s) => { - if (deletion.target?.id === s.id) { + if (!isMobile && deletion.target?.id === s.id) { return (
); })} - + {isMobile && deletion.target && ( + { + if (!open) deletion.close(); + }} + onConfirm={onDelete} + /> + )} + ); } +function SaveQueryButton({ enabled, onClick }: { enabled: boolean; onClick: () => void }) { + const { t } = useTranslation(); + return ( + + ); +} + export function PresetsSidebar({ selected, onSelect, diff --git a/apps/web/components/integrations/integration-filters-sheet.tsx b/apps/web/components/integrations/integration-filters-sheet.tsx new file mode 100644 index 0000000000..841b4e83c4 --- /dev/null +++ b/apps/web/components/integrations/integration-filters-sheet.tsx @@ -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 ( + + {({ active, contentProps }) => + isMobile ? ( + + + + + {title} + +
+ {children} +
+
+
+
+ ) : ( + + + +
+ + {title} + + {children} +
+
+
+
+ ) + } +
+ ); +} diff --git a/apps/web/components/integrations/presets-scope-bar-base.tsx b/apps/web/components/integrations/presets-scope-bar-base.tsx index a3813c774e..d81ae3cf78 100644 --- a/apps/web/components/integrations/presets-scope-bar-base.tsx +++ b/apps/web/components/integrations/presets-scope-bar-base.tsx @@ -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; }) { const { t } = useTranslation(); return ( - {hasConfig && (isFinePointer || !confirmingDelete) && ( + {hasConfig && (isMobile || isFinePointer || !confirmingDelete) && ( )} - {hasConfig && !isFinePointer && confirmingDelete ? ( - setConfirmingDelete(false)} - onClose={() => setConfirmingDelete(false)} - onConfirm={onDelete} - /> - ) : null} - {hasConfig && isFinePointer ? ( - setConfirmingDelete(false)} - onConfirm={onDelete} - /> - ) : null} + setConfirmingDelete(false)} + onClose={() => setConfirmingDelete(false)} + onConfirm={onDelete} + /> + ) : ( + setConfirmingDelete(false)} + onConfirm={onDelete} + /> + ) + } + />
); } diff --git a/apps/web/components/jira/jira-issue-watch-table.tsx b/apps/web/components/jira/jira-issue-watch-table.tsx index c144738722..82739888ed 100644 --- a/apps/web/components/jira/jira-issue-watch-table.tsx +++ b/apps/web/components/jira/jira-issue-watch-table.tsx @@ -123,6 +123,8 @@ function WatchActions({ {t("common:reset")} ({ 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)); diff --git a/apps/web/components/jira/my-jira/list-toolbar.tsx b/apps/web/components/jira/my-jira/list-toolbar.tsx index 7b95c683c2..705715cfe5 100644 --- a/apps/web/components/jira/my-jira/list-toolbar.tsx +++ b/apps/web/components/jira/my-jira/list-toolbar.tsx @@ -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(null); + const triggerRef = useRef(null); const builtin = views.filter((v) => v.builtin); const custom = views.filter((v) => !v.builtin); - const deletion = useSavedTaskViewDeleteConfirmation(custom); + const deletion = useSavedTaskViewDeleteMenu(custom, isMobile); const deleteProps: ViewDeletionProps = { - isFinePointer, + isFinePointer: !isMobile && isFinePointer, deleteTarget: deletion.target, deleteAnchorRef: deletion.anchorRef, onDeleteOpenChange: (nextOpen) => { @@ -199,15 +199,9 @@ function ViewsDropdown({ }; return ( - { - setOpen(nextOpen); - if (!nextOpen) deletion.close(); - }} - > + - @@ -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} /> )} - {isFinePointer && deletion.target ? ( + {(isMobile || isFinePointer) && deletion.target ? ( { if (!nextOpen) deletion.close(); diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 4d9f261407..28abada61d 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -472,6 +472,7 @@ function KanbanCardFrame({
{t("common:reset")} ({ 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)); diff --git a/apps/web/components/linear/linear-settings.tsx b/apps/web/components/linear/linear-settings.tsx index 2b17dc7095..f366890766 100644 --- a/apps/web/components/linear/linear-settings.tsx +++ b/apps/web/components/linear/linear-settings.tsx @@ -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(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")} - {hasConfig && (isFinePointer || !confirmingDelete) && ( + {hasConfig && (isMobile || isFinePointer || !confirmingDelete) && ( )} - {hasConfig && !isFinePointer && confirmingDelete ? ( - setConfirmingDelete(false)} - onClose={() => setConfirmingDelete(false)} - onConfirm={onDelete} - /> - ) : null} - {hasConfig && isFinePointer ? ( - setConfirmingDelete(false)} - onConfirm={onDelete} - /> - ) : null} + setConfirmingDelete(false)} + onClose={() => setConfirmingDelete(false)} + onConfirm={onDelete} + /> + ) : ( + setConfirmingDelete(false)} + onConfirm={onDelete} + /> + ) + } + /> ); } @@ -514,6 +538,7 @@ export function LinearConnectionSection({ workspaceId }: { workspaceId: 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 (
+ + + - {isFinePointer || !confirmDiscardOpen ? ( + {isMobile || isFinePointer || !confirmDiscardOpen ? ( - {(isFinePointer || !confirmingDelete) && ( + {(isMobile || isFinePointer || !confirmingDelete) && ( )}
- {!isFinePointer && confirmingDelete ? ( - - ) : null} - {isFinePointer ? ( - { - if (!open) cancelDelete(); - }} - onCancel={cancelDelete} - onConfirm={onDeleteConfirm} - /> - ) : null} + { + if (!open) cancelDelete(); + }} + focusReturnRef={deleteAnchorRef} + fallback={ + !isFinePointer ? ( + + ) : ( + { + if (!open) cancelDelete(); + }} + onCancel={cancelDelete} + /> + ) + } + /> ); diff --git a/apps/web/components/sentry/sentry-issue-watch-table.tsx b/apps/web/components/sentry/sentry-issue-watch-table.tsx index f33d6934bd..6e7dc94d14 100644 --- a/apps/web/components/sentry/sentry-issue-watch-table.tsx +++ b/apps/web/components/sentry/sentry-issue-watch-table.tsx @@ -150,6 +150,8 @@ function WatchActions({ {t("common:reset")} { + cleanup(); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 }); +}); function renderLocalConfirmation( isFinePointer: boolean, @@ -25,6 +28,8 @@ function renderLocalConfirmation( Delete { + 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); diff --git a/apps/web/components/settings/agent-profile-delete-dialog.tsx b/apps/web/components/settings/agent-profile-delete-dialog.tsx index 4643fdabe6..4d03570c3e 100644 --- a/apps/web/components/settings/agent-profile-delete-dialog.tsx +++ b/apps/web/components/settings/agent-profile-delete-dialog.tsx @@ -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; @@ -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 ( - - ); - } - - if (!open) return null; - return ( + const fallback = isFinePointer ? ( + + ) : ( ); + return ( + + ); } export function AgentProfileDisableConflictDialog({ diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index d6260448c7..7f636609e7 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -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; }; -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(null); const closeDeleteConfirmation = () => { onOpenChange(false); @@ -161,7 +170,7 @@ function DeleteProfileCard({ onDelete, open, onOpenChange, onConfirm }: DeletePr

{t("agents:removeThisProfile")}

{t("agents:actionCannotBeUndone")}

- {!open || isFinePointer ? ( + {!open || isFinePointer || isMobile ? ( - + { + if (!pendingDelete.current) return; + pendingDelete.current = false; + event.preventDefault(); + onConfirmDelete(); + }} + > {profile.kind !== "dynamic" && ( { + if (isMobile) pendingDelete.current = true; + else onConfirmDelete(); + }} > {t("agents:delete")} @@ -168,6 +182,8 @@ function ProfileRowInlineActions({ } type ProfileRowDeleteConfirmationProps = { + profileId: string; + profileName: string; open: boolean; isFinePointer: boolean; anchorRef: RefObject; @@ -179,6 +195,8 @@ type ProfileRowDeleteConfirmationProps = { type ProfileRowDeleteConfirmationBaseProps = Omit; 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 = (
{canManage && - !(confirmOpen && !isFinePointer) && + (isMobile || !(confirmOpen && !isFinePointer)) && (isFullDesktop ? ( (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, diff --git a/apps/web/components/settings/layouts/layout-profile-delete-confirmation.test.tsx b/apps/web/components/settings/layouts/layout-profile-delete-confirmation.test.tsx new file mode 100644 index 0000000000..b8ccc3253d --- /dev/null +++ b/apps/web/components/settings/layouts/layout-profile-delete-confirmation.test.tsx @@ -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(null); + return ( + + + + ); + } + render(); + 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)); +}); diff --git a/apps/web/components/settings/layouts/layout-profile-delete-confirmation.tsx b/apps/web/components/settings/layouts/layout-profile-delete-confirmation.tsx index e3018ac2e5..2c9212fcb4 100644 --- a/apps/web/components/settings/layouts/layout-profile-delete-confirmation.tsx +++ b/apps/web/components/settings/layouts/layout-profile-delete-confirmation.tsx @@ -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 onOpenChange(true)} />; - return ( - onOpenChange(false)} - onConfirm={onConfirm} - /> - ); - } - + const fallback = !isFinePointer ? ( + onOpenChange(false)} + onConfirm={onConfirm} + /> + ) : ( + + ); return ( <> - onOpenChange(true)} /> - onOpenChange(true)} /> + )} + ); diff --git a/apps/web/components/settings/plugins/plugin-detail.tsx b/apps/web/components/settings/plugins/plugin-detail.tsx index 6e9145c85a..793e932e95 100644 --- a/apps/web/components/settings/plugins/plugin-detail.tsx +++ b/apps/web/components/settings/plugins/plugin-detail.tsx @@ -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")} )} - {(isFinePointer || !confirmingUninstall) && ( + {(isMobile || isFinePointer || !confirmingUninstall) && ( + onUpdate={onUpdate} + /> )} {canEnable && ( + ); +} diff --git a/apps/web/components/settings/plugins/uninstall-plugin-dialog.test.tsx b/apps/web/components/settings/plugins/uninstall-plugin-dialog.test.tsx index 83a70c610d..e95d2e984f 100644 --- a/apps/web/components/settings/plugins/uninstall-plugin-dialog.test.tsx +++ b/apps/web/components/settings/plugins/uninstall-plugin-dialog.test.tsx @@ -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(); + 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(); diff --git a/apps/web/components/settings/plugins/uninstall-plugin-dialog.tsx b/apps/web/components/settings/plugins/uninstall-plugin-dialog.tsx index 3ac36eabe9..761de6561d 100644 --- a/apps/web/components/settings/plugins/uninstall-plugin-dialog.tsx +++ b/apps/web/components/settings/plugins/uninstall-plugin-dialog.tsx @@ -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 ( - onOpenChange(false)} - onConfirm={onConfirm} - /> - ); - } - - return ( + const fallback = !isFinePointer ? ( + onOpenChange(false)} + onConfirm={onConfirm} + /> + ) : ( ); + return ( + + ); } diff --git a/apps/web/components/settings/prompt-delete-confirmation.tsx b/apps/web/components/settings/prompt-delete-confirmation.tsx index 921891ea0a..4649005557 100644 --- a/apps/web/components/settings/prompt-delete-confirmation.tsx +++ b/apps/web/components/settings/prompt-delete-confirmation.tsx @@ -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 ? ( - - ) : null; - } - - return ( + const fallback = !isFinePointer ? ( + + ) : ( ); + return ( + { + if (!nextOpen) onClose(); + }} + onCancel={onCancel} + onClose={onClose} + onConfirm={onConfirm} + fallback={fallback} + /> + ); } diff --git a/apps/web/components/settings/prompt-row-actions.tsx b/apps/web/components/settings/prompt-row-actions.tsx index e1eaf31969..bab7997674 100644 --- a/apps/web/components/settings/prompt-row-actions.tsx +++ b/apps/web/components/settings/prompt-row-actions.tsx @@ -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; 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 (
- {isFinePointer || !isDeleteTarget ? ( + {isMobile || isFinePointer || !isDeleteTarget ? ( <>
); } diff --git a/apps/web/components/settings/prompts-settings.test.tsx b/apps/web/components/settings/prompts-settings.test.tsx index 28da4c071e..1662e0351f 100644 --- a/apps/web/components/settings/prompts-settings.test.tsx +++ b/apps/web/components/settings/prompts-settings.test.tsx @@ -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(); diff --git a/apps/web/components/settings/prompts-settings.tsx b/apps/web/components/settings/prompts-settings.tsx index d45da5ef6f..e30db94ab5 100644 --- a/apps/web/components/settings/prompts-settings.tsx +++ b/apps/web/components/settings/prompts-settings.tsx @@ -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} />
- {!isFinePointer ? ( - - ) : null} + {isEditing ? (
+ This will permanently remove{" "} + {secret.name}. This action cannot be + undone. + + ); + 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 ( - - This will permanently remove{" "} - {secret.name}. This action cannot - be undone. - - ) + } - 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} /> ); } diff --git a/apps/web/components/settings/secrets-list-item-row.test.tsx b/apps/web/components/settings/secrets-list-item-row.test.tsx index 754dcea4aa..48e2cec87f 100644 --- a/apps/web/components/settings/secrets-list-item-row.test.tsx +++ b/apps/web/components/settings/secrets-list-item-row.test.tsx @@ -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[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 }); diff --git a/apps/web/components/settings/secrets-list-item-row.tsx b/apps/web/components/settings/secrets-list-item-row.tsx index 08c42b47bb..b53eb2958e 100644 --- a/apps/web/components/settings/secrets-list-item-row.tsx +++ b/apps/web/components/settings/secrets-list-item-row.tsx @@ -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(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} diff --git a/apps/web/components/settings/system/users-table.test.tsx b/apps/web/components/settings/system/users-table.test.tsx index 10fa75a08a..1382935d32 100644 --- a/apps/web/components/settings/system/users-table.test.tsx +++ b/apps/web/components/settings/system/users-table.test.tsx @@ -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"); }); }); diff --git a/apps/web/components/settings/system/users-table.tsx b/apps/web/components/settings/system/users-table.tsx index ad3c0b2420..3a2967f111 100644 --- a/apps/web/components/settings/system/users-table.tsx +++ b/apps/web/components/settings/system/users-table.tsx @@ -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(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 ( - {isFinePointer && action ? ( + {(isMobile || isFinePointer) && action ? ( 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 ( - !open && onCancel()} - onConfirm={() => onConfirm(action)} + targetKey={`${action.user.id}:${JSON.stringify(action.next)}`} + focusReturnRef={anchorRef} + fallback={ + + } /> ); } diff --git a/apps/web/components/settings/workflow-sync-dialog.test.tsx b/apps/web/components/settings/workflow-sync-dialog.test.tsx index 28fd3abe6f..47507b1dc9 100644 --- a/apps/web/components/settings/workflow-sync-dialog.test.tsx +++ b/apps/web/components/settings/workflow-sync-dialog.test.tsx @@ -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 = {}): 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((resolve) => { + finish = resolve; + }), + ), + }); + const onOpenChange = vi.fn(); + const { rerender } = render( + , + ); + fireEvent.click(screen.getByTestId(REMOVE_TEST_ID)); + fireEvent.click(screen.getByTestId(REMOVE_CONFIRM_TEST_ID)); + await waitFor(() => expect(sync.handleDelete).toHaveBeenCalledTimes(1)); + rerender( + , + ); + 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((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue(true), + }); + const onOpenChange = vi.fn(); + render(); + 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(); diff --git a/apps/web/components/settings/workflow-sync-dialog.tsx b/apps/web/components/settings/workflow-sync-dialog.tsx index 088c64e429..61c1c43f02 100644 --- a/apps/web/components/settings/workflow-sync-dialog.tsx +++ b/apps/web/components/settings/workflow-sync-dialog.tsx @@ -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(null); if (!hasConfig) return null; const removeLabel = t("workflows:remove"); - if (confirming) { - return ( - + ); + + return ( + <> + {(isMobile || !confirming) && ( + + )} + { + if (!open) onCancel(); + }} onConfirm={onConfirm} + completionPolicy="await-with-retry" + focusReturnRef={anchorRef} + fallback={fallback} /> - ); - } - - return ( - + ); } @@ -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 ( +
+ + {switchingProvider && ( +

+ {t("workflows:switchProviderWarning")} +

+ )} + + + +

+ {t("workflows:syncDirectoryHelp", { + format: WORKFLOW_EXPORT_FORMAT, + extensions: WORKFLOW_EXPORT_EXTENSIONS, + })} +

+
+ ); +} + // 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 ( - - { - if (!removeConfirming) return; - event.preventDefault(); - setRemoveConfirming(false); - }} - > - - {t("workflows:syncTitle")} - {t("workflows:syncDescription")} - -
- - {switchingProvider && ( -

- {t("workflows:switchProviderWarning")} -

- )} - - - -

- {t("workflows:syncDirectoryHelp", { - format: WORKFLOW_EXPORT_FORMAT, - extensions: WORKFLOW_EXPORT_EXTENSIONS, - })} -

-
- - setRemoveConfirming(true)} - onCancel={() => setRemoveConfirming(false)} - onConfirm={handleRemove} - /> - - -
-
+ +
+ + {t("workflows:syncTitle")} + {t("workflows:syncDescription")} + + + + setRemoveConfirming(true)} + onCancel={() => setRemoveConfirming(false)} + onConfirm={handleRemove} + /> + + +
+
+ +
+ )} + ); } +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"); diff --git a/apps/web/components/settings/workflow-sync-section.tsx b/apps/web/components/settings/workflow-sync-section.tsx index e5cb9317ad..f54fcde83c 100644 --- a/apps/web/components/settings/workflow-sync-section.tsx +++ b/apps/web/components/settings/workflow-sync-section.tsx @@ -62,7 +62,12 @@ export function WorkflowSyncSection({ )} - + ); } diff --git a/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx b/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx index 8e8920f28e..f3d10a6cd6 100644 --- a/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx @@ -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 ( diff --git a/apps/web/components/task/chat/chat-input-toolbar.test.tsx b/apps/web/components/task/chat/chat-input-toolbar.test.tsx index 58f1ad14da..af65abe1b2 100644 --- a/apps/web/components/task/chat/chat-input-toolbar.test.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar.test.tsx @@ -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" }) => ( - ), @@ -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", () => { diff --git a/apps/web/components/task/chat/reset-context-button.test.tsx b/apps/web/components/task/chat/reset-context-button.test.tsx index 5b5e156947..fce2bc0102 100644 --- a/apps/web/components/task/chat/reset-context-button.test.tsx +++ b/apps/web/components/task/chat/reset-context-button.test.tsx @@ -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 }); }); diff --git a/apps/web/components/task/chat/reset-context-button.tsx b/apps/web/components/task/chat/reset-context-button.tsx index 05daf24040..683be27c25 100644 --- a/apps/web/components/task/chat/reset-context-button.tsx +++ b/apps/web/components/task/chat/reset-context-button.tsx @@ -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 ( - + - e.stopPropagation()}> + e.stopPropagation()} + onCloseAutoFocus={(event) => { + if (!pendingDelete.current) return; + event.preventDefault(); + pendingDelete.current = false; + onAskDelete(); + }} + > { + if (isMobile) pendingDelete.current = true; + else onAskDelete(); + }} > {t("task:delete")} @@ -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; -}) { - const { t } = useTranslation(); - return ( - } - 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 (
@@ -282,9 +266,14 @@ function SessionRowTrailingActions({ onHandoffProfile: (profileId: string) => void; actions: ReturnType; }) { - if (isConfirming) { - return ( - (null); + return ( + <> + void actions.remove()} /> - ); - } - return ( - void actions.setPrimary()} - onStop={() => void actions.stop()} - onResume={() => void actions.resume()} - onAskDelete={onAskDelete} - onHandoffProfile={onHandoffProfile} - /> + {(!isConfirming || isMobile) && ( + 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} /> - + (null); return (
{terminal.label} - {isRunning && !isConfirming && ( + {isRunning && (!isConfirming || isMobile) && ( {t("task:running")} )} - {terminal.closable && - (isConfirming ? ( + {terminal.closable && ( + <> - ) : ( - - ))} + {(!isConfirming || isMobile) && ( + + )} + + )}
); } diff --git a/apps/web/components/task/mobile/mobile-terminals-section.tsx b/apps/web/components/task/mobile/mobile-terminals-section.tsx index 82096534eb..763cef1075 100644 --- a/apps/web/components/task/mobile/mobile-terminals-section.tsx +++ b/apps/web/components/task/mobile/mobile-terminals-section.tsx @@ -184,7 +184,13 @@ export const MobileTerminalsPicker = memo(function MobileTerminalsPicker({ data-testid="mobile-terminals-pill" ariaLabel={t("task:activeTerminalTapToSwitch", { label })} /> - + setOpen(false)} /> diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx index 077a60f2b0..8795b5e84d 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx @@ -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 ( + + Tasks + + + + + ); + } + const view = render(); + 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(); diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index c0aa795861..da7a3ba06e 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -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" ? ( - - - {surfaceContent} - - + + {surfaceContent} + ) : ( void; + onCloseAutoFocus?: (event: Event) => void; + children: ReactNode; +}) { + return ( + + {({ contentProps }) => ( + + + {children} + + + )} + + ); +} diff --git a/apps/web/components/task/parked-terminals-menu.tsx b/apps/web/components/task/parked-terminals-menu.tsx index 4f7d77d951..10c5b78706 100644 --- a/apps/web/components/task/parked-terminals-menu.tsx +++ b/apps/web/components/task/parked-terminals-menu.tsx @@ -116,6 +116,8 @@ function ParkedTerminalRow({ className="w-full px-1" > onConfirm(layout.id), + }; return ( - onConfirm(layout.id)} + + } /> ); } diff --git a/apps/web/components/task/sidebar-filter/sidebar-filter-popover.test.tsx b/apps/web/components/task/sidebar-filter/sidebar-filter-popover.test.tsx index 3b29d07b97..ebb4cd845f 100644 --- a/apps/web/components/task/sidebar-filter/sidebar-filter-popover.test.tsx +++ b/apps/web/components/task/sidebar-filter/sidebar-filter-popover.test.tsx @@ -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", () => { diff --git a/apps/web/components/task/sidebar-filter/sidebar-filter-popover.tsx b/apps/web/components/task/sidebar-filter/sidebar-filter-popover.tsx index 7d5f918499..1a73d211e0 100644 --- a/apps/web/components/task/sidebar-filter/sidebar-filter-popover.tsx +++ b/apps/web/components/task/sidebar-filter/sidebar-filter-popover.tsx @@ -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 ( - { - if (!nextOpen) deletion.close(); - onOpenChange(nextOpen); - }} - > - {trigger} - - - {title} - -
+ {({ contentProps }) => ( + { + if (!nextOpen) deletion.close(); + onOpenChange(nextOpen); + }} > - {editor} -
-
-
+ {trigger} + + + + {title} + +
+ {editor} +
+
+
+ + )} + ); } diff --git a/apps/web/components/task/sidebar-filter/view-manager.tsx b/apps/web/components/task/sidebar-filter/view-manager.tsx index 2e9f51ac97..bbb55b7e9d 100644 --- a/apps/web/components/task/sidebar-filter/view-manager.tsx +++ b/apps/web/components/task/sidebar-filter/view-manager.tsx @@ -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("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
{props.deleteConfirmation}
; } return ( -
-
- - {mode === "saveAs" ? t("task:saveAs") : t("task:view")} - - {isEditing ? ( - - ) : ( - - )} + <> +
+
+ + {mode === "saveAs" ? t("task:saveAs") : t("task:view")} + + {isEditing ? ( + + ) : ( + + )} +
+
+ {isEditing ? ( + + ) : ( + + )} +
-
- {isEditing ? ( - - ) : ( - - )} -
-
+ {isMobile ? props.deleteConfirmation : null} + ); } diff --git a/apps/web/components/task/simple/components/parent-picker.tsx b/apps/web/components/task/simple/components/parent-picker.tsx index 14590978b5..4a68edb7a6 100644 --- a/apps/web/components/task/simple/components/parent-picker.tsx +++ b/apps/web/components/task/simple/components/parent-picker.tsx @@ -190,6 +190,7 @@ export function ParentPicker({ task }: ParentPickerProps) { testId="parent-picker-trigger" /> @@ -155,12 +162,8 @@ export function TaskActionsMenuDialogs({ /> )} ; - onConfirm: (opts: { cascade: boolean }) => void; + onConfirm: (opts: { cascade: boolean }) => void | Promise; confirmTestId?: string; /** Preflight result supplied by the local confirmation adapter. */ subtaskClassification?: SubtaskCountResult; @@ -110,6 +111,55 @@ function isArchiveActionDisabled( ); } +function archiveCleanup({ + isBulkOperation, + executorTypes, + executorType, +}: Pick) { + 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 && } + {subtaskCount > 0 && ( + + )} + + ); +} + // 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 = ( +
+

{firstLine}

+ +
+ ); + const options = ( + + ); + const desktop = (
-
-

{firstLine}

- -
+ {description}
- {taskIsInFlight && ( - - )} - {subtaskCount > 0 && ( - - )} + {options}
@@ -244,4 +287,24 @@ export function TaskArchiveConfirmDialog({
); + return ( + onConfirm({ cascade })} + fallback={desktop} + > + {classification.status === "loading" &&

{t("common:loading")}

} + {options} +
+ ); } diff --git a/apps/web/components/task/task-archive-confirmation.test.tsx b/apps/web/components/task/task-archive-confirmation.test.tsx index d0aedb9d92..6738878371 100644 --- a/apps/web/components/task/task-archive-confirmation.test.tsx +++ b/apps/web/components/task/task-archive-confirmation.test.tsx @@ -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(null); + const [open, setOpen] = useState(true); return ( <> - { + 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); }); diff --git a/apps/web/components/task/task-archive-confirmation.tsx b/apps/web/components/task/task-archive-confirmation.tsx index e440b5c3ce..0451063669 100644 --- a/apps/web/components/task/task-archive-confirmation.tsx +++ b/apps/web/components/task/task-archive-confirmation.tsx @@ -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; focusReturnRef?: RefObject; @@ -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 ( { + 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(null); + return ( + <> + + + + ); + } + render(); + 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 }) { const [open, setOpen] = useState(true); diff --git a/apps/web/components/task/task-detach-confirm-dialog.tsx b/apps/web/components/task/task-detach-confirm-dialog.tsx index 4c12eb17cf..08ac7050b6 100644 --- a/apps/web/components/task/task-detach-confirm-dialog.tsx +++ b/apps/web/components/task/task-detach-confirm-dialog.tsx @@ -15,10 +15,13 @@ import { 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"; const DETACH_TASK_FROM_PARENT_KEY = "task:detachTaskFromParent"; const DETACH_LABEL_KEY = "task:detach"; +const DETACH_CONFIRM_TEST_ID = "detach-task-confirm"; +const CANCEL_KEY = "common:cancel"; export type TaskDetachConfirmationCopyProps = { taskTitle?: string; @@ -108,10 +111,10 @@ export function TaskDetachConfirmPopover({ sharesParentWorkspace={sharesParentWorkspace} /> } - cancelLabel={t("common:cancel")} + cancelLabel={t(CANCEL_KEY)} confirmLabel={t(DETACH_LABEL_KEY)} confirmAriaLabel={detachConfirmAriaLabel(t, taskTitle)} - confirmTestId="detach-task-confirm" + confirmTestId={DETACH_CONFIRM_TEST_ID} testId="detach-task-confirm-popover" onOpenChange={onOpenChange} onCancel={onCancel} @@ -145,10 +148,10 @@ export function TaskDetachInlineConfirmation({ sharesParentWorkspace={sharesParentWorkspace} /> } - cancelLabel={t("common:cancel")} + cancelLabel={t(CANCEL_KEY)} confirmLabel={t(DETACH_LABEL_KEY)} confirmAriaLabel={detachConfirmAriaLabel(t, taskTitle)} - confirmTestId="detach-task-confirm" + confirmTestId={DETACH_CONFIRM_TEST_ID} onCancel={onCancel} onClose={onClose} onConfirm={onConfirm} @@ -157,6 +160,7 @@ export function TaskDetachInlineConfirmation({ } export type TaskDetachConfirmationSurfaceProps = TaskDetachConfirmationCopyProps & { + taskId: string; open: boolean; anchorRef: RefObject; focusReturnRef?: RefObject; @@ -166,6 +170,7 @@ export type TaskDetachConfirmationSurfaceProps = TaskDetachConfirmationCopyProps }; export function TaskDetachConfirmationSurface({ + taskId, open, anchorRef, focusReturnRef, @@ -175,25 +180,22 @@ export function TaskDetachConfirmationSurface({ onOpenChange, onConfirm, }: TaskDetachConfirmationSurfaceProps) { + const { t } = useTranslation(); const { isFinePointer } = useResponsiveBreakpoint(); - if (isFinePointer) { - return ( - onOpenChange(false)} - onConfirm={onConfirm} - /> - ); - } - if (!open) return null; - return ( + const fallback = isFinePointer ? ( + onOpenChange(false)} + onConfirm={onConfirm} + /> + ) : ( ); + return ( + + } + cancelLabel={t(CANCEL_KEY)} + confirmLabel={t(DETACH_LABEL_KEY)} + confirmAriaLabel={detachConfirmAriaLabel(t, taskTitle)} + confirmTestId={DETACH_CONFIRM_TEST_ID} + focusReturnRef={focusReturnRef ?? anchorRef} + onOpenChange={onOpenChange} + onConfirm={onConfirm} + fallback={fallback} + /> + ); } type TaskDetachConfirmDialogProps = { @@ -244,12 +267,12 @@ export function TaskDetachConfirmDialog({ - {t("common:cancel")} + {t(CANCEL_KEY)} { event.preventDefault(); if (!isDetaching) onConfirm(); diff --git a/apps/web/components/task/task-plan-revision-restore-actions.test.tsx b/apps/web/components/task/task-plan-revision-restore-actions.test.tsx new file mode 100644 index 0000000000..330633aa39 --- /dev/null +++ b/apps/web/components/task/task-plan-revision-restore-actions.test.tsx @@ -0,0 +1,80 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import type { TaskPlanRevision } from "@/lib/types/http"; +import { TaskPlanRevisions } from "./task-plan-revisions"; + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ isMobile: true, isFinePointer: false }), +})); +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ tasks: { activeSessionId: null }, taskSessions: { items: {} } }), +})); +vi.mock("@/lib/toast/sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock("@/components/agent-logo", () => ({ AgentLogo: () => null })); +vi.mock("./task-plan-preview-dialog", () => ({ PlanRevisionPreviewDialog: () => null })); +vi.mock("./task-plan-diff-dialog", () => ({ PlanRevisionDiffDialog: () => null })); +afterEach(cleanup); + +const revisions: TaskPlanRevision[] = [2, 1].map((version) => ({ + id: `revision-${version}`, + task_id: "task-1", + revision_number: version, + title: `Version ${version}`, + content: `Plan ${version}`, + author_kind: "user", + author_name: "user", + created_at: "2026-09-10T10:00:00Z", + updated_at: "2026-09-10T10:00:00Z", +})); + +function history() { + const props = { + taskId: "task-1", + revisions, + isLoading: false, + isSaving: false, + onOpen: vi.fn(), + onRevert: vi.fn().mockResolvedValue(revisions[0]), + loadRevisionContent: vi.fn().mockResolvedValue("content"), + previewRevisionId: null, + setPreviewRevision: vi.fn(), + comparePair: [null, null] as [string | null, string | null], + toggleCompareSelection: vi.fn(), + clearComparePair: vi.fn(), + }; + const view = render(); + const trigger = screen.getByTestId("plan-rewind-button"); + const open = () => { + fireEvent.click(trigger); + fireEvent.click(screen.getByTestId("plan-revision-revert-button")); + }; + open(); + return { ...view, props, trigger, open }; +} + +it("hands off plan history to a standalone phone sheet and restores trigger focus", async () => { + const { props, trigger, open } = history(); + const sheet = await screen.findByRole("dialog", { name: "Restore to version 1?" }); + expect(sheet.getAttribute("data-slot")).toBe("drawer-content"); + expect(screen.queryByTestId("plan-revisions-popover")).toBeNull(); + expect(screen.queryByTestId("plan-revision-restore-inline-confirmation")).toBeNull(); + fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + expect(props.onRevert).not.toHaveBeenCalled(); + open(); + const confirm = await screen.findByTestId("plan-revision-restore-confirm"); + fireEvent.click(confirm); + fireEvent.click(confirm); + await waitFor(() => expect(props.onRevert).toHaveBeenCalledExactlyOnceWith("revision-1")); +}); + +it("cancels a captured revision when the task changes", async () => { + const { props, rerender } = history(); + const sheet = await screen.findByRole("dialog", { name: "Restore to version 1?" }); + const confirm = within(sheet).getByTestId("plan-revision-restore-confirm"); + rerender(); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + fireEvent.click(confirm); + expect(props.onRevert).not.toHaveBeenCalled(); +}); diff --git a/apps/web/components/task/task-plan-revision-restore-actions.tsx b/apps/web/components/task/task-plan-revision-restore-actions.tsx index 11628710d3..d6da5e0b2c 100644 --- a/apps/web/components/task/task-plan-revision-restore-actions.tsx +++ b/apps/web/components/task/task-plan-revision-restore-actions.tsx @@ -1,12 +1,13 @@ "use client"; -import { useRef } from "react"; +import { useRef, type RefObject } from "react"; import { IconRestore } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; 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 type { TaskPlanRevision } from "@/lib/types/http"; type RevisionRestoreProps = { @@ -23,6 +24,43 @@ type RevisionRestoreActionProps = RevisionRestoreProps & { onRevertRequest: (revision: TaskPlanRevision) => void; }; +export function MobileRevisionRestoreConfirmation({ + taskId, + target, + isSaving, + anchorRef, + onClose, + onRevert, +}: { + taskId: string; + target: TaskPlanRevision | null; + isSaving: boolean; + anchorRef: RefObject; + onClose: () => void; + onRevert: (revision: TaskPlanRevision) => Promise; +}) { + const { t } = useTranslation(); + const version = target?.revision_number; + return ( + { + if (!next) onClose(); + }} + onConfirm={() => (target ? onRevert(target) : undefined)} + /> + ); +} + export function RevisionRestoreAction({ revision, isCurrent, @@ -49,7 +87,7 @@ export function RevisionRestoreAction({ size="sm" variant="ghost" disabled={isSaving} - className="h-7 px-2 text-xs cursor-pointer shrink-0 gap-1" + className="h-11 md:h-7 px-2 text-xs cursor-pointer shrink-0 gap-1" onClick={(event) => { event.stopPropagation(); onRevertRequest(revision); diff --git a/apps/web/components/task/task-plan-revisions.tsx b/apps/web/components/task/task-plan-revisions.tsx index 604f077a97..6632d6ba23 100644 --- a/apps/web/components/task/task-plan-revisions.tsx +++ b/apps/web/components/task/task-plan-revisions.tsx @@ -18,6 +18,7 @@ import { PlanRevisionDiffDialog } from "./task-plan-diff-dialog"; import { RevertConfirmDialog } from "./task-plan-revert-confirm-dialog"; import { TaskPlanRevisionRow } from "./task-plan-revision-row"; import { useTranslation } from "react-i18next"; +import { MobileRevisionRestoreConfirmation } from "./task-plan-revision-restore-actions"; import type { TFunction } from "i18next"; type ComparePair = [string | null, string | null]; @@ -48,12 +49,14 @@ type TaskPlanRevisionsProps = { const USER_DISPLAY_NAME_KEY = "common:you"; export function TaskPlanRevisions(props: TaskPlanRevisionsProps) { - const { t } = useTranslation(); - const { isFinePointer } = useResponsiveBreakpoint(); + const { isFinePointer, isMobile } = useResponsiveBreakpoint(); const [open, setOpen] = useState(false); const [confirmTarget, setConfirmTarget] = useState(null); const [rowConfirmTarget, setRowConfirmTarget] = useState(null); const [diffOpen, setDiffOpen] = useState(false); + const [mobileTarget, setMobileTarget] = useState(null); + const pendingMobileTarget = useRef(null); + const historyTriggerRef = useRef(null); const agentName = useActiveAgentBackendName(); const triggerOpenChange = useTriggerOnFirstOpen(setOpen, props.onOpen); const handleOpenChange = useCallback( @@ -65,26 +68,7 @@ export function TaskPlanRevisions(props: TaskPlanRevisionsProps) { ); const closePopover = useCallback(() => handleOpenChange(false), [handleOpenChange]); - const handleRevert = useCallback( - async (revision: TaskPlanRevision) => { - try { - const result = await props.onRevert(revision.id); - if (result) { - toast.success(t("task:planRestoredToV", { revisionnumber: revision.revision_number })); - closePopover(); - props.setPreviewRevision(null); - setDiffOpen(false); - } else { - // `revertTo` (the hook impl) swallows errors and returns null, so - // surface the failure instead of closing silently. - toast.error(t("task:failedToRestorePlan")); - } - } catch (err) { - toast.error(err instanceof Error ? err.message : t("task:failedToRestorePlan")); - } - }, - [closePopover, props.onRevert, props.setPreviewRevision, t], - ); + const handleRevert = useRevisionRestore(props, closePopover, setDiffOpen); const previewRevision = useMemo( () => props.revisions.find((r) => r.id === props.previewRevisionId) ?? null, @@ -98,20 +82,49 @@ export function TaskPlanRevisions(props: TaskPlanRevisionsProps) { [props.revisions, props.comparePair], ); const headRevision = props.revisions[0] ?? null; + useEffect(() => { + if ( + mobileTarget && + (mobileTarget.task_id !== props.taskId || + !props.revisions.slice(1).some((revision) => revision.id === mobileTarget.id)) + ) { + setMobileTarget(null); + } + }, [mobileTarget, props.taskId, props.revisions]); return ( <> { + const target = pendingMobileTarget.current; + pendingMobileTarget.current = null; + if (!target || target.task_id !== props.taskId) return; + event.preventDefault(); + setMobileTarget(target); + }} open={open} onOpenChange={handleOpenChange} agentName={agentName} rowConfirmTarget={rowConfirmTarget} isFinePointer={isFinePointer} - onRowRevertRequest={setRowConfirmTarget} + onRowRevertRequest={(revision) => { + if (!isMobile) return setRowConfirmTarget(revision); + pendingMobileTarget.current = revision; + closePopover(); + }} onRowRevertCancel={() => setRowConfirmTarget(null)} onRowRevert={handleRevert} {...props} /> + setMobileTarget(null)} + onRevert={handleRevert} + /> , + closePopover: () => void, + setDiffOpen: (open: boolean) => void, +) { + const { t } = useTranslation(); + return useCallback( + async (revision: TaskPlanRevision) => { + try { + const result = await props.onRevert(revision.id); + if (result) { + toast.success(t("task:planRestoredToV", { revisionnumber: revision.revision_number })); + closePopover(); + props.setPreviewRevision(null); + setDiffOpen(false); + } else { + // The persistence hook reports a failed restore as null. + toast.error(t("task:failedToRestorePlan")); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : t("task:failedToRestorePlan")); + } + }, + [closePopover, props.onRevert, props.setPreviewRevision, setDiffOpen, t], + ); +} + /** Lazy-load revisions on the first popover open; subsequent opens reuse the * cached list. Wraps the open setter so callers don't need a ref of their own. */ function useTriggerOnFirstOpen(setOpen: (v: boolean) => void, onOpen: () => void) { @@ -149,6 +189,8 @@ function useTriggerOnFirstOpen(setOpen: (v: boolean) => void, onOpen: () => void } function RevisionsPopover({ + historyTriggerRef, + onCloseAutoFocus, open, onOpenChange, agentName, @@ -163,6 +205,8 @@ function RevisionsPopover({ setPreviewRevision, disabled = false, }: TaskPlanRevisionsProps & { + historyTriggerRef: React.RefObject; + onCloseAutoFocus: (event: Event) => void; open: boolean; onOpenChange: (next: boolean) => void; agentName: string | null; @@ -178,9 +222,10 @@ function RevisionsPopover({
{ if (isWorkflowMoveOptionsTarget(event.target)) { diff --git a/apps/web/components/task/terminal-close-inline-confirmation.test.tsx b/apps/web/components/task/terminal-close-inline-confirmation.test.tsx new file mode 100644 index 0000000000..5eff253ac2 --- /dev/null +++ b/apps/web/components/task/terminal-close-inline-confirmation.test.tsx @@ -0,0 +1,78 @@ +import { useRef, useState } from "react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { TerminalCloseInlineConfirmation } from "./terminal-close-inline-confirmation"; +import { MobilePickerSheet } from "./mobile/mobile-picker-sheet"; + +beforeEach(() => Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 })); +afterEach(() => { + cleanup(); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 }); +}); + +function Picker({ onConfirm, events }: { onConfirm: () => Promise; events: string[] }) { + const [open, setOpen] = useState(false); + const trigger = useRef(null); + return ( + + + + {open && ( + { + events.push("cancel"); + setOpen(false); + }} + onClose={() => { + events.push("close"); + setOpen(false); + }} + onConfirm={onConfirm} + /> + )} + + ); +} + +it("closes its named picker step before teardown and permits only one dispatch", async () => { + const events: string[] = []; + let finish!: () => void; + const onConfirm = vi.fn(() => { + events.push("dispatch"); + return new Promise((resolve) => { + finish = resolve; + }); + }); + render(); + const picker = screen.getByRole("dialog", { name: "Terminals" }); + fireEvent.click(screen.getByRole("button", { name: "Close build shell" })); + expect(screen.getByRole("dialog", { name: "Close terminal?" })).toBe(picker); + expect(screen.getByRole("group").textContent).toContain("Build shell"); + expect(screen.queryByRole("button", { name: "Sibling shell" })).toBeNull(); + const confirm = screen.getByRole("button", { name: "Close terminal" }); + act(() => { + fireEvent.click(confirm); + fireEvent.click(confirm); + }); + expect(screen.getByRole("button", { name: "Sibling shell" })).toBeTruthy(); + await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce()); + expect(events).toEqual(["close", "dispatch"]); + await act(async () => finish()); +}); + +it("Back restores the original trigger without calling close or teardown", async () => { + const events: string[] = []; + const onConfirm = vi.fn().mockResolvedValue(undefined); + render(); + const trigger = screen.getByRole("button", { name: "Close build shell" }); + fireEvent.click(trigger); + fireEvent.click(screen.getByRole("button", { name: "Back" })); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + expect(events).toEqual(["cancel"]); + expect(onConfirm).not.toHaveBeenCalled(); +}); diff --git a/apps/web/components/task/terminal-close-inline-confirmation.tsx b/apps/web/components/task/terminal-close-inline-confirmation.tsx index e45b814859..e38b2f4894 100644 --- a/apps/web/components/task/terminal-close-inline-confirmation.tsx +++ b/apps/web/components/task/terminal-close-inline-confirmation.tsx @@ -1,10 +1,16 @@ "use client"; import { useTranslation } from "react-i18next"; +import type { RefObject } from "react"; import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions"; +import { MobileActionConfirmation } from "@/components/confirmation/mobile-action-confirmation"; type TerminalCloseInlineConfirmationProps = { + open?: boolean; + terminalId: string; + terminalLabel: string; + focusReturnRef?: RefObject; density?: "compact" | "touch"; testId?: string; onCancel: () => void; @@ -13,6 +19,10 @@ type TerminalCloseInlineConfirmationProps = { }; export function TerminalCloseInlineConfirmation({ + open = true, + terminalId, + terminalLabel, + focusReturnRef, density = "compact", testId = "terminal-menu-close-confirmation", onCancel, @@ -21,7 +31,7 @@ export function TerminalCloseInlineConfirmation({ }: TerminalCloseInlineConfirmationProps) { const { t } = useTranslation(); - return ( + const inline = ( ); + return ( + { + if (!next) onCancel(); + }} + onClose={onClose} + onConfirm={onConfirm} + focusReturnRef={focusReturnRef} + fallback={inline} + /> + ); } diff --git a/apps/web/components/threads/threads-view-controls.test.tsx b/apps/web/components/threads/threads-view-controls.test.tsx index 5bdb8d08ff..c3b584ef39 100644 --- a/apps/web/components/threads/threads-view-controls.test.tsx +++ b/apps/web/components/threads/threads-view-controls.test.tsx @@ -3,7 +3,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ThreadView, ThreadViewDraft } from "@/lib/state/slices/ui/thread-view-types"; import { ThreadsViewControls } from "./threads-view-controls"; -const responsive = vi.hoisted(() => ({ usesDesktopWorkbench: true, isFinePointer: true })); +const responsive = vi.hoisted(() => ({ + usesDesktopWorkbench: true, + isFinePointer: true, + isMobile: false, +})); const EMPTY_CANDIDATES: never[] = []; const VIEW_PICKER_TEST_ID = "threads-view-picker"; const DELETE_ACTION_TEST_ID = "threads-view-delete"; @@ -59,6 +63,7 @@ afterEach(() => { vi.clearAllMocks(); responsive.usesDesktopWorkbench = true; responsive.isFinePointer = true; + responsive.isMobile = false; state.threadViews.draft = null; state.threadViews.syncError = null; }); @@ -91,7 +96,8 @@ describe("ThreadsViewControls", () => { await waitFor(() => expect(screen.queryByTestId("threads-view-settings-popover")).toBeNull()); }); - it("keeps mobile deletion inline until the user confirms", async () => { + it("hosts mobile deletion in the existing editor drawer until confirmed", async () => { + responsive.isMobile = true; responsive.usesDesktopWorkbench = false; responsive.isFinePointer = false; render( @@ -105,13 +111,18 @@ describe("ThreadsViewControls", () => { fireEvent.click(screen.getByTestId("threads-mobile-view-trigger")); fireEvent.click(await screen.findByTestId("threads-mobile-view-settings")); - fireEvent.click(await screen.findByTestId(DELETE_ACTION_TEST_ID)); + const trigger = await screen.findByTestId(DELETE_ACTION_TEST_ID); + const drawerId = screen.getByTestId(MOBILE_DRAWER_TEST_ID).id; + fireEvent.click(trigger); expect(state.deleteThreadView).not.toHaveBeenCalled(); const confirmation = screen.getByRole("group", { name: "Delete All threads?" }); expect(within(confirmation).getByRole("button", { name: "Cancel" }).className).toContain( - "h-11", + "min-h-12", ); + expect(trigger.isConnected).toBe(true); + expect(screen.getByRole("dialog", { name: "Delete All threads?" }).id).toBe(drawerId); + expect(document.querySelectorAll('[data-slot="drawer-content"]')).toHaveLength(1); fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" })); expect(screen.getByTestId(MOBILE_DRAWER_TEST_ID).dataset.state).toBe("open"); diff --git a/apps/web/components/threads/threads-view-controls.tsx b/apps/web/components/threads/threads-view-controls.tsx index 0fa4b4a329..7daa2b1317 100644 --- a/apps/web/components/threads/threads-view-controls.tsx +++ b/apps/web/components/threads/threads-view-controls.tsx @@ -18,6 +18,10 @@ import { DropdownMenuTrigger, } from "@kandev/ui/dropdown-menu"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer"; +import { + MobileConfirmationHost, + MobileConfirmationHostBody, +} from "@/components/confirmation/mobile-confirmation-host"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { useTranslation } from "react-i18next"; import { useAppStore } from "@/components/state-provider"; @@ -373,95 +377,103 @@ function MobileThreadsViewControls({ {t("threads:failedToSyncViews")} )} - { - setOpen(nextOpen); - if (nextOpen) setPage("views"); - }} - > - { - event.preventDefault(); - triggerRef.current?.focus(); - }} - onAnimationEnd={(event) => { - if (event.currentTarget.dataset.state === "closed") { - triggerRef.current?.focus(); - } - }} - > - -
- {page === "editor" && ( - - )} - - {page === "editor" ? t("threads:viewSettings") : t("threads:title")} - -
-
-
+ {({ contentProps }) => ( + { + setOpen(nextOpen); + if (nextOpen) setPage("views"); + }} > - {syncError && isMobile && ( - - )} - {page === "views" ? ( - setPage("editor")} - /> - ) : ( - { - onDelete(viewId); - closeDrawer(); - }} - onDuplicate={onDuplicate} - onReapplySort={onReapplySort} - /> - )} -
-
-
+ { + event.preventDefault(); + triggerRef.current?.focus(); + }} + onAnimationEnd={(event) => { + if (event.currentTarget.dataset.state === "closed") { + triggerRef.current?.focus(); + } + }} + > + + +
+ {page === "editor" && ( + + )} + + {page === "editor" ? t("threads:viewSettings") : t("threads:title")} + +
+
+
+ {syncError && isMobile && ( + + )} + {page === "views" ? ( + setPage("editor")} + /> + ) : ( + { + onDelete(viewId); + closeDrawer(); + }} + onDuplicate={onDuplicate} + onReapplySort={onReapplySort} + /> + )} +
+
+
+ + )} + ); } diff --git a/apps/web/components/threads/threads-view-editor.tsx b/apps/web/components/threads/threads-view-editor.tsx index 7f9a972caa..2160f1db63 100644 --- a/apps/web/components/threads/threads-view-editor.tsx +++ b/apps/web/components/threads/threads-view-editor.tsx @@ -14,6 +14,7 @@ import { ThreadsViewTaskPicker } from "./threads-view-task-picker"; import { EditorBody, type EditorBodyProps } from "./threads-view-editor-sections"; import { parseThreadMaxColumns } from "./threads-view-editor-utils"; import { threadViewName } from "@/lib/state/slices/ui/thread-view-builtins"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; type EditorProps = { activeView: ThreadView; @@ -55,6 +56,7 @@ export function ThreadsViewEditor({ deleteFocusBoundaryRef, }: EditorProps) { const { t } = useTranslation(); + const { isMobile } = useResponsiveBreakpoint(); const [pickerOpen, setPickerOpen] = useState(false); const [nameMode, setNameMode] = useState<"rename" | "saveAs" | null>(null); const [name, setName] = useState(""); @@ -68,7 +70,6 @@ export function ThreadsViewEditor({ current.taskScope.mode === "selected" && current.taskScope.taskIds.length === 0; const hasDraft = !!draft && draft.baseViewId === activeView.id; const repositoryNames = useMemo(() => mapRepositoryNames(repositories), [repositories]); - const invalidDraft = invalidSelectedScope || maxColumnsInvalid; const deleteConfirmation = deletion.target ? ( { @@ -134,7 +135,7 @@ export function ThreadsViewEditor({ }} onOpenPicker={() => setPickerOpen(true)} /> - {!mobile ? deleteConfirmation : null} + {!mobile || isMobile ? deleteConfirmation : null} ); } diff --git a/apps/web/components/watches/watcher-delete-action.test.tsx b/apps/web/components/watches/watcher-delete-action.test.tsx new file mode 100644 index 0000000000..5e664bbbbf --- /dev/null +++ b/apps/web/components/watches/watcher-delete-action.test.tsx @@ -0,0 +1,34 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { WatcherDeleteAction } from "./watcher-delete-action"; + +afterEach(() => { + cleanup(); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 }); +}); +it("names the phone watch and preserves the trigger through cancellation", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + const onConfirm = vi.fn(); + render( + , + ); + const trigger = screen.getByRole("button", { name: "Delete watcher" }); + fireEvent.click(trigger); + const sheet = screen.getByRole("dialog", { name: "Delete this watcher?" }); + expect(within(sheet).getByText("ENG release bugs", { selector: "p" })).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.getByRole("button", { name: "Delete" })); + await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1)); +}); diff --git a/apps/web/components/watches/watcher-delete-action.tsx b/apps/web/components/watches/watcher-delete-action.tsx index 74c651770b..8bbfb330db 100644 --- a/apps/web/components/watches/watcher-delete-action.tsx +++ b/apps/web/components/watches/watcher-delete-action.tsx @@ -7,8 +7,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; 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 WatcherDeleteActionProps = { + targetKey: string; + subject: ReactNode; title: ReactNode; cancelLabel: ReactNode; confirmLabel: ReactNode; @@ -24,10 +27,11 @@ type WatcherDeleteActionProps = { * Local delete confirmation used by watcher rows/cards. * * Fine pointers keep the initiating delete button as the popover anchor. - * Coarse pointers replace the action region with one inline cancel/delete - * group so a phone never stacks a second overlay on top of its row. + * Phones use a named sheet; wider coarse pointers keep their inline actions. */ export function WatcherDeleteAction({ + targetKey, + subject, title, cancelLabel, confirmLabel, @@ -38,25 +42,36 @@ export function WatcherDeleteAction({ testId = "watcher-delete-confirmation", onConfirm, }: WatcherDeleteActionProps) { - const { isFinePointer } = useResponsiveBreakpoint(); + const { isFinePointer, isMobile } = useResponsiveBreakpoint(); const [open, setOpen] = useState(false); const anchorRef = useRef(null); - if (!isFinePointer && open) { - return ( - setOpen(false)} - onClose={() => setOpen(false)} - onConfirm={onConfirm} - /> - ); - } + const fallback = !isFinePointer ? ( + setOpen(false)} + onClose={() => setOpen(false)} + onConfirm={onConfirm} + /> + ) : ( + setOpen(false)} + onConfirm={onConfirm} + /> + ); const trigger = (