feat(FN-2092): merge fusion/fn-2092

This commit is contained in:
gsxdsm
2026-04-18 22:07:20 -07:00
parent b0028c077f
commit 4e3e2ca7fe
6 changed files with 115 additions and 8 deletions

View File

@@ -170,7 +170,9 @@ function AppInner() {
.then((data: { unreadCount: number }) => { .then((data: { unreadCount: number }) => {
setMailboxUnreadCount(data.unreadCount); setMailboxUnreadCount(data.unreadCount);
}) })
.catch(() => {}); .catch((err) => {
console.warn("[App] Failed to fetch mailbox unread count:", err);
});
}, [currentProject?.id]); }, [currentProject?.id]);
// Nodes management is an overlay view (not a modal), so it stays local to App. // Nodes management is an overlay view (not a modal), so it stays local to App.
@@ -445,6 +447,7 @@ function AppInner() {
resumeSessionId={missionResumeSessionId} resumeSessionId={missionResumeSessionId}
targetMissionId={missionTargetId} targetMissionId={missionTargetId}
milestoneSliceResumeSessionId={milestoneSliceResumeSessionId} milestoneSliceResumeSessionId={milestoneSliceResumeSessionId}
onMilestoneSliceResumeFetchError={() => setMilestoneSliceResumeSessionId(undefined)}
/> />
</PageErrorBoundary> </PageErrorBoundary>
); );

View File

@@ -117,6 +117,8 @@ interface MissionManagerProps {
targetMissionId?: string; targetMissionId?: string;
/** Resume session ID for milestone/slice interview sessions */ /** Resume session ID for milestone/slice interview sessions */
milestoneSliceResumeSessionId?: string; milestoneSliceResumeSessionId?: string;
/** Called when milestone/slice resume session fetch fails */
onMilestoneSliceResumeFetchError?: () => void;
} }
// Status badge colors — use CSS custom-property-compatible tokens // Status badge colors — use CSS custom-property-compatible tokens
@@ -446,7 +448,7 @@ function getAutopilotActivitySummary(state: AutopilotState, lastActivityAt?: str
return `Last activation ${getRelativeTime(lastActivityAt)}`; return `Last activation ${getRelativeTime(lastActivityAt)}`;
} }
export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId }: MissionManagerProps) { export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError }: MissionManagerProps) {
const isActive = isInline || isOpen; const isActive = isInline || isOpen;
const [missions, setMissions] = useState<MissionWithSummary[]>([]); const [missions, setMissions] = useState<MissionWithSummary[]>([]);
const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null); const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
@@ -521,7 +523,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
(s) => s.type === "mission_interview" && (s.status === "awaiting_input" || s.status === "error"), (s) => s.type === "mission_interview" && (s.status === "awaiting_input" || s.status === "error"),
); );
setPendingInterviewSessions(pending); setPendingInterviewSessions(pending);
}).catch(() => {}); }).catch((err) => {
console.warn("[MissionManager] Failed to fetch pending interview sessions:", err);
});
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [isActive, projectId, effectiveResumeSessionId]); }, [isActive, projectId, effectiveResumeSessionId]);
@@ -553,9 +557,13 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
resumeSessionId: milestoneSliceResumeSessionId, resumeSessionId: milestoneSliceResumeSessionId,
}); });
} }
}).catch(() => {}); }).catch((err) => {
if (cancelled) return;
console.warn("[MissionManager] Failed to fetch session for milestone/slice resume:", err);
onMilestoneSliceResumeFetchError?.();
});
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [isActive, milestoneSliceResumeSessionId]); }, [isActive, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError]);
// Delete confirmation // Delete confirmation
const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string } | null>(null); const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string } | null>(null);

View File

