FN-5914: fix mission activity counts and chronology

Keep mission activity counts accurate before event data loads and show events in chronological order.

- add persisted mission event counts to core mission summaries and dashboard mission summary types
- use the summary event count for the Activity tab preload badge instead of the currently loaded page total
- render mission activity oldest-to-newest, keep load-more prepends stable, and scroll to the latest event on initial/live updates
- extend mission store, dashboard, and MissionManager tests for event count and ordering coverage

Files changed:
 packages/core/src/__tests__/mission-store.test.ts  |  19 ++++
 packages/core/src/mission-store.ts                 |  24 ++++-
 packages/dashboard/app/api/legacy.ts               |   2 +
 .../dashboard/app/components/MissionManager.tsx    |  19 ++--
 .../components/__tests__/MissionManager.test.tsx   | 107 ++++++++++++++++-----
 packages/dashboard/app/components/mission-types.ts |   1 +
 .../dashboard/src/__tests__/mission-e2e.test.ts    |   7 +-
 7 files changed, 143 insertions(+), 36 deletions(-)

Fusion-Task-Id: FN-5914

Fusion-Task-Lineage: b3119e94-66bd-437d-bcde-d5e4e43cb29e
This commit is contained in:
gsxdsm
2026-06-02 19:28:01 -07:00
parent 0041962309
commit a411b8a7e0
7 changed files with 143 additions and 36 deletions

View File

@@ -6919,6 +6919,8 @@ export interface MissionSummary {
completedMilestones: number;
totalFeatures: number;
completedFeatures: number;
linkedGoalCount: number;
eventCount: number;
progressPercent: number;
}

View File

