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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7156-missions-overview-default.md
Normal file
7
.changeset/fn-7156-missions-overview-default.md
Normal file
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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<MissionWithSummary[]>(missionsCacheKey);
|
||||
const [missions, setMissions] = useState<MissionWithSummary[]>(() => (Array.isArray(initialMissions) ? initialMissions : []));
|
||||
const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
|
||||
@@ -1252,23 +1255,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
hasHydratedRef.current = hasCachedMissions;
|
||||
}, [missionsCacheKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedMissionId = readCache<string | null>(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<string | null>(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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../api")>();
|
||||
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<ComponentProps<typeof MissionManager>> = {}) {
|
||||
return render(
|
||||
<MissionManager
|
||||
isInline
|
||||
isOpen
|
||||
onClose={() => {}}
|
||||
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(
|
||||
<MissionManager
|
||||
isInline
|
||||
isOpen={false}
|
||||
onClose={() => {}}
|
||||
addToast={() => {}}
|
||||
projectId="p1"
|
||||
/>,
|
||||
);
|
||||
rerender(
|
||||
<MissionManager
|
||||
isInline
|
||||
isOpen
|
||||
onClose={() => {}}
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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:");
|
||||
|
||||
@@ -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:",
|
||||
|
||||
Reference in New Issue
Block a user