From 69f754f8687c5e2db714e7b4d1820e3980a76829 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Jul 2026 20:42:23 -0700 Subject: [PATCH] fix: anchor task Activity view dropdown under its tab The Activity view menu (Live/Feed/Raw) is position:fixed and portaled to , 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) --- .changeset/fix-activity-dropdown-alignment.md | 7 +++ .../app/components/TaskDetailModal.tsx | 53 +++++++------------ ...askDetailModal.task-activity-chat.test.tsx | 51 ++++++++++++++++++ 3 files changed, 76 insertions(+), 35 deletions(-) create mode 100644 .changeset/fix-activity-dropdown-alignment.md diff --git a/.changeset/fix-activity-dropdown-alignment.md b/.changeset/fix-activity-dropdown-alignment.md new file mode 100644 index 0000000000..83b7b45897 --- /dev/null +++ b/.changeset/fix-activity-dropdown-alignment.md @@ -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. diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 8a386c8094..5f8be7cfd7 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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 , 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>(() => [ { value: "current", label: t("taskDetail.activity.current", "Live") }, diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.task-activity-chat.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.task-activity-chat.test.tsx index cf4b2f7806..77028170ae 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.task-activity-chat.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.task-activity-chat.test.tsx @@ -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([]);