FN-6886: move planning mode into sidebar view

Planning Mode now opens as a top-level embedded dashboard view instead of a header modal affordance.

- Add Planning to the left sidebar navigation and persisted task-view registry.
- Mount PlanningModeModal as an embedded main-content view while preserving initial-plan and resume-session payloads.
- Remove legacy desktop and compact header planning buttons so compact layouts have one canonical MobileNavBar planning entry.
- Update dashboard navigation tests and add a published package patch changeset.

Files changed:
 .changeset/fn-6886-planning-sidebar-view.md        |   5 +
 packages/dashboard/app/App.tsx                     |  47 ++++++--
 .../app/__tests__/tablet-header-controls.test.tsx  |  16 +--
 packages/dashboard/app/components/AppModals.tsx    |  20 ----
 packages/dashboard/app/components/Header.tsx       |  50 +-------
 .../dashboard/app/components/LeftSidebarNav.tsx    |  14 +++
 .../dashboard/app/components/PlanningModeModal.css |  23 ++++
 .../dashboard/app/components/PlanningModeModal.tsx |  29 +++--
 .../app/components/__tests__/App.test.tsx          |  57 ++++-----
 .../app/components/__tests__/Header.test.tsx       | 132 +++------------------
 .../components/__tests__/LeftSidebarNav.test.tsx   |  10 ++
 .../PlanningModeModal.planning-flow.test.tsx       |  25 ++++
 packages/dashboard/app/hooks/useModalManager.ts    |   5 +-
 packages/dashboard/app/hooks/useViewState.ts       |   7 +-
 14 files changed, 188 insertions(+), 252 deletions(-)

Fusion-Task-Id: FN-6886
Fusion-Task-Lineage: 18111cc7-ef2e-40a0-8272-220c01390ae2
This commit is contained in:
gsxdsm
2026-06-21 19:52:16 -07:00
parent 2db99ec6fd
commit 15d427bc11
14 changed files with 188 additions and 252 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Move Planning Mode into the dashboard sidebar as a first-class embedded view while removing the desktop toolbar affordance.

View File

