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:
Fusion
2026-05-12 13:06:35 -07:00
committed by gsxdsm
parent 8775267857
commit 062b5a9039
22 changed files with 782 additions and 10 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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