diff --git a/.changeset/fn-6998-mobile-mission-back.md b/.changeset/fn-6998-mobile-mission-back.md new file mode 100644 index 0000000000..6c2cf89d50 --- /dev/null +++ b/.changeset/fn-6998-mobile-mission-back.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix mobile Missions back navigation from mission detail tabs. +category: fix +dev: Tracks mission detail visibility for mobile history entries instead of selected mission IDs. diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index bdc2f78e86..f873c3d224 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -2476,22 +2476,37 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr t, ); - const previousSelectedMissionIdRef = useRef(selectedMission?.id ?? null); + const previousMobileDetailVisibleRef = useRef(false); useEffect(() => { - const previousSelectedMissionId = previousSelectedMissionIdRef.current; - const currentSelectedMissionId = selectedMission?.id ?? null; - previousSelectedMissionIdRef.current = currentSelectedMissionId; + if (!isActive) { + previousMobileDetailVisibleRef.current = false; + } + }, [isActive]); - if (!isActive || !isMobile || !currentSelectedMissionId || previousSelectedMissionId === currentSelectedMissionId) { + useEffect(() => { + const isMobileDetailVisible = isActive && isMobile && Boolean(selectedMission); + + if (!isMobile) { + // Keep the mobile detail flag untouched on desktop so split-pane refreshes do not consume or create mobile nav entries. return; } - // MissionManager may already sit behind an App-level modal nav entry. - // On mobile, selecting a mission stacks a view entry on top so back goes - // detail → list → modal close instead of skipping the in-modal list. - pushNav({ type: "view", revert: handleBackToList }); - }, [handleBackToList, isActive, isMobile, pushNav, selectedMission?.id]); + if (!isMobileDetailVisible) { + previousMobileDetailVisibleRef.current = false; + return; + } + + /* + FNXC:MissionNavigation 2026-06-24-23:48: + Mobile Missions treats Structure and Activity as tabs inside one detail view. Push one view entry when detail becomes visible so browser/Android Back exits to the Missions list before any outer modal or app navigation, and do not push again for tab switches, SSE refreshes, or cached mission rehydration. + */ + if (!previousMobileDetailVisibleRef.current) { + pushNav({ type: "view", revert: handleBackToList }); + } + + previousMobileDetailVisibleRef.current = true; + }, [handleBackToList, isActive, isMobile, pushNav, selectedMission]); const selectedMilestoneTelemetry = useMemo(() => { if (!validationTelemetry || !selectedMilestoneId || !isMilestoneValidationTelemetry(validationTelemetry)) { diff --git a/packages/dashboard/app/components/__tests__/MissionManager.swipe-back.test.tsx b/packages/dashboard/app/components/__tests__/MissionManager.swipe-back.test.tsx index 3884e8f5f1..c6a0934587 100644 --- a/packages/dashboard/app/components/__tests__/MissionManager.swipe-back.test.tsx +++ b/packages/dashboard/app/components/__tests__/MissionManager.swipe-back.test.tsx @@ -1,5 +1,5 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { useState, type ReactNode } from "react"; +import { type ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { MissionManager } from "../MissionManager"; import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory"; @@ -8,6 +8,7 @@ const mockViewportMode = vi.fn<() => "mobile" | "desktop">(); const mockFetchMissions = vi.fn(); const mockFetchMission = vi.fn(); const mockFetchMissionsHealth = vi.fn(); +const mockFetchMissionEvents = vi.fn(); const mockFetchAssertions = vi.fn(); const mockFetchMilestoneValidation = vi.fn(); const mockFetchMilestoneValidationTelemetry = vi.fn(); @@ -42,6 +43,7 @@ vi.mock("../../api", async (importOriginal) => { fetchMissions: (...args: unknown[]) => mockFetchMissions(...args), fetchMission: (...args: unknown[]) => mockFetchMission(...args), fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args), + fetchMissionEvents: (...args: unknown[]) => mockFetchMissionEvents(...args), fetchAssertions: (...args: unknown[]) => mockFetchAssertions(...args), fetchMilestoneValidation: (...args: unknown[]) => mockFetchMilestoneValidation(...args), fetchMilestoneValidationTelemetry: (...args: unknown[]) => mockFetchMilestoneValidationTelemetry(...args), @@ -107,6 +109,18 @@ const rollup = { state: "not_started" as const, }; +const missionEvents = [ + { + id: "ME-001", + missionId: "M-001", + eventType: "mission_updated", + description: "Activity tab loaded", + metadata: null, + timestamp: "2026-01-03T00:00:00.000Z", + seq: 1, + }, +]; + function HistoryHarness({ children }: { children: ReactNode }) { const history = useNavigationHistory({ enabled: true }); return {children}; @@ -121,6 +135,7 @@ describe("MissionManager mobile swipe-back", () => { mockFetchMissions.mockResolvedValue(missions); mockFetchMission.mockResolvedValue(missionDetail); mockFetchMissionsHealth.mockResolvedValue({}); + mockFetchMissionEvents.mockResolvedValue({ events: missionEvents, total: missionEvents.length }); mockFetchAssertions.mockResolvedValue([]); mockFetchMilestoneValidation.mockResolvedValue(rollup); mockFetchMilestoneValidationTelemetry.mockResolvedValue(null); @@ -135,11 +150,7 @@ describe("MissionManager mobile swipe-back", () => { }); it("pushes a mobile nav entry when opening mission detail and popstate returns to the list", async () => { - render( - - - , - ); + renderMissionManager(); await userSelectMission(); await waitFor(() => { @@ -151,28 +162,77 @@ describe("MissionManager mobile swipe-back", () => { window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } })); }); - await waitFor(() => { - expect(screen.queryByText("Database Schema")).not.toBeInTheDocument(); - }); - expect(screen.getByText("Build Auth System")).toBeInTheDocument(); + await expectMissionListVisible(); }); - it("does not push a nav entry on desktop mission selection", async () => { + it("returns from the Activity tab to the mobile mission list on browser back", async () => { + renderMissionManager(); + + await userSelectMission(); + await openActivityTab(); + expect(screen.getByTestId("mission-activity-tab")).toBeInTheDocument(); + expect(screen.getByText("Activity tab loaded")).toBeInTheDocument(); + expect(window.history.pushState).toHaveBeenCalledTimes(1); + + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } })); + }); + + await expectMissionListVisible(); + }); + + it("returns from the Activity tab with the visible mobile Back button", async () => { + renderMissionManager(); + + await userSelectMission(); + await openActivityTab(); + fireEvent.click(screen.getByTestId("mission-back-btn")); + + await expectMissionListVisible(); + }); + + it("does not add duplicate mobile nav entries for repeated tab changes", async () => { + renderMissionManager(); + + await userSelectMission(); + await openActivityTab(); + fireEvent.click(screen.getByTestId("mission-tab-structure")); + fireEvent.click(screen.getByTestId("mission-tab-activity")); + fireEvent.click(screen.getByTestId("mission-tab-activity")); + + expect(window.history.pushState).toHaveBeenCalledTimes(1); + + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: null })); + }); + + await expectMissionListVisible(); + }); + + it("does not push a nav entry on desktop mission selection or tab switching", async () => { mockViewportMode.mockReturnValue("desktop"); - render( - - - , - ); + renderMissionManager(); await waitFor(() => { expect(screen.getByText("Database Schema")).toBeInTheDocument(); }); + fireEvent.click(screen.getByTestId("mission-tab-activity")); + await waitFor(() => { + expect(screen.getByTestId("mission-activity-tab")).toBeInTheDocument(); + }); expect(window.history.pushState).not.toHaveBeenCalled(); }); }); +function renderMissionManager() { + render( + + + , + ); +} + async function userSelectMission() { await waitFor(() => { expect(screen.getByText("Build Auth System")).toBeInTheDocument(); @@ -182,3 +242,27 @@ async function userSelectMission() { expect(mockFetchMission).toHaveBeenCalledWith("M-001", undefined); }); } + +async function openActivityTab() { + await waitFor(() => { + expect(screen.getByTestId("mission-tab-activity")).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId("mission-tab-activity")); + await waitFor(() => { + expect(mockFetchMissionEvents).toHaveBeenCalledWith( + "M-001", + expect.objectContaining({ limit: 50, offset: 0 }), + undefined, + ); + }); +} + +async function expectMissionListVisible() { + await waitFor(() => { + expect(screen.queryByText("Database Schema")).not.toBeInTheDocument(); + expect(screen.queryByTestId("mission-activity-tab")).not.toBeInTheDocument(); + expect(screen.queryByText("Activity tab loaded")).not.toBeInTheDocument(); + }); + expect(screen.getByText("Build Auth System")).toBeInTheDocument(); + expect(screen.getByText("API Redesign")).toBeInTheDocument(); +}