feat(FN-4139): add swipe-back navigation to chat, mission, and planning flo
Implements swipe-back gesture support for mobile navigation across ChatView, MissionManager, and PlanningModeModal, wired through a shared navigation history context and covered by a comprehensive test suite (~548 lines of swipe-back tests). A small fixup ensures swipe-back behaves correctly when la Fusion-Task-Id: FN-4139
This commit is contained in:
5
.changeset/fn-4139-mobile-swipe-back.md
Normal file
5
.changeset/fn-4139-mobile-swipe-back.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Mobile swipe-back from chat conversation, mission detail, and planning session detail now returns to the corresponding list instead of escaping the view.
|
||||
@@ -110,6 +110,11 @@ AI-guided interactive planning for creating well-specified tasks from high-level
|
||||
- **PR Management**: Create, monitor, and merge pull requests for in-review tasks
|
||||
- **Deep Links**: Dashboard task links using `?task=FN-123` (or `?project=proj_456&task=FN-123` for cross-project) open the task detail modal as a one-time launch. Dismissing the modal removes the `task` parameter from the URL so that refreshing the page does not reopen it. Other query parameters (e.g., `?project=...`) are preserved. Task detail modals opened normally from the board, list, or activity log are not affected.
|
||||
|
||||
### Back navigation on mobile
|
||||
`App.tsx` owns the single browser-history-integrated navigation stack via `useNavigationHistory({ enabled: true })` and now provides `{ pushNav, replaceCurrent }` to descendants through `NavigationHistoryProvider` / `useNavigationHistoryContext()`.
|
||||
|
||||
Any mobile list→detail surface that swaps panes in place (for example Chat, Missions, or Planning) must push a `view` entry when detail opens, with an idempotent `revert` callback that returns to the list. This keeps iOS swipe-back and Android/browser back aligned with the in-app back button instead of skipping the intermediate list state.
|
||||
|
||||
### Responsive Header
|
||||
The dashboard header adapts across three responsive tiers to remain usable without wrapping or dropping controls:
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ import { useMobileScrollLock } from "./hooks/useMobileScrollLock";
|
||||
import { useSetupReadiness } from "./hooks/useSetupReadiness";
|
||||
import { useUpdateCheck } from "./hooks/useUpdateCheck";
|
||||
import { useViewState, type TaskView } from "./hooks/useViewState";
|
||||
import { useNavigationHistory } from "./hooks/useNavigationHistory";
|
||||
import { NavigationHistoryProvider, useNavigationHistory } from "./hooks/useNavigationHistory";
|
||||
import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews";
|
||||
import { PluginDashboardViewHost } from "./plugins/PluginDashboardViewHost";
|
||||
import { isPluginViewId, isPluginViewRegistered } from "./plugins/pluginViewRegistry";
|
||||
@@ -1492,7 +1492,8 @@ function AppInner() {
|
||||
!isPostOnboardingDismissed();
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavigationHistoryProvider value={{ pushNav, replaceCurrent }}>
|
||||
<>
|
||||
<Header
|
||||
shellHost={shellHost.host}
|
||||
onOpenSettings={openSettingsWithNav}
|
||||
@@ -1749,7 +1750,8 @@ function AppInner() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
</NavigationHistoryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import { useFileMention } from "../hooks/useFileMention";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { matchesAgentMentionFilter } from "./mentionMatching";
|
||||
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||
|
||||
export interface ChatViewProps {
|
||||
projectId?: string;
|
||||
@@ -910,6 +911,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||
const [copyFeedbackByMessageId, setCopyFeedbackByMessageId] = useState<Record<string, CopyFeedbackState>>({});
|
||||
const [mobileSessionMenuOpen, setMobileSessionMenuOpen] = useState(false);
|
||||
const { pushNav } = useNavigationHistoryContext();
|
||||
|
||||
// File mention state and hook
|
||||
const [, setFileMentionPopupVisible] = useState(false);
|
||||
@@ -1903,6 +1905,12 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
setMobileSessionMenuOpen(false);
|
||||
}, [selectSession]);
|
||||
|
||||
const handleRoomBack = useCallback(() => {
|
||||
rooms.selectRoom(null);
|
||||
setSidebarVisible(true);
|
||||
setMobileSessionMenuOpen(false);
|
||||
}, [rooms]);
|
||||
|
||||
// Render empty state (no active session)
|
||||
const renderEmptyState = () => {
|
||||
return (
|
||||
@@ -1920,6 +1928,28 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
const activeModelTag = formatModelTag(activeSession?.modelProvider, activeSession?.modelId);
|
||||
const activeModelProvider = activeSession?.modelProvider ?? null;
|
||||
const hasThreadInView = Boolean(activeSession || isStreaming || messages.length > 0);
|
||||
const hasMobileDetailSelection = chatScope === "rooms" ? roomThreadActive : Boolean(activeSession);
|
||||
const previousHasMobileDetailSelectionRef = useRef(hasMobileDetailSelection);
|
||||
|
||||
useEffect(() => {
|
||||
const previousHasMobileDetailSelection = previousHasMobileDetailSelectionRef.current;
|
||||
previousHasMobileDetailSelectionRef.current = hasMobileDetailSelection;
|
||||
|
||||
if (!isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousHasMobileDetailSelection || !hasMobileDetailSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mobile list/detail surfaces must stack a view entry on top of the
|
||||
// shared browser-history nav entry so swipe-back returns to the list.
|
||||
pushNav({
|
||||
type: "view",
|
||||
revert: chatScope === "rooms" ? handleRoomBack : handleBack,
|
||||
});
|
||||
}, [chatScope, handleBack, handleRoomBack, hasMobileDetailSelection, isMobile, pushNav]);
|
||||
|
||||
const threadHeaderTitle = activeSession?.agentId === FN_AGENT_ID
|
||||
? (activeModelTag ?? "Fusion")
|
||||
@@ -2330,10 +2360,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
<>
|
||||
<div className="chat-room-thread-header">
|
||||
{isMobile && (
|
||||
<button className="btn-icon" onClick={() => {
|
||||
rooms.selectRoom(null);
|
||||
setSidebarVisible(true);
|
||||
}} data-testid="chat-back-btn">
|
||||
<button className="btn-icon" onClick={handleRoomBack} data-testid="chat-back-btn">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { MissionInterviewModal } from "./MissionInterviewModal";
|
||||
import { MilestoneSliceInterviewModal } from "./MilestoneSliceInterviewModal";
|
||||
@@ -465,6 +466,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const isMobile = useViewportMode() === "mobile";
|
||||
const { pushNav } = useNavigationHistoryContext();
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
|
||||
if (typeof window === "undefined") return MISSION_SIDEBAR_DEFAULT_WIDTH;
|
||||
const stored = window.localStorage.getItem(MISSION_SIDEBAR_STORAGE_KEY);
|
||||
@@ -2031,6 +2033,23 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
selectedMission?.lastAutopilotActivityAt,
|
||||
);
|
||||
|
||||
const previousSelectedMissionIdRef = useRef<string | null>(selectedMission?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
const previousSelectedMissionId = previousSelectedMissionIdRef.current;
|
||||
const currentSelectedMissionId = selectedMission?.id ?? null;
|
||||
previousSelectedMissionIdRef.current = currentSelectedMissionId;
|
||||
|
||||
if (!isActive || !isMobile || !currentSelectedMissionId || previousSelectedMissionId === currentSelectedMissionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// MissionManager may already sit behind an App-level modal nav entry.
|
||||
// On mobile, selecting a mission stacks a view entry on top so back goes
|
||||
// detail → list → modal close instead of skipping the in-modal list.
|
||||
pushNav({ type: "view", revert: handleBackToList });
|
||||
}, [handleBackToList, isActive, isMobile, pushNav, selectedMission?.id]);
|
||||
|
||||
const selectedMilestoneTelemetry = useMemo(() => {
|
||||
if (!validationTelemetry || !selectedMilestoneId || !isMilestoneValidationTelemetry(validationTelemetry)) {
|
||||
return null;
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useSessionLock } from "../hooks/useSessionLock";
|
||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
|
||||
@@ -238,6 +239,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
|
||||
useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
|
||||
const viewportMode = useViewportMode();
|
||||
const { pushNav } = useNavigationHistoryContext();
|
||||
|
||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } =
|
||||
useMobileKeyboard({ enabled: viewportMode === "mobile" });
|
||||
@@ -974,6 +976,42 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setMobileShowDetail(false);
|
||||
}, []);
|
||||
|
||||
const handleClearSelectedSession = useCallback(() => {
|
||||
setSelectedSessionId(null);
|
||||
setMobileShowDetail(false);
|
||||
}, []);
|
||||
|
||||
const previousSelectedSessionIdRef = useRef<string | null>(selectedSessionId);
|
||||
const previousMobileShowDetailRef = useRef(mobileShowDetail);
|
||||
|
||||
useEffect(() => {
|
||||
const previousSelectedSessionId = previousSelectedSessionIdRef.current;
|
||||
previousSelectedSessionIdRef.current = selectedSessionId;
|
||||
|
||||
if (viewportMode !== "mobile" || !selectedSessionId || previousSelectedSessionId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
pushNav({
|
||||
type: "view",
|
||||
revert: handleClearSelectedSession,
|
||||
});
|
||||
}, [handleClearSelectedSession, pushNav, selectedSessionId, viewportMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousMobileShowDetail = previousMobileShowDetailRef.current;
|
||||
previousMobileShowDetailRef.current = mobileShowDetail;
|
||||
|
||||
if (viewportMode !== "mobile" || !mobileShowDetail || previousMobileShowDetail || selectedSessionId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
pushNav({
|
||||
type: "view",
|
||||
revert: handleBackToList,
|
||||
});
|
||||
}, [handleBackToList, mobileShowDetail, pushNav, selectedSessionId, viewportMode]);
|
||||
|
||||
const syncPlanningDraft = useCallback(
|
||||
async (sessionId: string, planText: string) => {
|
||||
const trimmedPlan = planText.trim();
|
||||
|
||||
@@ -12,6 +12,13 @@ Element.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useChat");
|
||||
vi.mock("../../hooks/useChatRooms");
|
||||
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 {
|
||||
|
||||
@@ -10,6 +10,13 @@ import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
||||
|
||||
vi.mock("../../hooks/useChat");
|
||||
vi.mock("../../hooks/useChatRooms");
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChatView } from "../ChatView";
|
||||
import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory";
|
||||
import * as useChatModule from "../../hooks/useChat";
|
||||
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
||||
import type { ChatSessionInfo } from "../../hooks/useChat";
|
||||
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useChat");
|
||||
vi.mock("../../hooks/useChatRooms");
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../api")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue(undefined),
|
||||
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
const mockUseChat = vi.mocked(useChatModule.useChat);
|
||||
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
|
||||
|
||||
const session: ChatSessionInfo = {
|
||||
id: "session-001",
|
||||
agentId: "agent-001",
|
||||
status: "active",
|
||||
title: "Session One",
|
||||
createdAt: "2026-04-08T00:00:00.000Z",
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function mockViewport(mode: "mobile" | "desktop") {
|
||||
if (!window.matchMedia) {
|
||||
Object.defineProperty(window, "matchMedia", { value: vi.fn(), configurable: true, writable: true });
|
||||
}
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
value: mode === "mobile" ? 375 : 1280,
|
||||
configurable: true,
|
||||
});
|
||||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
matches: mode === "mobile" && query === "(max-width: 768px)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
function HistoryHarness({ children }: { children: ReactNode }) {
|
||||
const history = useNavigationHistory({ enabled: true });
|
||||
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
|
||||
}
|
||||
|
||||
const selectSessionSpy = vi.fn();
|
||||
|
||||
function StatefulChatView() {
|
||||
const [activeSessionId, setActiveSessionId] = useState("");
|
||||
const handleSelectSession = (id: string) => {
|
||||
selectSessionSpy(id);
|
||||
setActiveSessionId(id);
|
||||
};
|
||||
|
||||
mockUseChat.mockImplementation(() => ({
|
||||
sessions: [session],
|
||||
activeSession: activeSessionId ? session : null,
|
||||
sessionsLoading: false,
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
isStreaming: false,
|
||||
streamingText: "",
|
||||
streamingThinking: "",
|
||||
streamingToolCalls: [],
|
||||
selectSession: handleSelectSession,
|
||||
createSession: vi.fn(),
|
||||
archiveSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
stopStreaming: vi.fn(),
|
||||
pendingMessage: "",
|
||||
clearPendingMessage: vi.fn(),
|
||||
loadMoreMessages: vi.fn(),
|
||||
hasMoreMessages: false,
|
||||
searchQuery: "",
|
||||
setSearchQuery: vi.fn(),
|
||||
filteredSessions: [session],
|
||||
refreshSessions: vi.fn(),
|
||||
agentsMap: new Map(),
|
||||
}));
|
||||
|
||||
mockUseChatRooms.mockReturnValue({
|
||||
rooms: [],
|
||||
roomsLoading: false,
|
||||
roomsError: null,
|
||||
activeRoom: null,
|
||||
activeRoomMembers: [],
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
selectRoom: vi.fn(),
|
||||
createRoom: vi.fn(),
|
||||
deleteRoom: vi.fn(),
|
||||
sendRoomMessage: vi.fn(),
|
||||
refreshRooms: vi.fn(),
|
||||
});
|
||||
|
||||
return <ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />;
|
||||
}
|
||||
|
||||
describe("ChatView mobile swipe-back", () => {
|
||||
const originalPushState = window.history.pushState;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
selectSessionSpy.mockClear();
|
||||
window.history.pushState = vi.fn();
|
||||
});
|
||||
|
||||
it("pushes a mobile nav entry when opening a conversation and popstate returns to the list", async () => {
|
||||
mockViewport("mobile");
|
||||
|
||||
render(
|
||||
<HistoryHarness>
|
||||
<StatefulChatView />
|
||||
</HistoryHarness>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-session-session-001"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.history.pushState).toHaveBeenCalledWith(expect.objectContaining({ navIndex: 1 }), "");
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(selectSessionSpy).toHaveBeenCalledWith("");
|
||||
expect(screen.getByText("Start a new conversation")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not push a nav entry on desktop selection", async () => {
|
||||
mockViewport("desktop");
|
||||
|
||||
render(
|
||||
<HistoryHarness>
|
||||
<StatefulChatView />
|
||||
</HistoryHarness>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-session-session-001"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("chat-thread-header-identity")).toBeInTheDocument();
|
||||
});
|
||||
expect(window.history.pushState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.history.pushState = originalPushState;
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,13 @@ import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
|
||||
// Mock the hooks
|
||||
vi.mock("../../hooks/useChat");
|
||||
vi.mock("../../hooks/useChatRooms");
|
||||
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
const mockUseChat = vi.mocked(useChatModule.useChat);
|
||||
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MissionManager } from "../MissionManager";
|
||||
import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory";
|
||||
|
||||
const mockViewportMode = vi.fn<() => "mobile" | "desktop">();
|
||||
const mockFetchMissions = vi.fn();
|
||||
const mockFetchMission = vi.fn();
|
||||
const mockFetchMissionsHealth = vi.fn();
|
||||
const mockFetchAssertions = vi.fn();
|
||||
const mockFetchMilestoneValidation = vi.fn();
|
||||
const mockFetchMilestoneValidationTelemetry = vi.fn();
|
||||
const mockFetchAiSessions = vi.fn();
|
||||
const mockFetchAiSession = vi.fn();
|
||||
const mockSubscribeSse = vi.fn(() => vi.fn());
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
useViewportMode: () => mockViewportMode(),
|
||||
}));
|
||||
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: (...args: unknown[]) => mockSubscribeSse(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../MissionInterviewModal", () => ({
|
||||
MissionInterviewModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../MilestoneSliceInterviewModal", () => ({
|
||||
MilestoneSliceInterviewModal: () => null,
|
||||
}));
|
||||
|
||||
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),
|
||||
fetchAssertions: (...args: unknown[]) => mockFetchAssertions(...args),
|
||||
fetchMilestoneValidation: (...args: unknown[]) => mockFetchMilestoneValidation(...args),
|
||||
fetchMilestoneValidationTelemetry: (...args: unknown[]) => mockFetchMilestoneValidationTelemetry(...args),
|
||||
fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
|
||||
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args),
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
const missions = [
|
||||
{
|
||||
id: "M-001",
|
||||
title: "Build Auth System",
|
||||
description: "Complete authentication flow",
|
||||
status: "planning",
|
||||
interviewState: "not_started",
|
||||
milestones: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "M-002",
|
||||
title: "API Redesign",
|
||||
description: "Redesign the REST API",
|
||||
status: "active",
|
||||
interviewState: "not_started",
|
||||
milestones: [],
|
||||
createdAt: "2026-01-02T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
const missionDetail = {
|
||||
id: "M-001",
|
||||
title: "Build Auth System",
|
||||
description: "Complete authentication flow",
|
||||
status: "planning",
|
||||
milestones: [
|
||||
{
|
||||
id: "MS-001",
|
||||
title: "Database Schema",
|
||||
description: "Set up auth tables",
|
||||
status: "planning",
|
||||
interviewState: "not_started",
|
||||
dependencies: [],
|
||||
slices: [],
|
||||
missionId: "M-001",
|
||||
},
|
||||
],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const rollup = {
|
||||
milestoneId: "MS-001",
|
||||
totalAssertions: 0,
|
||||
passedAssertions: 0,
|
||||
failedAssertions: 0,
|
||||
blockedAssertions: 0,
|
||||
pendingAssertions: 0,
|
||||
unlinkedAssertions: 0,
|
||||
state: "not_started" as const,
|
||||
};
|
||||
|
||||
function HistoryHarness({ children }: { children: ReactNode }) {
|
||||
const history = useNavigationHistory({ enabled: true });
|
||||
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
|
||||
}
|
||||
|
||||
describe("MissionManager mobile swipe-back", () => {
|
||||
const originalPushState = window.history.pushState;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockViewportMode.mockReturnValue("mobile");
|
||||
mockFetchMissions.mockResolvedValue(missions);
|
||||
mockFetchMission.mockResolvedValue(missionDetail);
|
||||
mockFetchMissionsHealth.mockResolvedValue({});
|
||||
mockFetchAssertions.mockResolvedValue([]);
|
||||
mockFetchMilestoneValidation.mockResolvedValue(rollup);
|
||||
mockFetchMilestoneValidationTelemetry.mockResolvedValue(null);
|
||||
mockFetchAiSessions.mockResolvedValue([]);
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
window.history.pushState = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.history.pushState = originalPushState;
|
||||
});
|
||||
|
||||
it("pushes a mobile nav entry when opening mission detail and popstate returns to the list", async () => {
|
||||
render(
|
||||
<HistoryHarness>
|
||||
<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />
|
||||
</HistoryHarness>,
|
||||
);
|
||||
|
||||
await userSelectMission();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.history.pushState).toHaveBeenCalledWith(expect.objectContaining({ navIndex: 1 }), "");
|
||||
});
|
||||
expect(screen.getByTestId("mission-back-btn")).toBeInTheDocument();
|
||||
expect(screen.getByText("Database Schema")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("mission-back-btn")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Database Schema")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not push a nav entry on desktop mission selection", async () => {
|
||||
mockViewportMode.mockReturnValue("desktop");
|
||||
|
||||
render(
|
||||
<HistoryHarness>
|
||||
<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} isInline={true} />
|
||||
</HistoryHarness>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Database Schema")).toBeInTheDocument();
|
||||
});
|
||||
expect(window.history.pushState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
async function userSelectMission() {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getAllByText("Build Auth System")[0]);
|
||||
await waitFor(() => {
|
||||
expect(mockFetchMission).toHaveBeenCalledWith("M-001", undefined);
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,14 @@ const mockSkipMilestoneInterview = vi.fn();
|
||||
const mockSkipSliceInterview = vi.fn();
|
||||
const mockTriageFeature = vi.fn();
|
||||
|
||||
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 () => {
|
||||
const actual = await vi.importActual<typeof import("../../api")>("../../api");
|
||||
return {
|
||||
|
||||
@@ -24,6 +24,14 @@ const {
|
||||
mockClearMissionGoal: vi.fn<(projectId?: string) => void>(),
|
||||
}));
|
||||
|
||||
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("../../hooks/modalPersistence", () => ({
|
||||
savePlanningDescription: (description: string, projectId?: string) => mockSavePlanningDescription(description, projectId),
|
||||
getPlanningDescription: (projectId?: string) => mockGetPlanningDescription(projectId),
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import * as api from "../../api";
|
||||
import { PlanningModeModal } from "../PlanningModeModal";
|
||||
|
||||
@@ -3,6 +3,14 @@ import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@te
|
||||
import * as api from "../../api";
|
||||
import { PlanningModeModal } from "../PlanningModeModal";
|
||||
import { TaskDetailModal } from "../TaskDetailModal";
|
||||
|
||||
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
import { useSessionLock } from "../../hooks/useSessionLock";
|
||||
import { getSessionTabId } from "../../utils/getSessionTabId";
|
||||
import type { MergeResult } from "@fusion/core";
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import * as api from "../../api";
|
||||
import { PlanningModeModal } from "../PlanningModeModal";
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import * as api from "../../api";
|
||||
import { PlanningModeModal } from "../PlanningModeModal";
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { type ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PlanningModeModal } from "../PlanningModeModal";
|
||||
import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory";
|
||||
|
||||
const mockViewportMode = vi.fn<() => "mobile" | "desktop">();
|
||||
const mockFetchAiSessions = vi.fn();
|
||||
const mockFetchAiSession = vi.fn();
|
||||
const mockFetchModels = vi.fn();
|
||||
const mockSubscribeSse = vi.fn(() => vi.fn());
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
useViewportMode: () => mockViewportMode(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useSessionLock", () => ({
|
||||
useSessionLock: () => ({
|
||||
isLockedByOther: false,
|
||||
takeControl: vi.fn(),
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useAiSessionSync", () => ({
|
||||
useAiSessionSync: () => ({
|
||||
activeTabMap: new Map(),
|
||||
broadcastUpdate: vi.fn(),
|
||||
broadcastCompleted: vi.fn(),
|
||||
broadcastLock: vi.fn(),
|
||||
broadcastUnlock: vi.fn(),
|
||||
broadcastHeartbeat: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/getSessionTabId", () => ({
|
||||
getSessionTabId: () => "tab-1",
|
||||
}));
|
||||
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: (...args: unknown[]) => mockSubscribeSse(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../api")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
|
||||
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args),
|
||||
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
|
||||
parseConversationHistory: () => [],
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
const planningSessionSummary = {
|
||||
id: "plan-1",
|
||||
type: "planning" as const,
|
||||
title: "Roadmap draft",
|
||||
preview: "Plan authentication",
|
||||
status: "draft" as const,
|
||||
archived: false,
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
updatedAt: "2026-05-01T00:00:00.000Z",
|
||||
projectId: null,
|
||||
};
|
||||
|
||||
const planningSessionDetail = {
|
||||
...planningSessionSummary,
|
||||
inputPayload: JSON.stringify({ initialPlan: "Plan authentication" }),
|
||||
conversationHistory: "[]",
|
||||
thinkingOutput: "",
|
||||
currentQuestion: null,
|
||||
result: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function HistoryHarness({ children }: { children: ReactNode }) {
|
||||
const history = useNavigationHistory({ enabled: true });
|
||||
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
|
||||
}
|
||||
|
||||
describe("PlanningModeModal mobile swipe-back", () => {
|
||||
const originalPushState = window.history.pushState;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockViewportMode.mockReturnValue("mobile");
|
||||
mockFetchAiSessions.mockResolvedValue([planningSessionSummary]);
|
||||
mockFetchAiSession.mockResolvedValue(planningSessionDetail);
|
||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
window.history.pushState = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.history.pushState = originalPushState;
|
||||
});
|
||||
|
||||
it("pushes a mobile nav entry when opening a planning session and popstate returns to the list", async () => {
|
||||
render(
|
||||
<HistoryHarness>
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onTaskCreated={vi.fn()}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={[]}
|
||||
/>
|
||||
</HistoryHarness>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Roadmap draft")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Roadmap draft"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSession).toHaveBeenCalledWith("plan-1");
|
||||
expect(window.history.pushState).toHaveBeenCalledWith(expect.objectContaining({ navIndex: 1 }), "");
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText("Back to sessions")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Back to sessions")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("pushes a mobile nav entry when opening the new-session detail and popstate returns to the list", async () => {
|
||||
render(
|
||||
<HistoryHarness>
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onTaskCreated={vi.fn()}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={[]}
|
||||
/>
|
||||
</HistoryHarness>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Roadmap draft")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /new session/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.history.pushState).toHaveBeenCalledWith(expect.objectContaining({ navIndex: 1 }), "");
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText("Back to sessions")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Back to sessions")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not push a nav entry on desktop session selection", async () => {
|
||||
mockViewportMode.mockReturnValue("desktop");
|
||||
|
||||
render(
|
||||
<HistoryHarness>
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onTaskCreated={vi.fn()}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={[]}
|
||||
/>
|
||||
</HistoryHarness>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Roadmap draft")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Roadmap draft"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSession).toHaveBeenCalledWith("plan-1");
|
||||
});
|
||||
expect(window.history.pushState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import * as api from "../../api";
|
||||
import { PlanningModeModal } from "../PlanningModeModal";
|
||||
|
||||
@@ -25,7 +25,7 @@ const defaultSettings: Settings = {
|
||||
worktreeInitCommand: "",
|
||||
testCommand: "",
|
||||
buildCommand: "",
|
||||
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true },
|
||||
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, evalsView: true },
|
||||
};
|
||||
|
||||
const mockSubscribeSse = vi.fn((..._args: any[]) => vi.fn());
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useNavigationHistory } from "../useNavigationHistory";
|
||||
import {
|
||||
NavigationHistoryProvider,
|
||||
useNavigationHistory,
|
||||
useNavigationHistoryContext,
|
||||
type UseNavigationHistoryResult,
|
||||
} from "../useNavigationHistory";
|
||||
|
||||
describe("useNavigationHistory", () => {
|
||||
const originalPushState = window.history.pushState;
|
||||
@@ -314,4 +320,24 @@ describe("useNavigationHistory", () => {
|
||||
|
||||
expect(revert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("useNavigationHistoryContext returns the provided value", () => {
|
||||
const value: UseNavigationHistoryResult = {
|
||||
pushNav: vi.fn(),
|
||||
replaceCurrent: vi.fn(),
|
||||
};
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(NavigationHistoryProvider, { value }, children);
|
||||
|
||||
const { result } = renderHook(() => useNavigationHistoryContext(), { wrapper });
|
||||
|
||||
expect(result.current).toBe(value);
|
||||
});
|
||||
|
||||
it("useNavigationHistoryContext throws outside the provider", () => {
|
||||
expect(() => renderHook(() => useNavigationHistoryContext())).toThrow(
|
||||
"useNavigationHistoryContext must be used within a NavigationHistoryProvider",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
createContext,
|
||||
createElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
type PropsWithChildren,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* A navigation entry on the back-navigation stack.
|
||||
@@ -27,6 +35,23 @@ export interface UseNavigationHistoryResult {
|
||||
replaceCurrent: (entry: NavEntry) => void;
|
||||
}
|
||||
|
||||
export const NavigationHistoryContext = createContext<UseNavigationHistoryResult | null>(null);
|
||||
|
||||
export function NavigationHistoryProvider({
|
||||
value,
|
||||
children,
|
||||
}: PropsWithChildren<{ value: UseNavigationHistoryResult }>) {
|
||||
return createElement(NavigationHistoryContext.Provider, { value }, children);
|
||||
}
|
||||
|
||||
export function useNavigationHistoryContext(): UseNavigationHistoryResult {
|
||||
const context = useContext(NavigationHistoryContext);
|
||||
if (!context) {
|
||||
throw new Error("useNavigationHistoryContext must be used within a NavigationHistoryProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized back-navigation hook that integrates the browser History API
|
||||
* (`pushState`/`popstate`) with modal and view state machines.
|
||||
|
||||
Reference in New Issue
Block a user