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:
gsxdsm
2026-05-23 02:05:16 -07:00
parent f36abcc56e
commit b22112af89
6 changed files with 213 additions and 3 deletions

View File

@@ -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. `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 ### 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`. - 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`.

View File

@@ -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: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: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:*-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. - **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. - **Sandbox** — backend lifecycle events from `SandboxBackend` wiring in executor/merger/routine-runner (`sandbox:prepare`, `sandbox:run`, `sandbox:failure`, `sandbox:fallback`) introduced after FN-4636.

View File

@@ -765,8 +765,11 @@ function TaskCardComponent({
const isQuickTap = touchDuration < TOUCH_TAP_MAX_DURATION; const isQuickTap = touchDuration < TOUCH_TAP_MAX_DURATION;
const isStationary = !hasTouchMovedRef.current; 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) { if (isQuickTap && isStationary) {
e.preventDefault();
touchOpenHandledRef.current = true; touchOpenHandledRef.current = true;
void handleClick(); void handleClick();
} }

View File

@@ -1,6 +1,9 @@
import React from "react";
import { afterEach, describe, it, expect, vi } from "vitest"; import { afterEach, describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { TaskCard, formatElapsedDurationDone, __test_areTaskCardPropsEqual } from "../TaskCard"; 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 { ConfirmOptions } from "../../hooks/useConfirm";
import type { Task } from "@fusion/core"; 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", () => { describe("TaskCard agent badge", () => {
let clearAgentCache: () => void; let clearAgentCache: () => void;

View File

@@ -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);
});
});

View File

@@ -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 * 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): { export function useOverlayDismiss(onClose: () => void): {
onMouseDown: (e: React.MouseEvent) => void; onMouseDown: (e: React.MouseEvent) => void;
onMouseUp: (e: React.MouseEvent) => void; onMouseUp: (e: React.MouseEvent) => void;
onTouchStart: () => void;
onTouchEnd: () => void;
} { } {
const startedOnOverlayRef = useRef(false); const startedOnOverlayRef = useRef(false);
const lastTouchAtRef = useRef(0);
const markTouch = useCallback(() => {
lastTouchAtRef.current = Date.now();
}, []);
const onMouseDown = useCallback((e: React.MouseEvent) => { 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; 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( const onMouseUp = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
const shouldClose = startedOnOverlayRef.current && e.target === e.currentTarget; const shouldClose = startedOnOverlayRef.current && e.target === e.currentTarget;
@@ -34,5 +63,5 @@ export function useOverlayDismiss(onClose: () => void): {
[onClose], [onClose],
); );
return { onMouseDown, onMouseUp }; return { onMouseDown, onMouseUp, onTouchStart: markTouch, onTouchEnd: markTouch };
} }