diff --git a/apps/web/components/confirmation/action-confirm-popover.test.tsx b/apps/web/components/confirmation/action-confirm-popover.test.tsx index 8bae9f5f36..3a9ba176e4 100644 --- a/apps/web/components/confirmation/action-confirm-popover.test.tsx +++ b/apps/web/components/confirmation/action-confirm-popover.test.tsx @@ -137,43 +137,50 @@ describe("ActionConfirmPopover anchor lifecycle", () => { }); }); +function BoundaryHarness() { + const [open, setOpen] = useState(true); + const anchorRef = useRef(null); + const menuRef = useRef(null); + return ( + <> + +
+ Menu +
+ + + ); +} + describe("ActionConfirmPopover focus boundaries", () => { afterEach(cleanup); it("keeps the popover open while focus moves inside the optional menu boundary", async () => { - function MenuHarness() { - const [open, setOpen] = useState(true); - const anchorRef = useRef(null); - const menuRef = useRef(null); - return ( - <> - -
- Menu -
- - - ); - } - - render(); + render(); screen.getByTestId("menu-boundary").focus(); await waitFor(() => expect(screen.getByRole("dialog", { name: deleteTitle })).toBeTruthy()); }); + it("keeps the popover open while pointer interaction stays inside the optional boundary", async () => { + render(); + fireEvent.pointerDown(screen.getByTestId("menu-boundary")); + + await waitFor(() => expect(screen.getByRole("dialog", { name: deleteTitle })).toBeTruthy()); + }); + it("dismisses when focus moves outside the optional menu boundary", async () => { function MenuHarness() { const [open, setOpen] = useState(true); diff --git a/apps/web/components/confirmation/action-confirm-popover.tsx b/apps/web/components/confirmation/action-confirm-popover.tsx index 367c16915a..f193b61dc9 100644 --- a/apps/web/components/confirmation/action-confirm-popover.tsx +++ b/apps/web/components/confirmation/action-confirm-popover.tsx @@ -173,6 +173,12 @@ function ActionConfirmPopoverContent({ onFocusOutside={(event) => { if (focusBoundaryRef?.current?.contains(event.target as Node)) event.preventDefault(); }} + onInteractOutside={(event) => { + const target = event.target as Node; + if (anchorRef.current?.contains(target) || focusBoundaryRef?.current?.contains(target)) { + event.preventDefault(); + } + }} onCloseAutoFocus={(event) => { event.preventDefault(); if (!confirmedRef.current && isConnected(anchorRef.current)) anchorRef.current.focus(); diff --git a/apps/web/components/confirmation/inline-confirm-actions.tsx b/apps/web/components/confirmation/inline-confirm-actions.tsx index 10d23ef733..567dc1caa0 100644 --- a/apps/web/components/confirmation/inline-confirm-actions.tsx +++ b/apps/web/components/confirmation/inline-confirm-actions.tsx @@ -66,7 +66,7 @@ export function InlineConfirmActions({ data-testid={testId} className={ description - ? "flex w-full min-w-0 flex-col items-stretch gap-2" + ? "flex min-w-0 flex-1 basis-full flex-col items-stretch gap-2" : `flex shrink-0 items-center justify-end gap-1 ${touch ? "min-h-11" : "w-full min-h-10"}` } onPointerDown={(event) => event.stopPropagation()} @@ -78,7 +78,11 @@ export function InlineConfirmActions({ onCancel(); }} > - {description ?

{description}

: null} + {description ? ( +

+ {description} +

+ ) : null}
+
+ ), +})); + +import { DiscardDialog } from "./changes-panel-dialogs"; + +afterEach(cleanup); + +function DiscardHarness({ + fileToDiscard, + filesToDiscard, +}: { + fileToDiscard: string | null; + filesToDiscard: string[] | null; +}) { + const [open, setOpen] = useState(true); + const anchorRef = useRef(null); + return ( + <> + + + + ); +} + +describe("DiscardDialog target scope", () => { + it.each([ + ["direct file", "src/one.ts", null], + ["one selected file", null, ["src/one.ts"]], + ] as const)("uses local confirmation for a %s", (_kind, fileToDiscard, filesToDiscard) => { + render( + , + ); + + expect(screen.queryByRole("alertdialog")).toBeNull(); + expect(screen.getByTestId("discard-local-confirmation")).toBeTruthy(); + }); + + it("keeps discard-all scope in the existing modal", () => { + render(); + + expect(screen.getByRole("alertdialog")).toBeTruthy(); + expect(screen.queryByTestId("discard-local-confirmation")).toBeNull(); + }); + + it("does not invoke confirmation mutation before local confirm", () => { + const onConfirm = vi.fn(); + function Harness() { + const [open, setOpen] = useState(true); + const anchorRef = useRef(null); + return ( + <> + + + + ); + } + + render(); + expect(onConfirm).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "confirm" })); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/components/task/changes-panel-dialogs.tsx b/apps/web/components/task/changes-panel-dialogs.tsx index 0594c8d432..7a45864685 100644 --- a/apps/web/components/task/changes-panel-dialogs.tsx +++ b/apps/web/components/task/changes-panel-dialogs.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, type RefObject } from "react"; import { IconGitCommit, IconLoader2, IconCheck } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; @@ -27,6 +27,7 @@ import { Input } from "@kandev/ui/input"; import { Textarea } from "@kandev/ui/textarea"; import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group"; import { Trans, useTranslation } from "react-i18next"; +import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover"; // --- Discard Confirmation Dialog --- @@ -35,45 +36,71 @@ type DiscardDialogProps = { onOpenChange: (open: boolean) => void; fileToDiscard: string | null; filesToDiscard: string[] | null; + anchorRef: RefObject; onConfirm: () => void; }; +function DiscardFileDescription({ file }: { file: string | null }) { + return ( + + This will permanently discard all changes to{" "} + {file}. This action cannot be + undone. + + ); +} + export function DiscardDialog({ open, onOpenChange, fileToDiscard, filesToDiscard, + anchorRef, onConfirm, }: DiscardDialogProps) { const { t } = useTranslation(); - const isBulk = filesToDiscard && filesToDiscard.length > 1; + const bulkCount = filesToDiscard?.length ?? 0; + const isBulk = bulkCount > 1; const displayFile = fileToDiscard ?? (filesToDiscard?.length === 1 ? filesToDiscard[0] : null); - const description = isBulk - ? t("task:discardAllChangesToFiles", { count: filesToDiscard.length }) - : null; + const description = isBulk ? t("task:discardAllChangesToFiles", { count: bulkCount }) : null; + const title = t("task:discardChanges"); + const cancelLabel = t("common:cancel"); + const confirmLabel = t("task:discard"); + + if (!isBulk) { + return ( + } + cancelLabel={cancelLabel} + confirmLabel={confirmLabel} + confirmTestId="discard-local-changes-confirm" + testId="discard-local-changes-confirm-popover" + onOpenChange={onOpenChange} + onCancel={() => onOpenChange(false)} + onConfirm={onConfirm} + /> + ); + } return ( - {t("task:discardChanges")} + {title} - {description ?? ( - - This will permanently discard all changes to{" "} - {displayFile}. This - action cannot be undone. - - )} + {description ?? } - {t("common:cancel")} + {cancelLabel} - {t("task:discard")} + {confirmLabel} diff --git a/apps/web/components/task/changes-panel-file-row.test.tsx b/apps/web/components/task/changes-panel-file-row.test.tsx index 57a0b66b9b..c00397731a 100644 --- a/apps/web/components/task/changes-panel-file-row.test.tsx +++ b/apps/web/components/task/changes-panel-file-row.test.tsx @@ -1,8 +1,18 @@ -import { describe, it, expect, vi } from "vitest"; +import { afterEach, describe, it, expect, vi } from "vitest"; import { render, fireEvent } from "@testing-library/react"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { FileRow } from "./changes-panel-file-row"; +const responsive = vi.hoisted(() => ({ isFinePointer: true })); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => responsive, +})); + +afterEach(() => { + responsive.isFinePointer = true; +}); + const noop = () => {}; const noopSelect = () => false; @@ -216,6 +226,18 @@ describe("FileRow hover swap (stats <-> actions occupy same cell)", () => { expect(gridWrapper.className).toContain("col-start-1"); expect(gridWrapper.className).toContain("row-start-1"); }); + + it("hides statistics behind always-visible actions for coarse pointers", () => { + responsive.isFinePointer = false; + const { container } = renderRow("file.go"); + const li = container.querySelector("li") as HTMLElement; + const gridWrapper = li.children[1] as HTMLElement; + const statsLayer = gridWrapper.children[0] as HTMLElement; + const actionsLayer = gridWrapper.children[1] as HTMLElement; + + expect(statsLayer.className.split(/\s+/)).toContain("opacity-0"); + expect(actionsLayer.className.split(/\s+/)).toContain("opacity-100"); + }); }); describe("FileRow tree-mode hover stage action", () => { diff --git a/apps/web/components/task/changes-panel-file-row.tsx b/apps/web/components/task/changes-panel-file-row.tsx index d85d70f7bd..b8407f6af3 100644 --- a/apps/web/components/task/changes-panel-file-row.tsx +++ b/apps/web/components/task/changes-panel-file-row.tsx @@ -19,6 +19,7 @@ import { getFileCategory } from "@/lib/utils/file-types"; import type { ChangedFile } from "./changes-panel-helpers"; import type { OpenDiffOptions } from "./changes-diff-target"; import { useTranslation } from "react-i18next"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; const splitPath = (path: string) => { const lastSlash = path.lastIndexOf("/"); @@ -42,7 +43,7 @@ type FileRowProps = { // in two repos) cannot be disambiguated by path alone. onStage: (path: string, repo?: string) => void; onUnstage: (path: string, repo?: string) => void; - onDiscard: (path: string, repo?: string) => void; + onDiscard: (path: string, repo?: string, anchor?: HTMLElement) => void; onEditFile: (path: string, repo?: string) => void; /** * Tree mode: skip the folder prefix, swap the left-side stage button for a @@ -69,6 +70,7 @@ export function FileRow({ treeMode, indentPx, }: FileRowProps) { + const { isFinePointer } = useResponsiveBreakpoint(); const { folder, file: name } = splitPath(file.path); const showFolder = !treeMode && folder; @@ -138,13 +140,11 @@ export function FileRow({
-
- - -
+ @@ -153,6 +153,20 @@ export function FileRow({ ); } +function FileRowStats({ file, isFinePointer }: { file: ChangedFile; isFinePointer: boolean }) { + return ( +
+ + +
+ ); +} + function TreeModeFileActionSlot({ name, isPending, @@ -250,28 +264,39 @@ function StageButton({ function FileRowActions({ path, repo, + isFinePointer, onDiscard, onEditFile, }: { path: string; repo?: string; - onDiscard: (path: string, repo?: string) => void; + isFinePointer: boolean; + onDiscard: (path: string, repo?: string, anchor?: HTMLElement) => void; onEditFile: (path: string, repo?: string) => void; }) { const { t } = useTranslation(); return (
diff --git a/apps/web/components/task/changes-panel-hooks.ts b/apps/web/components/task/changes-panel-hooks.ts index ee0b8aaa74..0be540ad55 100644 --- a/apps/web/components/task/changes-panel-hooks.ts +++ b/apps/web/components/task/changes-panel-hooks.ts @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useRef, useState, useCallback } from "react"; import { t } from "@/lib/i18n"; import { gitOperationLabel } from "@/hooks/use-git-with-feedback"; import type { useToast } from "@/components/toast-provider"; @@ -202,22 +202,33 @@ function useChangesDiscardAmendHandlers( const [showDiscardDialog, setShowDiscardDialog] = useState(false); const [fileToDiscard, setFileToDiscard] = useState(null); const [filesToDiscard, setFilesToDiscard] = useState(null); + const discardAnchorRef = useRef(null); // Multi-repo: remember the clicked file's repo so the discard op routes to // the right git repo. Path alone is ambiguous when two repos share a name. const [repoToDiscard, setRepoToDiscard] = useState(undefined); - const handleDiscardClick = useCallback((filePath: string, repo?: string) => { - setFileToDiscard(filePath); - setRepoToDiscard(repo); - setFilesToDiscard(null); - setShowDiscardDialog(true); - }, []); - const handleBulkDiscardClick = useCallback((paths: string[]) => { + const handleDiscardClick = useCallback( + (filePath: string, repo?: string, anchor?: HTMLElement) => { + discardAnchorRef.current = anchor ?? null; + setFileToDiscard(filePath); + setRepoToDiscard(repo); + setFilesToDiscard(null); + setShowDiscardDialog(true); + }, + [], + ); + const handleBulkDiscardClick = useCallback((paths: string[], anchor?: HTMLElement) => { + discardAnchorRef.current = anchor ?? null; setFilesToDiscard(paths); setFileToDiscard(null); setRepoToDiscard(undefined); setShowDiscardDialog(true); }, []); + const handleDiscardOpenChange = useCallback((open: boolean) => { + setShowDiscardDialog(open); + // Keep the anchor through the popover's close autofocus. A later discard + // replaces it, while confirmation clears it after the mutation settles. + }, []); const handleDiscardConfirm = useCallback(async () => { const paths = filesToDiscard ?? (fileToDiscard ? [fileToDiscard] : null); if (!paths) return; @@ -237,6 +248,7 @@ function useChangesDiscardAmendHandlers( }); } finally { setShowDiscardDialog(false); + discardAnchorRef.current = null; setFileToDiscard(null); setFilesToDiscard(null); setRepoToDiscard(undefined); @@ -271,6 +283,8 @@ function useChangesDiscardAmendHandlers( return { showDiscardDialog, setShowDiscardDialog, + discardAnchorRef, + handleDiscardOpenChange, fileToDiscard, filesToDiscard, handleDiscardClick, diff --git a/apps/web/components/task/changes-panel-timeline.tsx b/apps/web/components/task/changes-panel-timeline.tsx index ca69a95c2d..f229f1df7a 100644 --- a/apps/web/components/task/changes-panel-timeline.tsx +++ b/apps/web/components/task/changes-panel-timeline.tsx @@ -249,10 +249,10 @@ type FileListSectionProps = { // hits the right git repo. Same-named files across repos collide by path. onStage: (path: string, repo?: string) => void; onUnstage: (path: string, repo?: string) => void; - onDiscard: (path: string, repo?: string) => void; + onDiscard: (path: string, repo?: string, anchor?: HTMLElement) => void; onBulkStage?: (paths: string[]) => void; onBulkUnstage?: (paths: string[]) => void; - onBulkDiscard?: (paths: string[]) => void; + onBulkDiscard?: (paths: string[], anchor?: HTMLElement) => void; // Per-repo action handlers shown inline with each repo group header. Always // rendered, including single-repo (with one entry pre-selected) — the empty // `repo` argument routes ops to the workspace root in that case. @@ -280,7 +280,7 @@ type FileListBodyProps = { onEditFile: (path: string, repo?: string) => void; onStage: (path: string, repo?: string) => void; onUnstage: (path: string, repo?: string) => void; - onDiscard: (path: string, repo?: string) => void; + onDiscard: (path: string, repo?: string, anchor?: HTMLElement) => void; /** Per-repo group header actions; primary = Stage all (unstaged) / Commit (staged). */ onRepoAction?: (repo: string) => void; onRepoSecondaryAction?: (repo: string) => void; diff --git a/apps/web/components/task/changes-panel-tree.tsx b/apps/web/components/task/changes-panel-tree.tsx index 18d86da7d3..ae7540e12f 100644 --- a/apps/web/components/task/changes-panel-tree.tsx +++ b/apps/web/components/task/changes-panel-tree.tsx @@ -86,7 +86,7 @@ type ChangesTreeProps = { onEditFile: (path: string, repo?: string) => void; onStage: (path: string, repo?: string) => void; onUnstage: (path: string, repo?: string) => void; - onDiscard: (path: string, repo?: string) => void; + onDiscard: (path: string, repo?: string, anchor?: HTMLElement) => void; variant: "unstaged" | "staged"; /** Shared with the parent FileListSection so the BulkActionBar reflects * selections made in tree mode. */ @@ -181,7 +181,7 @@ type RepoTreeGroupProps = { onEditFile: (path: string, repo?: string) => void; onStage: (path: string, repo?: string) => void; onUnstage: (path: string, repo?: string) => void; - onDiscard: (path: string, repo?: string) => void; + onDiscard: (path: string, repo?: string, anchor?: HTMLElement) => void; primaryLabel: string; secondaryLabel?: string; onRepoAction?: (repo: string) => void; diff --git a/apps/web/components/task/file-browser-context-action.test.tsx b/apps/web/components/task/file-browser-context-action.test.tsx index ef79158bfb..805c01c98f 100644 --- a/apps/web/components/task/file-browser-context-action.test.tsx +++ b/apps/web/components/task/file-browser-context-action.test.tsx @@ -6,6 +6,7 @@ import type { FileBrowserRow } from "./file-browser-hooks"; vi.mock("./file-context-menu", () => ({ FileContextMenu: ({ children }: { children: ReactNode }) => <>{children}, + useFileDeleteAction: () => null, useFileRename: () => ({ isRenaming: false, renameValue: "", diff --git a/apps/web/components/task/file-browser-parts.tsx b/apps/web/components/task/file-browser-parts.tsx index 82f8db3c94..f7ce9fb9ca 100644 --- a/apps/web/components/task/file-browser-parts.tsx +++ b/apps/web/components/task/file-browser-parts.tsx @@ -8,6 +8,7 @@ import { IconFolderOpen, IconRefresh, IconDotsVertical, + IconTrash, } from "@tabler/icons-react"; import { DropdownMenu, @@ -18,6 +19,7 @@ import { import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { FileIcon } from "@/components/ui/file-icon"; +import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions"; import type { FileTreeNode } from "@/lib/types/backend"; import type { FileInfo } from "@/lib/state/store"; import type { FileBrowserRow } from "./file-browser-hooks"; @@ -25,6 +27,7 @@ import { InlineFileInput } from "./inline-file-input"; import { renderSessionOrLoadState } from "./file-browser-load-state"; import { FileContextMenu, + useFileDeleteAction, useFileRename, TreeNodeName, getGitStatusTextClass, @@ -151,11 +154,29 @@ export function FileTreeNodeTouchActions({ showTouchActions?: boolean; onAddToChatContext?: (node: FileTreeNode) => void; }) { - const { t } = useTranslation("chat"); - if (!showTouchActions || !onAddToChatContext) return null; + const { t } = useTranslation(); + const deleteAction = useFileDeleteAction(); + if (!showTouchActions || (!onAddToChatContext && !deleteAction)) return null; const stopRowInteraction = (event: React.SyntheticEvent) => event.stopPropagation(); + if (deleteAction?.confirming && !deleteAction.isBulk) { + return ( + + ); + } + return ( @@ -178,13 +199,28 @@ export function FileTreeNodeTouchActions({ data-testid="file-tree-touch-menu" onClick={stopRowInteraction} > - onAddToChatContext(node)} - > - {t("chat:addToChatContext")} - + {onAddToChatContext && ( + onAddToChatContext(node)} + > + {t("chat:addToChatContext")} + + )} + {deleteAction && ( + + + {deleteAction.isBulk + ? t("task:deleteItemsLabel", { count: deleteAction.selectedCount }) + : t("task:delete")} + + )} ); @@ -213,6 +249,7 @@ export function TreeNodeItem(props: TreeNodeRowProps) { const rename = useFileRename(node, tree, setTree, onRenameFile); const isSelected = props.isSelectedFn?.(node.path) ?? false; const isDropTarget = node.is_dir && props.dragOverPath === node.path; + const rowAnchorRef = React.useRef(null); const handleClick = (e: React.MouseEvent) => { if (e.button === 2) return; @@ -225,6 +262,7 @@ export function TreeNodeItem(props: TreeNodeRowProps) { // Inline the row JSX so ContextMenuTrigger asChild can attach directly to the DOM div const rowContent = (
{rowContent} @@ -453,7 +489,7 @@ function FileTreeView(props: FileBrowserContentAreaProps) { const { tree, visibleRows, creatingInPath, onCreateFileSubmit, onCancelCreate } = props; if (!tree) return null; return ( -
+
{creatingInPath === "" && ( ({ {children}
), + useFileDeleteAction: () => null, useFileRename: () => ({ isRenaming: false, renameValue: "", diff --git a/apps/web/components/task/file-context-menu.test.tsx b/apps/web/components/task/file-context-menu.test.tsx index 4a4428db9a..f61914b661 100644 --- a/apps/web/components/task/file-context-menu.test.tsx +++ b/apps/web/components/task/file-context-menu.test.tsx @@ -1,8 +1,14 @@ import React, { act } from "react"; -import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { FileTreeNode } from "@/lib/types/backend"; +const responsive = vi.hoisted(() => ({ isFinePointer: true })); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => responsive, +})); + vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: vi.fn() }), })); @@ -13,9 +19,11 @@ vi.mock("@/components/toast-provider", () => ({ // raw `ns:key` and the assertions then only prove the stub works. import { FileContextMenu, TreeNodeName, useFileRename } from "./file-context-menu"; +import { FileTreeNodeTouchActions } from "./file-browser-parts"; const FILE_NODE: FileTreeNode = { name: "README.md", path: "README.md", is_dir: false, size: 0 }; const DIR_NODE: FileTreeNode = { name: "src", path: "src", is_dir: true, size: 0 }; +const DELETE_CONFIRM_POPOVER_ID = "file-delete-confirm-popover"; const RENAME_ROW = "rename-row"; const FOCUS_ANCHOR = "focus-anchor"; const BULK_TREE: FileTreeNode = { @@ -31,6 +39,7 @@ const BULK_TREE: FileTreeNode = { afterEach(() => { cleanup(); + responsive.isFinePointer = true; vi.useRealTimers(); }); @@ -323,6 +332,60 @@ describe("FileContextMenu chat context item", () => { }); describe("FileContextMenu bulk deletion", () => { + it.each([ + ["file", FILE_NODE], + ["folder", DIR_NODE], + ] as const)("uses local confirmation for one %s", async (_kind, node) => { + const onDeleteFile = vi.fn().mockResolvedValue(true); + render( + {}} + onDeleteFile={onDeleteFile} + onStartRename={() => {}} + > +
row
+
, + ); + + openMenu("single-delete-row"); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); + + expect(onDeleteFile).not.toHaveBeenCalled(); + expect(screen.queryByRole("alertdialog")).toBeNull(); + await waitFor(() => expect(screen.getByTestId(DELETE_CONFIRM_POPOVER_ID)).toBeTruthy()); + const confirmation = screen.getByTestId(DELETE_CONFIRM_POPOVER_ID); + if (node.is_dir) { + expect(confirmation.textContent).toContain("This will permanently delete src"); + } else { + expect(confirmation.textContent).toContain("This will permanently delete README.md"); + expect(confirmation.textContent).not.toContain("file inside it"); + } + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.queryByTestId(DELETE_CONFIRM_POPOVER_ID)).toBeNull()); + expect(onDeleteFile).not.toHaveBeenCalled(); + + openMenu("single-delete-row"); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); + await waitFor(() => expect(screen.getByTestId("file-delete-confirm")).toBeTruthy()); + fireEvent.click(screen.getByTestId("file-delete-confirm")); + + await waitFor(() => expect(onDeleteFile).toHaveBeenCalledWith(node.path)); + }); + + it("keeps a multi-selection in the scope-explaining modal", () => { + const onDeleteFile = vi.fn().mockResolvedValue(true); + render(); + + openMenu("bulk-row"); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete 2 items" })); + + expect(screen.getByRole("alertdialog")).toBeTruthy(); + expect(screen.queryByTestId(DELETE_CONFIRM_POPOVER_ID)).toBeNull(); + }); + it("keeps failed paths visible after a partial deletion failure", async () => { const onDeleteFile = vi.fn(async (path: string) => path === "a.txt"); render(); @@ -336,3 +399,40 @@ describe("FileContextMenu bulk deletion", () => { expect(screen.getByTestId("tree-paths").textContent).not.toContain("a.txt"); }); }); + +describe("FileContextMenu touch actions", () => { + it("exposes Delete in the touch menu and replaces it with 44px inline actions", async () => { + responsive.isFinePointer = false; + const onDeleteFile = vi.fn().mockResolvedValue(true); + render( + {}} + onDeleteFile={onDeleteFile} + onStartRename={() => {}} + > +
+ +
+
, + ); + + fireEvent.pointerDown(screen.getByTestId("file-tree-node-actions")); + fireEvent.click(screen.getByTestId("file-tree-touch-delete")); + + await waitFor(() => expect(screen.getByTestId("file-delete-inline-confirmation")).toBeTruthy()); + expect(screen.queryByTestId(DELETE_CONFIRM_POPOVER_ID)).toBeNull(); + expect(onDeleteFile).not.toHaveBeenCalled(); + expect( + screen.getByTestId("file-delete-inline-confirmation").querySelectorAll("button"), + ).toHaveLength(2); + + fireEvent.click( + within(screen.getByTestId("file-delete-inline-confirmation")).getByTestId( + "file-delete-confirm", + ), + ); + await waitFor(() => expect(onDeleteFile).toHaveBeenCalledWith(FILE_NODE.path)); + }); +}); diff --git a/apps/web/components/task/file-context-menu.tsx b/apps/web/components/task/file-context-menu.tsx index ac6575711c..16ba575a99 100644 --- a/apps/web/components/task/file-context-menu.tsx +++ b/apps/web/components/task/file-context-menu.tsx @@ -3,16 +3,7 @@ import React, { useState, useCallback, useRef, useEffect } from "react"; import { Input } from "@kandev/ui/input"; import { IconDownload, IconMessageDots, IconPencil, IconTrash } from "@tabler/icons-react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@kandev/ui/alert-dialog"; +import { AlertDialog } from "@kandev/ui/alert-dialog"; import { ContextMenu, ContextMenuContent, @@ -22,7 +13,9 @@ import { } from "@kandev/ui/context-menu"; import { cn } from "@/lib/utils"; import { useToast } from "@/components/toast-provider"; -import { Trans, useTranslation } from "react-i18next"; +import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { useTranslation } from "react-i18next"; import type { FileTreeNode } from "@/lib/types/backend"; import type { FileInfo } from "@/lib/state/store"; import { @@ -36,32 +29,33 @@ import { canOpenNodeInEditor, useFileTreeEditorActions, } from "./file-tree-editor-menu"; +import { + DeleteConfirmDialog, + DeleteFileDescription, + DeleteFolderDescription, +} from "./file-delete-confirmation"; type GitFileStatus = FileInfo["status"] | undefined; -/** Folder-delete copy. Split out so the two `` bodies do not become a - * nested ternary inside the dialog. */ -function DeleteFolderDescription({ name, fileCount }: { name: string; fileCount: number }) { - if (fileCount > 0) { - return ( - - This will permanently delete {name} and{" "} - {fileCount} files inside it. This action cannot be - undone. - - ); - } - return ( - - This will permanently delete {name}. This action cannot - be undone. - - ); +export type FileDeleteAction = { + isBulk: boolean; + selectedCount: number; + confirming: boolean; + title: string; + label: string; + cancelLabel: string; + description: React.ReactNode; + onDelete: () => void; + onCancel: () => void; + onConfirm: () => void; +}; + +const FileDeleteActionContext = React.createContext(null); + +export function useFileDeleteAction() { + return React.useContext(FileDeleteActionContext); } + function deleteNodeOptimistically( tree: FileTreeNode | null, setTree: React.Dispatch>, @@ -91,45 +85,6 @@ function removeSuccessfullyDeletedPaths( return nextTree; } -function DeleteConfirmDialog({ - isBulk, - selectedCount, - node, - fileCount, - onConfirm, -}: { - isBulk: boolean; - selectedCount: number; - node: FileTreeNode; - fileCount: number; - onConfirm: () => void; -}) { - const { t } = useTranslation(); - const title = isBulk - ? t("task:deleteItemsTitle", { count: selectedCount }) - : t("task:deleteFolderTitle"); - return ( - - - {title} - - {isBulk ? ( - t("task:thisWillPermanentlyDeleteSelectedItems", { selectedCount }) - ) : ( - - )} - - - - {t("common:cancel")} - - {t("task:delete")} - - - - ); -} - type FileContextMenuItemsProps = { node: FileTreeNode; isBulk: boolean; @@ -138,7 +93,7 @@ type FileContextMenuItemsProps = { onRenameFile?: (oldPath: string, newPath: string) => Promise; onDownloadFile?: (path: string) => Promise; onStartRename: () => void; - onDelete: () => void; + onDelete: (event: Event) => void; }; function FileContextMenuItems({ @@ -203,12 +158,23 @@ function ChatContextMenuItem({ ); } +function attachAnchorRef( + children: React.ReactNode, + anchorRef: React.RefObject, +): React.ReactNode { + if (!React.isValidElement(children)) { + return {children}; + } + return React.cloneElement(children as React.ReactElement<{ ref?: React.Ref }>, { + ref: anchorRef, + }); +} + function useFileContextMenuDelete({ tree, setTree, node, isBulk, - needsConfirmation, onDeleteFile, selectedPaths, }: { @@ -216,14 +182,13 @@ function useFileContextMenuDelete({ setTree: React.Dispatch>; node: FileTreeNode; isBulk: boolean; - needsConfirmation: boolean; onDeleteFile?: (path: string) => Promise; selectedPaths?: Set; }) { - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false); const handleConfirmDelete = useCallback(() => { - setDeleteDialogOpen(false); + setDeleteConfirmationOpen(false); if (!onDeleteFile) return; if (isBulk && selectedPaths) { const snapshot = tree; @@ -245,14 +210,44 @@ function useFileContextMenuDelete({ const handleDelete = useCallback(() => { if (!onDeleteFile) return; - if (needsConfirmation) { - setDeleteDialogOpen(true); - return; - } - deleteNodeOptimistically(tree, setTree, node.path, onDeleteFile); - }, [tree, setTree, node.path, onDeleteFile, needsConfirmation]); + setDeleteConfirmationOpen(true); + }, [onDeleteFile]); - return { deleteDialogOpen, setDeleteDialogOpen, handleConfirmDelete, handleDelete }; + return { + deleteConfirmationOpen, + setDeleteConfirmationOpen, + handleConfirmDelete, + handleDelete, + }; +} + +function useFileMenuDeleteHandler({ + handleDelete, + isBulk, + isFinePointer, + contextMenuRef, +}: { + handleDelete: () => void; + isBulk: boolean; + isFinePointer: boolean; + contextMenuRef: React.RefObject; +}) { + return useCallback( + (event: Event) => { + const item = event.currentTarget; + contextMenuRef.current = + item instanceof HTMLElement + ? (item.closest('[data-slot="context-menu-content"]') as HTMLElement | null) + : null; + if (isBulk || !isFinePointer) { + handleDelete(); + } else { + // Let the 100 ms context-menu exit animation finish before anchoring the popover. + setTimeout(handleDelete, 150); + } + }, + [handleDelete, isBulk, isFinePointer, contextMenuRef], + ); } type FileContextMenuSurfaceProps = { @@ -265,14 +260,17 @@ type FileContextMenuSurfaceProps = { onAddToChatContext?: (node: FileTreeNode) => void; selectedCount: number; isBulk: boolean; - needsConfirmation: boolean; + isFinePointer: boolean; showOpenInEditor: boolean; hasFileActions: boolean; showAddToChatContext: boolean; - deleteDialogOpen: boolean; - setDeleteDialogOpen: (open: boolean) => void; + deleteConfirmationOpen: boolean; + setDeleteConfirmationOpen: (open: boolean) => void; + anchorRef: React.RefObject; + focusBoundaryRef: React.RefObject; + deleteAction: FileDeleteAction | null; onConfirmDelete: () => void; - onDelete: () => void; + onDelete: (event: Event) => void; }; function FileContextMenuSurface({ @@ -285,12 +283,15 @@ function FileContextMenuSurface({ onAddToChatContext, selectedCount, isBulk, - needsConfirmation, + isFinePointer, showOpenInEditor, hasFileActions, showAddToChatContext, - deleteDialogOpen, - setDeleteDialogOpen, + deleteConfirmationOpen, + setDeleteConfirmationOpen, + anchorRef, + focusBoundaryRef, + deleteAction, onConfirmDelete, onDelete, }: FileContextMenuSurfaceProps) { @@ -311,7 +312,7 @@ function FileContextMenuSurface({ ); return ( - <> + {children} @@ -333,18 +334,29 @@ function FileContextMenuSurface({ /> - {needsConfirmation && ( - - + {isBulk && ( + + )} - + {!isBulk && isFinePointer && deleteAction && ( + setDeleteConfirmationOpen(false)} + onConfirm={onConfirmDelete} + /> + )} + ); } @@ -361,6 +373,7 @@ export function FileContextMenu({ onAddToChatContext, selectedCount = 0, selectedPaths, + anchorRef: providedAnchorRef, }: { children: React.ReactNode; node: FileTreeNode; @@ -373,24 +386,55 @@ export function FileContextMenu({ onAddToChatContext?: (node: FileTreeNode) => void; selectedCount?: number; selectedPaths?: Set; + anchorRef?: React.RefObject; }) { const editorActions = useFileTreeEditorActions(); + const { isFinePointer } = useResponsiveBreakpoint(); + const { t } = useTranslation(); const isBulk = selectedCount > 1; - const needsConfirmation = node.is_dir || isBulk; // A bulk selection would make a single-node "Open in " ambiguous. const showOpenInEditor = !isBulk && canOpenNodeInEditor(editorActions, node); const hasFileActions = !!onDeleteFile || !!onRenameFile || !!onDownloadFile; const showAddToChatContext = !isBulk && !!onAddToChatContext; - const { deleteDialogOpen, setDeleteDialogOpen, handleConfirmDelete, handleDelete } = + const fallbackAnchorRef = useRef(null); + const anchorRef = providedAnchorRef ?? fallbackAnchorRef; + const contextMenuRef = useRef(null); + const { deleteConfirmationOpen, setDeleteConfirmationOpen, handleConfirmDelete, handleDelete } = useFileContextMenuDelete({ tree, setTree, node, isBulk, - needsConfirmation, onDeleteFile, selectedPaths, }); + const handleMenuDelete = useFileMenuDeleteHandler({ + handleDelete, + isBulk, + isFinePointer, + contextMenuRef, + }); + + const deleteAction = onDeleteFile + ? { + isBulk, + selectedCount, + confirming: deleteConfirmationOpen, + title: isBulk + ? t("task:deleteItemsTitle", { count: selectedCount }) + : t("task:delete2", { name: node.name }), + label: isBulk ? t("task:deleteItemsLabel", { count: selectedCount }) : t("task:delete"), + cancelLabel: t("common:cancel"), + description: node.is_dir ? ( + + ) : ( + + ), + onDelete: handleDelete, + onCancel: () => setDeleteConfirmationOpen(false), + onConfirm: handleConfirmDelete, + } + : null; if (!hasFileActions && !showOpenInEditor && !showAddToChatContext) return <>{children}; @@ -404,16 +448,19 @@ export function FileContextMenu({ onAddToChatContext={onAddToChatContext} selectedCount={selectedCount} isBulk={isBulk} - needsConfirmation={needsConfirmation} + isFinePointer={isFinePointer} showOpenInEditor={showOpenInEditor} hasFileActions={hasFileActions} showAddToChatContext={showAddToChatContext} - deleteDialogOpen={deleteDialogOpen} - setDeleteDialogOpen={setDeleteDialogOpen} + deleteConfirmationOpen={deleteConfirmationOpen} + setDeleteConfirmationOpen={setDeleteConfirmationOpen} + anchorRef={anchorRef} + focusBoundaryRef={contextMenuRef} + deleteAction={deleteAction} onConfirmDelete={handleConfirmDelete} - onDelete={handleDelete} + onDelete={handleMenuDelete} > - {children} + {providedAnchorRef ? children : attachAnchorRef(children, anchorRef)} ); } @@ -553,7 +600,7 @@ export function TreeNodeName({ return ( + This will permanently delete {name}. This action cannot + be undone. + + ); +} + +export function DeleteFolderDescription({ name, fileCount }: { name: string; fileCount: number }) { + if (fileCount > 0) { + return ( + + This will permanently delete {name} and{" "} + {fileCount} files inside it. This action cannot be + undone. + + ); + } + return ( + + This will permanently delete {name}. This action cannot + be undone. + + ); +} + +export function DeleteConfirmDialog({ + selectedCount, + onConfirm, +}: { + selectedCount: number; + onConfirm: () => void; +}) { + const { t } = useTranslation(); + return ( + + + {t("task:deleteItemsTitle", { count: selectedCount })} + + {t("task:thisWillPermanentlyDeleteSelectedItems", { selectedCount })} + + + + {t("common:cancel")} + + {t("task:delete")} + + + + ); +} diff --git a/apps/web/e2e/tests/file-tree-multi-select.spec.ts b/apps/web/e2e/tests/file-tree-multi-select.spec.ts index cf490758f6..dd82a0a1c6 100644 --- a/apps/web/e2e/tests/file-tree-multi-select.spec.ts +++ b/apps/web/e2e/tests/file-tree-multi-select.spec.ts @@ -287,11 +287,12 @@ test.describe("File Tree Multi-Select", () => { // --- Right-Click Context Menu --- - test("right-click single file shows context menu with delete", async ({ + test("right-click single file uses local delete confirmation", async ({ testPage, apiClient, seedData, backend, + prCapture, }) => { const git = new GitHelper( path.join(backend.tmpDir, "repos", "e2e-repo"), @@ -312,9 +313,19 @@ test.describe("File Tree Multi-Select", () => { await expect(file).toBeVisible({ timeout: 15_000 }); await file.click({ button: "right" }); - await expect(testPage.getByRole("menuitem", { name: "Delete" })).toBeVisible({ + const deleteItem = testPage.getByRole("menuitem", { name: "Delete" }); + await expect(deleteItem).toBeVisible({ timeout: 5_000, }); + + await deleteItem.click(); + await expect(testPage.getByTestId("file-delete-confirm-popover")).toBeVisible(); + await expect(testPage.getByRole("alertdialog")).toHaveCount(0); + await prCapture.screenshot("desktop-file-delete-confirmation", { + caption: "Desktop file context menu with one-file delete confirmation", + }); + await testPage.getByTestId("file-delete-confirm").click(); + await expect(file).not.toBeVisible({ timeout: 10_000 }); }); test("right-click on multi-selection shows bulk delete", async ({ diff --git a/apps/web/e2e/tests/git/diff-update.spec.ts b/apps/web/e2e/tests/git/diff-update.spec.ts index cc8fecf605..ffbb771849 100644 --- a/apps/web/e2e/tests/git/diff-update.spec.ts +++ b/apps/web/e2e/tests/git/diff-update.spec.ts @@ -170,7 +170,9 @@ test.describe("File editor auto-update on file change", () => { await openChangesTab(testPage); await openFileDiff(testPage, "diff_update_test.txt"); await waitForDiffText(testPage, "FIRST_MODIFICATION", 15_000); - const editFile = testPage.getByRole("button", { name: "Edit", exact: true }); + const editFile = testPage + .getByTestId("review-file-actions") + .getByRole("button", { name: "Edit", exact: true }); await expect(editFile).toBeVisible(); await editFile.click(); @@ -226,7 +228,9 @@ test.describe("File editor auto-update on file change", () => { // Open the editor from the already-loaded diff. A late git-status update is // allowed to focus Changes, so routing through the Files tab would race that // intentional focus transition instead of testing editor refresh behavior. - const editFile = testPage.getByRole("button", { name: "Edit", exact: true }); + const editFile = testPage + .getByTestId("review-file-actions") + .getByRole("button", { name: "Edit", exact: true }); await expect(editFile).toBeVisible(); await editFile.click(); const editorTab = testPage.locator(".dv-default-tab[type='file-editor']", { diff --git a/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts b/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts index c6add1dae3..83fadf8bdf 100644 --- a/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts +++ b/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts @@ -243,6 +243,60 @@ test.describe("Changes panel focus behavior", () => { await expect(session.chat).toBeVisible({ timeout: 5_000 }); }); + test("discarding one changed file uses local confirmation", async ({ + testPage, + apiClient, + seedData, + backend, + prCapture, + }) => { + test.setTimeout(90_000); + + const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); + const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir)); + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Single file discard confirmation", + seedData.agentProfileId, + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + await session.clickTab("Changes"); + + git.createFile("single-discard.txt", "discard me"); + const file = session.changesFileRow("single-discard.txt"); + await expect(file).toBeVisible({ timeout: 15_000 }); + await file.hover(); + + const discard = file.getByRole("button", { name: "Discard changes" }); + await expect(discard).toBeVisible(); + await discard.click(); + + const confirmation = testPage.getByTestId("discard-local-changes-confirm-popover"); + await expect(confirmation).toBeVisible(); + await expect(testPage.getByRole("alertdialog")).toHaveCount(0); + await expect(file).toBeVisible(); + await prCapture.screenshot("desktop-discard-file-confirmation", { + caption: "Desktop Changes panel with one-file discard confirmation", + }); + + await testPage.keyboard.press("Escape"); + await expect(confirmation).not.toBeVisible(); + await expect(discard).toBeFocused(); + + await discard.click(); + await expect(confirmation).toBeVisible(); + await testPage.getByTestId("discard-local-changes-confirm").click(); + await expect(file).not.toBeVisible({ timeout: 15_000 }); + }); + test("changes panel does not auto-focus when grouped with agent session panels", async ({ testPage, apiClient, diff --git a/apps/web/e2e/tests/task/mobile-file-confirmations.spec.ts b/apps/web/e2e/tests/task/mobile-file-confirmations.spec.ts new file mode 100644 index 0000000000..22547b13f7 --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-file-confirmations.spec.ts @@ -0,0 +1,146 @@ +import path from "node:path"; +import { test, expect, type SeedData } from "../../fixtures/test-base"; +import type { Page } from "@playwright/test"; +import type { ApiClient } from "../../helpers/api-client"; +import type { BackendContext } from "../../fixtures/backend"; +import { GitHelper, makeGitEnv } from "../../helpers/git-helper"; +import { SessionPage } from "../../pages/session-page"; + +async function setupMobileFileTask( + testPage: Page, + apiClient: ApiClient, + seedData: SeedData, + backend: BackendContext, +) { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const filePath = `mobile-delete-file-${suffix}.md`; + const git = new GitHelper( + path.join(backend.tmpDir, "repos", "e2e-repo"), + makeGitEnv(backend.tmpDir), + ); + const originalSha = git.getCurrentSha(); + const restoreRepository = () => git.exec(`git reset --hard ${originalSha}`); + + try { + git.createFile(filePath, "mobile delete confirmation\n"); + git.stageAll(); + git.commit(`add mobile delete fixture ${suffix}`); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile file confirmations", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 45_000 }); + return { session, filePath, restoreRepository }; + } catch (error) { + restoreRepository(); + throw error; + } +} + +let restoreRepository: (() => void) | undefined; + +test.describe("Mobile file confirmations", () => { + test.afterEach(() => { + restoreRepository?.(); + restoreRepository = undefined; + }); + + test("confirms one file deletion inline without horizontal overflow", async ({ + testPage, + apiClient, + seedData, + backend, + prCapture, + }) => { + test.setTimeout(90_000); + const fixture = await setupMobileFileTask(testPage, apiClient, seedData, backend); + const { session, filePath } = fixture; + restoreRepository = fixture.restoreRepository; + + expect(await testPage.evaluate(() => matchMedia("(any-pointer: coarse)").matches)).toBe(true); + await testPage.getByRole("button", { name: "Files" }).tap(); + + const file = session.fileTreeNode(filePath); + await expect(file).toBeVisible({ timeout: 15_000 }); + const trigger = session.fileTreeNodeActions(filePath); + await expect(trigger).toBeVisible(); + const triggerBox = await trigger.boundingBox(); + expect(triggerBox).not.toBeNull(); + expect(triggerBox!.width).toBeGreaterThanOrEqual(44); + expect(triggerBox!.height).toBeGreaterThanOrEqual(44); + + await trigger.tap(); + const menu = session.fileTreeTouchMenu(); + await expect(menu).toBeVisible(); + const deleteItem = testPage.getByTestId("file-tree-touch-delete"); + await expect(deleteItem).toBeVisible(); + await expect + .poll(async () => (await deleteItem.boundingBox())?.height ?? 0, { timeout: 2_000 }) + .toBeGreaterThanOrEqual(44); + + const viewport = await testPage.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + const menuBox = await menu.boundingBox(); + expect(menuBox).not.toBeNull(); + expect(menuBox!.x).toBeGreaterThanOrEqual(0); + expect(menuBox!.x + menuBox!.width).toBeLessThanOrEqual(viewport.width); + expect(menuBox!.y).toBeGreaterThanOrEqual(0); + expect(menuBox!.y + menuBox!.height).toBeLessThanOrEqual(viewport.height); + + await deleteItem.tap(); + const inline = testPage.getByTestId("file-delete-inline-confirmation"); + await expect(inline).toBeVisible(); + await expect(testPage.getByRole("alertdialog")).toHaveCount(0); + const confirm = inline.getByTestId("file-delete-confirm"); + const cancel = inline.getByRole("button", { name: "Cancel" }); + await expect(confirm).toBeVisible(); + await expect(cancel).toBeVisible(); + const inlineBox = await inline.boundingBox(); + expect(inlineBox).not.toBeNull(); + expect( + inlineBox!.x, + `inline box: ${JSON.stringify(inlineBox)}, viewport: ${viewport.width}`, + ).toBeGreaterThanOrEqual(0); + expect( + inlineBox!.x + inlineBox!.width, + `inline box: ${JSON.stringify(inlineBox)}, viewport: ${viewport.width}`, + ).toBeLessThanOrEqual(viewport.width); + for (const button of [cancel, confirm]) { + const buttonBox = await button.boundingBox(); + expect(buttonBox).not.toBeNull(); + expect(buttonBox!.x).toBeGreaterThanOrEqual(0); + expect(buttonBox!.x + buttonBox!.width).toBeLessThanOrEqual(viewport.width); + } + await expect + .poll(async () => (await confirm.boundingBox())?.height ?? 0, { timeout: 2_000 }) + .toBeGreaterThanOrEqual(44); + await expect + .poll(async () => (await cancel.boundingBox())?.height ?? 0, { timeout: 2_000 }) + .toBeGreaterThanOrEqual(44); + + expect( + await testPage.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ).toBe(true); + await prCapture.screenshot("mobile-file-delete-confirmation", { + caption: "Pixel 5 file actions keep one-file deletion confirmation inline and touch-safe", + }); + + await confirm.tap(); + await expect(file).not.toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index cd46eeb993..f816778069 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -317,6 +317,7 @@ "delete2": "Delete {{name}}", "deleteAgent": "Delete agent", "deleteComment": "Delete comment", + "deleteFileConfirm": "This will permanently delete <1>{{name}}. This action cannot be undone.", "deleteFolderConfirm": "This will permanently delete <1>{{name}}. This action cannot be undone.", "deleteFolderTitle": "Delete folder?", "deleteFolderWithFilesConfirm_one": "This will permanently delete <1>{{name}} and <4>{{count}} file inside it. This action cannot be undone.", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 995ed9dc92..54d121152a 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -317,6 +317,7 @@ "delete2": "Ďēĺēţē {{name}}", "deleteAgent": "Ďēĺēţē àĝēńţ", "deleteComment": "Ďēĺēţē ćōḿḿēńţ", + "deleteFileConfirm": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ďēĺēţē <1>{{name}}. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.", "deleteFolderConfirm": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ďēĺēţē <1>{{name}}. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.", "deleteFolderTitle": "Ďēĺēţē ƒōĺďēŕ?", "deleteFolderWithFilesConfirm_one": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ďēĺēţē <1>{{name}} àńď <4>{{count}} ƒĩĺē ĩńśĩďē ĩţ. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index ea69c720aa..053bf828bd 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -316,6 +316,7 @@ "delete2": "Eliminar {{name}}", "deleteAgent": "Eliminar agente", "deleteComment": "Eliminar comentário", + "deleteFileConfirm": "Isto elimina permanentemente <1>{{name}}. Esta ação não pode ser anulada.", "deleteFolderConfirm": "Isto elimina permanentemente <1>{{name}}. Esta ação não pode ser anulada.", "deleteFolderTitle": "Eliminar a pasta?", "deleteFolderWithFilesConfirm_one": "Isto elimina permanentemente <1>{{name}} e <4>{{count}} ficheiro que contém. Esta ação não pode ser anulada.", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index e65918827e..e13948f168 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -316,6 +316,7 @@ "delete2": "删除 {{name}}", "deleteAgent": "删除智能体", "deleteComment": "删除评论", + "deleteFileConfirm": "将永久删除 <1>{{name}}。此操作无法撤销。", "deleteFolderConfirm": "将永久删除 <1>{{name}}。此操作无法撤销。", "deleteFolderTitle": "删除文件夹?", "deleteFolderWithFilesConfirm_one": "将永久删除 <1>{{name}} 及其内的 <4>{{count}} 个文件。此操作无法撤销。", diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 62e9d514ac..2cd9ece5cc 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -316,6 +316,7 @@ "delete2": "刪除 {{name}}", "deleteAgent": "刪除代理程式", "deleteComment": "刪除評論", + "deleteFileConfirm": "將永久刪除 <1>{{name}}。此操作無法撤銷。", "deleteFolderConfirm": "將永久刪除 <1>{{name}}。此操作無法撤銷。", "deleteFolderTitle": "刪除資料夾?", "deleteFolderWithFilesConfirm_one": "將永久刪除 <1>{{name}} 及其內的 <4>{{count}} 個檔案。此操作無法撤銷。", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 8ebdc7de52..046ccc81a8 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -316,6 +316,7 @@ "delete2": "刪除 {{name}}", "deleteAgent": "刪除代理程式", "deleteComment": "刪除評論", + "deleteFileConfirm": "將永久刪除 <1>{{name}}。此操作無法撤銷。", "deleteFolderConfirm": "將永久刪除 <1>{{name}}。此操作無法撤銷。", "deleteFolderTitle": "刪除資料夾?", "deleteFolderWithFilesConfirm_one": "將永久刪除 <1>{{name}} 及其內的 <4>{{count}} 個檔案。此操作無法撤銷。",