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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 09:18:47 -07:00
parent c194248d4b
commit 7e5d908efa
6 changed files with 254 additions and 34 deletions

View File

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

View File

@@ -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 {

View File

@@ -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<ActivityViewMenuPosition | null>(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<HTMLDivElement>(null);
const moveButtonRef = useRef<HTMLButtonElement>(null);
const actionsMenuRef = useRef<HTMLDivElement>(null);
const activityViewDropdownRef = useRef<HTMLDivElement>(null);
const activityViewMenuRef = useRef<HTMLDivElement>(null);
const activityViewButtonRef = useRef<HTMLButtonElement>(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<Array<{ value: ActivitySegment; label: string }>>(() => [
{ 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<HTMLButtonElement>) => {
@@ -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<HTMLButtonElement>(".activity-view-menu-item[aria-current='true']");
const firstMenuItem = activityViewMenuRef.current?.querySelector<HTMLButtonElement>(".activity-view-menu-item");
(selectedMenuItem ?? firstMenuItem)?.focus();
}, [showActivityViewMenu]);
}, [showActivityViewMenu, activityViewMenuPosition]);
const renderActivityViewMenu = () => {
if (!showActivityViewMenu || !activityViewMenuPosition || typeof document === "undefined") {
return null;
}
return createPortal(
<div
ref={activityViewMenuRef}
className="activity-view-menu"
role="menu"
aria-label={t("taskDetail.activity.menuLabel", "Activity views")}
onKeyDown={handleActivityViewMenuKeyDown}
style={{
top: activityViewMenuPosition.top,
left: activityViewMenuPosition.left,
minWidth: activityViewMenuPosition.minWidth,
maxHeight: activityViewMenuPosition.maxHeight,
}}
>
{activityViewOptions.map((option) => (
<button
key={option.value}
type="button"
className="activity-view-menu-item"
role="menuitem"
aria-current={activitySegment === option.value ? "true" : undefined}
onClick={() => selectActivityView(option.value)}
>
{option.label}
</button>
))}
</div>,
document.body,
);
};
const renderActivityTab = () => (
<div className="detail-tab-dropdown" ref={activityViewMenuRef}>
<div className="detail-tab-dropdown" ref={activityViewDropdownRef}>
{/*
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.
*/}
<button
ref={activityViewButtonRef}
@@ -2896,22 +3053,7 @@ export function TaskDetailContent({
<span>{t("taskDetail.tabs.activity", "Activity")}</span>
<ChevronDown className="detail-tab-chevron" aria-hidden="true" />
</button>
{showActivityViewMenu && (
<div className="activity-view-menu" role="menu" aria-label={t("taskDetail.activity.menuLabel", "Activity views")} onKeyDown={handleActivityViewMenuKeyDown}>
{activityViewOptions.map((option) => (
<button
key={option.value}
type="button"
className="activity-view-menu-item"
role="menuitem"
aria-current={activitySegment === option.value ? "true" : undefined}
onClick={() => selectActivityView(option.value)}
>
{option.label}
</button>
))}
</div>
)}
{renderActivityViewMenu()}
</div>
);

View File

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

View File

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

View File

@@ -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([]);