fix: anchor task Activity view dropdown under its tab

The Activity view menu (Live/Feed/Raw) is position:fixed and portaled to
<body>, so it is anchored to the layout viewport. It was clamped using
window.visualViewport width/offset, which under pinch-zoom or an open mobile
keyboard diverges from the layout viewport and shoved the popup to the left of
the modal. Position it purely from the layout viewport
(document.documentElement.clientWidth/clientHeight) with no visual-viewport
offset so it stays under the "Activity" trigger.

Adds a regression test asserting the menu anchors under the trigger even when
the visual viewport diverges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-01 20:42:23 -07:00
parent abd596b700
commit 69f754f868
3 changed files with 76 additions and 35 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Align the task Activity view dropdown under its tab instead of drifting to the left of the modal.
category: fix
dev: TaskDetailModal's position:fixed Activity menu now clamps to the layout viewport (document.documentElement.clientWidth/clientHeight) and no longer mixes in window.visualViewport width/offset, which shoved the popup off-anchor under pinch-zoom or an open mobile keyboard.

View File

@@ -2845,41 +2845,26 @@ export function TaskDetailContent({
activityViewButtonRef.current?.focus();
}, []);
const getEffectiveViewport = useCallback(() => {
const visualViewport = window.visualViewport;
if (visualViewport && visualViewport.width > 0 && visualViewport.height > 0) {
return {
width: visualViewport.width,
height: visualViewport.height,
offsetTop: visualViewport.offsetTop,
offsetLeft: visualViewport.offsetLeft,
};
}
return {
width: window.innerWidth,
height: window.innerHeight,
offsetTop: 0,
offsetLeft: 0,
};
}, []);
/*
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.
Position it purely from the layout viewport (`document.documentElement.clientWidth/clientHeight`) and never mix in `window.visualViewport` width/height/offset: under pinch-zoom or an open mobile keyboard the visual viewport diverges from the layout viewport (smaller width, nonzero offsetLeft/Top), and combining a shrunken visual-viewport width with a layout-viewport `getBoundingClientRect()` clamped `left` far off the trigger, so the popup rendered detached to the left of the modal instead of under the "Activity" tab.
*/
const updateActivityViewMenuPosition = useCallback(() => {
const trigger = activityViewButtonRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
const docEl = document.documentElement;
const viewportWidth = docEl?.clientWidth || window.innerWidth;
const viewportHeight = docEl?.clientHeight || window.innerHeight;
const horizontalPadding = ACTIVITY_VIEW_MENU_VIEWPORT_PADDING;
const verticalPadding = ACTIVITY_VIEW_MENU_VIEWPORT_PADDING;
const gap = ACTIVITY_VIEW_MENU_TRIGGER_GAP;
const preferredWidth = Math.max(rect.width, ACTIVITY_VIEW_MENU_MIN_WIDTH);
const width = Math.min(preferredWidth, Math.max(viewportWidth - horizontalPadding * 2, ACTIVITY_VIEW_MENU_MIN_WIDTH));
const triggerTop = rect.top - offsetTop;
const triggerBottom = rect.bottom - offsetTop;
const triggerLeft = rect.left - offsetLeft;
const spaceBelow = viewportHeight - triggerBottom;
const spaceAbove = triggerTop;
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
const availableBelow = Math.max(spaceBelow - verticalPadding - gap, ACTIVITY_VIEW_MENU_MIN_HEIGHT);
const availableAbove = Math.max(spaceAbove - verticalPadding - gap, ACTIVITY_VIEW_MENU_MIN_HEIGHT);
const openUpward = spaceBelow < ACTIVITY_VIEW_MENU_MIN_HEIGHT && spaceAbove > spaceBelow;
@@ -2887,19 +2872,17 @@ export function TaskDetailContent({
Math.min(openUpward ? availableAbove : availableBelow, ACTIVITY_VIEW_MENU_MAX_HEIGHT),
ACTIVITY_VIEW_MENU_MIN_HEIGHT,
);
const left = Math.min(
Math.max(triggerLeft, horizontalPadding),
viewportWidth - horizontalPadding - width,
) + offsetLeft;
// Anchor the menu's left edge under the trigger, shifting left only enough to stay on-screen, and never past the left padding.
const left = Math.max(
horizontalPadding,
Math.min(rect.left, viewportWidth - horizontalPadding - width),
);
const top = openUpward
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
: Math.min(
triggerBottom + gap + offsetTop,
viewportHeight + offsetTop - verticalPadding - maxHeight,
);
? Math.max(verticalPadding, rect.top - maxHeight - gap)
: Math.min(rect.bottom + gap, viewportHeight - verticalPadding - maxHeight);
setActivityViewMenuPosition({ top, left, minWidth: width, maxHeight });
}, [getEffectiveViewport]);
}, []);
const activityViewOptions = useMemo<Array<{ value: ActivitySegment; label: string }>>(() => [
{ value: "current", label: t("taskDetail.activity.current", "Live") },

View File

@@ -198,6 +198,57 @@ describe("TaskDetailModal Activity and planner Chat tab integration", () => {
}
});
// 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
// detached to the far left of the modal instead of under the "Activity" tab.
it("anchors the Activity menu under the trigger regardless of a diverging visual viewport", () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
const originalVisualViewport = Object.getOwnPropertyDescriptor(window, "visualViewport");
const documentElement = document.documentElement;
// Layout viewport is 1280x800 (wide desktop); the Activity trigger sits well inside it.
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1280 });
Object.defineProperty(window, "innerHeight", { configurable: true, value: 800 });
Object.defineProperty(documentElement, "clientWidth", { configurable: true, value: 1280 });
Object.defineProperty(documentElement, "clientHeight", { configurable: true, value: 800 });
// A pinch-zoomed visual viewport: much smaller and panned. The old code fed this into a fixed
// element's clamp and shoved it to the left edge.
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: { width: 320, height: 480, offsetLeft: 240, offsetTop: 120, addEventListener: vi.fn(), removeEventListener: vi.fn() },
});
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
if (this.classList.contains("detail-tab--activity")) {
return { x: 700, y: 96, top: 96, right: 792, bottom: 132, left: 700, width: 92, height: 36, toJSON: () => ({}) } as DOMRect;
}
return originalGetBoundingClientRect.call(this);
};
try {
renderModal();
const menu = openActivityViewMenu();
// Left edge anchored under the trigger (rect.left = 700), NOT clamped to a shrunken visual
// viewport and NOT displaced by visualViewport.offsetLeft.
expect(menu.style.left).toBe("700px");
// Opens below the trigger (rect.bottom = 132) + gap, unaffected by visualViewport.offsetTop.
expect(menu.style.top).toBe("136px");
} finally {
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;
}
});
it("restores Chat-first ordering and omitted non-done default when the project setting is enabled", () => {
mockRawLogs([]);