@@ -98,6 +98,7 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "./utils/projectS
import { subscribeSse } from "./sse-bus";
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth";
import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog";
import { PlanningModeModal } from "./components/PlanningModeModal";
// ChatView's CSS is imported eagerly so the styles bundle into the main
// CSS file. Without this, the lazy ChatView JS chunk loaded its own CSS
@@ -1249,20 +1250,24 @@ function AppInner() {
pushNav({ type: "modal", close: modalManager.closeNewTask });
}, [modalManager, pushNav]);
/*
FNXC:Navigation 2026-06-21-00:00:
FN-6886 keeps the existing planning payload setters but routes every programmatic Planning Mode entry point to the docked `planning` view instead of pushing a modal overlay history entry.
*/
const openPlanningWithNav = useCallback(() => {
modalManager.openPlanning();
pushNav({ type: "modal", close: modalManager.closePlanning });
}, [modalManager, pushNav]);
handleTaskViewChange("planning");
}, [handleTaskViewChange, modalManager]);
const openPlanningWithInitialPlanWithNav = useCallback((initialPlan: string, workflowId?: string | null) => {
modalManager.openPlanningWithInitialPlan(initialPlan, workflowId);
pushNav({ type: "modal", close: modalManager.closePlanning });
}, [modalManager, pushNav]);
handleTaskViewChange("planning");
}, [handleTaskViewChange, modalManager]);
const resumePlanningWithNav = useCallback(() => {
modalManager.resumePlanning();
pushNav({ type: "modal", close: modalManager.closePlanning });
}, [modalManager, pushNav]);
handleTaskViewChange("planning");
}, [handleTaskViewChange, modalManager]);
const openSubtaskBreakdownWithNav = useCallback((description: string, workflowId?: string | null) => {
modalManager.openSubtaskBreakdown(description, workflowId);
@@ -1810,6 +1815,33 @@ function AppInner() {
);
}
if (taskView === "planning") {
/*
FNXC:Navigation 2026-06-21-00:00:
FN-6886 renders Planning Mode as a top-level main-content destination. Sidebar navigation opens an empty planning view, while Board, Todos, inline create, and resume entry points carry their initial plan/workflow/session state through modalManager.
*/
const closePlanningView = () => {
modalManager.closePlanning();
handleChangeTaskView("board");
};
return (
<PageErrorBoundary>
<PlanningModeModal
isOpen={true}
onClose={closePlanningView}
onTaskCreated={handlePlanningTaskCreated}
onTasksCreated={handlePlanningTasksCreated}
tasks={tasks}
initialPlan={modalManager.planningInitialPlan ?? undefined}
projectId={currentProject?.id}
workflowId={modalManager.planningWorkflowId}
resumeSessionId={modalManager.planningResumeSessionId}
presentation="embedded"
/>
</PageErrorBoundary>
);
}
if (taskView === "devserver" || taskView === "dev-server") {
if (!settingsLoaded || !devServerEnabled) {
return null;
@@ -1944,9 +1976,6 @@ function AppInner() {
shellHost={shellHost.host}
onOpenSettings={openSettingsWithNav}
onOpenGitHubImport={openGitHubImportWithNav}
onOpenPlanning={openPlanningWithNav}
onResumePlanning={resumePlanningWithNav}
activePlanningSessionCount={bgPlanningSessions.length}
onOpenUsage={openUsageWithNav}
onOpenActivityLog={openActivityLogWithNav}
onOpenMailbox={() => handleTaskViewChange("mailbox")}

View File

@@ -162,7 +162,7 @@ describe("tablet header controls", () => {
});
it("does not render planning button inline on tablet", () => {
renderTabletHeader({ onOpenPlanning: noop });
renderTabletHeader();
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
});
@@ -214,10 +214,10 @@ describe("tablet header controls", () => {
expect(screen.getByText("Settings")).toBeDefined();
});
it("overflow menu contains planning on tablet", () => {
renderTabletHeader({ onOpenPlanning: noop });
it("overflow menu omits planning on tablet", () => {
renderTabletHeader();
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-planning-btn")).toBeDefined();
expect(screen.queryByTestId("overflow-planning-btn")).toBeNull();
});
it("overflow menu contains GitHub import on tablet", () => {
@@ -296,14 +296,6 @@ describe("tablet header controls", () => {
expect(onToggleTerminal).toHaveBeenCalled();
});
it("calls onOpenPlanning from overflow menu on tablet", () => {
const onOpenPlanning = vi.fn();
renderTabletHeader({ onOpenPlanning });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-planning-btn"));
expect(onOpenPlanning).toHaveBeenCalled();
});
it("calls onOpenUsage from overflow menu on tablet", () => {
const onOpenUsage = vi.fn();
renderTabletHeader({ onOpenUsage });

View File

@@ -8,7 +8,6 @@ import type { Toast, ToastType } from "../hooks/useToast";
import { ModalErrorBoundary } from "./ErrorBoundary";
import { TaskDetailModal } from "./TaskDetailModal";
import { GitHubImportModal } from "./GitHubImportModal";
import { PlanningModeModal } from "./PlanningModeModal";
import { SubtaskBreakdownModal } from "./SubtaskBreakdownModal";
import { TerminalModal } from "./TerminalModal";
import { ScriptsModal } from "./ScriptsModal";
@@ -157,11 +156,6 @@ export function AppModals({
modalManager.closeGitHubImport();
}, [modalManager.closeGitHubImport, removeNav]);
const closePlanningWithNav = useCallback(() => {
removeNav(modalManager.closePlanning);
modalManager.closePlanning();
}, [modalManager.closePlanning, removeNav]);
const closeSubtaskWithNav = useCallback(() => {
removeNav(modalManager.closeSubtask);
modalManager.closeSubtask();
@@ -354,20 +348,6 @@ export function AppModals({
projectId={projectId}
/>
<ModalErrorBoundary>
<PlanningModeModal
isOpen={modalManager.isPlanningOpen}
onClose={closePlanningWithNav}
onTaskCreated={taskHandlers.handlePlanningTaskCreated}
onTasksCreated={taskHandlers.handlePlanningTasksCreated}
tasks={tasks}
initialPlan={modalManager.planningInitialPlan ?? undefined}
projectId={projectId}
workflowId={modalManager.planningWorkflowId}
resumeSessionId={modalManager.planningResumeSessionId}
/>
</ModalErrorBoundary>
<ModalErrorBoundary>
<SubtaskBreakdownModal
isOpen={modalManager.isSubtaskOpen}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Settings, Play, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge, PanelRight } from "lucide-react";
import { Settings, Play, LayoutGrid, List, Terminal, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock, Gauge, PanelRight } from "lucide-react";
import "./Header.css";
// ProjectSelector styles used by the imported standalone component.
import "./ProjectSelector.css";
@@ -58,11 +58,6 @@ interface DropdownPosition {
export interface HeaderProps {
onOpenSettings?: () => void;
onOpenGitHubImport?: () => void;
onOpenPlanning?: () => void;
/** Resume an in-flight planning session. Takes priority over onOpenPlanning when activePlanningSessionCount > 0 */
onResumePlanning?: () => void;
/** Number of active planning sessions. When > 0, shows a badge on the Planning button. */
activePlanningSessionCount?: number;
onOpenUsage?: (anchorRect?: DOMRect | null) => void;
onOpenActivityLog?: () => void;
/** Opens the mailbox view */
@@ -131,9 +126,6 @@ export interface HeaderProps {
export function Header({
onOpenSettings,
onOpenGitHubImport,
onOpenPlanning,
onResumePlanning,
activePlanningSessionCount = 0,
onOpenUsage,
onOpenActivityLog,
onOpenMailbox,
@@ -1354,26 +1346,10 @@ export function Header({
</button>
)}
{!isCompact && (
<button
className={`btn-icon${activePlanningSessionCount > 0 ? " btn-icon--has-indicator" : ""}`}
onClick={activePlanningSessionCount > 0 && onResumePlanning ? onResumePlanning : onOpenPlanning}
title={activePlanningSessionCount > 0 ? t("header.resumePlanningSession", "Resume planning session") : t("header.createTaskWithPlanning", "Create a task with AI planning")}
data-testid="planning-btn"
style={{ position: "relative" }}
>
<Lightbulb size={16} />
{activePlanningSessionCount > 0 && (
<span
className="header-badge header-badge--pulse"
data-testid="planning-badge"
aria-label={t("header.activePlanningSessions", { count: activePlanningSessionCount, defaultValue_one: "{{count}} active planning session", defaultValue_other: "{{count}} active planning sessions" })}
>
{activePlanningSessionCount}
</span>
)}
</button>
)}
{/*
FNXC:Navigation 2026-06-21-00:00:
FN-6886 removes the header Lightbulb affordances because Planning Mode is now a primary left-sidebar destination after Command Center and a single canonical MobileNavBar More item on compact breakpoints.
*/}
{/* Terminal split button - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && (
@@ -1621,22 +1597,6 @@ export function Header({
<span>{t("header.browseFiles", "Browse Files")}</span>
</button>
)}
<button
className={`mobile-overflow-item${activePlanningSessionCount > 0 ? " mobile-overflow-item--has-indicator" : ""}`}
onClick={() => handleOverflowAction(activePlanningSessionCount > 0 && onResumePlanning ? onResumePlanning : onOpenPlanning)}
role="menuitem"
data-testid="overflow-planning-btn"
>
<span className="mobile-overflow-icon-wrapper">
<Lightbulb size={16} />
{activePlanningSessionCount > 0 && (
<span className="header-badge header-badge--pulse" data-testid="overflow-planning-badge">
{activePlanningSessionCount}
</span>
)}
</span>
<span>{activePlanningSessionCount > 0 ? t("header.resumePlanningSessionCount", "Resume planning session ({{count}})", { count: activePlanningSessionCount }) : t("header.createTaskWithPlanning", "Create a task with AI planning")}</span>
</button>
{/* Git Manager - in overflow on mobile */}
{onOpenGitManager && (
<button

View File

@@ -14,6 +14,7 @@ import {
FileText,
Gauge,
History,
Lightbulb,
LayoutGrid,
List,
Mail,
@@ -272,6 +273,19 @@ export function LeftSidebarNav({
testId: "sidebar-nav-command-center",
onSelect: () => onChangeView("command-center"),
},
{
id: "planning",
/*
FNXC:Navigation 2026-06-21-00:00:
FN-6886 makes Planning Mode a first-class sidebar destination immediately after Command Center so the experimental sidebar owns the desktop planning affordance.
*/
label: t("nav.planning", "Planning"),
view: "planning",
isActive: view === "planning",
icon: Lightbulb,
testId: "sidebar-nav-planning",
onSelect: () => onChangeView("planning"),
},
{
id: "missions",
label: t("nav.missions", "Missions"),

View File

@@ -49,6 +49,29 @@
resize: both;
}
/*
FNXC:PlanningMode 2026-06-21-00:00:
FN-6886 promotes Planning Mode into the main app content area. The embedded shell fills the available content pane and intentionally disables modal-only sizing/resizing so the left sidebar owns navigation and no backdrop shell remains.
*/
.planning-view {
height: 100%;
min-height: 0;
display: flex;
padding: var(--space-lg);
overflow: hidden;
}
.planning-modal--embedded {
width: 100%;
max-width: none;
min-width: 0;
height: 100%;
min-height: 0;
max-height: none;
resize: none;
box-shadow: none;
}
.planning-modal .modal-header {
flex-shrink: 0;
}

View File

@@ -1,7 +1,7 @@
import "./PlanningModeModal.css";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { useState, useCallback, useEffect, useRef, useMemo, type MouseEvent } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Task, PlanningQuestion, PlanningSummary, TaskPriority } from "@fusion/core";
@@ -71,6 +71,8 @@ interface PlanningModeModalProps {
workflowId?: string | null;
/** When set, reconnect to a persisted background session instead of starting fresh */
resumeSessionId?: string;
/** Render without the full-screen modal chrome when Planning Mode is mounted as a top-level app view. */
presentation?: "modal" | "embedded";
}
interface QuestionResponse {
@@ -193,8 +195,9 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
};
}
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, tasks, initialPlan: initialPlanProp, projectId, workflowId, resumeSessionId }: PlanningModeModalProps) {
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, tasks, initialPlan: initialPlanProp, projectId, workflowId, resumeSessionId, presentation = "modal" }: PlanningModeModalProps) {
const { t } = useTranslation("app");
const isEmbedded = presentation === "embedded";
const [initialPlan, setInitialPlan] = useState("");
const [view, setView] = useState<ViewState>({ type: "initial" });
const [error, setError] = useState<string | null>(null);
@@ -298,7 +301,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
modelId?: string;
} | null>(null);
useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
useModalResizePersist(modalRef, isOpen && !isEmbedded, "fusion:planning-modal-size");
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
const { addToast } = useToast();
@@ -306,7 +309,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } =
useMobileKeyboard({ enabled: viewportMode === "mobile" });
useMobileScrollLock(viewportMode === "mobile" && isOpen);
useMobileScrollLock(viewportMode === "mobile" && isOpen && !isEmbedded);
// Drive --vv-height / --keyboard-overlap / --vv-offset-top imperatively
// rather than via React's style prop. Reason: when React removes a CSS
@@ -1799,24 +1802,30 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId;
const allowTakeover = isLockedByOther && (!activeRemoteTab || activeLockInfo.stale);
/*
FNXC:PlanningMode 2026-06-21-00:00:
FN-6886 keeps the existing Planning Mode workflow component but lets App mount it as an embedded main-content view. Embedded mode must not draw a full-screen overlay, close on backdrop clicks, lock mobile scrolling, or persist resizable modal dimensions.
*/
if (!isOpen) return null;
return (
<div
className="modal-overlay open"
onMouseDown={(e) => {
className={isEmbedded ? "planning-view open" : "modal-overlay open"}
data-testid={isEmbedded ? "planning-view" : undefined}
onMouseDown={isEmbedded ? undefined : (e: MouseEvent<HTMLDivElement>) => {
overlayMouseDownOnSelfRef.current = e.target === e.currentTarget;
}}
onClick={(e) => {
onClick={isEmbedded ? undefined : (e: MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget && overlayMouseDownOnSelfRef.current) {
handleClose();
}
overlayMouseDownOnSelfRef.current = false;
}}
role="dialog"
aria-modal="true"
role={isEmbedded ? "region" : "dialog"}
aria-label={isEmbedded ? t("planning.title", "Planning Mode") : undefined}
aria-modal={isEmbedded ? undefined : "true"}
>
<div className="modal modal-lg planning-modal" ref={modalRef}>
<div className={isEmbedded ? "modal modal-lg planning-modal planning-modal--embedded" : "modal modal-lg planning-modal"} ref={modalRef}>
<div className="modal-header">
<div className="detail-title-row">
{mobileShowDetail && (

View File

@@ -302,9 +302,9 @@ vi.mock("../../components/GitHubImportModal", () => ({
}));
vi.mock("../../components/PlanningModeModal", () => ({
PlanningModeModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) =>
PlanningModeModal: ({ isOpen, onClose, presentation = "modal" }: { isOpen: boolean; onClose: () => void; presentation?: "modal" | "embedded" }) =>
isOpen ? (
<div className="modal-overlay open">
<div className={presentation === "embedded" ? "planning-view open" : "modal-overlay open"} data-testid={presentation === "embedded" ? "planning-view" : undefined}>
<button type="button" aria-label="Close" onClick={onClose}>
Close
</button>
@@ -2326,15 +2326,10 @@ describe("App view switching", () => {
},
});
localStorage.setItem(taskViewStorageKey(), "todos");
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
fireEvent.click(screen.getByTestId("view-overflow-todos"));
await waitFor(() => {
expect(screen.getByTestId("todo-view")).toBeInTheDocument();
});
@@ -2342,6 +2337,7 @@ describe("App view switching", () => {
fireEvent.click(screen.getByTestId("todo-planning-button"));
await waitFor(() => {
expect(screen.getByTestId("planning-view")).toBeInTheDocument();
expect(screen.getByText("Planning Mode")).toBeInTheDocument();
});
@@ -2775,61 +2771,48 @@ describe("App GitHub import", () => {
});
describe("App Planning Mode", () => {
it("opens Planning Mode modal when plan button is clicked", async () => {
it("opens Planning Mode as an embedded view from the sidebar destination", async () => {
render(<App />);
// Wait for the header to render
await waitFor(() => {
expect(screen.getByTitle("Create a task with AI planning")).toBeTruthy();
});
const planningNavItem = await screen.findByTestId("sidebar-nav-planning");
fireEvent.click(planningNavItem);
// Click the plan button
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
// Planning modal should be visible
await waitFor(() => {
expect(screen.getByTestId("planning-view")).toBeTruthy();
expect(screen.getByText("Planning Mode")).toBeTruthy();
});
expect(screen.queryByTestId("planning-btn")).toBeNull();
});
it("closes Planning Mode modal on close button click", async () => {
it("closes Planning Mode embedded view back to the board", async () => {
render(<App />);
fireEvent.click(await screen.findByTestId("sidebar-nav-planning"));
await waitFor(() => {
expect(screen.getByTitle("Create a task with AI planning")).toBeTruthy();
expect(screen.getByTestId("planning-view")).toBeTruthy();
});
// Open the modal
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
await waitFor(() => {
expect(screen.getByText("Planning Mode")).toBeTruthy();
});
// Close the modal using the close button
fireEvent.click(screen.getByLabelText("Close"));
// Modal should be closed
await waitFor(() => {
expect(screen.queryByText("Transform your idea into a detailed task")).toBeNull();
expect(screen.getByTestId("sidebar-nav-board").getAttribute("aria-current")).toBe("page");
});
});
it("renders planning modal with correct initial state", async () => {
it("renders planning embedded view with correct initial state", async () => {
localStorage.setItem(taskViewStorageKey(), "planning");
render(<App />);
await waitFor(() => {
expect(screen.getByTitle("Create a task with AI planning")).toBeTruthy();
});
// Open the modal
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
// Initial view should show
await waitFor(() => {
expect(screen.getByTestId("planning-view")).toBeTruthy();
expect(screen.getByText("Transform your idea into a detailed task")).toBeTruthy();
expect(screen.getByPlaceholderText(/e.g., Build a user authentication system with login/)).toBeTruthy();
expect(screen.getByText("Start Planning")).toBeTruthy();
});
localStorage.removeItem(taskViewStorageKey());
});
});

View File

@@ -794,95 +794,19 @@ describe("Header", () => {
});
describe("planning button", () => {
it("renders planning button with correct title on desktop", () => {
renderHeader({ onOpenPlanning: vi.fn() }, "desktop");
expect(screen.getByTitle("Create a task with AI planning")).toBeDefined();
});
it("does not render planning button inline on mobile", () => {
renderHeader({ onOpenPlanning: vi.fn() }, "mobile");
it("does not render legacy planning affordances in the header on desktop", () => {
renderHeader({}, "desktop");
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
expect(screen.queryByTitle("Resume planning session")).toBeNull();
expect(screen.queryByTestId("planning-btn")).toBeNull();
expect(screen.queryByTestId("planning-badge")).toBeNull();
});
it("calls onOpenPlanning when planning button is clicked", () => {
const onOpenPlanning = vi.fn();
renderHeader({ onOpenPlanning }, "desktop");
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
expect(onOpenPlanning).toHaveBeenCalled();
});
it("has correct data-testid for testing on desktop", () => {
renderHeader({ onOpenPlanning: vi.fn() }, "desktop");
expect(screen.getByTestId("planning-btn")).toBeDefined();
});
describe("active session badge", () => {
it("does not render badge when activePlanningSessionCount is 0", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 0 }, "desktop");
expect(screen.queryByTestId("planning-badge")).toBeNull();
});
it("does not render badge when activePlanningSessionCount is undefined", () => {
renderHeader({ onOpenPlanning: vi.fn() }, "desktop");
expect(screen.queryByTestId("planning-badge")).toBeNull();
});
it("renders badge when activePlanningSessionCount > 0", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 1 }, "desktop");
expect(screen.getByTestId("planning-badge")).toBeDefined();
});
it("badge shows correct count", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 3 }, "desktop");
expect(screen.getByTestId("planning-badge").textContent).toBe("3");
});
it("updates title to 'Resume planning session' when count > 0", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 1 }, "desktop");
expect(screen.getByTitle("Resume planning session")).toBeDefined();
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
});
it("keeps original title when count is 0", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 0 }, "desktop");
expect(screen.getByTitle("Create a task with AI planning")).toBeDefined();
});
it("calls onResumePlanning when clicked with active sessions", () => {
const onResumePlanning = vi.fn();
const onOpenPlanning = vi.fn();
renderHeader({ onOpenPlanning, onResumePlanning, activePlanningSessionCount: 2 }, "desktop");
fireEvent.click(screen.getByTitle("Resume planning session"));
expect(onResumePlanning).toHaveBeenCalled();
expect(onOpenPlanning).not.toHaveBeenCalled();
});
it("calls onOpenPlanning when clicked with no active sessions", () => {
const onResumePlanning = vi.fn();
const onOpenPlanning = vi.fn();
renderHeader({ onOpenPlanning, onResumePlanning, activePlanningSessionCount: 0 }, "desktop");
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
expect(onOpenPlanning).toHaveBeenCalled();
expect(onResumePlanning).not.toHaveBeenCalled();
});
it("calls onOpenPlanning when clicked with active sessions but no onResumePlanning", () => {
const onOpenPlanning = vi.fn();
renderHeader({ onOpenPlanning, activePlanningSessionCount: 1 }, "desktop");
// Without onResumePlanning, falls back to onOpenPlanning even with active sessions
fireEvent.click(screen.getByTitle("Resume planning session"));
expect(onOpenPlanning).toHaveBeenCalled();
});
it("badge has correct aria-label", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 2 }, "desktop");
expect(screen.getByTestId("planning-badge").getAttribute("aria-label")).toBe("2 active planning sessions");
});
it("badge aria-label uses singular for count of 1", () => {
renderHeader({ onOpenPlanning: vi.fn(), activePlanningSessionCount: 1 }, "desktop");
expect(screen.getByTestId("planning-badge").getAttribute("aria-label")).toBe("1 active planning session");
});
it("does not render legacy planning affordances in the header on mobile", () => {
renderHeader({}, "mobile");
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
expect(screen.queryByTestId("overflow-planning-btn")).toBeNull();
expect(screen.queryByTestId("overflow-planning-badge")).toBeNull();
});
});
@@ -1073,39 +997,13 @@ describe("Header", () => {
expect(screen.getByText("Import from GitHub")).toBeDefined();
});
it("shows planning in overflow menu on mobile", () => {
renderHeader({ onOpenPlanning: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-planning-btn")).toBeDefined();
});
it("shows planning badge in overflow menu when activePlanningSessionCount > 0", () => {
renderHeader({ onOpenPlanning: noop, activePlanningSessionCount: 1 }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-planning-badge")).toBeDefined();
expect(screen.getByTestId("overflow-planning-badge").textContent).toBe("1");
});
it("does not show planning badge in overflow menu when count is 0", () => {
renderHeader({ onOpenPlanning: noop, activePlanningSessionCount: 0 }, "mobile");
it("omits planning from the header overflow menu on mobile", () => {
renderHeader({}, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.queryByTestId("overflow-planning-btn")).toBeNull();
expect(screen.queryByTestId("overflow-planning-badge")).toBeNull();
});
it("calls onResumePlanning from overflow menu when active sessions exist", () => {
const onResumePlanning = vi.fn();
const onOpenPlanning = vi.fn();
renderHeader({ onOpenPlanning, onResumePlanning, activePlanningSessionCount: 2 }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-planning-btn"));
expect(onResumePlanning).toHaveBeenCalled();
expect(onOpenPlanning).not.toHaveBeenCalled();
});
it("shows resume text in overflow menu when active sessions exist", () => {
renderHeader({ onOpenPlanning: noop, activePlanningSessionCount: 1 }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByText("Resume planning session (1)")).toBeDefined();
expect(screen.queryByText("Create a task with AI planning")).toBeNull();
expect(screen.queryByText("Resume planning session (1)")).toBeNull();
});
it("shows settings in overflow menu on mobile", () => {

View File

@@ -211,6 +211,7 @@ describe("LeftSidebarNav", () => {
"sidebar-nav-list",
"sidebar-nav-agents",
"sidebar-nav-command-center",
"sidebar-nav-planning",
"sidebar-nav-missions",
"sidebar-nav-chat",
"sidebar-nav-documents",
@@ -231,6 +232,11 @@ describe("LeftSidebarNav", () => {
}
expect(screen.getByTestId("sidebar-nav-documents")).toHaveTextContent("Artifacts");
expect(screen.getByTestId("sidebar-nav-planning")).toHaveTextContent("Planning");
const primaryNav = screen.getByRole("navigation", { name: "Primary navigation" });
const primaryButtons = within(primaryNav).getAllByRole("button");
expect(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-planning"))).toBe(primaryButtons.indexOf(screen.getByTestId("sidebar-nav-command-center")) + 1);
const sidebar = screen.getByTestId("left-sidebar-nav");
const footer = screen.getByTestId("sidebar-nav-settings").closest(".left-sidebar-nav__footer");
@@ -358,6 +364,7 @@ describe("LeftSidebarNav", () => {
it.each<[TaskView, string]>([
["board", "sidebar-nav-board"],
["research", "sidebar-nav-research"],
["planning", "sidebar-nav-planning"],
["plugin:fusion-plugin-primary:primary-view", "sidebar-nav-plugin-fusion-plugin-primary-primary-view"],
["plugin:fusion-plugin-overflow:overflow-view", "sidebar-nav-plugin-fusion-plugin-overflow-overflow-view"],
])("highlights active destination %s", (view, testId) => {
@@ -528,6 +535,9 @@ describe("LeftSidebarNav", () => {
fireEvent.click(screen.getByTestId("sidebar-nav-list"));
expect(onChangeView).toHaveBeenCalledWith("list");
fireEvent.click(screen.getByTestId("sidebar-nav-planning"));
expect(onChangeView).toHaveBeenCalledWith("planning");
fireEvent.click(screen.getByTestId("sidebar-nav-plugin-fusion-plugin-overflow-overflow-view"));
expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-overflow:overflow-view");

View File

@@ -200,6 +200,31 @@ describe("PlanningModeModal", () => {
});
});
describe("embedded presentation", () => {
it("renders as a main-content region without modal overlay or backdrop-close behavior", async () => {
const { container } = render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
presentation="embedded"
/>
);
const region = await screen.findByTestId("planning-view");
expect(region.getAttribute("role")).toBe("region");
expect(region.getAttribute("aria-modal")).toBeNull();
expect(container.querySelector(".modal-overlay")).toBeNull();
expect(container.querySelector(".planning-modal--embedded")).toBeTruthy();
fireEvent.mouseDown(region);
fireEvent.click(region);
expect(mockOnClose).not.toHaveBeenCalled();
});
});
describe("Planning flow", () => {
it("starts planning and shows question view", async () => {
render(

View File

@@ -199,7 +199,10 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
groupModalGroupId ||
settingsOpen ||
newTaskModalOpen ||
isPlanningOpen ||
/*
FNXC:Navigation 2026-06-21-00:00:
FN-6886 reuses Planning Mode state only as docked-view payload storage, so it must not make the app behave as though a blocking modal overlay is open.
*/
isSubtaskOpen ||
terminalOpen ||
filesOpen ||

View File

@@ -5,7 +5,7 @@ import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
export type ViewMode = "overview" | "project";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "todos" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "stash-recovery" | "pull-requests";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "todos" | "planning" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "stash-recovery" | "pull-requests";
export type PluginTaskView = `plugin:${string}:${string}`;
export type TaskView = BuiltInTaskView | PluginTaskView;
@@ -25,6 +25,11 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
FN-6829 promotes project Todos from modal-only state into the persisted built-in task-view registry so dashboard navigation can dock it in the right content area.
*/
"todos",
/*
FNXC:Navigation 2026-06-21-00:00:
FN-6886 promotes Planning Mode into a persisted top-level docked task view instead of treating it as a modal-only overlay.
*/
"planning",
"skills",
"mailbox",