@@ -918,6 +918,15 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const activityEventsEndRef = useRef<HTMLDivElement>(null);
const activityTabEventCount = useMemo(() => {
if (!selectedMission?.id) {
return eventsTotal;
}
return missions.find((mission) => mission.id === selectedMission.id)?.summary?.eventCount ?? eventsTotal;
}, [eventsTotal, missions, selectedMission?.id]);
const displayedMissionEvents = useMemo(() => [...missionEvents].reverse(), [missionEvents]);
// Keep latest state available to long-lived SSE handlers without reconnect churn.
missionsRef.current = missions;
selectedMissionRef.current = selectedMission;
@@ -1538,10 +1547,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
if (shouldAutoScroll) {
requestAnimationFrame(() => {
const container = activityEventsContainerRef.current;
if (container) {
container.scrollTop = 0;
}
scrollActivityToLatest();
});
}
} catch {
@@ -1584,6 +1590,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
projectId,
refreshMissionSidebar,
refreshValidationTelemetry,
scrollActivityToLatest,
]);
// Mission handlers
@@ -2752,7 +2759,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
aria-selected={activeTab === "activity"}
data-testid="mission-tab-activity"
>
Activity ({eventsTotal})
Activity ({activityTabEventCount})
</button>
</div>
@@ -3994,7 +4001,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
className="mission-events"
data-testid="mission-activity-events"
>
{missionEvents.map((event) => {
{displayedMissionEvents.map((event) => {
const hasMetadata = Boolean(event.metadata && Object.keys(event.metadata).length > 0);
const metadataExpanded = expandedEventMetadata.has(event.id);

View File

@@ -92,6 +92,15 @@ const mockMissions = [
status: "planning",
interviewState: "not_started",
milestones: [],
summary: {
totalMilestones: 1,
completedMilestones: 0,
totalFeatures: 2,
completedFeatures: 0,
linkedGoalCount: 0,
eventCount: 4,
progressPercent: 0,
},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
@@ -110,6 +119,7 @@ const mockMissions = [
totalFeatures: 5,
completedFeatures: 3,
linkedGoalCount: 1,
eventCount: 2,
progressPercent: 60,
},
createdAt: "2026-01-02T00:00:00.000Z",
@@ -302,20 +312,12 @@ const mockMilestoneValidationTelemetry = {
const mockMissionEvents = [
{
id: "E-001",
id: "E-004",
missionId: "M-001",
eventType: "mission_started",
description: "Mission started",
metadata: null,
timestamp: "2026-01-03T10:00:00.000Z",
},
{
id: "E-002",
missionId: "M-001",
eventType: "warning",
description: "Task queue is delayed",
metadata: { queueDepth: 4 },
timestamp: "2026-01-03T10:10:00.000Z",
eventType: "autopilot_state_changed",
description: "Autopilot moved to watching",
metadata: { previous: "inactive", next: "watching" },
timestamp: "2026-01-03T10:30:00.000Z",
},
{
id: "E-003",
@@ -326,12 +328,20 @@ const mockMissionEvents = [
timestamp: "2026-01-03T10:20:00.000Z",
},
{
id: "E-004",
id: "E-002",
missionId: "M-001",
eventType: "autopilot_state_changed",
description: "Autopilot moved to watching",
metadata: { previous: "inactive", next: "watching" },
timestamp: "2026-01-03T10:30:00.000Z",
eventType: "warning",
description: "Task queue is delayed",
metadata: { queueDepth: 4 },
timestamp: "2026-01-03T10:10:00.000Z",
},
{
id: "E-001",
missionId: "M-001",
eventType: "mission_started",
description: "Mission started",
metadata: null,
timestamp: "2026-01-03T10:00:00.000Z",
},
];
@@ -342,7 +352,7 @@ const mockMissionEventsPaged = Array.from({ length: 65 }, (_, index) => ({
description: `Mission event ${index + 1}`,
metadata: { index: index + 1 },
timestamp: new Date(Date.UTC(2026, 0, 3, 10, index)).toISOString(),
}));
})).reverse();
/** Create a mock Response that matches the real api() function's expectations (text + content-type headers) */
function mockApiResponse(data: unknown) {
@@ -1158,7 +1168,7 @@ describe("MissionManager", () => {
expect(screen.queryByText(/"queueDepth": 4/)).toBeNull();
});
it("loads more mission activity events", async () => {
it("loads more older mission activity events at the top", async () => {
globalThis.fetch = createDetailFetchMock(mockMissionEventsPaged as unknown as typeof mockMissionEvents);
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
@@ -1173,6 +1183,8 @@ describe("MissionManager", () => {
fireEvent.click(screen.getByTestId("mission-tab-activity"));
const eventsContainer = await screen.findByTestId("mission-activity-events");
await waitFor(() => {
expect(screen.getByText("Mission event 50")).toBeDefined();
expect(
@@ -1181,6 +1193,10 @@ describe("MissionManager", () => {
}),
).toBeDefined();
expect(screen.getByTestId("mission-activity-load-more")).toBeDefined();
const eventDescriptions = Array.from(eventsContainer.querySelectorAll(".mission-event__description"));
expect(eventDescriptions[0]?.textContent).toBe("Mission event 16");
expect(eventDescriptions[eventDescriptions.length - 1]?.textContent).toBe("Mission event 65");
});
fireEvent.click(screen.getByTestId("mission-activity-load-more"));
@@ -1189,10 +1205,30 @@ describe("MissionManager", () => {
const activityCount = document.querySelector(".mission-detail__activity-count");
expect(activityCount?.textContent?.trim()).toBe("65 of 65");
expect(screen.queryByTestId("mission-activity-load-more")).toBeNull();
const eventDescriptions = Array.from(eventsContainer.querySelectorAll(".mission-event__description"));
expect(eventDescriptions[0]?.textContent).toBe("Mission event 1");
expect(eventDescriptions[eventDescriptions.length - 1]?.textContent).toBe("Mission event 65");
}, { timeout: 5000 });
}, 15000);
it("auto-scrolls to latest mission activity on initial load", async () => {
it("shows the summary event count before activity events load", async () => {
globalThis.fetch = createDetailFetchMock(mockMissionEvents);
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
expect(screen.getByTestId("mission-tab-activity")).toHaveTextContent("Activity (4)");
});
});
it("auto-scrolls to the latest mission activity on initial load", async () => {
globalThis.fetch = createDetailFetchMock(mockMissionEvents);
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
@@ -1217,14 +1253,29 @@ describe("MissionManager", () => {
await waitFor(() => {
expect(screen.getByText("Mission started")).toBeDefined();
expect(scrollIntoViewSpy).toHaveBeenCalled();
expect(scrollIntoViewSpy).toHaveBeenCalledWith({ block: "end", behavior: "auto" });
});
const eventsContainer = await screen.findByTestId("mission-activity-events");
const eventDescriptions = Array.from(eventsContainer.querySelectorAll(".mission-event__description"));
expect(eventDescriptions.map((node) => node.textContent)).toEqual([
"Mission started",
"Task queue is delayed",
"Feature F-001 completed",
"Autopilot moved to watching",
]);
});
it("prepends real-time mission events and scrolls to top when near bottom", async () => {
it("appends real-time mission events at the bottom and scrolls to latest when near bottom", async () => {
globalThis.fetch = createDetailFetchMock(mockMissionEvents);
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
const scrollIntoViewSpy = vi.fn();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: scrollIntoViewSpy,
});
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
@@ -1258,11 +1309,17 @@ describe("MissionManager", () => {
await waitFor(() => {
expect(screen.getByText("Real-time warning event")).toBeDefined();
expect(eventsContainer.scrollTop).toBe(0);
expect(scrollIntoViewSpy).toHaveBeenLastCalledWith({ block: "end", behavior: "auto" });
});
const eventDescriptions = Array.from(eventsContainer.querySelectorAll(".mission-event__description"));
expect(eventDescriptions[0]?.textContent).toBe("Real-time warning event");
expect(eventDescriptions.map((node) => node.textContent)).toEqual([
"Mission started",
"Task queue is delayed",
"Feature F-001 completed",
"Autopilot moved to watching",
"Real-time warning event",
]);
});
it("ignores real-time mission events for non-selected missions", async () => {

View File

@@ -260,6 +260,7 @@ export interface MissionSummary {
totalFeatures: number;
completedFeatures: number;
linkedGoalCount?: number;
eventCount?: number;
progressPercent: number;
}

View File

@@ -133,6 +133,8 @@ function createMockMissionStore(options?: {
completedMilestones: 0,
totalFeatures: 0,
completedFeatures: 0,
linkedGoalCount: 0,
eventCount: 0,
progressPercent: 0,
},
}))
@@ -141,10 +143,11 @@ function createMockMissionStore(options?: {
getMissionSummary: vi.fn((_missionId: string) => ({
totalMilestones: 0,
completedMilestones: 0,
totalSlices: 0,
completedSlices: 0,
totalFeatures: 0,
completedFeatures: 0,
linkedGoalCount: 0,
eventCount: 0,
progressPercent: 0,
})),
getMissionEvents: vi.fn((missionId: string, options?: { limit?: number; offset?: number; eventType?: string }) => {