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:
Fusion
2026-05-03 10:31:21 -07:00
committed by gsxdsm
parent 7feb29502c
commit fbd2ed0c5f
3 changed files with 159 additions and 0 deletions

View File

@@ -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(() => {

View File

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