From 7e5d908efa70089c9bdf1ebe0186096f3cb01ff5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Jul 2026 09:18:47 -0700 Subject: [PATCH] FN-7375: fix Activity dropdown clipping Keep the task Activity view selector visible without blanking the mobile tab strip. - Portal the Activity view menu to the document body and clamp its fixed position to the visual viewport. - Close and refocus the menu safely across task changes, scrolling, resizing, keyboard selection, and outside clicks. - Add regression coverage for mobile clipping, tab-strip preservation, and the changeset release note. Files changed: .changeset/fn-7375-activity-dropdown-overlay.md | 7 + .../dashboard/app/components/TaskDetailModal.css | 15 +- .../dashboard/app/components/TaskDetailModal.tsx | 186 ++++++++++++++++++--- .../__tests__/TaskDetailModal.css.test.ts | 7 +- ...etailModal.responsive-and-dependencies.test.tsx | 12 +- .../TaskDetailModal.task-activity-chat.test.tsx | 61 +++++++ 6 files changed, 254 insertions(+), 34 deletions(-) Fusion-Task-Id: FN-7375 Fusion-Task-Lineage: 5999507a-edd3-4485-935a-885f7c143ed4 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7375-activity-dropdown-overlay.md | 7 + .../app/components/TaskDetailModal.css | 15 +- .../app/components/TaskDetailModal.tsx | 186 +++++++++++++++--- .../__tests__/TaskDetailModal.css.test.ts | 7 +- ...Modal.responsive-and-dependencies.test.tsx | 12 +- ...askDetailModal.task-activity-chat.test.tsx | 61 ++++++ 6 files changed, 254 insertions(+), 34 deletions(-) create mode 100644 .changeset/fn-7375-activity-dropdown-overlay.md diff --git a/.changeset/fn-7375-activity-dropdown-overlay.md b/.changeset/fn-7375-activity-dropdown-overlay.md new file mode 100644 index 0000000000..1da2e69ddf --- /dev/null +++ b/.changeset/fn-7375-activity-dropdown-overlay.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix the mobile task Activity view dropdown so it opens above the tab strip without clipping. +category: fix +dev: Root-portals and viewport-clamps the task-detail Activity Live/Feed/Raw menu with regression coverage. diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index f4f70663ea..f6160409f4 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -2503,6 +2503,9 @@ FNXC:TaskDetailTabs 2026-06-26-00:35: /* FNXC:TaskDetailActivity 2026-06-30-23:59: The Activity tab itself is the Live/Feed/Raw dropdown trigger. Keep it in the `.detail-tabs` scroller with the same tab sizing and active underline, and keep the Activity panel toolbar only for the expand affordance so no duplicate in-content selector or empty mobile shell remains. + +FNXC:TaskDetailActivity 2026-07-01-00:00: +The Activity view menu is portaled to the document body and fixed-positioned because `.detail-tabs` uses overflow scrolling on mobile. Keep overflow containment on the tab strip and move only the menu overlay out of that clipping chain. */ .detail-tab-dropdown { position: relative; @@ -2522,12 +2525,8 @@ The Activity tab itself is the Live/Feed/Raw dropdown trigger. Keep it in the `. } .activity-view-menu { - position: absolute; - inset-block-start: 100%; - inset-inline-start: 0; - z-index: 20; - min-inline-size: 100%; - margin-block-start: var(--space-xs); + position: fixed; + z-index: 1000; padding: var(--space-xs); display: flex; flex-direction: column; @@ -2536,6 +2535,8 @@ The Activity tab itself is the Live/Feed/Raw dropdown trigger. Keep it in the `. border: var(--btn-border-width) solid var(--border); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); + overflow-y: auto; + overscroll-behavior: contain; } .activity-view-menu-item { @@ -2596,7 +2597,7 @@ The Activity tab itself is the Live/Feed/Raw dropdown trigger. Keep it in the `. } .activity-view-menu { - min-inline-size: calc(100% + var(--space-xl)); + max-inline-size: calc(100vw - (var(--space-md) * 2)); } .activity-expand-toggle { diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index dc5fd30df4..d97e058ad0 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -1,5 +1,6 @@ import "./TaskDetailModal.css"; import React, { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles, Maximize2, Minimize2 } from "lucide-react"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; @@ -73,6 +74,18 @@ import type { DetailTaskInitialActionRequest } from "../hooks/useModalManager"; const STALE_PAUSED_REVIEW_LOG_REGEX = /^Stale paused review surfaced \[([^\]]+)\]/; const EMPTY_MARKDOWN_CHILD_SEPARATOR = ""; const STRING_OBJECT_TAG = "[object String]"; +const ACTIVITY_VIEW_MENU_VIEWPORT_PADDING = 16; +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; + +type ActivityViewMenuPosition = { + top: number; + left: number; + minWidth: number; + maxHeight: number; +}; function isStringValue(value: unknown): value is string { return Object.prototype.toString.call(value) === STRING_OBJECT_TAG; @@ -922,6 +935,7 @@ export function TaskDetailContent({ const [showMoveMenu, setShowMoveMenu] = useState(false); const [showActionsMenu, setShowActionsMenu] = useState(false); const [showActivityViewMenu, setShowActivityViewMenu] = useState(false); + const [activityViewMenuPosition, setActivityViewMenuPosition] = useState(null); const [sourceIssueExpanded, setSourceIssueExpanded] = useState(false); const [retriesExpanded, setRetriesExpanded] = useState(initialTab === "retries"); const [githubTrackingExpanded, setGithubTrackingExpanded] = useState(false); @@ -934,6 +948,7 @@ export function TaskDetailContent({ const activityListRef = useRef(null); const moveButtonRef = useRef(null); const actionsMenuRef = useRef(null); + const activityViewDropdownRef = useRef(null); const activityViewMenuRef = useRef(null); const activityViewButtonRef = useRef(null); @@ -1278,7 +1293,7 @@ export function TaskDetailContent({ const target = e.target as Node; const inMoveMenu = moveMenuRef.current?.contains(target); const inActionsMenu = actionsMenuRef.current?.contains(target); - const inActivityViewMenu = activityViewMenuRef.current?.contains(target); + const inActivityViewMenu = activityViewMenuRef.current?.contains(target) || activityViewButtonRef.current?.contains(target); if (!inMoveMenu && showMoveMenu) { setShowMoveMenu(false); @@ -2819,6 +2834,68 @@ export function TaskDetailContent({ closeMoveMenuAndFocusTrigger(); }, [closeMoveMenuAndFocusTrigger]); + const closeActivityViewMenuAndFocusTrigger = useCallback(() => { + setShowActivityViewMenu(false); + setActivityViewMenuPosition(null); + 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, + }; + }, []); + + const updateActivityViewMenuPosition = useCallback(() => { + const trigger = activityViewButtonRef.current; + if (!trigger) return; + + const rect = trigger.getBoundingClientRect(); + const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport(); + 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 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; + const maxHeight = Math.max( + 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; + const top = openUpward + ? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop) + : Math.min( + triggerBottom + gap + offsetTop, + viewportHeight + offsetTop - verticalPadding - maxHeight, + ); + + setActivityViewMenuPosition({ top, left, minWidth: width, maxHeight }); + }, [getEffectiveViewport]); + const activityViewOptions = useMemo>(() => [ { value: "current", label: t("taskDetail.activity.current", "Live") }, { value: "feed", label: t("taskDetail.activity.feed", "Feed") }, @@ -2830,6 +2907,8 @@ export function TaskDetailContent({ setActiveTab("chat"); setActivitySegment(value); setShowActivityViewMenu(false); + setActivityViewMenuPosition(null); + requestAnimationFrame(() => activityViewButtonRef.current?.focus()); }, []); const handleActivityTabKeyDown = useCallback((event: React.KeyboardEvent) => { @@ -2850,9 +2929,8 @@ export function TaskDetailContent({ event.preventDefault(); event.stopPropagation(); - setShowActivityViewMenu(false); - activityViewButtonRef.current?.focus(); - }, []); + closeActivityViewMenuAndFocusTrigger(); + }, [closeActivityViewMenuAndFocusTrigger]); useEffect(() => { if (!showMoveMenu) { @@ -2863,21 +2941,100 @@ export function TaskDetailContent({ firstMenuItem?.focus(); }, [showMoveMenu]); + useLayoutEffect(() => { + if (!showActivityViewMenu) { + setActivityViewMenuPosition(null); + return; + } + + updateActivityViewMenuPosition(); + }, [showActivityViewMenu, updateActivityViewMenuPosition]); + useEffect(() => { if (!showActivityViewMenu) { return; } + const handleViewportChange = () => { + setShowActivityViewMenu(false); + setActivityViewMenuPosition(null); + }; + + window.addEventListener("resize", handleViewportChange); + window.addEventListener("orientationchange", handleViewportChange); + window.addEventListener("scroll", handleViewportChange, true); + const visualViewport = window.visualViewport; + visualViewport?.addEventListener("resize", handleViewportChange); + visualViewport?.addEventListener("scroll", handleViewportChange); + + return () => { + window.removeEventListener("resize", handleViewportChange); + window.removeEventListener("orientationchange", handleViewportChange); + window.removeEventListener("scroll", handleViewportChange, true); + visualViewport?.removeEventListener("resize", handleViewportChange); + visualViewport?.removeEventListener("scroll", handleViewportChange); + }; + }, [showActivityViewMenu]); + + useEffect(() => { + setShowActivityViewMenu(false); + setActivityViewMenuPosition(null); + }, [task.id]); + + useEffect(() => { + if (!showActivityViewMenu || !activityViewMenuPosition) { + return; + } + const selectedMenuItem = activityViewMenuRef.current?.querySelector(".activity-view-menu-item[aria-current='true']"); const firstMenuItem = activityViewMenuRef.current?.querySelector(".activity-view-menu-item"); (selectedMenuItem ?? firstMenuItem)?.focus(); - }, [showActivityViewMenu]); + }, [showActivityViewMenu, activityViewMenuPosition]); + + const renderActivityViewMenu = () => { + if (!showActivityViewMenu || !activityViewMenuPosition || typeof document === "undefined") { + return null; + } + + return createPortal( +
+ {activityViewOptions.map((option) => ( + + ))} +
, + document.body, + ); + }; const renderActivityTab = () => ( -
+
{/* FNXC:TaskDetailActivity 2026-06-30-23:59: The top-level Activity tab is the only Activity view dropdown trigger. Keep the stable internal `chat` tab id and `current`/`feed`/`raw-logs` segment ids, but remove the in-panel Activity view select so desktop, embedded, and mobile tab strips have one canonical view switcher. + + FNXC:TaskDetailActivity 2026-07-01-00:00: + Mobile task-detail tabs intentionally overflow-scroll horizontally, so the Activity view menu must be root-portaled and viewport-positioned instead of rendered inside `.detail-tabs` where overflow clipping can blank adjacent tabs and content. */} - {showActivityViewMenu && ( -
- {activityViewOptions.map((option) => ( - - ))} -
- )} + {renderActivityViewMenu()}
); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.css.test.ts b/packages/dashboard/app/components/__tests__/TaskDetailModal.css.test.ts index fc5ce1d0a0..20cecba18b 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.css.test.ts +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.css.test.ts @@ -16,12 +16,15 @@ describe("TaskDetailModal CSS contract", () => { expect(css).toMatch(/\.detail-tab\s*\{[^}]*flex-shrink\s*:\s*0\s*;/); }); - it("FN-7351 keeps the Activity tab dropdown reachable on narrow task-detail surfaces", async () => { + it("FN-7351/FN-7375 keeps the Activity tab dropdown portal-safe on narrow task-detail surfaces", async () => { const css = await loadAllAppCssBaseOnly(); 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*\{[^}]*min-inline-size\s*:\s*100%\s*;/); + expect(css).toMatch(/\.activity-view-menu\s*\{[^}]*position\s*:\s*fixed\s*;/); + expect(css).toMatch(/\.activity-view-menu\s*\{[^}]*overflow-y\s*:\s*auto\s*;/); + expect(css).not.toMatch(/\.activity-view-menu\s*\{[^}]*position\s*:\s*absolute\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"); expect(css).not.toContain(".activity-segment"); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx index 8da49489c2..74d6636401 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx @@ -361,7 +361,7 @@ describe("TaskDetailModal", () => { expect(detailBodyBlock).not.toContain("overflow: hidden;"); }); - it("keeps the Activity tab dropdown compact and reachable on mobile", () => { + it("keeps the Activity tab dropdown portal-safe and reachable on mobile", () => { const css = readDashboardStylesSource(); const tabDropdownBlock = getExactCssRuleBlock(css, ".detail-tab-dropdown"); const activityTabBlock = getExactCssRuleBlock(css, ".detail-tab--activity"); @@ -373,9 +373,15 @@ describe("TaskDetailModal", () => { expect(tabDropdownBlock).toContain("flex-shrink: 0;"); expect(activityTabBlock).toContain("display: inline-flex;"); expect(activityTabBlock).toContain("gap: var(--space-xs);"); - expect(menuBlock).toContain("min-inline-size: 100%;"); + expect(menuBlock).toContain("position: fixed;"); + expect(menuBlock).toContain("z-index: 1000;"); expect(menuBlock).toContain("padding: var(--space-xs);"); - expect(mobileMenuBlock).toContain("min-inline-size: calc(100% + var(--space-xl));"); + expect(menuBlock).toContain("overflow-y: auto;"); + expect(menuBlock).not.toContain("position: absolute;"); + expect(menuBlock).not.toContain("inset-block-start"); + expect(menuBlock).not.toContain("inset-inline-start"); + expect(menuBlock).not.toContain("min-inline-size: 100%;"); + expect(mobileMenuBlock).toContain("max-inline-size: calc(100vw - (var(--space-md) * 2));"); expect(css).not.toContain(".activity-view-select"); expect(css).not.toContain(".activity-segmented-control"); expect(css).not.toContain(".activity-segment"); 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 a4207250d7..cf4b2f7806 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 @@ -137,6 +137,67 @@ describe("TaskDetailModal Activity and planner Chat tab integration", () => { expect(screen.getByText("raw executor line")).toBeInTheDocument(); }); + it("portals the mobile Activity view menu outside the tab scroller while keeping tabs and content visible", () => { + const originalInnerWidth = window.innerWidth; + const originalInnerHeight = window.innerHeight; + const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 844 }); + 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[]); + renderModal(); + + const tabs = document.querySelector(".detail-tabs"); + expect(tabs).not.toBeNull(); + expect(screen.getByText("Existing steering guidance")).toBeInTheDocument(); + + const menu = openActivityViewMenu(); + expect(menu.parentElement).toBe(document.body); + expect(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(activityViewLabels()).toEqual(["Live", "Feed", "Raw"]); + expect(topLevelTabLabels()).toEqual(expect.arrayContaining(["Activity", "Chat", "Plan", "Changes", "Review"])); + expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Plan" })).toBeInTheDocument(); + expect(screen.getByText("Existing steering guidance")).toBeInTheDocument(); + + fireEvent.keyDown(menu, { key: "Escape" }); + expect(screen.queryByRole("menu", { name: "Activity views" })).not.toBeInTheDocument(); + expect(topLevelTabLabels()).toEqual(expect.arrayContaining(["Activity", "Chat", "Plan", "Changes", "Review"])); + expect(screen.getByText("Existing steering guidance")).toBeInTheDocument(); + + selectActivityView("feed"); + expect(screen.getByRole("heading", { name: "Feed" })).toBeInTheDocument(); + expect(screen.getByText("Posted update")).toBeInTheDocument(); + expect(topLevelTabLabels()).toEqual(expect.arrayContaining(["Activity", "Chat", "Plan", "Changes", "Review"])); + + 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.queryByRole("heading", { name: "Feed" })).not.toBeInTheDocument(); + expect(screen.queryByTestId("agent-log-viewer")).not.toBeInTheDocument(); + } finally { + HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect; + Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInnerHeight }); + } + }); + it("restores Chat-first ordering and omitted non-done default when the project setting is enabled", () => { mockRawLogs([]);