feat(FN-3269): add mobile empty-session auto-routing to Planning Mode compo
Merges mobile Planning Mode empty-state auto-routing to the composer (Step 2 of FN-3269) alongside wired Insights create-task integration with real task creation (FN-3265), with corresponding tests and documentation updates. Fusion-Task-Id: FN-3269
This commit is contained in:
@@ -42,6 +42,7 @@ import { ConversationHistory } from "./ConversationHistory";
|
||||
import { OnboardingDisclosure } from "./OnboardingDisclosure";
|
||||
import { useSessionLock } from "../hooks/useSessionLock";
|
||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
|
||||
interface PlanningModeModalProps {
|
||||
@@ -180,6 +181,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
} | null>(null);
|
||||
|
||||
useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
|
||||
const viewportMode = useViewportMode();
|
||||
|
||||
// Keep the streaming AI thinking pane pinned to the bottom as new tokens
|
||||
// arrive. If the user has scrolled up to read earlier output, we leave the
|
||||
@@ -741,6 +743,29 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
void refreshSessionsList();
|
||||
}, [isOpen, refreshSessionsList]);
|
||||
|
||||
// Mobile empty-session routing: when the session list finishes loading and
|
||||
// is empty, and there is no resumeSessionId or selected session, auto-
|
||||
// switch to the detail pane so mobile users land on the composer instead
|
||||
// of an empty sidebar. Desktop/tablet split-view is unaffected because
|
||||
// both panes are visible simultaneously.
|
||||
//
|
||||
// We gate on sessionsLoading to avoid firing on the initial render (where
|
||||
// planningSessions is empty but hasn't been fetched yet). When the sessions
|
||||
// list finishes loading, planningSessions reflects the server response and
|
||||
// we can make an informed routing decision.
|
||||
const prevSessionsLoadingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const justFinishedLoading = prevSessionsLoadingRef.current && !sessionsLoading;
|
||||
prevSessionsLoadingRef.current = sessionsLoading;
|
||||
if (!justFinishedLoading) return;
|
||||
if (viewportMode !== "mobile") return;
|
||||
if (mobileShowDetail) return;
|
||||
if (resumeSessionId) return;
|
||||
if (selectedSessionId) return;
|
||||
if (planningSessions.length > 0) return;
|
||||
setMobileShowDetail(true);
|
||||
}, [viewportMode, mobileShowDetail, resumeSessionId, selectedSessionId, sessionsLoading, planningSessions.length]);
|
||||
|
||||
// SSE subscription keeps the list live (mirrors useBackgroundSessions, but
|
||||
// unfiltered by status so completed/errored sessions stay visible).
|
||||
useEffect(() => {
|
||||
|
||||
@@ -36,6 +36,7 @@ const mockRequestSpecRevision = vi.fn();
|
||||
const mockApprovePlan = vi.fn();
|
||||
const mockRejectPlan = vi.fn();
|
||||
const mockRefineTask = vi.fn();
|
||||
const mockFetchAiSessions = vi.fn();
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
startPlanning: (...args: any[]) => mockStartPlanning(...args),
|
||||
@@ -72,6 +73,7 @@ vi.mock("../../api", () => ({
|
||||
getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"),
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
duplicateTask: vi.fn().mockResolvedValue({}),
|
||||
fetchAiSessions: (...args: any[]) => mockFetchAiSessions(...args),
|
||||
}));
|
||||
|
||||
const mockConfirm = vi.fn();
|
||||
@@ -213,6 +215,22 @@ function getMediaBlocks(css: string, mediaQuery: string): string[] {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function mockViewport(mode: "mobile" | "desktop" | "tablet") {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => {
|
||||
const isMobileQuery = query === "(max-width: 768px)";
|
||||
const isTabletQuery = query === "(min-width: 769px) and (max-width: 1024px)";
|
||||
return {
|
||||
matches: mode === "mobile" ? isMobileQuery : mode === "tablet" ? isTabletQuery : false,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("PlanningModeModal", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnTaskCreated = vi.fn();
|
||||
@@ -224,6 +242,8 @@ describe("PlanningModeModal", () => {
|
||||
MockEventSource.reset();
|
||||
vi.stubGlobal("EventSource", MockEventSource as any);
|
||||
window.sessionStorage.clear();
|
||||
// Default to desktop viewport; mobile-specific tests override per-test.
|
||||
mockViewport("desktop");
|
||||
|
||||
// Default mock for streaming
|
||||
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" });
|
||||
@@ -236,6 +256,7 @@ describe("PlanningModeModal", () => {
|
||||
mockRetryPlanningSession.mockResolvedValue({ success: true, sessionId: "session-123" });
|
||||
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
mockFetchAiSessions.mockResolvedValue([]);
|
||||
mockParseConversationHistory.mockImplementation((raw: string) => {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
@@ -2587,6 +2608,113 @@ describe("PlanningModeModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mobile empty-session routing (FN-3269)", () => {
|
||||
it("shows detail pane on mobile when session list is empty", async () => {
|
||||
mockViewport("mobile");
|
||||
mockFetchAiSessions.mockResolvedValue([]);
|
||||
|
||||
const { container } = render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSessions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait for the session list to load and the routing effect to fire
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
// The modal body should show detail pane (composer), not list pane
|
||||
const body = container.querySelector(".planning-modal-body");
|
||||
expect(body?.classList.contains("planning-modal-body--show-detail")).toBe(true);
|
||||
expect(body?.classList.contains("planning-modal-body--show-list")).toBe(false);
|
||||
|
||||
// The composer textarea should be visible
|
||||
expect(screen.getByPlaceholderText(/e.g., Build a user authentication/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("stays on list pane on mobile when sessions exist", async () => {
|
||||
mockViewport("mobile");
|
||||
mockFetchAiSessions.mockResolvedValue([
|
||||
{
|
||||
id: "session-existing",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Existing session",
|
||||
preview: "An existing planning session",
|
||||
projectId: null,
|
||||
lockedByTab: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
archived: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const { container } = render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSessions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait one more tick to let state updates settle
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
// The modal body should show list pane (sidebar), not detail pane
|
||||
const body = container.querySelector(".planning-modal-body");
|
||||
expect(body?.classList.contains("planning-modal-body--show-list")).toBe(true);
|
||||
expect(body?.classList.contains("planning-modal-body--show-detail")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not auto-show detail pane on desktop with empty sessions", async () => {
|
||||
mockViewport("desktop");
|
||||
mockFetchAiSessions.mockResolvedValue([]);
|
||||
|
||||
const { container } = render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSessions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Wait one more tick to let state updates settle
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
// Desktop shows both panes in split view regardless of mobileShowDetail.
|
||||
// Both sidebar and detail pane should be present in the DOM.
|
||||
expect(container.querySelector(".planning-sidebar")).not.toBeNull();
|
||||
expect(container.querySelector(".planning-detail")).not.toBeNull();
|
||||
// The mobile-only back button should NOT be visible (it's gated on mobileShowDetail,
|
||||
// which stays false on desktop since the routing effect skips non-mobile viewports).
|
||||
expect(container.querySelector(".planning-mobile-back")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Model favorites persistence", () => {
|
||||
it("persists provider favorite toggle to global settings", async () => {
|
||||
mockFetchModels.mockResolvedValue({
|
||||
|
||||
Reference in New Issue
Block a user