From b570a834ba915922bf20863a046b2c8fca5a7a42 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 20:27:09 -0700 Subject: [PATCH] FN-7156: open Missions on overview by default Ensure the Missions tab opens to the overview unless an explicit mission target is requested. - Remove selected-mission cache restore and inline first-mission auto-selection. - Treat hidden inline Missions as inactive so reopening resets to overview. - Update SWR cache keys and dashboard tests for the overview-first behavior. - Add a patch changeset documenting the operator-facing fix. Files changed: .changeset/fn-7156-missions-overview-default.md | 7 + .../dashboard/app/components/DashboardLoader.tsx | 1 - .../dashboard/app/components/MissionManager.tsx | 50 ++---- .../components/__tests__/DashboardLoader.test.tsx | 1 - .../MissionManager.overview-default.test.tsx | 174 +++++++++++++++++++++ .../dashboard/app/utils/__tests__/swrCache.test.ts | 1 - packages/dashboard/app/utils/swrCache.ts | 1 - 7 files changed, 191 insertions(+), 44 deletions(-) Fusion-Task-Id: FN-7156 Fusion-Task-Lineage: 1be987d5-33c6-4a59-905f-14c8d22b75d1 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7156-missions-overview-default.md | 7 + .../app/components/DashboardLoader.tsx | 1 - .../app/components/MissionManager.tsx | 50 +---- .../__tests__/DashboardLoader.test.tsx | 1 - .../MissionManager.overview-default.test.tsx | 174 ++++++++++++++++++ .../app/utils/__tests__/swrCache.test.ts | 1 - packages/dashboard/app/utils/swrCache.ts | 1 - 7 files changed, 191 insertions(+), 44 deletions(-) create mode 100644 .changeset/fn-7156-missions-overview-default.md create mode 100644 packages/dashboard/app/components/__tests__/MissionManager.overview-default.test.tsx diff --git a/.changeset/fn-7156-missions-overview-default.md b/.changeset/fn-7156-missions-overview-default.md new file mode 100644 index 0000000000..9666f6f0a7 --- /dev/null +++ b/.changeset/fn-7156-missions-overview-default.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Missions tab now always opens the mission overview instead of a specific mission. +category: fix +dev: Removed MissionManager cache-restore and default-select effects; targetMissionId deep-links and interview resume still open a specific mission. diff --git a/packages/dashboard/app/components/DashboardLoader.tsx b/packages/dashboard/app/components/DashboardLoader.tsx index 4b4134c82f..9d602d7166 100644 --- a/packages/dashboard/app/components/DashboardLoader.tsx +++ b/packages/dashboard/app/components/DashboardLoader.tsx @@ -67,7 +67,6 @@ export function DashboardLoader({ stage }: DashboardLoaderProps) { clearCache(SWR_CACHE_KEYS.EVALS_RUNS_PREFIX); clearCache(SWR_CACHE_KEYS.EVALS_RESULTS_PREFIX); clearCache(SWR_CACHE_KEYS.MISSIONS_PREFIX); - clearCache(SWR_CACHE_KEYS.MISSIONS_SELECTED_ID_PREFIX); clearCache(SWR_CACHE_KEYS.MAILBOX_INBOX_PREFIX); clearCache(SWR_CACHE_KEYS.MAILBOX_OUTBOX_PREFIX); clearCache(SWR_CACHE_KEYS.MAILBOX_UNREAD_COUNT_PREFIX); diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index 91b985cfb7..b9a53f7ee0 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -619,10 +619,13 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr const { t } = useTranslation("app"); const { confirm } = useConfirm(); const sessionTabId = useMemo(() => getSessionTabId(), []); - const isActive = isInline || isOpen; + /* + FNXC:Missions 2026-06-27-20:25: + Inline Missions can stay mounted while hidden, so tab visibility must drive active state. Re-opening the tab must reset to overview instead of preserving the previously selected mission. + */ + const isActive = isOpen; const cacheSuffix = projectId ?? ""; const missionsCacheKey = `${SWR_CACHE_KEYS.MISSIONS_PREFIX}${cacheSuffix}`; - const selectedMissionIdCacheKey = `${SWR_CACHE_KEYS.MISSIONS_SELECTED_ID_PREFIX}${cacheSuffix}`; const initialMissions = readCache(missionsCacheKey); const [missions, setMissions] = useState(() => (Array.isArray(initialMissions) ? initialMissions : [])); const [selectedMission, setSelectedMission] = useState(null); @@ -1252,23 +1255,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr hasHydratedRef.current = hasCachedMissions; }, [missionsCacheKey]); - useEffect(() => { - const selectedMissionId = readCache(selectedMissionIdCacheKey); - if (!selectedMissionId) { - return; - } - const hasMissionSummary = missions.some((mission) => mission.id === selectedMissionId); - if (hasMissionSummary && !selectedMission) { - void loadMissionDetail(selectedMissionId); - } - }, [loadMissionDetail, missions, selectedMission, selectedMissionIdCacheKey]); - - useEffect(() => { - if (selectedMission?.id) { - writeCache(selectedMissionIdCacheKey, selectedMission.id, { maxBytes: 500_000 }); - } - }, [selectedMission?.id, selectedMissionIdCacheKey]); - useEffect(() => { if (isActive) { loadMissions(); @@ -1284,6 +1270,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr } }, [isActive, loadActiveGoals, loadMissions]); + /* + FNXC:Missions 2026-06-27-00:00: + Entering the Missions tab must always land on the overview list with an empty detail pane. Only explicit `targetMissionId` deep links or interview resume sessions may open a specific mission automatically; do not restore the last selected mission or default-select the first mission on tab entry. + */ // Auto-load target mission when specified const targetLoadedRef = useRef(null); useEffect(() => { @@ -1300,25 +1290,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr } }, [isActive]); - // Default-select the first mission once the list loads (inline desktop view). - // Gated on `isInline` so the standalone modal flow (and unit tests that - // render without isInline) keep the explicit "select a mission" empty state. - const defaultSelectedRef = useRef(false); - useEffect(() => { - if (!isActive || !isInline || isMobile || loading) return; - if (defaultSelectedRef.current) return; - if (selectedMission || targetMissionId) return; - if (missions.length === 0) return; - defaultSelectedRef.current = true; - loadMissionDetail(missions[0].id); - }, [isActive, isInline, isMobile, loading, missions, selectedMission, targetMissionId, loadMissionDetail]); - - useEffect(() => { - if (!isActive) { - defaultSelectedRef.current = false; - } - }, [isActive]); - useEffect(() => { if (!isActive || !selectedMission || activeTab !== "activity") { return; @@ -2470,7 +2441,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr }, [loadMissionDetail]); const handleBackToList = useCallback(() => { - writeCache(selectedMissionIdCacheKey, null, { maxBytes: 500_000 }); setSelectedMission(null); setSelectedMilestoneId(null); setValidationTelemetry(null); @@ -2480,7 +2450,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr setEventsFilter("all"); setExpandedEventMetadata(new Set()); loadMissions(); - }, [loadMissions, selectedMissionIdCacheKey]); + }, [loadMissions]); const hasMoreEvents = missionEvents.length < eventsTotal; const autopilotState = (selectedMission?.autopilotState ?? "inactive") as AutopilotState; diff --git a/packages/dashboard/app/components/__tests__/DashboardLoader.test.tsx b/packages/dashboard/app/components/__tests__/DashboardLoader.test.tsx index 45fc700ac2..5082caeadc 100644 --- a/packages/dashboard/app/components/__tests__/DashboardLoader.test.tsx +++ b/packages/dashboard/app/components/__tests__/DashboardLoader.test.tsx @@ -93,7 +93,6 @@ describe("DashboardLoader", () => { expect(mockClearCache).toHaveBeenCalledWith(SWR_CACHE_KEYS.EVALS_RUNS_PREFIX); expect(mockClearCache).toHaveBeenCalledWith(SWR_CACHE_KEYS.EVALS_RESULTS_PREFIX); expect(mockClearCache).toHaveBeenCalledWith(SWR_CACHE_KEYS.MISSIONS_PREFIX); - expect(mockClearCache).toHaveBeenCalledWith(SWR_CACHE_KEYS.MISSIONS_SELECTED_ID_PREFIX); expect(mockClearCache).toHaveBeenCalledWith(SWR_CACHE_KEYS.MAILBOX_INBOX_PREFIX); expect(mockClearCache).toHaveBeenCalledWith(SWR_CACHE_KEYS.MAILBOX_OUTBOX_PREFIX); expect(mockClearCache).toHaveBeenCalledWith(SWR_CACHE_KEYS.MAILBOX_UNREAD_COUNT_PREFIX); diff --git a/packages/dashboard/app/components/__tests__/MissionManager.overview-default.test.tsx b/packages/dashboard/app/components/__tests__/MissionManager.overview-default.test.tsx new file mode 100644 index 0000000000..e9b918d4a2 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/MissionManager.overview-default.test.tsx @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { MissionManager } from "../MissionManager"; + +const mockFetchMissions = vi.fn(); +const mockFetchMission = vi.fn(); +const mockFetchMissionsHealth = vi.fn(); +const mockFetchAiSessions = vi.fn(); +const mockFetchMissionInterviewDrafts = vi.fn(); + +const STALE_SELECTED_MISSION_CACHE_KEY = "kb-dashboard-mission-selected-cache:p1"; + +vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), + }; +}); + +vi.mock("../../api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchMissions: (...args: unknown[]) => mockFetchMissions(...args), + fetchMission: (...args: unknown[]) => mockFetchMission(...args), + fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args), + fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args), + fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args), + }; +}); + +const missions = [ + { id: "M-001", title: "First Mission", description: "", status: "planning", milestones: [] }, + { id: "M-002", title: "Deep Linked Mission", description: "", status: "active", milestones: [] }, +]; + +function missionDetail(id: string) { + const summary = missions.find((mission) => mission.id === id) ?? missions[0]; + return { + ...summary, + milestones: [], + }; +} + +function setViewport(width: number) { + Object.defineProperty(window, "innerWidth", { value: width, configurable: true }); + if (!window.matchMedia) { + Object.defineProperty(window, "matchMedia", { value: vi.fn(), configurable: true, writable: true }); + } + vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ + matches: query.includes("max-width: 768px") ? width <= 768 : query.includes("min-width: 769px") && query.includes("max-width: 1024px") ? width >= 769 && width <= 1024 : false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); +} + +function renderMissionManager(props: Partial> = {}) { + return render( + {}} + addToast={() => {}} + projectId="p1" + {...props} + />, + ); +} + +describe("MissionManager overview default", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + setViewport(1280); + mockFetchMissions.mockResolvedValue(missions); + mockFetchMission.mockImplementation((missionId: string) => Promise.resolve(missionDetail(missionId))); + mockFetchMissionsHealth.mockResolvedValue({}); + mockFetchAiSessions.mockResolvedValue([]); + mockFetchMissionInterviewDrafts.mockResolvedValue([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders the desktop overview for a populated inline mission list without auto-loading the first mission", async () => { + renderMissionManager(); + + expect(await screen.findByText("First Mission")).toBeInTheDocument(); + expect(screen.getByTestId("mission-empty-detail")).toHaveTextContent("Select a mission to view details"); + await waitFor(() => expect(mockFetchMission).not.toHaveBeenCalled()); + }); + + it("resets to the overview when an inline tab is hidden and shown after selecting a mission", async () => { + const { rerender } = renderMissionManager(); + + fireEvent.click(await screen.findByText("First Mission")); + await waitFor(() => expect(mockFetchMission).toHaveBeenCalledWith("M-001", "p1")); + + rerender( + {}} + addToast={() => {}} + projectId="p1" + />, + ); + rerender( + {}} + addToast={() => {}} + projectId="p1" + />, + ); + + expect(await screen.findByText("First Mission")).toBeInTheDocument(); + expect(screen.getByTestId("mission-empty-detail")).toHaveTextContent("Select a mission to view details"); + await waitFor(() => expect(mockFetchMission).toHaveBeenCalledTimes(1)); + }); + + it("ignores a stale selected-mission cache value on desktop and mobile entry", async () => { + localStorage.setItem( + STALE_SELECTED_MISSION_CACHE_KEY, + JSON.stringify({ savedAt: Date.now(), data: "M-002" }), + ); + + const { unmount } = renderMissionManager(); + expect(await screen.findByText("First Mission")).toBeInTheDocument(); + expect(screen.getByTestId("mission-empty-detail")).toHaveTextContent("Select a mission to view details"); + await waitFor(() => expect(mockFetchMission).not.toHaveBeenCalled()); + + unmount(); + vi.clearAllMocks(); + mockFetchMissions.mockResolvedValue(missions); + mockFetchMission.mockImplementation((missionId: string) => Promise.resolve(missionDetail(missionId))); + mockFetchMissionsHealth.mockResolvedValue({}); + mockFetchAiSessions.mockResolvedValue([]); + mockFetchMissionInterviewDrafts.mockResolvedValue([]); + setViewport(375); + + renderMissionManager(); + expect(await screen.findByText("First Mission")).toBeInTheDocument(); + expect(screen.queryByTestId("mission-empty-detail")).not.toBeInTheDocument(); + await waitFor(() => expect(mockFetchMission).not.toHaveBeenCalled()); + }); + + it("still auto-opens an explicit targetMissionId deep link", async () => { + renderMissionManager({ targetMissionId: "M-002" }); + + await waitFor(() => { + expect(mockFetchMission).toHaveBeenCalledWith("M-002", "p1"); + }); + }); + + it("renders the overview empty state when there are no missions", async () => { + mockFetchMissions.mockResolvedValue([]); + + renderMissionManager(); + + expect(await screen.findByText("No missions yet")).toBeInTheDocument(); + expect(screen.getByTestId("mission-empty-detail")).toHaveTextContent("Select a mission to view details"); + expect(mockFetchMission).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/app/utils/__tests__/swrCache.test.ts b/packages/dashboard/app/utils/__tests__/swrCache.test.ts index e9a4f0605d..8dea187510 100644 --- a/packages/dashboard/app/utils/__tests__/swrCache.test.ts +++ b/packages/dashboard/app/utils/__tests__/swrCache.test.ts @@ -119,7 +119,6 @@ describe("swrCache", () => { expect(SWR_CACHE_KEYS.EVALS_RUNS_PREFIX).toBe("kb-dashboard-evals-runs-cache:"); expect(SWR_CACHE_KEYS.EVALS_RESULTS_PREFIX).toBe("kb-dashboard-evals-results-cache:"); expect(SWR_CACHE_KEYS.MISSIONS_PREFIX).toBe("kb-dashboard-missions-cache:"); - expect(SWR_CACHE_KEYS.MISSIONS_SELECTED_ID_PREFIX).toBe("kb-dashboard-mission-selected-cache:"); expect(SWR_CACHE_KEYS.CHAT_SESSIONS_PREFIX).toBe("kb-dashboard-chat-sessions-cache:"); expect(SWR_CACHE_KEYS.CHAT_MESSAGES_PREFIX).toBe("kb-dashboard-chat-messages-cache:"); expect(SWR_CACHE_KEYS.CHAT_AGENTS_MAP_PREFIX).toBe("kb-dashboard-chat-agents-map-cache:"); diff --git a/packages/dashboard/app/utils/swrCache.ts b/packages/dashboard/app/utils/swrCache.ts index 9ea0638f3c..45d6edf779 100644 --- a/packages/dashboard/app/utils/swrCache.ts +++ b/packages/dashboard/app/utils/swrCache.ts @@ -35,7 +35,6 @@ export const SWR_CACHE_KEYS = { EVALS_RUNS_PREFIX: "kb-dashboard-evals-runs-cache:", EVALS_RESULTS_PREFIX: "kb-dashboard-evals-results-cache:", MISSIONS_PREFIX: "kb-dashboard-missions-cache:", - MISSIONS_SELECTED_ID_PREFIX: "kb-dashboard-mission-selected-cache:", MAILBOX_INBOX_PREFIX: "kb-dashboard-mailbox-inbox-cache:", MAILBOX_OUTBOX_PREFIX: "kb-dashboard-mailbox-outbox-cache:", MAILBOX_UNREAD_COUNT_PREFIX: "kb-dashboard-mailbox-unread-cache:",