@@ -41,6 +41,7 @@ vi.mock("../../api", async (importOriginal) => {
fetchGitRemotes: vi.fn(() => Promise.resolve([])), fetchGitRemotes: vi.fn(() => Promise.resolve([])),
fetchAgents: vi.fn(() => Promise.resolve([])), fetchAgents: vi.fn(() => Promise.resolve([])),
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })), fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })),
fetchUnreadCount: vi.fn(() => Promise.resolve({ unreadCount: 0 })),
fetchScripts: vi.fn(() => Promise.resolve({ build: "npm run build", test: "pnpm test" })), fetchScripts: vi.fn(() => Promise.resolve({ build: "npm run build", test: "pnpm test" })),
runScript: vi.fn(() => Promise.resolve({ sessionId: "sess-script-1", command: "echo hello" })), runScript: vi.fn(() => Promise.resolve({ sessionId: "sess-script-1", command: "echo hello" })),
killPtyTerminalSession: vi.fn(() => Promise.resolve({ killed: true })), killPtyTerminalSession: vi.fn(() => Promise.resolve({ killed: true })),
@@ -220,7 +221,7 @@ vi.mock("../../hooks/useNodes", () => ({
})); }));
import { App } from "../../App"; import { App } from "../../App";
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, updateSettings, runScript, fetchScripts, fetchModels } from "../../api"; import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels } from "../../api";
import * as apiNodeModule from "../../hooks/useRemoteNodeData"; import * as apiNodeModule from "../../hooks/useRemoteNodeData";
beforeEach(() => { beforeEach(() => {
@@ -276,6 +277,30 @@ beforeEach(() => {
mockGetStepData.mockReturnValue(null); mockGetStepData.mockReturnValue(null);
}); });
describe("App mailbox unread count", () => {
it("logs a warning when unread count fetch fails and keeps the zero-count fallback", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const unreadFetchError = new Error("Mailbox unavailable");
(fetchUnreadCount as ReturnType<typeof vi.fn>).mockRejectedValueOnce(unreadFetchError);
render(<App />);
await waitFor(() => {
expect(fetchUnreadCount).toHaveBeenCalledWith("proj_123");
});
await waitFor(() => {
expect(warnSpy).toHaveBeenCalledWith(
"[App] Failed to fetch mailbox unread count:",
unreadFetchError,
);
});
expect(screen.getByRole("status", { name: "Loading Fusion dashboard" })).toBeInTheDocument();
warnSpy.mockRestore();
});
});
describe("App deep link handling", () => { describe("App deep link handling", () => {
const originalLocation = window.location; const originalLocation = window.location;
const originalReplaceState = window.history.replaceState; const originalReplaceState = window.history.replaceState;

View File

@@ -3,6 +3,7 @@ import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"
import { MissionManager } from "../MissionManager"; import { MissionManager } from "../MissionManager";
const mockFetchAiSession = vi.fn(); const mockFetchAiSession = vi.fn();
const mockFetchAiSessions = vi.fn();
const mockCancelMissionInterview = vi.fn(); const mockCancelMissionInterview = vi.fn();
const mockConnectMissionInterviewStream = vi.fn(); const mockConnectMissionInterviewStream = vi.fn();
const mockPreviewEnrichedDescription = vi.fn(); const mockPreviewEnrichedDescription = vi.fn();
@@ -15,7 +16,7 @@ vi.mock("../../api", async () => {
return { return {
...actual, ...actual,
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args), fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
fetchAiSessions: () => Promise.resolve([]), fetchAiSessions: (...args: any[]) => mockFetchAiSessions(...args),
cancelMissionInterview: (...args: any[]) => mockCancelMissionInterview(...args), cancelMissionInterview: (...args: any[]) => mockCancelMissionInterview(...args),
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args), connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
previewEnrichedDescription: (...args: any[]) => mockPreviewEnrichedDescription(...args), previewEnrichedDescription: (...args: any[]) => mockPreviewEnrichedDescription(...args),
@@ -647,9 +648,11 @@ describe("MissionManager", () => {
originalFetch = globalThis.fetch; originalFetch = globalThis.fetch;
originalEventSource = globalThis.EventSource; originalEventSource = globalThis.EventSource;
mockFetchAiSession.mockReset(); mockFetchAiSession.mockReset();
mockFetchAiSessions.mockReset();
mockCancelMissionInterview.mockReset(); mockCancelMissionInterview.mockReset();
mockConnectMissionInterviewStream.mockReset(); mockConnectMissionInterviewStream.mockReset();
mockFetchAiSession.mockResolvedValue(null); mockFetchAiSession.mockResolvedValue(null);
mockFetchAiSessions.mockResolvedValue([]);
mockCancelMissionInterview.mockResolvedValue(undefined); mockCancelMissionInterview.mockResolvedValue(undefined);
mockConnectMissionInterviewStream.mockReturnValue({ mockConnectMissionInterviewStream.mockReturnValue({
close: vi.fn(), close: vi.fn(),
@@ -1577,6 +1580,54 @@ describe("MissionManager", () => {
}); });
}); });
it("logs a warning when pending interview session fetch fails", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const pendingFetchError = new Error("Pending sessions failed");
mockFetchAiSessions.mockRejectedValueOnce(pendingFetchError);
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} isInline={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(warnSpy).toHaveBeenCalledWith(
"[MissionManager] Failed to fetch pending interview sessions:",
pendingFetchError,
);
});
expect(screen.getByText("Missions")).toBeInTheDocument();
warnSpy.mockRestore();
});
it("logs a warning when milestone/slice resume session fetch fails", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const resumeFetchError = new Error("Resume session failed");
const onResumeFetchError = vi.fn();
mockFetchAiSession.mockRejectedValueOnce(resumeFetchError);
globalThis.fetch = createFetchMock();
render(
<MissionManager
isOpen={true}
isInline={true}
onClose={vi.fn()}
addToast={vi.fn()}
milestoneSliceResumeSessionId="sess-resume-1"
onMilestoneSliceResumeFetchError={onResumeFetchError}
/>,
);
await waitFor(() => {
expect(warnSpy).toHaveBeenCalledWith(
"[MissionManager] Failed to fetch session for milestone/slice resume:",
resumeFetchError,
);
});
expect(onResumeFetchError).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
it("shows milestone hierarchy in detail view", async () => { it("shows milestone hierarchy in detail view", async () => {
globalThis.fetch = createDetailFetchMock(); globalThis.fetch = createDetailFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />); render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);

View File

@@ -81,6 +81,24 @@ describe("useBackgroundSessions", () => {
}); });
}); });
it("logs a warning when fetching background sessions fails", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const networkError = new Error("Network error");
mockFetchAiSessions.mockRejectedValueOnce(networkError);
const { result } = renderHook(() => useBackgroundSessions());
await waitFor(() => {
expect(warnSpy).toHaveBeenCalledWith(
"[useBackgroundSessions] Failed to fetch AI sessions:",
networkError,
);
});
expect(result.current.sessions).toEqual([]);
warnSpy.mockRestore();
});
it("applies SSE-driven session updates reactively", async () => { it("applies SSE-driven session updates reactively", async () => {
const { result } = renderHook(() => useBackgroundSessions()); const { result } = renderHook(() => useBackgroundSessions());

View File

@@ -57,7 +57,9 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
sessionTimestampsRef.current = nextTimestampMap; sessionTimestampsRef.current = nextTimestampMap;
setSessions(fetched); setSessions(fetched);
}) })
.catch(() => {}); .catch((err) => {
console.warn("[useBackgroundSessions] Failed to fetch AI sessions:", err);
});
}, [projectId]); }, [projectId]);
// Initial load: request state from sibling tabs first, then fetch authoritative API state. // Initial load: request state from sibling tabs first, then fetch authoritative API state.