FN-7485: keep Activity menu open on mobile iOS

Keep the task-detail Activity view picker usable during mobile iOS opening taps.

- Add a short visualViewport opening guard for the root-portaled Activity views menu while preserving normal close behavior afterward.
- Reset menu position and guard state consistently on outside click, Escape, selection, task changes, and tab toggles.
- Cover the iOS visualViewport echo, portal/CSS contract, and documentation plus a patch changeset.

Files changed:
 .changeset/activity-menu-ios.md                    |   7 +
 docs/dashboard-guide.md                            |   3 +-
 .../dashboard/app/components/TaskDetailModal.tsx   |  68 +++++++---
 .../__tests__/TaskDetailModal.css.test.ts          |   9 +-
 .../TaskDetailModal.task-activity-chat.test.tsx    | 141 ++++++++++++++++++++-
 5 files changed, 210 insertions(+), 18 deletions(-)

Fusion-Task-Id: FN-7485

Fusion-Task-Lineage: 291331c4-453c-481b-b991-41f57db23e3b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-03 21:45:30 -07:00
parent 0f05156cb7
commit b42ba9f515
5 changed files with 210 additions and 18 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep the task-detail Activity view menu open during mobile iOS taps.
category: fix
dev: Guards the Activity views dropdown against iOS visualViewport resize/scroll echoes during menu opening.

View File

@@ -1179,7 +1179,8 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig
<!-- FNXC:TaskDetailActivity 2026-06-30-23:55: Activity Live is the explicit operational steering-comment entry surface, preserving the legacy internal `current` segment id. Feed and Raw Logs remain read-only Activity segments, the Activity-wide expand control is available on every segment, and the top-level Chat tab is intentionally separate planner-model conversation rather than steering. -->
<!-- FNXC:TaskDetailActivityFirst 2026-06-30-23:59: Task Detail is Activity-first by default for active tasks: Activity renders before planner Chat and omitted non-done opens land on Activity → Live. Settings → Appearance → Open task details with Chat first restores the previous Chat-first order/default without changing explicit Activity, Chat, or Logs links. -->
The **Activity** tab is the first task-detail tab by default and presents a segmented control for **Live**, **Feed**, and **Raw Logs**. Live contains the live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Agent group headers and user message headers show a small muted relative timestamp (for example, “just now”, “1m ago”, or “2h ago”) based on the transcript timestamp, while agent group metadata still includes the entry count. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default and mirrors regular Chat's dense treatment; the summary stays single-line/ellipsis-friendly on desktop and mobile, counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When older task-agent history exists, scrolling to the top or selecting **Load previous messages** prepends earlier transcript entries without moving the message you were reading. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For non-`done` tasks, the Activity Live composer sends typed guidance through the same steering path used by comments, including active planning/triage, `in-progress`, and `in-review` sessions, plus live CLI-agent sessions reported by the session bridge; an `in-review` Activity Live message or Comments-tab task comment re-engages an executor unless an open PR blocks moving the task back, and other messages are still saved as queued guidance when no session is currently live. Feed and Raw Logs do not show the composer. On a `done` task, the same composer starts a refinement task using the typed text as feedback and shows a success toast with the new task ID, while the current task detail modal remains on the completed task. The task-detail Activity Live segment keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” for steering mode and switches to refinement copy for completed tasks, with the same inline, icon-only send affordance to the right of the input at every breakpoint. In the composer, plain **Enter** sends, **Shift+Enter** inserts a newline, and **Cmd/Ctrl+Enter** remains a supported send shortcut.
<!-- FNXC:TaskDetailActivityMobile 2026-07-03-21:30: On narrow mobile task-detail layouts, Activity view switching uses a fixed root-portaled Activity views menu so iOS viewport resize/scroll echoes during the tap cannot hide Live, Feed, or Raw Logs choices. -->
The **Activity** tab is the first task-detail tab by default and presents **Live**, **Feed**, and **Raw Logs** as a segmented control on wider layouts and as a fixed, root-portaled **Activity views** dropdown on narrow mobile layouts. Live contains the live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Agent group headers and user message headers show a small muted relative timestamp (for example, “just now”, “1m ago”, or “2h ago”) based on the transcript timestamp, while agent group metadata still includes the entry count. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default and mirrors regular Chat's dense treatment; the summary stays single-line/ellipsis-friendly on desktop and mobile, counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When older task-agent history exists, scrolling to the top or selecting **Load previous messages** prepends earlier transcript entries without moving the message you were reading. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For non-`done` tasks, the Activity Live composer sends typed guidance through the same steering path used by comments, including active planning/triage, `in-progress`, and `in-review` sessions, plus live CLI-agent sessions reported by the session bridge; an `in-review` Activity Live message or Comments-tab task comment re-engages an executor unless an open PR blocks moving the task back, and other messages are still saved as queued guidance when no session is currently live. Feed and Raw Logs do not show the composer. On a `done` task, the same composer starts a refinement task using the typed text as feedback and shows a success toast with the new task ID, while the current task detail modal remains on the completed task. The task-detail Activity Live segment keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” for steering mode and switches to refinement copy for completed tasks, with the same inline, icon-only send affordance to the right of the input at every breakpoint. In the composer, plain **Enter** sends, **Shift+Enter** inserts a newline, and **Cmd/Ctrl+Enter** remains a supported send shortcut.
The top-level **Chat** tab opens the planner-model conversation for the same task instead of posting steering comments. It appears after Activity by default, or before Activity when **Settings → Appearance → Open task details with Chat first** is enabled. Each send includes server-built, bounded context for the task id, status/column/progress/current step, dependencies, recent activity/comment excerpts, prompt/plan content, and available source/review state; unavailable sections are labeled so the planner states uncertainty rather than inventing execution evidence. Opening the tab with no existing history does not create a database chat row; when no planner-chat history is found, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review. Selecting a starter creates/resumes the planner chat and sends that prompt as an ordinary chat message through the task-context-aware planner-chat composer/stream path, including for completed tasks. On live tasks, clear bounded implementation-change requests are routed to task steering; on `done` tasks, clear follow-up implementation or improvement requests are routed through a task-scoped planner refinement tool that calls the same refinement creation path as the completed-task Activity composer. The starter prompts disappear while history is loading or after conversation history exists, so Activity Live, Feed, Raw Logs, and the steering/refinement composer remain separate. Planner Chat uses the same standard chat bubble, markdown/plain assistant rendering, thinking details, tool-call/question cards, and mobile first-tap send/stop affordance as the main Chat view while keeping task-scoped planner sessions separate. Planner Chat defaults to focused mode, keeps its composer visible at the bottom while only the transcript scrolls, and on narrow/mobile task-detail layouts collapses nonessential rows above the chat until the user selects the Chat collapse control.

