fix(FN-3161): migrate roadmap dashboard surface to bundled plugin

- Move RoadmapsView UI, hooks, and tests from dashboard app into fusion-plugin-roadmap
- Replace the built-in roadmaps route/nav wiring with plugin view registration and host rendering
- Add bundled plugin view registration bootstrap in dashboard startup
- Update roadmap plugin build/test config and add FN-3161 removal changeset

Fusion-Task-Id: FN-3161
This commit is contained in:
Fusion
2026-05-08 16:51:04 -07:00
committed by gsxdsm
parent 7cb9ab78fe
commit d42d8ee2f2
33 changed files with 295 additions and 149 deletions

View File

@@ -50,6 +50,7 @@ import { useNavigationHistory } from "./hooks/useNavigationHistory";
import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews";
import { PluginDashboardViewHost } from "./plugins/PluginDashboardViewHost";
import { isPluginViewId } from "./plugins/pluginViewRegistry";
import { registerBundledPluginViews } from "./plugins/registerBundledPluginViews";
import { useProjectActions } from "./hooks/useProjectActions";
import { useTaskHandlers } from "./hooks/useTaskHandlers";
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
@@ -85,7 +86,7 @@ const ResearchView = lazy(() => import("./components/ResearchView").then((m) =>
const EvalsView = lazy(() => import("./components/EvalsView").then((m) => ({ default: m.EvalsView })));
const NodesView = lazy(() => import("./components/NodesView").then((m) => ({ default: m.NodesView })));
const ChatView = lazy(() => import("./components/ChatView").then((m) => ({ default: m.ChatView })));
const RoadmapsView = lazy(() => import("./components/RoadmapsView").then((m) => ({ default: m.RoadmapsView })));
const SkillsView = lazy(() => import("./components/SkillsView").then((m) => ({ default: m.SkillsView })));
const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ default: m.MemoryView })));
const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
@@ -110,7 +111,7 @@ function prefetchLazyViews() {
void import("./components/EvalsView");
void import("./components/NodesView");
void import("./components/ChatView");
void import("./components/RoadmapsView");
void import("./components/SkillsView");
void import("./components/MemoryView");
void import("./components/DevServerView");
@@ -118,6 +119,8 @@ function prefetchLazyViews() {
});
}
registerBundledPluginViews();
const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed";
const ACTIVE_CHAT_SESSION_STORAGE_KEY = "kb-chat-active-session";
const WORKING_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-working-branch-filter";
@@ -574,9 +577,6 @@ function AppInner() {
if (taskView === "insights" && !insightsEnabled) {
handleChangeTaskView("board");
}
if (taskView === "roadmaps" && !roadmapEnabled) {
handleChangeTaskView("board");
}
if (taskView === "agents" && !agentsEnabled) {
handleChangeTaskView("board");
}
@@ -592,7 +592,7 @@ function AppInner() {
if (taskView === "evals" && !evalsEnabled) {
handleChangeTaskView("board");
}
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, roadmapEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, evalsEnabled, graphPluginTaskView]);
}, [taskView, settingsLoaded, skillsEnabled, insightsEnabled, handleChangeTaskView, agentsEnabled, memoryEnabled, devServerEnabled, researchEnabled, evalsEnabled, graphPluginTaskView]);
// Auto-close nodes overlay if feature flag is toggled off while overlay is open
useEffect(() => {
@@ -1047,6 +1047,7 @@ function AppInner() {
disableDrag={true}
/>
),
addToast,
}}
/>
</PageErrorBoundary>
@@ -1092,18 +1093,6 @@ function AppInner() {
);
}
if (taskView === "roadmaps") {
if (!settingsLoaded || !roadmapEnabled) {
return null;
}
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<RoadmapsView addToast={addToast} projectId={currentProject?.id} />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "missions") {
return (

View File

@@ -4,7 +4,6 @@ import { resolve } from "node:path";
const EXPECTED_DOCUMENTED_VIEWS = new Set([
"AgentsView",
"RoadmapsView",
"NodesView",
"ChatView",
"MemoryView",
@@ -29,7 +28,6 @@ const EXPECTED_APP_LEVEL_VIEWS = new Set([
"EvalsView",
"NodesView",
"ChatView",
"RoadmapsView",
"SkillsView",
"MemoryView",
"DevServerView",
@@ -64,11 +62,11 @@ describe("AGENTS lazy-loaded views inventory", () => {
const section = extractLazyLoadedSection(agentsDoc);
const countMatch = section.match(/These\s+(\d+)\s+views\s+are lazy-loaded/);
expect(countMatch).toBeTruthy();
expect(Number(countMatch?.[1])).toBe(16);
expect(Number(countMatch?.[1])).toBe(15);
const documentedViews = extractBacktickedNamesFromBullets(section);
expect(new Set(documentedViews)).toEqual(EXPECTED_DOCUMENTED_VIEWS);
expect(documentedViews).toHaveLength(16);
expect(documentedViews).toHaveLength(15);
expect(section).toContain("`ResearchView`");
expect(section).toContain("`TodoView`");

View File

@@ -105,11 +105,10 @@ describe("tablet header controls", () => {
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined();
});
it("opens overflow menu with Insights, Roadmaps, Skills on tablet when trigger is clicked", () => {
it("opens overflow menu with Insights and Skills on tablet when trigger is clicked", () => {
renderTabletHeader({ onChangeView: noop, showSkillsTab: true, experimentalFeatures: { insights: true, roadmap: true } });
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.getByTestId("view-overflow-insights")).toBeDefined();
expect(screen.getByTestId("view-overflow-roadmaps")).toBeDefined();
expect(screen.getByTestId("view-overflow-skills")).toBeDefined();
});

View File

@@ -359,7 +359,7 @@ export function Header({
}, [overflowScripts]);
const hasRoadmapsPluginView = useMemo(
() => pluginDashboardViews.some((entry) => entry.pluginId === "fusion-plugin-roadmap"),
() => pluginDashboardViews.some((entry) => entry.pluginId === "roadmap-planner"),
[pluginDashboardViews],
);
@@ -369,7 +369,7 @@ export function Header({
experimentalFeatures?.researchView ||
todosEnabled ||
experimentalFeatures?.insights ||
(experimentalFeatures?.roadmap && !hasRoadmapsPluginView) ||
showSkillsTab ||
experimentalFeatures?.memoryView ||
experimentalFeatures?.devServerView ||
@@ -1178,7 +1178,7 @@ export function Header({
<>
<button
ref={viewOverflowTriggerRef}
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver", "graph"].includes(view) || (experimentalFeatures?.evalsView && view === "evals") || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
className={`view-toggle-btn${["research", "skills", "insights", "memory", "dev-server", "devserver", "graph"].includes(view) || (experimentalFeatures?.evalsView && view === "evals") || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
title="More views"
aria-label="More views"
@@ -1237,19 +1237,7 @@ export function Header({
<span>Insights</span>
</button>
)}
{experimentalFeatures?.roadmap && !hasRoadmapsPluginView && (
<button
className={`view-toggle-overflow-item${view === "roadmaps" ? " active" : ""}`}
onClick={() => {
onChangeView("roadmaps");
setIsViewOverflowOpen(false);
}}
role="menuitem"
data-testid="view-overflow-roadmaps"
>
<span>Roadmaps</span>
</button>
)}
{showSkillsTab && (
<button
className={`view-toggle-overflow-item${view === "skills" ? " active" : ""}`}

View File

@@ -27,7 +27,6 @@ import {
Target,
Terminal,
Workflow,
Map,
Zap,
} from "lucide-react";
import { fetchScripts } from "../api";
@@ -205,15 +204,12 @@ export function MobileNavBar({
const planningHandler = activePlanningSessionCount > 0 && onResumePlanning ? onResumePlanning : onOpenPlanning;
const hasRoadmapsPluginView = pluginDashboardViews.some((entry) => entry.pluginId === "fusion-plugin-roadmap");
const roadmapEnabled = Boolean(experimentalFeatures?.roadmap) && !hasRoadmapsPluginView;
const skillsEnabled = Boolean(showSkillsTab);
const todoViewEnabled = Boolean(experimentalFeatures?.todoView);
// Keep a maximum of one optional primary tab visible at once to preserve touch-target width.
// Overflowed destinations remain available in the More sheet.
const showRoadmapsTopLevel = roadmapEnabled && (!skillsEnabled || view === "roadmaps");
const showSkillsTopLevel = skillsEnabled && (!roadmapEnabled || view !== "roadmaps");
const showSkillsTopLevel = skillsEnabled;
const showSkillsInMore = skillsEnabled && !showSkillsTopLevel;
const sortedPrimaryPluginViews = pluginDashboardViews
.filter((entry) => entry.view.placement === "primary")
@@ -236,7 +232,6 @@ export function MobileNavBar({
|| view === "devserver"
|| view === "dev-server"
|| (todosOpen && todoViewEnabled)
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|| (view === "skills" && !showSkillsTopLevel)
|| view === "graph"
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
@@ -338,19 +333,6 @@ export function MobileNavBar({
</button>
)}
{showRoadmapsTopLevel && (
<button
type="button"
className={`mobile-nav-tab${view === "roadmaps" ? " mobile-nav-tab--active" : ""}`}
data-testid="mobile-nav-tab-roadmaps"
role="tab"
aria-selected={view === "roadmaps"}
onClick={() => onChangeView("roadmaps")}
>
<Map />
<span className="mobile-nav-tab-label">Roadmaps</span>
</button>
)}
{topLevelPrimaryPluginViews.map((entry) => {
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
@@ -635,17 +617,7 @@ export function MobileNavBar({
</button>
)}
{roadmapEnabled && (
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-roadmaps"
onClick={() => handleMoreAction(() => onChangeView("roadmaps"))}
>
<Map />
<span>Roadmaps</span>
</button>
)}
{experimentalFeatures?.researchView && (
<button

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -70,29 +70,28 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined();
});
it("renders roadmaps tab when experimentalFeatures.roadmap is true", () => {
it("does not render legacy roadmaps tab when experimentalFeatures.roadmap is true", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ roadmap: true }} />);
expect(screen.getByTestId("mobile-nav-tab-roadmaps")).toBeDefined();
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
});
it("keeps optional tabs within mobile top-level capacity by overflowing roadmaps into More", () => {
it("keeps skills available without rendering legacy roadmaps destinations", () => {
render(<MobileNavBar {...createDefaultProps()} showSkillsTab={true} experimentalFeatures={{ roadmap: true }} />);
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.getByTestId("mobile-more-item-roadmaps")).toBeDefined();
expect(screen.queryByTestId("mobile-more-item-roadmaps")).toBeNull();
});
it("moves skills into More when roadmaps is the active optional top-level tab", () => {
render(<MobileNavBar {...createDefaultProps()} view="roadmaps" showSkillsTab={true} experimentalFeatures={{ roadmap: true }} />);
it("keeps skills top-level regardless of legacy roadmaps view value", () => {
render(<MobileNavBar {...createDefaultProps()} view="board" showSkillsTab={true} experimentalFeatures={{ roadmap: true }} />);
expect(screen.getByTestId("mobile-nav-tab-roadmaps")).toBeDefined();
expect(screen.queryByTestId("mobile-nav-tab-skills")).toBeNull();
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.getByTestId("mobile-more-item-skills")).toBeDefined();
expect(screen.queryByTestId("mobile-more-item-skills")).toBeNull();
});
it("does not render skills tab when showSkillsTab is false", () => {
@@ -388,10 +387,10 @@ describe("MobileNavBar", () => {
expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined();
});
it("shows roadmaps in more sheet when experimentalFeatures.roadmap is true", () => {
it("does not show legacy roadmaps in more sheet when experimentalFeatures.roadmap is true", () => {
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ roadmap: true }} />);
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.getByTestId("mobile-more-item-roadmaps")).toBeDefined();
expect(screen.queryByTestId("mobile-more-item-roadmaps")).toBeNull();
});
it("suppresses legacy roadmaps entries when roadmap plugin view is registered", () => {
@@ -401,7 +400,7 @@ describe("MobileNavBar", () => {
experimentalFeatures={{ roadmap: true }}
pluginDashboardViews={[
{
pluginId: "fusion-plugin-roadmap",
pluginId: "roadmap-planner",
view: { viewId: "roadmaps", label: "Roadmaps", componentPath: "./RoadmapsView", icon: "Map", placement: "primary" },
},
]}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useViewState } from "../useViewState";
import * as pluginViewRegistry from "../../plugins/pluginViewRegistry";
import type { ProjectInfo } from "../../api";
import type { ThemeMode } from "@fusion/core";
@@ -33,6 +34,7 @@ describe("useViewState", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
vi.spyOn(pluginViewRegistry, "isPluginViewRegistered").mockImplementation(() => false);
});
it("returns default viewMode and taskView when no localStorage exists", async () => {
@@ -64,6 +66,28 @@ describe("useViewState", () => {
});
});
it("migrates legacy roadmaps state to plugin view when registered", async () => {
vi.spyOn(pluginViewRegistry, "isPluginViewRegistered").mockReturnValue(true);
localStorage.setItem("kb-dashboard-task-view", "roadmaps");
const { result } = renderHook(() => useViewState(createOptions()));
await waitFor(() => {
expect(result.current.taskView).toBe("plugin:roadmap-planner:roadmaps");
});
});
it("falls back to board for legacy roadmaps state when plugin is unavailable", async () => {
vi.spyOn(pluginViewRegistry, "isPluginViewRegistered").mockReturnValue(false);
localStorage.setItem("kb-dashboard-task-view", "roadmaps");
const { result } = renderHook(() => useViewState(createOptions()));
await waitFor(() => {
expect(result.current.taskView).toBe("board");
});
});
it("persists viewMode changes to localStorage", async () => {
const { result } = renderHook(() => useViewState(createOptions()));

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,10 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { ThemeMode } from "@fusion/core";
import type { ProjectInfo } from "../api";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { isPluginViewId } from "../plugins/pluginViewRegistry";
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
export type ViewMode = "overview" | "project";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
export type PluginTaskView = `plugin:${string}:${string}`;
export type TaskView = BuiltInTaskView | PluginTaskView;
@@ -19,7 +19,7 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
"documents",
"research",
"evals",
"roadmaps",
"skills",
"mailbox",
"insights",
@@ -36,10 +36,19 @@ function isTaskView(value: string | null): value is TaskView {
return value !== null && (isBuiltInTaskView(value) || isPluginViewId(value));
}
const LEGACY_ROADMAPS_PLUGIN_VIEW = getPluginViewId("roadmap-planner", "roadmaps");
function normalizeTaskView(value: TaskView): TaskView {
return value === "devserver" ? "dev-server" : value;
}
function migrateLegacyRoadmapsView(value: string): TaskView {
if (value !== "roadmaps") {
return "board";
}
return isPluginViewRegistered("roadmap-planner", "roadmaps") ? LEGACY_ROADMAPS_PLUGIN_VIEW : "board";
}
interface UseViewStateOptions {
projectsLoading: boolean;
projectsError: string | null;
@@ -84,6 +93,7 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
const [taskView, setTaskView] = useState<TaskView>(() => {
const saved = getScopedItem("kb-dashboard-task-view");
if (saved === "roadmaps") return migrateLegacyRoadmapsView(saved);
if (isTaskView(saved)) return saved;
return "board";
});
@@ -95,7 +105,9 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
useEffect(() => {
const saved = getScopedItem("kb-dashboard-task-view", currentProject?.id);
if (isTaskView(saved)) {
if (saved === "roadmaps") {
setTaskView(migrateLegacyRoadmapsView(saved));
} else if (isTaskView(saved)) {
const preserveLegacyOnFirstScopedHydration =
!hasHydratedScopedTaskViewRef.current && saved === "devserver";

View File

@@ -6,6 +6,7 @@ import { installAuthFetch } from "./auth";
import { installVersionCheck } from "./versionCheck";
import { installSwUpdate } from "./swUpdate";
import { bootstrapShellHostContext } from "./shell-host";
import { registerBundledPluginViews } from "./plugins/registerBundledPluginViews";
import "./styles.css";
// Install the bearer-token fetch wrapper before React mounts so every API
@@ -15,6 +16,7 @@ import "./styles.css";
installAuthFetch();
installVersionCheck();
bootstrapShellHostContext();
registerBundledPluginViews();
createRoot(document.getElementById("root")!).render(
<StrictMode>

View File

@@ -1,5 +1,6 @@
import { PluginDashboardViewHost as RegistryPluginDashboardViewHost } from "./pluginViewRegistry";
import type { PluginDashboardViewContext, PluginTaskView } from "./pluginViewRegistry";
import type { PluginTaskView } from "./pluginViewRegistry";
import type { PluginDashboardViewContext } from "./types";
export function PluginDashboardViewHost({ taskView, context }: { taskView: PluginTaskView; context?: PluginDashboardViewContext }) {
return <RegistryPluginDashboardViewHost viewId={taskView} context={context} />;

View File

@@ -1,20 +1,11 @@
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
import type { PluginDashboardViewContext } from "./types";
import { AlertTriangle } from "lucide-react";
import { lazy, Suspense, type LazyExoticComponent, type ReactElement, type ReactNode } from "react";
import { ErrorBoundary } from "../components/ErrorBoundary";
import type { DetailTaskTab } from "../hooks/useModalManager";
import "./pluginViewRegistry.css";
export type PluginTaskView = `plugin:${string}:${string}`;
export interface PluginDashboardViewContext {
projectId?: string;
tasks: Task[];
workflowSteps: WorkflowStep[];
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
renderTaskCard?: (task: Task | TaskDetail) => ReactNode;
}
type PluginViewComponent = LazyExoticComponent<({ context }: { context?: PluginDashboardViewContext }) => ReactElement>;
const registry = new Map<string, PluginViewComponent>();
@@ -46,25 +37,15 @@ export function getPluginViewComponent(pluginId: string, viewId: string): Plugin
return registry.get(getPluginViewId(pluginId, viewId)) ?? null;
}
export function isPluginViewRegistered(pluginId: string, viewId: string): boolean {
return registry.has(getPluginViewId(pluginId, viewId));
}
/** Test helper for clearing global registry state. */
export function __test_clearPluginViewRegistry(): void {
registry.clear();
registerBundledPluginViews();
}
function registerBundledPluginViews(): void {
registerPluginView(
"fusion-plugin-dependency-graph",
"graph",
lazy(async () => {
const mod = await import("@fusion-plugin-examples/dependency-graph/dashboard-view");
return { default: mod.DependencyGraphDashboardView };
}),
);
}
registerBundledPluginViews();
function PluginViewUnavailable({ viewId }: { viewId: string }): ReactNode {
return (
<section className="card plugin-dashboard-view-missing" data-testid="plugin-view-unavailable">

View File

@@ -0,0 +1,27 @@
import { lazy } from "react";
import { registerPluginView } from "./pluginViewRegistry";
let registered = false;
export function registerBundledPluginViews(): void {
if (registered) return;
registered = true;
registerPluginView(
"fusion-plugin-dependency-graph",
"graph",
lazy(async () => {
const mod = await import("@fusion-plugin-examples/dependency-graph/dashboard-view");
return { default: mod.DependencyGraphDashboardView };
}),
);
registerPluginView(
"roadmap-planner",
"roadmaps",
lazy(async () => {
const mod = await import("@fusion-plugin-examples/roadmap/dashboard-view");
return { default: mod.RoadmapDashboardView };
}),
);
}

View File

@@ -14,6 +14,8 @@ import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
/** Tab identifiers for the task detail modal. Mirrors the dashboard's local enum. */
export type DetailTaskTab = "definition" | "logs" | "changes" | "comments" | "model" | "workflow";
export type PluginToastType = "success" | "error" | "warning" | "info";
/** Runtime context passed to a plugin dashboard view component. */
export interface PluginDashboardViewContext {
projectId?: string;
@@ -21,6 +23,7 @@ export interface PluginDashboardViewContext {
workflowSteps: WorkflowStep[];
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
renderTaskCard?: (task: Task | TaskDetail) => ReactNode;
addToast?: (message: string, type?: PluginToastType) => void;
}
/** Composite view ID format: `plugin:{pluginId}:{viewId}`. */