feat(FN-5482): suppress touch-synthesized mouse events on overlay dismiss
Adds a `useOverlayDismiss` hook that suppresses touch-synthesized mouse events on modal/dropdown overlays to prevent unintended close behavior on touch devices, with tests covering TaskCard dismissal and overlay interaction edge cases. Documentation updates in AGENTS.md and docs/architecture.md capt Fusion-Task-Id: FN-5482 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> Fusion-Task-Id: FN-5482
This commit is contained in:
@@ -150,6 +150,10 @@ When `settings.autoMerge: false`, `in-review` is terminal-until-merged by a huma
|
||||
|
||||
`testMode?: boolean` is now available in both project and global settings. If project `testMode === true` (or the resolved default provider is `"mock"` at any tier), every AI lane is forced to `mock/scripted`, overriding per-task and per-lane model selections. The dashboard exposes this via the Settings Modal "Enable test mode" toggle and a persistent "Test mode — no real AI calls" banner.
|
||||
|
||||
### Architecture invariants
|
||||
|
||||
- FN-5482: self-healing reclaim paths must never leave a `todo` row stranded with preserved commits plus stale ownership metadata (`assignedAgentId`/`worktree`/`branch`); route to `in-review` for explicit handoff.
|
||||
|
||||
### Reliability Mechanism Coverage
|
||||
|
||||
- FN-5432 backstop: `packages/engine/src/__tests__/reliability-interactions/dependency-cycle-reconcile.test.ts` extends FN-5256 coverage with long-cycle ambiguous sweep, write-boundary/sweep race, self-defeating+cycle non-contradiction across one maintenance flow, and audit-event shape regression; core regression cases (long cycle, self-loop via update, incremental-update closes a loop, moveTask seam invariant, DependencyCycleError shape) live in `packages/core/src/__tests__/store-dependency-cycle.test.ts`.
|
||||
|
||||
@@ -1062,6 +1062,7 @@ The run-audit system records every mutation performed by the engine across four
|
||||
- **Database / `task:auto-recover-misrouted-foreign-commit`** — emitted per dropped misrouted commit during FN-4948 contamination recovery. `target` is the recovering task; metadata carries `{ droppedSha, foreignTaskId, paths }`.
|
||||
- **Database / `task:orphan-detected-no-action`** — emitted by `recoverOrphanedExecutions` (FN-5337) when row metadata looks orphaned after grace windows; annotation-only event with no lifecycle mutation (`in-progress` task stays put).
|
||||
- **Database / `task:*-no-action` backward-move family (FN-5335)** — backward self-healing sweeps now emit annotation-only events when triple proof fails instead of mutating lifecycle state. New mutation types: `task:reclaim-pr-conflict-no-action`, `task:reclaim-self-owned-branch-conflict-no-action`, `task:auto-rebound-scope-decay-no-action`, `task:finalize-no-op-review-no-action`, `task:stale-incomplete-review-no-action`, `task:ghost-review-no-action`, `task:stuck-merge-deadlock-no-action`, `task:no-progress-no-task-done-no-action`, `task:missing-worktree-review-no-action`, `task:partial-progress-no-task-done-no-action`. See `docs/self-healing-backward-move-audit.md` for per-stage disposition.
|
||||
- **Database / `task:auto-recover-reclaim-self-owned-routed-to-review` (FN-5482)** — emitted when self-healing finds preserved commits on a self-owned branch while the row is stranded in `todo` and routes it to `in-review` for explicit handoff (preserving `worktree`/`branch`/`baseCommitSha`). Metadata shape: `{ taskId, branch, worktreePath, preservedCommitCount, tipSha, sourceColumn, priorAssignedAgentId, reason }`, where `reason ∈ { "preserved-commits-with-stale-todo-metadata", "stranded-todo-reconcile" }`.
|
||||
- **Filesystem** — file:write, prompt:write, attachment:create, etc.
|
||||
- **Sandbox** — backend lifecycle events from `SandboxBackend` wiring in executor/merger/routine-runner (`sandbox:prepare`, `sandbox:run`, `sandbox:failure`, `sandbox:fallback`) introduced after FN-4636.
|
||||
|
||||
|
||||
@@ -765,8 +765,11 @@ function TaskCardComponent({
|
||||
const isQuickTap = touchDuration < TOUCH_TAP_MAX_DURATION;
|
||||
const isStationary = !hasTouchMovedRef.current;
|
||||
|
||||
// Only open modal for quick taps that didn't move significantly
|
||||
// Only open modal for quick taps that didn't move significantly.
|
||||
// Prevent default here to suppress Android compatibility mouse events
|
||||
// (mousedown/mouseup/click) that would otherwise hit a newly-mounted overlay.
|
||||
if (isQuickTap && isStationary) {
|
||||
e.preventDefault();
|
||||
touchOpenHandledRef.current = true;
|
||||
void handleClick();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from "react";
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { TaskCard, formatElapsedDurationDone, __test_areTaskCardPropsEqual } from "../TaskCard";
|
||||
import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory";
|
||||
import { useOverlayDismiss } from "../../hooks/useOverlayDismiss";
|
||||
import type { ConfirmOptions } from "../../hooks/useConfirm";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
@@ -3979,6 +3982,126 @@ describe("TaskCard mission badge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard Android tap regression", () => {
|
||||
function AndroidTapHarness({
|
||||
task,
|
||||
onOpenDetail,
|
||||
onOpenDetailWithTab,
|
||||
onClose,
|
||||
}: {
|
||||
task: Task;
|
||||
onOpenDetail: (task: Task) => void;
|
||||
onOpenDetailWithTab: (task: Task, tab: "changes") => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const nav = useNavigationHistory({ enabled: true });
|
||||
const overlayDismiss = useOverlayDismiss(() => {
|
||||
onClose();
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<NavigationHistoryProvider value={nav}>
|
||||
<TaskCard
|
||||
task={task}
|
||||
onOpenDetail={(nextTask) => {
|
||||
onOpenDetail(nextTask as Task);
|
||||
setIsOpen(true);
|
||||
nav.pushNav({
|
||||
type: "modal",
|
||||
close: () => {
|
||||
onClose();
|
||||
setIsOpen(false);
|
||||
},
|
||||
});
|
||||
}}
|
||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||
addToast={noop}
|
||||
/>
|
||||
{isOpen && (
|
||||
<div className="modal-overlay" data-testid="android-modal-overlay" {...overlayDismiss}>
|
||||
<div className="modal-content">detail</div>
|
||||
</div>
|
||||
)}
|
||||
</NavigationHistoryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
it("keeps modal open after Android compatibility mouse sequence and supports popstate close", () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
const onOpenDetailWithTab = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const pushStateSpy = vi.spyOn(window.history, "pushState");
|
||||
|
||||
render(
|
||||
<AndroidTapHarness
|
||||
task={makeTask({ column: "todo", status: undefined, mergeDetails: { landedFiles: ["a.ts"] } } as any)}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const card = document.querySelector(".card") as HTMLElement;
|
||||
fireEvent.touchStart(card, {
|
||||
touches: [{ clientX: 20, clientY: 20 }],
|
||||
changedTouches: [{ clientX: 20, clientY: 20 }],
|
||||
});
|
||||
fireEvent.touchEnd(card, {
|
||||
touches: [],
|
||||
changedTouches: [{ clientX: 20, clientY: 20 }],
|
||||
});
|
||||
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(1);
|
||||
expect(pushStateSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const overlay = screen.getByTestId("android-modal-overlay");
|
||||
fireEvent.mouseDown(overlay);
|
||||
fireEvent.mouseUp(overlay);
|
||||
expect(onClose).toHaveBeenCalledTimes(0);
|
||||
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps files-changed chip touch path opening changes tab once", () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
const onOpenDetailWithTab = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const task = makeTask({
|
||||
column: "done",
|
||||
status: undefined,
|
||||
mergeDetails: { landedFiles: ["a.ts", "b.ts"] },
|
||||
} as any);
|
||||
|
||||
render(
|
||||
<AndroidTapHarness
|
||||
task={task}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenDetailWithTab={onOpenDetailWithTab as any}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const filesChip = screen.getByRole("button", { name: "2 files changed" });
|
||||
fireEvent.touchStart(filesChip, {
|
||||
touches: [{ clientX: 12, clientY: 12 }],
|
||||
changedTouches: [{ clientX: 12, clientY: 12 }],
|
||||
});
|
||||
fireEvent.touchEnd(filesChip, {
|
||||
touches: [],
|
||||
changedTouches: [{ clientX: 12, clientY: 12 }],
|
||||
});
|
||||
fireEvent.click(filesChip);
|
||||
|
||||
expect(onOpenDetailWithTab).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenDetailWithTab).toHaveBeenCalledWith(task, "changes");
|
||||
expect(onOpenDetail).toHaveBeenCalledTimes(0);
|
||||
expect(onClose).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard agent badge", () => {
|
||||
let clearAgentCache: () => void;
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, fireEvent } from "@testing-library/react";
|
||||
import { useOverlayDismiss } from "../useOverlayDismiss";
|
||||
|
||||
function OverlayHarness({ onClose }: { onClose: () => void }) {
|
||||
const props = useOverlayDismiss(onClose);
|
||||
return (
|
||||
<div data-testid="overlay" {...props}>
|
||||
<div data-testid="modal-content">content</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useOverlayDismiss", () => {
|
||||
it("closes on real overlay mouse down/up", () => {
|
||||
const onClose = vi.fn();
|
||||
const { getByTestId } = render(<OverlayHarness onClose={onClose} />);
|
||||
const overlay = getByTestId("overlay");
|
||||
|
||||
fireEvent.mouseDown(overlay);
|
||||
fireEvent.mouseUp(overlay);
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores compatibility mouse sequence immediately after touch", () => {
|
||||
const onClose = vi.fn();
|
||||
const { getByTestId } = render(<OverlayHarness onClose={onClose} />);
|
||||
const overlay = getByTestId("overlay");
|
||||
|
||||
fireEvent.touchStart(overlay);
|
||||
fireEvent.touchEnd(overlay);
|
||||
fireEvent.mouseDown(overlay);
|
||||
fireEvent.mouseUp(overlay);
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("does not close when mouse starts inside modal and ends on overlay", () => {
|
||||
const onClose = vi.fn();
|
||||
const { getByTestId } = render(<OverlayHarness onClose={onClose} />);
|
||||
const overlay = getByTestId("overlay");
|
||||
const modal = getByTestId("modal-content");
|
||||
|
||||
fireEvent.mouseDown(modal);
|
||||
fireEvent.mouseUp(overlay);
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Returns props for a modal-overlay element that dismisses only when a real
|
||||
@@ -18,13 +18,42 @@ import { useCallback, useRef } from "react";
|
||||
export function useOverlayDismiss(onClose: () => void): {
|
||||
onMouseDown: (e: React.MouseEvent) => void;
|
||||
onMouseUp: (e: React.MouseEvent) => void;
|
||||
onTouchStart: () => void;
|
||||
onTouchEnd: () => void;
|
||||
} {
|
||||
const startedOnOverlayRef = useRef(false);
|
||||
const lastTouchAtRef = useRef(0);
|
||||
|
||||
const markTouch = useCallback(() => {
|
||||
lastTouchAtRef.current = Date.now();
|
||||
}, []);
|
||||
|
||||
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
// Android/webview may emit compatibility mouse events right after touchend.
|
||||
// Ignore those so a newly-mounted overlay is not dismissed immediately.
|
||||
if (Date.now() - lastTouchAtRef.current < 500) {
|
||||
startedOnOverlayRef.current = false;
|
||||
return;
|
||||
}
|
||||
startedOnOverlayRef.current = e.target === e.currentTarget;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const handleDocumentTouch = () => {
|
||||
lastTouchAtRef.current = Date.now();
|
||||
};
|
||||
|
||||
document.addEventListener("touchstart", handleDocumentTouch, { passive: true });
|
||||
document.addEventListener("touchend", handleDocumentTouch, { passive: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("touchstart", handleDocumentTouch);
|
||||
document.removeEventListener("touchend", handleDocumentTouch);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onMouseUp = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const shouldClose = startedOnOverlayRef.current && e.target === e.currentTarget;
|
||||
@@ -34,5 +63,5 @@ export function useOverlayDismiss(onClose: () => void): {
|
||||
[onClose],
|
||||
);
|
||||
|
||||
return { onMouseDown, onMouseUp };
|
||||
return { onMouseDown, onMouseUp, onTouchStart: markTouch, onTouchEnd: markTouch };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user