View File

@@ -79,6 +79,7 @@ const ACTIVITY_VIEW_MENU_TRIGGER_GAP = 4;
const ACTIVITY_VIEW_MENU_MIN_WIDTH = 160;
const ACTIVITY_VIEW_MENU_MIN_HEIGHT = 120;
const ACTIVITY_VIEW_MENU_MAX_HEIGHT = 320;
const ACTIVITY_VIEW_MENU_OPEN_VIEWPORT_GUARD_MS = 350;
type ActivityViewMenuPosition = {
top: number;
@@ -963,6 +964,7 @@ export function TaskDetailContent({
const activityViewDropdownRef = useRef<HTMLDivElement>(null);
const activityViewMenuRef = useRef<HTMLDivElement>(null);
const activityViewButtonRef = useRef<HTMLButtonElement>(null);
const activityViewMenuViewportGuardUntilRef = useRef(0);
// Plugin UI slots for task-detail-tab
const { getSlotsForId: getPluginSlots } = usePluginUiSlots(projectId);
@@ -1314,7 +1316,9 @@ export function TaskDetailContent({
setShowActionsMenu(false);
}
if (!inActivityViewMenu && showActivityViewMenu) {
activityViewMenuViewportGuardUntilRef.current = 0;
setShowActivityViewMenu(false);
setActivityViewMenuPosition(null);
}
};
@@ -1332,7 +1336,11 @@ export function TaskDetailContent({
e.stopPropagation(); // Prevent modal from closing
if (showMoveMenu) setShowMoveMenu(false);
if (showActionsMenu) setShowActionsMenu(false);
if (showActivityViewMenu) setShowActivityViewMenu(false);
if (showActivityViewMenu) {
activityViewMenuViewportGuardUntilRef.current = 0;
setShowActivityViewMenu(false);
setActivityViewMenuPosition(null);
}
}
};
@@ -2891,11 +2899,17 @@ export function TaskDetailContent({
}, [closeMoveMenuAndFocusTrigger]);
const closeActivityViewMenuAndFocusTrigger = useCallback(() => {
activityViewMenuViewportGuardUntilRef.current = 0;
setShowActivityViewMenu(false);
setActivityViewMenuPosition(null);
activityViewButtonRef.current?.focus();
}, []);
const markActivityViewMenuOpening = useCallback(() => {
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
activityViewMenuViewportGuardUntilRef.current = now + ACTIVITY_VIEW_MENU_OPEN_VIEWPORT_GUARD_MS;
}, []);
/*
FNXC:TaskDetailActivity 2026-07-01-12:20:
The Activity view menu is `position: fixed` and portaled to <body>, so it is anchored to the LAYOUT viewport, and `getBoundingClientRect()` returns layout-viewport-relative coordinates that a fixed element consumes directly.
@@ -2943,6 +2957,7 @@ export function TaskDetailContent({
const selectedActivityViewLabel = activityViewOptions.find((option) => option.value === activitySegment)?.label ?? activityViewOptions[0]?.label ?? "Live";
const selectActivityView = useCallback((value: ActivitySegment) => {
activityViewMenuViewportGuardUntilRef.current = 0;
setActiveTab("chat");
setActivitySegment(value);
setShowActivityViewMenu(false);
@@ -2957,9 +2972,10 @@ export function TaskDetailContent({
}
event.preventDefault();
markActivityViewMenuOpening();
setActiveTab("chat");
setShowActivityViewMenu(true);
}, []);
}, [markActivityViewMenuOpening]);
const handleActivityViewMenuKeyDown = useCallback((event: React.KeyboardEvent<HTMLElement>) => {
if (event.key !== "Escape") {
@@ -2994,28 +3010,43 @@ export function TaskDetailContent({
return;
}
const handleViewportChange = () => {
const closeForViewportChange = () => {
activityViewMenuViewportGuardUntilRef.current = 0;
setShowActivityViewMenu(false);
setActivityViewMenuPosition(null);
};
window.addEventListener("resize", handleViewportChange);
window.addEventListener("orientationchange", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
/*
FNXC:TaskDetailActivity 2026-07-03-18:00:
Mobile iOS can emit visualViewport scroll/resize as part of the same tap sequence that opens the root-portaled Activity menu. Ignore only that short opening echo and keep the layout-viewport position fresh; later viewport, orientation, outside, Escape, task-change, and selection closes still clean up the menu.
*/
const handleVisualViewportChange = () => {
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
if (now <= activityViewMenuViewportGuardUntilRef.current) {
updateActivityViewMenuPosition();
return;
}
closeForViewportChange();
};
window.addEventListener("resize", closeForViewportChange);
window.addEventListener("orientationchange", closeForViewportChange);
window.addEventListener("scroll", closeForViewportChange, true);
const visualViewport = window.visualViewport;
visualViewport?.addEventListener("resize", handleViewportChange);
visualViewport?.addEventListener("scroll", handleViewportChange);
visualViewport?.addEventListener("resize", handleVisualViewportChange);
visualViewport?.addEventListener("scroll", handleVisualViewportChange);
return () => {
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("orientationchange", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
visualViewport?.removeEventListener("resize", handleViewportChange);
visualViewport?.removeEventListener("scroll", handleViewportChange);
window.removeEventListener("resize", closeForViewportChange);
window.removeEventListener("orientationchange", closeForViewportChange);
window.removeEventListener("scroll", closeForViewportChange, true);
visualViewport?.removeEventListener("resize", handleVisualViewportChange);
visualViewport?.removeEventListener("scroll", handleVisualViewportChange);
};
}, [showActivityViewMenu]);
}, [showActivityViewMenu, updateActivityViewMenuPosition]);
useEffect(() => {
activityViewMenuViewportGuardUntilRef.current = 0;
setShowActivityViewMenu(false);
setActivityViewMenuPosition(null);
}, [task.id]);
@@ -3080,8 +3111,15 @@ export function TaskDetailContent({
type="button"
className={`detail-tab detail-tab--activity${activeTab === "chat" ? " detail-tab-active" : ""}`}
onClick={() => {
const shouldOpen = !showActivityViewMenu;
setActiveTab("chat");
setShowActivityViewMenu((value) => !value);
if (shouldOpen) {
markActivityViewMenuOpening();
} else {
activityViewMenuViewportGuardUntilRef.current = 0;
setActivityViewMenuPosition(null);
}
setShowActivityViewMenu(shouldOpen);
}}
onKeyDown={handleActivityTabKeyDown}
aria-haspopup="menu"

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { loadAllAppCssBaseOnly } from "../../test/cssFixture";
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture";
function getCssRuleBlock(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -46,12 +46,19 @@ describe("TaskDetailModal CSS contract", () => {
it("FN-7351/FN-7375 keeps the Activity tab dropdown portal-safe on narrow task-detail surfaces", async () => {
const css = await loadAllAppCssBaseOnly();
const fullCss = await loadAllAppCss();
expect(css).toMatch(/\.detail-tab-dropdown\s*\{[^}]*flex-shrink\s*:\s*0\s*;/);
expect(css).toMatch(/\.detail-tab--activity\s*\{[^}]*display\s*:\s*inline-flex\s*;/);
expect(css).toMatch(/\.activity-view-menu\s*\{[^}]*position\s*:\s*fixed\s*;/);
expect(css).toMatch(/\.activity-view-menu\s*\{[^}]*z-index\s*:\s*1000\s*;/);
expect(css).toMatch(/\.activity-view-menu\s*\{[^}]*overflow-y\s*:\s*auto\s*;/);
expect(css).toMatch(/\.activity-view-menu\s*\{[^}]*overscroll-behavior\s*:\s*contain\s*;/);
expect(fullCss).toMatch(
/@media\s*\(max-width:\s*768px\)\s*\{[^}]*\.activity-view-menu\s*\{[^}]*max-inline-size\s*:\s*calc\(100vw - \(var\(--space-md\) \* 2\)\)\s*;/,
);
expect(css).not.toMatch(/\.activity-view-menu\s*\{[^}]*position\s*:\s*absolute\s*;/);
expect(css).not.toMatch(/\.activity-view-menu\s*\{[^}]*inset-(?:block|inline)-start\s*:/);
expect(css).not.toMatch(/\.activity-view-menu\s*\{[^}]*min-inline-size\s*:\s*100%\s*;/);
expect(css).not.toContain(".activity-view-select");
expect(css).not.toContain(".activity-segmented-control");

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { act, render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React, { type ComponentProps } from "react";
import type { AgentLogEntry } from "@fusion/core";
@@ -92,6 +92,18 @@ function mockRawLogs(entries: AgentLogEntry[]) {
});
}
function createMockVisualViewport(width: number, height: number) {
const visualViewport = new EventTarget() as EventTarget & Pick<VisualViewport, "width" | "height" | "offsetLeft" | "offsetTop" | "scale">;
Object.defineProperties(visualViewport, {
width: { configurable: true, value: width },
height: { configurable: true, value: height },
offsetLeft: { configurable: true, value: 0 },
offsetTop: { configurable: true, value: 0 },
scale: { configurable: true, value: 1 },
});
return visualViewport;
}
describe("TaskDetailModal Activity and planner Chat tab integration", () => {
it("defaults to Activity first while selecting Live, Feed, and Raw without duplicate panels on desktop", async () => {
const user = userEvent.setup();
@@ -198,6 +210,133 @@ describe("TaskDetailModal Activity and planner Chat tab integration", () => {
}
});
it("keeps the mobile iOS Activity menu usable through the opening visualViewport echo", () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
const originalVisualViewport = Object.getOwnPropertyDescriptor(window, "visualViewport");
const documentElement = document.documentElement;
const performanceNowSpy = vi.spyOn(performance, "now").mockReturnValue(100);
const visualViewport = createMockVisualViewport(390, 720);
const addVisualViewportListenerSpy = vi.spyOn(visualViewport, "addEventListener");
const removeVisualViewportListenerSpy = vi.spyOn(visualViewport, "removeEventListener");
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
Object.defineProperty(window, "innerHeight", { configurable: true, value: 844 });
Object.defineProperty(documentElement, "clientWidth", { configurable: true, value: 390 });
Object.defineProperty(documentElement, "clientHeight", { configurable: true, value: 844 });
Object.defineProperty(window, "visualViewport", { configurable: true, value: visualViewport });
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
if (this.classList.contains("detail-tab--activity")) {
return { x: 24, y: 96, top: 96, right: 116, bottom: 132, left: 24, width: 92, height: 36, toJSON: () => ({}) } as DOMRect;
}
return originalGetBoundingClientRect.call(this);
};
try {
mockRawLogs([
{ timestamp: "2026-06-30T20:03:00.000Z", taskId: "FN-7315", type: "text", agent: "executor", text: "raw executor line" },
] as AgentLogEntry[]);
const { rerender, unmount } = renderModal();
const activityButton = screen.getByRole("button", { name: "Activity" });
fireEvent.click(activityButton);
let menu = screen.getByRole("menu", { name: "Activity views" });
performanceNowSpy.mockReturnValue(120);
act(() => {
visualViewport.dispatchEvent(new Event("resize"));
visualViewport.dispatchEvent(new Event("scroll"));
});
menu = screen.getByRole("menu", { name: "Activity views" });
expect(menu.parentElement).toBe(document.body);
expect(document.querySelector(".detail-tabs")).not.toContainElement(menu);
expect(document.querySelector(".detail-tab-dropdown")?.contains(menu)).toBe(false);
expect(menu).toHaveStyle({ position: "fixed" });
expect(menu.style.top).not.toBe("");
expect(menu.style.left).not.toBe("");
expect(parseFloat(menu.style.left)).toBeGreaterThanOrEqual(0);
expect(parseFloat(menu.style.left)).toBeLessThan(390);
expect(parseFloat(menu.style.top)).toBeGreaterThanOrEqual(0);
expect(parseFloat(menu.style.top)).toBeLessThan(844);
expect(document.querySelectorAll(".activity-view-menu")).toHaveLength(1);
expect(document.querySelectorAll(".detail-tab--activity")).toHaveLength(1);
expect(activityButton).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("Existing steering guidance")).toBeInTheDocument();
fireEvent.click(screen.getByRole("menuitem", { name: "Feed" }));
expect(screen.queryByRole("menu", { name: "Activity views" })).not.toBeInTheDocument();
expect(activityButton).toHaveAttribute("aria-expanded", "false");
expect(screen.getByRole("heading", { name: "Feed" })).toBeInTheDocument();
expect(screen.getByText("Posted update")).toBeInTheDocument();
selectActivityView("raw-logs");
expect(screen.getByTestId("agent-log-viewer")).toBeInTheDocument();
expect(screen.getByText("raw executor line")).toBeInTheDocument();
selectActivityView("current");
expect(screen.getByText("Existing steering guidance")).toBeInTheDocument();
expect(screen.queryByTestId("agent-log-viewer")).not.toBeInTheDocument();
fireEvent.click(activityButton);
expect(screen.getByRole("menu", { name: "Activity views" })).toBeInTheDocument();
fireEvent.mouseDown(document.body);
expect(screen.queryByRole("menu", { name: "Activity views" })).not.toBeInTheDocument();
expect(activityButton).toHaveAttribute("aria-expanded", "false");
fireEvent.click(activityButton);
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("menu", { name: "Activity views" })).not.toBeInTheDocument();
expect(activityButton).toHaveAttribute("aria-expanded", "false");
fireEvent.click(activityButton);
performanceNowSpy.mockReturnValue(1000);
act(() => {
visualViewport.dispatchEvent(new Event("resize"));
});
expect(screen.queryByRole("menu", { name: "Activity views" })).not.toBeInTheDocument();
expect(activityButton).toHaveAttribute("aria-expanded", "false");
fireEvent.click(activityButton);
expect(screen.getByRole("menu", { name: "Activity views" })).toBeInTheDocument();
rerender(
<TaskDetailModal
task={makeTask({ id: "FN-7485-NEXT", column: "in-progress" as any, log: [], steeringComments: [] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByRole("menu", { name: "Activity views" })).not.toBeInTheDocument();
expect(document.querySelector(".activity-view-select")).toBeNull();
expect(document.querySelector(".activity-segmented-control")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
expect(screen.getByRole("menu", { name: "Activity views" })).toBeInTheDocument();
unmount();
expect(screen.queryByRole("menu", { name: "Activity views" })).not.toBeInTheDocument();
expect(removeVisualViewportListenerSpy).toHaveBeenCalledWith("resize", expect.any(Function));
expect(removeVisualViewportListenerSpy).toHaveBeenCalledWith("scroll", expect.any(Function));
expect(addVisualViewportListenerSpy).toHaveBeenCalledWith("resize", expect.any(Function));
expect(addVisualViewportListenerSpy).toHaveBeenCalledWith("scroll", expect.any(Function));
} finally {
performanceNowSpy.mockRestore();
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInnerHeight });
if (originalVisualViewport) {
Object.defineProperty(window, "visualViewport", originalVisualViewport);
} else {
delete (window as unknown as { visualViewport?: unknown }).visualViewport;
}
delete (documentElement as unknown as { clientWidth?: number }).clientWidth;
delete (documentElement as unknown as { clientHeight?: number }).clientHeight;
}
});
// FN-7375 regression: the position:fixed Activity menu is anchored to the layout viewport, so a
// diverging window.visualViewport (pinch-zoom / open mobile keyboard: smaller width, nonzero
// offsetLeft) must NOT shift the menu away from the trigger. Symptom was the popup rendering