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:
gsxdsm
2026-06-27 20:27:09 -07:00
parent 8ef9750064
commit b570a834ba
7 changed files with 191 additions and 44 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:",