From 8d36b99b22558d4a8e1470e474bb710a6cdd9604 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 16:13:38 -0700 Subject: [PATCH] FN-7536: fix Activity dropdown closing on mobile scroll/resize echoes Route window resize/orientationchange/scroll through the same opening-viewport guard used by visualViewport so a same-gesture echo repositions the Activity menu instead of closing it, fixing the dropdown not showing on mobile. - Add handleGuardedViewportChange shared by resize/orientationchange/visualViewport listeners so an opening-gesture echo only repositions the menu instead of closing it - Add handleScrollChange to treat scrolls originating inside the .detail-tabs horizontal tab strip as benign (reposition-only), since window scroll listens with capture and would otherwise see nested scroller scrolls as a close signal - Add regression tests covering mobile Activity dropdown open/scroll/resize behavior - Add changeset for the fix - Update dashboard guide docs Files changed: .changeset/fn-7536-activity-dropdown-mobile.md | 7 + docs/dashboard-guide.md | 1 + .../dashboard/app/components/TaskDetailModal.tsx | 38 +++-- .../TaskDetailModal.task-activity-chat.test.tsx | 161 +++++++++++++++++++++ 4 files changed, 196 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-7536 Fusion-Task-Lineage: c8a3b11e-9bd2-4a61-b49e-038c93cfe4d2 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7536-activity-dropdown-mobile.md | 7 + docs/dashboard-guide.md | 1 + .../app/components/TaskDetailModal.tsx | 38 +++-- ...askDetailModal.task-activity-chat.test.tsx | 161 ++++++++++++++++++ 4 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 .changeset/fn-7536-activity-dropdown-mobile.md diff --git a/.changeset/fn-7536-activity-dropdown-mobile.md b/.changeset/fn-7536-activity-dropdown-mobile.md new file mode 100644 index 0000000000..1fefce406b --- /dev/null +++ b/.changeset/fn-7536-activity-dropdown-mobile.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix task-detail Activity view dropdown not opening reliably on mobile. +category: fix +dev: Guards the Activity menu's window resize/orientationchange/scroll close-listener with the same opening-tap timing guard already used for visualViewport, and exempts scroll events originating in the `.detail-tabs` scroller, so a same-gesture mobile tap echo (Android/iOS, fixed modal or `.floating-window--task-detail` popup) no longer closes the menu the instant it opens. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 0cdeef4f30..c03740ec54 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1224,6 +1224,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig + 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. The dropdown stays above its owning task-detail modal or task popup and follows the Activity tab while a popup is dragged or resized. 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. diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 43a58f7ceb..23a9f5e89e 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -3037,8 +3037,11 @@ export function TaskDetailContent({ /* 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. + + FNXC:TaskDetailActivity 2026-07-04-19:10: + FN-7536: this recurred on Android/mobile Chrome because the window `resize`/`orientationchange`/`scroll` (capture) close path below had NO opening-guard, unlike visualViewport's. Tapping the Activity tab can itself trigger a same-gesture window `scroll` or `resize` echo (browser auto-scrolling the tapped element into view, URL-bar collapse, or IME/keyboard show), which closed the menu the instant it opened. Route ALL of resize/orientationchange/scroll through the SAME opening-viewport-guard as visualViewport so a same-gesture echo only repositions, while a later, real, viewport change still closes it. */ - const handleVisualViewportChange = () => { + const handleGuardedViewportChange = () => { const now = typeof performance !== "undefined" ? performance.now() : Date.now(); if (now <= activityViewMenuViewportGuardUntilRef.current) { updateActivityViewMenuPosition(); @@ -3047,6 +3050,19 @@ export function TaskDetailContent({ closeForViewportChange(); }; + /* + FNXC:TaskDetailActivity 2026-07-04-19:10: + `.detail-tabs` is an intentional horizontal overflow scroller (FN-6xx tab strip), so scrolling it — including a mobile drag that brings the Activity tab into view — is benign, expected interaction, NOT a "true viewport change" that should close the menu. The window `scroll` listener uses capture so it also observes this nested scroller's scroll events; reposition-only (never close) when the scroll originated inside `.detail-tabs`. + */ + const handleScrollChange = (event: Event) => { + const target = event.target; + if (target instanceof Node && activityViewDropdownRef.current?.closest(".detail-tabs")?.contains(target)) { + updateActivityViewMenuPosition(); + return; + } + handleGuardedViewportChange(); + }; + let positionFrame = 0; const schedulePositionUpdate = () => { if (positionFrame) return; @@ -3056,26 +3072,26 @@ export function TaskDetailContent({ }); }; - window.addEventListener("resize", closeForViewportChange); - window.addEventListener("orientationchange", closeForViewportChange); - window.addEventListener("scroll", closeForViewportChange, true); + window.addEventListener("resize", handleGuardedViewportChange); + window.addEventListener("orientationchange", handleGuardedViewportChange); + window.addEventListener("scroll", handleScrollChange, true); window.addEventListener(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, schedulePositionUpdate); document.addEventListener("pointermove", schedulePositionUpdate, true); document.addEventListener("pointerup", schedulePositionUpdate, true); const visualViewport = window.visualViewport; - visualViewport?.addEventListener("resize", handleVisualViewportChange); - visualViewport?.addEventListener("scroll", handleVisualViewportChange); + visualViewport?.addEventListener("resize", handleGuardedViewportChange); + visualViewport?.addEventListener("scroll", handleGuardedViewportChange); return () => { if (positionFrame) cancelAnimationFrame(positionFrame); - window.removeEventListener("resize", closeForViewportChange); - window.removeEventListener("orientationchange", closeForViewportChange); - window.removeEventListener("scroll", closeForViewportChange, true); + window.removeEventListener("resize", handleGuardedViewportChange); + window.removeEventListener("orientationchange", handleGuardedViewportChange); + window.removeEventListener("scroll", handleScrollChange, true); window.removeEventListener(FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT, schedulePositionUpdate); document.removeEventListener("pointermove", schedulePositionUpdate, true); document.removeEventListener("pointerup", schedulePositionUpdate, true); - visualViewport?.removeEventListener("resize", handleVisualViewportChange); - visualViewport?.removeEventListener("scroll", handleVisualViewportChange); + visualViewport?.removeEventListener("resize", handleGuardedViewportChange); + visualViewport?.removeEventListener("scroll", handleGuardedViewportChange); }; }, [showActivityViewMenu, updateActivityViewMenuPosition]); 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 5d17b666a2..bee0d0c4bb 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 @@ -338,6 +338,167 @@ describe("TaskDetailModal Activity and planner Chat tab integration", () => { } }); + // FN-7536 regression: Android/mobile Chrome can fire a `window` resize/scroll as an echo of the + // SAME tap that opens the Activity menu (URL-bar collapse, tapped-element auto-scroll-into-view), + // distinct from the iOS visualViewport echo above. Before the fix this unguarded window listener + // closed the menu the instant it opened; now it must be guarded the same way visualViewport is, + // while a later, real resize/scroll still closes the menu. + it("keeps the mobile Android Activity menu usable through the opening window resize/scroll echo", () => { + const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; + const performanceNowSpy = vi.spyOn(performance, "now").mockReturnValue(100); + 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([]); + renderModal(); + const activityButton = screen.getByRole("button", { name: "Activity" }); + + fireEvent.click(activityButton); + expect(screen.getByRole("menu", { name: "Activity views" })).toBeInTheDocument(); + + // Same-gesture echo: still inside the opening guard window, so it must reposition, not close. + performanceNowSpy.mockReturnValue(120); + act(() => { + fireEvent.scroll(window); + fireEvent(window, new Event("resize")); + }); + expect(screen.getByRole("menu", { name: "Activity views" })).toBeInTheDocument(); + expect(activityButton).toHaveAttribute("aria-expanded", "true"); + + // A later, real resize (well past the opening guard window) is a true viewport change and + // must still close the menu. + performanceNowSpy.mockReturnValue(10_000); + act(() => { + fireEvent(window, new Event("resize")); + }); + expect(screen.queryByRole("menu", { name: "Activity views" })).not.toBeInTheDocument(); + expect(activityButton).toHaveAttribute("aria-expanded", "false"); + + // Orientation change follows the same guarded path. + fireEvent.click(activityButton); + expect(screen.getByRole("menu", { name: "Activity views" })).toBeInTheDocument(); + performanceNowSpy.mockReturnValue(20_000); + act(() => { + fireEvent(window, new Event("orientationchange")); + }); + expect(screen.queryByRole("menu", { name: "Activity views" })).not.toBeInTheDocument(); + } finally { + performanceNowSpy.mockRestore(); + HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect; + } + }); + + // FN-7536 regression: `.detail-tabs` is an intentional horizontal overflow scroller. Scrolling it + // (e.g. a mobile drag that brings the Activity tab further into view while the menu is open) must + // reposition the menu, never close it — only a genuine page/viewport scroll outside the tab + // scroller is a "true viewport change". + it("repositions instead of closing the Activity menu when the .detail-tabs scroller itself scrolls", () => { + const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; + const performanceNowSpy = vi.spyOn(performance, "now").mockReturnValue(100); + 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([]); + renderModal(); + const activityButton = screen.getByRole("button", { name: "Activity" }); + + fireEvent.click(activityButton); + expect(screen.getByRole("menu", { name: "Activity views" })).toBeInTheDocument(); + + // Move well past the opening guard window so only the scroll-origin exemption is under test. + performanceNowSpy.mockReturnValue(10_000); + const tabsScroller = document.querySelector(".detail-tabs"); + expect(tabsScroller).not.toBeNull(); + act(() => { + fireEvent.scroll(tabsScroller as Element); + }); + expect(screen.getByRole("menu", { name: "Activity views" })).toBeInTheDocument(); + expect(activityButton).toHaveAttribute("aria-expanded", "true"); + + // A real page scroll (not originating in the tab scroller) still closes the menu. + act(() => { + fireEvent.scroll(window); + }); + expect(screen.queryByRole("menu", { name: "Activity views" })).not.toBeInTheDocument(); + } finally { + performanceNowSpy.mockRestore(); + HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect; + } + }); + + // FN-7536 regression: the .floating-window--task-detail popup host must ALSO honor the opening + // guard — a same-gesture window scroll/resize echo while the menu is opening inside a popup must + // not close it, matching the fixed-modal-host behavior above. + it("keeps the Activity menu open through an opening-tap window scroll echo inside the popup host", () => { + const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; + const performanceNowSpy = vi.spyOn(performance, "now").mockReturnValue(100); + HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() { + if (this.classList.contains("detail-tab--activity")) { + return { x: 144, y: 90, top: 90, right: 236, bottom: 126, left: 144, width: 92, height: 36, toJSON: () => ({}) } as DOMRect; + } + return originalGetBoundingClientRect.call(this); + }; + + try { + mockRawLogs([]); + render( + + + , + ); + + const activityButton = screen.getByRole("button", { name: "Activity" }); + fireEvent.click(activityButton); + expect(screen.getByRole("menu", { name: "Activity views" })).toBeInTheDocument(); + const menu = screen.getByRole("menu", { name: "Activity views" }); + expect(menu.parentElement).toBe(document.body); + + // Same-gesture echo inside the popup host: must reposition, not close. + performanceNowSpy.mockReturnValue(150); + act(() => { + fireEvent.scroll(window); + }); + expect(screen.getByRole("menu", { name: "Activity views" })).toBeInTheDocument(); + + // A later real scroll still closes it. + performanceNowSpy.mockReturnValue(10_000); + act(() => { + fireEvent.scroll(window); + }); + expect(screen.queryByRole("menu", { name: "Activity views" })).not.toBeInTheDocument(); + } finally { + performanceNowSpy.mockRestore(); + HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect; + } + }); + // 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