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
|
- **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.
|
- **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
|
### Responsive Header
|
||||||
The dashboard header adapts across three responsive tiers to remain usable without wrapping or dropping controls:
|
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 { useSetupReadiness } from "./hooks/useSetupReadiness";
|
||||||
import { useUpdateCheck } from "./hooks/useUpdateCheck";
|
import { useUpdateCheck } from "./hooks/useUpdateCheck";
|
||||||
import { useViewState, type TaskView } from "./hooks/useViewState";
|
import { useViewState, type TaskView } from "./hooks/useViewState";
|
||||||
import { useNavigationHistory } from "./hooks/useNavigationHistory";
|
import { NavigationHistoryProvider, useNavigationHistory } from "./hooks/useNavigationHistory";
|
||||||
import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews";
|
import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews";
|
||||||
import { PluginDashboardViewHost } from "./plugins/PluginDashboardViewHost";
|
import { PluginDashboardViewHost } from "./plugins/PluginDashboardViewHost";
|
||||||
import { isPluginViewId, isPluginViewRegistered } from "./plugins/pluginViewRegistry";
|
import { isPluginViewId, isPluginViewRegistered } from "./plugins/pluginViewRegistry";
|
||||||
@@ -1492,7 +1492,8 @@ function AppInner() {
|
|||||||
!isPostOnboardingDismissed();
|
!isPostOnboardingDismissed();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<NavigationHistoryProvider value={{ pushNav, replaceCurrent }}>
|
||||||
|
<>
|
||||||
<Header
|
<Header
|
||||||
shellHost={shellHost.host}
|
shellHost={shellHost.host}
|
||||||
onOpenSettings={openSettingsWithNav}
|
onOpenSettings={openSettingsWithNav}
|
||||||
@@ -1749,7 +1750,8 @@ function AppInner() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
</NavigationHistoryProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { useFileMention } from "../hooks/useFileMention";
|
|||||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { matchesAgentMentionFilter } from "./mentionMatching";
|
import { matchesAgentMentionFilter } from "./mentionMatching";
|
||||||
|
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||||
|
|
||||||
export interface ChatViewProps {
|
export interface ChatViewProps {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
@@ -910,6 +911,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||||
const [copyFeedbackByMessageId, setCopyFeedbackByMessageId] = useState<Record<string, CopyFeedbackState>>({});
|
const [copyFeedbackByMessageId, setCopyFeedbackByMessageId] = useState<Record<string, CopyFeedbackState>>({});
|
||||||
const [mobileSessionMenuOpen, setMobileSessionMenuOpen] = useState(false);
|
const [mobileSessionMenuOpen, setMobileSessionMenuOpen] = useState(false);
|
||||||
|
const { pushNav } = useNavigationHistoryContext();
|
||||||
|
|
||||||
// File mention state and hook
|
// File mention state and hook
|
||||||
const [, setFileMentionPopupVisible] = useState(false);
|
const [, setFileMentionPopupVisible] = useState(false);
|
||||||
@@ -1903,6 +1905,12 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
setMobileSessionMenuOpen(false);
|
setMobileSessionMenuOpen(false);
|
||||||
}, [selectSession]);
|
}, [selectSession]);
|
||||||
|
|
||||||
|
const handleRoomBack = useCallback(() => {
|
||||||
|
rooms.selectRoom(null);
|
||||||
|
setSidebarVisible(true);
|
||||||
|
setMobileSessionMenuOpen(false);
|
||||||
|
}, [rooms]);
|
||||||
|
|
||||||
// Render empty state (no active session)
|
// Render empty state (no active session)
|
||||||
const renderEmptyState = () => {
|
const renderEmptyState = () => {
|
||||||
return (
|
return (
|
||||||
@@ -1920,6 +1928,28 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
const activeModelTag = formatModelTag(activeSession?.modelProvider, activeSession?.modelId);
|
const activeModelTag = formatModelTag(activeSession?.modelProvider, activeSession?.modelId);
|
||||||
const activeModelProvider = activeSession?.modelProvider ?? null;
|
const activeModelProvider = activeSession?.modelProvider ?? null;
|
||||||
const hasThreadInView = Boolean(activeSession || isStreaming || messages.length > 0);
|
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
|
const threadHeaderTitle = activeSession?.agentId === FN_AGENT_ID
|
||||||
? (activeModelTag ?? "Fusion")
|
? (activeModelTag ?? "Fusion")
|
||||||
@@ -2330,10 +2360,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
<>
|
<>
|
||||||
<div className="chat-room-thread-header">
|
<div className="chat-room-thread-header">
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<button className="btn-icon" onClick={() => {
|
<button className="btn-icon" onClick={handleRoomBack} data-testid="chat-back-btn">
|
||||||
rooms.selectRoom(null);
|
|
||||||
setSidebarVisible(true);
|
|
||||||
}} data-testid="chat-back-btn">
|
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { useViewportMode } from "../hooks/useViewportMode";
|
import { useViewportMode } from "../hooks/useViewportMode";
|
||||||
|
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||||
import { subscribeSse } from "../sse-bus";
|
import { subscribeSse } from "../sse-bus";
|
||||||
import { MissionInterviewModal } from "./MissionInterviewModal";
|
import { MissionInterviewModal } from "./MissionInterviewModal";
|
||||||
import { MilestoneSliceInterviewModal } from "./MilestoneSliceInterviewModal";
|
import { MilestoneSliceInterviewModal } from "./MilestoneSliceInterviewModal";
|
||||||
@@ -465,6 +466,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const isMobile = useViewportMode() === "mobile";
|
const isMobile = useViewportMode() === "mobile";
|
||||||
|
const { pushNav } = useNavigationHistoryContext();
|
||||||
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
|
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
|
||||||
if (typeof window === "undefined") return MISSION_SIDEBAR_DEFAULT_WIDTH;
|
if (typeof window === "undefined") return MISSION_SIDEBAR_DEFAULT_WIDTH;
|
||||||
const stored = window.localStorage.getItem(MISSION_SIDEBAR_STORAGE_KEY);
|
const stored = window.localStorage.getItem(MISSION_SIDEBAR_STORAGE_KEY);
|
||||||
@@ -2031,6 +2033,23 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
selectedMission?.lastAutopilotActivityAt,
|
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(() => {
|
const selectedMilestoneTelemetry = useMemo(() => {
|
||||||
if (!validationTelemetry || !selectedMilestoneId || !isMilestoneValidationTelemetry(validationTelemetry)) {
|
if (!validationTelemetry || !selectedMilestoneId || !isMilestoneValidationTelemetry(validationTelemetry)) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { useSessionLock } from "../hooks/useSessionLock";
|
|||||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||||
import { useViewportMode } from "../hooks/useViewportMode";
|
import { useViewportMode } from "../hooks/useViewportMode";
|
||||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||||
|
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||||
|
|
||||||
@@ -238,6 +239,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
|
|
||||||
useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
|
useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
|
||||||
const viewportMode = useViewportMode();
|
const viewportMode = useViewportMode();
|
||||||
|
const { pushNav } = useNavigationHistoryContext();
|
||||||
|
|
||||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } =
|
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } =
|
||||||
useMobileKeyboard({ enabled: viewportMode === "mobile" });
|
useMobileKeyboard({ enabled: viewportMode === "mobile" });
|
||||||
@@ -974,6 +976,42 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
setMobileShowDetail(false);
|
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(
|
const syncPlanningDraft = useCallback(
|
||||||
async (sessionId: string, planText: string) => {
|
async (sessionId: string, planText: string) => {
|
||||||
const trimmedPlan = planText.trim();
|
const trimmedPlan = planText.trim();
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ Element.prototype.scrollIntoView = vi.fn();
|
|||||||
|
|
||||||
vi.mock("../../hooks/useChat");
|
vi.mock("../../hooks/useChat");
|
||||||
vi.mock("../../hooks/useChatRooms");
|
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) => {
|
vi.mock("../../api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../../api")>();
|
const actual = await importOriginal<typeof import("../../api")>();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
|||||||
|
|
||||||
vi.mock("../../hooks/useChat");
|
vi.mock("../../hooks/useChat");
|
||||||
vi.mock("../../hooks/useChatRooms");
|
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) => {
|
vi.mock("../../api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../../api")>();
|
const actual = await importOriginal<typeof import("../../api")>();
|
||||||
return {
|
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
|
// Mock the hooks
|
||||||
vi.mock("../../hooks/useChat");
|
vi.mock("../../hooks/useChat");
|
||||||
vi.mock("../../hooks/useChatRooms");
|
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 mockUseChat = vi.mocked(useChatModule.useChat);
|
||||||
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
|
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 mockSkipSliceInterview = vi.fn();
|
||||||
const mockTriageFeature = 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 () => {
|
vi.mock("../../api", async () => {
|
||||||
const actual = await vi.importActual<typeof import("../../api")>("../../api");
|
const actual = await vi.importActual<typeof import("../../api")>("../../api");
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ const {
|
|||||||
mockClearMissionGoal: vi.fn<(projectId?: string) => void>(),
|
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", () => ({
|
vi.mock("../../hooks/modalPersistence", () => ({
|
||||||
savePlanningDescription: (description: string, projectId?: string) => mockSavePlanningDescription(description, projectId),
|
savePlanningDescription: (description: string, projectId?: string) => mockSavePlanningDescription(description, projectId),
|
||||||
getPlanningDescription: (projectId?: string) => mockGetPlanningDescription(projectId),
|
getPlanningDescription: (projectId?: string) => mockGetPlanningDescription(projectId),
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
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 { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
import { PlanningModeModal } from "../PlanningModeModal";
|
import { PlanningModeModal } from "../PlanningModeModal";
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@te
|
|||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
import { PlanningModeModal } from "../PlanningModeModal";
|
import { PlanningModeModal } from "../PlanningModeModal";
|
||||||
import { TaskDetailModal } from "../TaskDetailModal";
|
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 { useSessionLock } from "../../hooks/useSessionLock";
|
||||||
import { getSessionTabId } from "../../utils/getSessionTabId";
|
import { getSessionTabId } from "../../utils/getSessionTabId";
|
||||||
import type { MergeResult } from "@fusion/core";
|
import type { MergeResult } from "@fusion/core";
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
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 { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
import { PlanningModeModal } from "../PlanningModeModal";
|
import { PlanningModeModal } from "../PlanningModeModal";
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
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 { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
import { PlanningModeModal } from "../PlanningModeModal";
|
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";
|
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 { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
import { PlanningModeModal } from "../PlanningModeModal";
|
import { PlanningModeModal } from "../PlanningModeModal";
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const defaultSettings: Settings = {
|
|||||||
worktreeInitCommand: "",
|
worktreeInitCommand: "",
|
||||||
testCommand: "",
|
testCommand: "",
|
||||||
buildCommand: "",
|
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());
|
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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { useNavigationHistory } from "../useNavigationHistory";
|
import {
|
||||||
|
NavigationHistoryProvider,
|
||||||
|
useNavigationHistory,
|
||||||
|
useNavigationHistoryContext,
|
||||||
|
type UseNavigationHistoryResult,
|
||||||
|
} from "../useNavigationHistory";
|
||||||
|
|
||||||
describe("useNavigationHistory", () => {
|
describe("useNavigationHistory", () => {
|
||||||
const originalPushState = window.history.pushState;
|
const originalPushState = window.history.pushState;
|
||||||
@@ -314,4 +320,24 @@ describe("useNavigationHistory", () => {
|
|||||||
|
|
||||||
expect(revert).toHaveBeenCalledTimes(1);
|
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.
|
* A navigation entry on the back-navigation stack.
|
||||||
@@ -27,6 +35,23 @@ export interface UseNavigationHistoryResult {
|
|||||||
replaceCurrent: (entry: NavEntry) => void;
|
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
|
* Centralized back-navigation hook that integrates the browser History API
|
||||||
* (`pushState`/`popstate`) with modal and view state machines.
|
* (`pushState`/`popstate`) with modal and view state machines.
|
||||||
|
|||||||
Reference in New Issue
Block a user