feat(FN-2092): merge fusion/fn-2092
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()} />);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user