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:
5
.changeset/fn-6886-planning-sidebar-view.md
Normal file
5
.changeset/fn-6886-planning-sidebar-view.md
Normal 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.
|
||||||
@@ -98,6 +98,7 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "./utils/projectS
|
|||||||
import { subscribeSse } from "./sse-bus";
|
import { subscribeSse } from "./sse-bus";
|
||||||
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth";
|
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth";
|
||||||
import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog";
|
import { AuthTokenRecoveryDialog } from "./components/AuthTokenRecoveryDialog";
|
||||||
|
import { PlanningModeModal } from "./components/PlanningModeModal";
|
||||||
|
|
||||||
// ChatView's CSS is imported eagerly so the styles bundle into the main
|
// 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
|
// 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 });
|
pushNav({ type: "modal", close: modalManager.closeNewTask });
|
||||||
}, [modalManager, pushNav]);
|
}, [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(() => {
|
const openPlanningWithNav = useCallback(() => {
|
||||||
modalManager.openPlanning();
|
modalManager.openPlanning();
|
||||||
pushNav({ type: "modal", close: modalManager.closePlanning });
|
handleTaskViewChange("planning");
|
||||||
}, [modalManager, pushNav]);
|
}, [handleTaskViewChange, modalManager]);
|
||||||
|
|
||||||
const openPlanningWithInitialPlanWithNav = useCallback((initialPlan: string, workflowId?: string | null) => {
|
const openPlanningWithInitialPlanWithNav = useCallback((initialPlan: string, workflowId?: string | null) => {
|
||||||
modalManager.openPlanningWithInitialPlan(initialPlan, workflowId);
|
modalManager.openPlanningWithInitialPlan(initialPlan, workflowId);
|
||||||
pushNav({ type: "modal", close: modalManager.closePlanning });
|
handleTaskViewChange("planning");
|
||||||
}, [modalManager, pushNav]);
|
}, [handleTaskViewChange, modalManager]);
|
||||||
|
|
||||||
const resumePlanningWithNav = useCallback(() => {
|
const resumePlanningWithNav = useCallback(() => {
|
||||||
modalManager.resumePlanning();
|
modalManager.resumePlanning();
|
||||||
pushNav({ type: "modal", close: modalManager.closePlanning });
|
handleTaskViewChange("planning");
|
||||||
}, [modalManager, pushNav]);
|
}, [handleTaskViewChange, modalManager]);
|
||||||
|
|
||||||
const openSubtaskBreakdownWithNav = useCallback((description: string, workflowId?: string | null) => {
|
const openSubtaskBreakdownWithNav = useCallback((description: string, workflowId?: string | null) => {
|
||||||
modalManager.openSubtaskBreakdown(description, workflowId);
|
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 (taskView === "devserver" || taskView === "dev-server") {
|
||||||
if (!settingsLoaded || !devServerEnabled) {
|
if (!settingsLoaded || !devServerEnabled) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1944,9 +1976,6 @@ function AppInner() {
|
|||||||
shellHost={shellHost.host}
|
shellHost={shellHost.host}
|
||||||
onOpenSettings={openSettingsWithNav}
|
onOpenSettings={openSettingsWithNav}
|
||||||
onOpenGitHubImport={openGitHubImportWithNav}
|
onOpenGitHubImport={openGitHubImportWithNav}
|
||||||
onOpenPlanning={openPlanningWithNav}
|
|
||||||
onResumePlanning={resumePlanningWithNav}
|
|
||||||
activePlanningSessionCount={bgPlanningSessions.length}
|
|
||||||
onOpenUsage={openUsageWithNav}
|
onOpenUsage={openUsageWithNav}
|
||||||
onOpenActivityLog={openActivityLogWithNav}
|
onOpenActivityLog={openActivityLogWithNav}
|
||||||
onOpenMailbox={() => handleTaskViewChange("mailbox")}
|
onOpenMailbox={() => handleTaskViewChange("mailbox")}
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ describe("tablet header controls", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not render planning button inline on tablet", () => {
|
it("does not render planning button inline on tablet", () => {
|
||||||
renderTabletHeader({ onOpenPlanning: noop });
|
renderTabletHeader();
|
||||||
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
|
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,10 +214,10 @@ describe("tablet header controls", () => {
|
|||||||
expect(screen.getByText("Settings")).toBeDefined();
|
expect(screen.getByText("Settings")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("overflow menu contains planning on tablet", () => {
|
it("overflow menu omits planning on tablet", () => {
|
||||||
renderTabletHeader({ onOpenPlanning: noop });
|
renderTabletHeader();
|
||||||
fireEvent.click(screen.getByTitle("More header actions"));
|
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", () => {
|
it("overflow menu contains GitHub import on tablet", () => {
|
||||||
@@ -296,14 +296,6 @@ describe("tablet header controls", () => {
|
|||||||
expect(onToggleTerminal).toHaveBeenCalled();
|
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", () => {
|
it("calls onOpenUsage from overflow menu on tablet", () => {
|
||||||
const onOpenUsage = vi.fn();
|
const onOpenUsage = vi.fn();
|
||||||
renderTabletHeader({ onOpenUsage });
|
renderTabletHeader({ onOpenUsage });
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type { Toast, ToastType } from "../hooks/useToast";
|
|||||||
import { ModalErrorBoundary } from "./ErrorBoundary";
|
import { ModalErrorBoundary } from "./ErrorBoundary";
|
||||||
import { TaskDetailModal } from "./TaskDetailModal";
|
import { TaskDetailModal } from "./TaskDetailModal";
|
||||||
import { GitHubImportModal } from "./GitHubImportModal";
|
import { GitHubImportModal } from "./GitHubImportModal";
|
||||||
import { PlanningModeModal } from "./PlanningModeModal";
|
|
||||||
import { SubtaskBreakdownModal } from "./SubtaskBreakdownModal";
|
import { SubtaskBreakdownModal } from "./SubtaskBreakdownModal";
|
||||||
import { TerminalModal } from "./TerminalModal";
|
import { TerminalModal } from "./TerminalModal";
|
||||||
import { ScriptsModal } from "./ScriptsModal";
|
import { ScriptsModal } from "./ScriptsModal";
|
||||||
@@ -157,11 +156,6 @@ export function AppModals({
|
|||||||
modalManager.closeGitHubImport();
|
modalManager.closeGitHubImport();
|
||||||
}, [modalManager.closeGitHubImport, removeNav]);
|
}, [modalManager.closeGitHubImport, removeNav]);
|
||||||
|
|
||||||
const closePlanningWithNav = useCallback(() => {
|
|
||||||
removeNav(modalManager.closePlanning);
|
|
||||||
modalManager.closePlanning();
|
|
||||||
}, [modalManager.closePlanning, removeNav]);
|
|
||||||
|
|
||||||
const closeSubtaskWithNav = useCallback(() => {
|
const closeSubtaskWithNav = useCallback(() => {
|
||||||
removeNav(modalManager.closeSubtask);
|
removeNav(modalManager.closeSubtask);
|
||||||
modalManager.closeSubtask();
|
modalManager.closeSubtask();
|
||||||
@@ -354,20 +348,6 @@ export function AppModals({
|
|||||||
projectId={projectId}
|
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>
|
<ModalErrorBoundary>
|
||||||
<SubtaskBreakdownModal
|
<SubtaskBreakdownModal
|
||||||
isOpen={modalManager.isSubtaskOpen}
|
isOpen={modalManager.isSubtaskOpen}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
import "./Header.css";
|
||||||
// ProjectSelector styles used by the imported standalone component.
|
// ProjectSelector styles used by the imported standalone component.
|
||||||
import "./ProjectSelector.css";
|
import "./ProjectSelector.css";
|
||||||
@@ -58,11 +58,6 @@ interface DropdownPosition {
|
|||||||
export interface HeaderProps {
|
export interface HeaderProps {
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
onOpenGitHubImport?: () => 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;
|
onOpenUsage?: (anchorRect?: DOMRect | null) => void;
|
||||||
onOpenActivityLog?: () => void;
|
onOpenActivityLog?: () => void;
|
||||||
/** Opens the mailbox view */
|
/** Opens the mailbox view */
|
||||||
@@ -131,9 +126,6 @@ export interface HeaderProps {
|
|||||||
export function Header({
|
export function Header({
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onOpenGitHubImport,
|
onOpenGitHubImport,
|
||||||
onOpenPlanning,
|
|
||||||
onResumePlanning,
|
|
||||||
activePlanningSessionCount = 0,
|
|
||||||
onOpenUsage,
|
onOpenUsage,
|
||||||
onOpenActivityLog,
|
onOpenActivityLog,
|
||||||
onOpenMailbox,
|
onOpenMailbox,
|
||||||
@@ -1354,26 +1346,10 @@ export function Header({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isCompact && (
|
{/*
|
||||||
<button
|
FNXC:Navigation 2026-06-21-00:00:
|
||||||
className={`btn-icon${activePlanningSessionCount > 0 ? " btn-icon--has-indicator" : ""}`}
|
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.
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Terminal split button - desktop only (moved to overflow on mobile/tablet) */}
|
{/* Terminal split button - desktop only (moved to overflow on mobile/tablet) */}
|
||||||
{!isCompact && (
|
{!isCompact && (
|
||||||
@@ -1621,22 +1597,6 @@ export function Header({
|
|||||||
<span>{t("header.browseFiles", "Browse Files")}</span>
|
<span>{t("header.browseFiles", "Browse Files")}</span>
|
||||||
</button>
|
</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 */}
|
{/* Git Manager - in overflow on mobile */}
|
||||||
{onOpenGitManager && (
|
{onOpenGitManager && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Gauge,
|
Gauge,
|
||||||
History,
|
History,
|
||||||
|
Lightbulb,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -272,6 +273,19 @@ export function LeftSidebarNav({
|
|||||||
testId: "sidebar-nav-command-center",
|
testId: "sidebar-nav-command-center",
|
||||||
onSelect: () => onChangeView("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",
|
id: "missions",
|
||||||
label: t("nav.missions", "Missions"),
|
label: t("nav.missions", "Missions"),
|
||||||
|
|||||||
@@ -49,6 +49,29 @@
|
|||||||
resize: both;
|
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 {
|
.planning-modal .modal-header {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import "./PlanningModeModal.css";
|
import "./PlanningModeModal.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "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 ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import type { Task, PlanningQuestion, PlanningSummary, TaskPriority } from "@fusion/core";
|
import type { Task, PlanningQuestion, PlanningSummary, TaskPriority } from "@fusion/core";
|
||||||
@@ -71,6 +71,8 @@ interface PlanningModeModalProps {
|
|||||||
workflowId?: string | null;
|
workflowId?: string | null;
|
||||||
/** When set, reconnect to a persisted background session instead of starting fresh */
|
/** When set, reconnect to a persisted background session instead of starting fresh */
|
||||||
resumeSessionId?: string;
|
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 {
|
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 { t } = useTranslation("app");
|
||||||
|
const isEmbedded = presentation === "embedded";
|
||||||
const [initialPlan, setInitialPlan] = useState("");
|
const [initialPlan, setInitialPlan] = useState("");
|
||||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -298,7 +301,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
modelId?: string;
|
modelId?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
|
useModalResizePersist(modalRef, isOpen && !isEmbedded, "fusion:planning-modal-size");
|
||||||
const viewportMode = useViewportMode();
|
const viewportMode = useViewportMode();
|
||||||
const isMobile = viewportMode === "mobile";
|
const isMobile = viewportMode === "mobile";
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
@@ -306,7 +309,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
|
|
||||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } =
|
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } =
|
||||||
useMobileKeyboard({ enabled: viewportMode === "mobile" });
|
useMobileKeyboard({ enabled: viewportMode === "mobile" });
|
||||||
useMobileScrollLock(viewportMode === "mobile" && isOpen);
|
useMobileScrollLock(viewportMode === "mobile" && isOpen && !isEmbedded);
|
||||||
|
|
||||||
// Drive --vv-height / --keyboard-overlap / --vv-offset-top imperatively
|
// Drive --vv-height / --keyboard-overlap / --vv-offset-top imperatively
|
||||||
// rather than via React's style prop. Reason: when React removes a CSS
|
// 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 activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId;
|
||||||
const allowTakeover = isLockedByOther && (!activeRemoteTab || activeLockInfo.stale);
|
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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="modal-overlay open"
|
className={isEmbedded ? "planning-view open" : "modal-overlay open"}
|
||||||
onMouseDown={(e) => {
|
data-testid={isEmbedded ? "planning-view" : undefined}
|
||||||
|
onMouseDown={isEmbedded ? undefined : (e: MouseEvent<HTMLDivElement>) => {
|
||||||
overlayMouseDownOnSelfRef.current = e.target === e.currentTarget;
|
overlayMouseDownOnSelfRef.current = e.target === e.currentTarget;
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={isEmbedded ? undefined : (e: MouseEvent<HTMLDivElement>) => {
|
||||||
if (e.target === e.currentTarget && overlayMouseDownOnSelfRef.current) {
|
if (e.target === e.currentTarget && overlayMouseDownOnSelfRef.current) {
|
||||||
handleClose();
|
handleClose();
|
||||||
}
|
}
|
||||||
overlayMouseDownOnSelfRef.current = false;
|
overlayMouseDownOnSelfRef.current = false;
|
||||||
}}
|
}}
|
||||||
role="dialog"
|
role={isEmbedded ? "region" : "dialog"}
|
||||||
aria-modal="true"
|
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="modal-header">
|
||||||
<div className="detail-title-row">
|
<div className="detail-title-row">
|
||||||
{mobileShowDetail && (
|
{mobileShowDetail && (
|
||||||
|
|||||||
@@ -302,9 +302,9 @@ vi.mock("../../components/GitHubImportModal", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../components/PlanningModeModal", () => ({
|
vi.mock("../../components/PlanningModeModal", () => ({
|
||||||
PlanningModeModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) =>
|
PlanningModeModal: ({ isOpen, onClose, presentation = "modal" }: { isOpen: boolean; onClose: () => void; presentation?: "modal" | "embedded" }) =>
|
||||||
isOpen ? (
|
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}>
|
<button type="button" aria-label="Close" onClick={onClose}>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
@@ -2326,15 +2326,10 @@ describe("App view switching", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
localStorage.setItem(taskViewStorageKey(), "todos");
|
||||||
|
|
||||||
render(<App />);
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("todo-view")).toBeInTheDocument();
|
expect(screen.getByTestId("todo-view")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -2342,6 +2337,7 @@ describe("App view switching", () => {
|
|||||||
fireEvent.click(screen.getByTestId("todo-planning-button"));
|
fireEvent.click(screen.getByTestId("todo-planning-button"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("planning-view")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Planning Mode")).toBeInTheDocument();
|
expect(screen.getByText("Planning Mode")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2775,61 +2771,48 @@ describe("App GitHub import", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("App Planning Mode", () => {
|
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 />);
|
render(<App />);
|
||||||
|
|
||||||
// Wait for the header to render
|
const planningNavItem = await screen.findByTestId("sidebar-nav-planning");
|
||||||
await waitFor(() => {
|
fireEvent.click(planningNavItem);
|
||||||
expect(screen.getByTitle("Create a task with AI planning")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click the plan button
|
|
||||||
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
|
|
||||||
|
|
||||||
// Planning modal should be visible
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("planning-view")).toBeTruthy();
|
||||||
expect(screen.getByText("Planning Mode")).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 />);
|
render(<App />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByTestId("sidebar-nav-planning"));
|
||||||
await waitFor(() => {
|
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"));
|
fireEvent.click(screen.getByLabelText("Close"));
|
||||||
|
|
||||||
// Modal should be closed
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByText("Transform your idea into a detailed task")).toBeNull();
|
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 />);
|
render(<App />);
|
||||||
|
|
||||||
await waitFor(() => {
|
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"));
|
|
||||||
|
|
||||||
// Initial view should show
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Transform your idea into a detailed task")).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.getByPlaceholderText(/e.g., Build a user authentication system with login/)).toBeTruthy();
|
||||||
expect(screen.getByText("Start Planning")).toBeTruthy();
|
expect(screen.getByText("Start Planning")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
localStorage.removeItem(taskViewStorageKey());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -794,95 +794,19 @@ describe("Header", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("planning button", () => {
|
describe("planning button", () => {
|
||||||
it("renders planning button with correct title on desktop", () => {
|
it("does not render legacy planning affordances in the header on desktop", () => {
|
||||||
renderHeader({ onOpenPlanning: vi.fn() }, "desktop");
|
renderHeader({}, "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");
|
|
||||||
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
|
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", () => {
|
it("does not render legacy planning affordances in the header on mobile", () => {
|
||||||
const onOpenPlanning = vi.fn();
|
renderHeader({}, "mobile");
|
||||||
renderHeader({ onOpenPlanning }, "desktop");
|
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
|
||||||
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
|
expect(screen.queryByTestId("overflow-planning-btn")).toBeNull();
|
||||||
expect(onOpenPlanning).toHaveBeenCalled();
|
expect(screen.queryByTestId("overflow-planning-badge")).toBeNull();
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1073,39 +997,13 @@ describe("Header", () => {
|
|||||||
expect(screen.getByText("Import from GitHub")).toBeDefined();
|
expect(screen.getByText("Import from GitHub")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows planning in overflow menu on mobile", () => {
|
it("omits planning from the header overflow menu on mobile", () => {
|
||||||
renderHeader({ onOpenPlanning: noop }, "mobile");
|
renderHeader({}, "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");
|
|
||||||
fireEvent.click(screen.getByTitle("More header actions"));
|
fireEvent.click(screen.getByTitle("More header actions"));
|
||||||
|
expect(screen.queryByTestId("overflow-planning-btn")).toBeNull();
|
||||||
expect(screen.queryByTestId("overflow-planning-badge")).toBeNull();
|
expect(screen.queryByTestId("overflow-planning-badge")).toBeNull();
|
||||||
});
|
expect(screen.queryByText("Create a task with AI planning")).toBeNull();
|
||||||
|
expect(screen.queryByText("Resume planning session (1)")).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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows settings in overflow menu on mobile", () => {
|
it("shows settings in overflow menu on mobile", () => {
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ describe("LeftSidebarNav", () => {
|
|||||||
"sidebar-nav-list",
|
"sidebar-nav-list",
|
||||||
"sidebar-nav-agents",
|
"sidebar-nav-agents",
|
||||||
"sidebar-nav-command-center",
|
"sidebar-nav-command-center",
|
||||||
|
"sidebar-nav-planning",
|
||||||
"sidebar-nav-missions",
|
"sidebar-nav-missions",
|
||||||
"sidebar-nav-chat",
|
"sidebar-nav-chat",
|
||||||
"sidebar-nav-documents",
|
"sidebar-nav-documents",
|
||||||
@@ -231,6 +232,11 @@ describe("LeftSidebarNav", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(screen.getByTestId("sidebar-nav-documents")).toHaveTextContent("Artifacts");
|
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 sidebar = screen.getByTestId("left-sidebar-nav");
|
||||||
const footer = screen.getByTestId("sidebar-nav-settings").closest(".left-sidebar-nav__footer");
|
const footer = screen.getByTestId("sidebar-nav-settings").closest(".left-sidebar-nav__footer");
|
||||||
@@ -358,6 +364,7 @@ describe("LeftSidebarNav", () => {
|
|||||||
it.each<[TaskView, string]>([
|
it.each<[TaskView, string]>([
|
||||||
["board", "sidebar-nav-board"],
|
["board", "sidebar-nav-board"],
|
||||||
["research", "sidebar-nav-research"],
|
["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-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"],
|
["plugin:fusion-plugin-overflow:overflow-view", "sidebar-nav-plugin-fusion-plugin-overflow-overflow-view"],
|
||||||
])("highlights active destination %s", (view, testId) => {
|
])("highlights active destination %s", (view, testId) => {
|
||||||
@@ -528,6 +535,9 @@ describe("LeftSidebarNav", () => {
|
|||||||
fireEvent.click(screen.getByTestId("sidebar-nav-list"));
|
fireEvent.click(screen.getByTestId("sidebar-nav-list"));
|
||||||
expect(onChangeView).toHaveBeenCalledWith("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"));
|
fireEvent.click(screen.getByTestId("sidebar-nav-plugin-fusion-plugin-overflow-overflow-view"));
|
||||||
expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-overflow:overflow-view");
|
expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-overflow:overflow-view");
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
describe("Planning flow", () => {
|
||||||
it("starts planning and shows question view", async () => {
|
it("starts planning and shows question view", async () => {
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -199,7 +199,10 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
|||||||
groupModalGroupId ||
|
groupModalGroupId ||
|
||||||
settingsOpen ||
|
settingsOpen ||
|
||||||
newTaskModalOpen ||
|
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 ||
|
isSubtaskOpen ||
|
||||||
terminalOpen ||
|
terminalOpen ||
|
||||||
filesOpen ||
|
filesOpen ||
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
|||||||
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
|
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
|
||||||
|
|
||||||
export type ViewMode = "overview" | "project";
|
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 PluginTaskView = `plugin:${string}:${string}`;
|
||||||
export type TaskView = BuiltInTaskView | PluginTaskView;
|
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.
|
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",
|
"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",
|
"skills",
|
||||||
"mailbox",
|
"mailbox",
|
||||||
|
|||||||
Reference in New Issue
Block a user