FN-8358: add roadmap-item structure previews

Enable native previews and hosted navigation for roadmap items.
- Extend native structure references and API resolution through the roadmap adapter.
- Register the bundled Roadmaps view across desktop and mobile navigation.
- Cover roadmap preview availability, navigation, and route behavior.

Files changed:
 ...n-8358-roadmap-item-native-structure-preview.md |  7 ++++
 docs/dashboard-guide.md                            |  6 ++--
 packages/core/src/types.ts                         | 24 ++++++++-----
 packages/dashboard/app/App.tsx                     | 24 ++++++++-----
 .../dashboard/app/components/LeftSidebarNav.tsx    | 12 +++----
 .../app/components/NativeStructurePreview.tsx      | 10 ++++--
 .../app/components/__tests__/App.test.tsx          | 13 ++++---
 .../app/components/__tests__/Header.test.tsx       |  5 ++-
 .../components/__tests__/LeftSidebarNav.test.tsx   |  5 ++-
 .../app/components/__tests__/MobileNavBar.test.tsx |  4 +--
 .../__tests__/NativeStructurePreview.test.tsx      | 40 ++++++++++++++++------
 .../__tests__/registerBundledPluginViews.test.tsx  | 24 ++++++++++---
 .../app/plugins/registerBundledPluginViews.ts      | 23 +++++++++++++
 packages/dashboard/src/native-structure-preview.ts | 25 +++++++++++++-
 .../native-structure-preview-routes.test.ts        | 31 +++++++++++++++--
 .../src/routes/register-task-workflow-routes.ts    |  4 +--
 packages/dashboard/vite.config.ts                  |  4 +++
 packages/dashboard/vitest.config.ts                |  4 +++
 plugins/fusion-plugin-roadmap/package.json         |  5 +++
 .../fusion-plugin-roadmap/src/dashboard-view.tsx   |  5 +++
 plugins/fusion-plugin-roadmap/src/index.ts         | 14 ++++++--
 21 files changed, 223 insertions(+), 66 deletions(-)

Fusion-Task-Id: FN-8358
Fusion-Task-Lineage: c369ce87-eef0-4d06-bf23-bced9c4363f9
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-19 15:45:45 -07:00
parent 41f387b219
commit d940bf3d5c
21 changed files with 223 additions and 66 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Preview roadmap items and open their hosted Roadmaps destination.
category: feature
dev: Adds the roadmap-item native structure contract through the plugin PostgreSQL read adapter.

View File

@@ -585,8 +585,8 @@ The full **New Task** dialog includes a compact **GitHub issue or PR** picker ne
Chat view provides project-scoped conversations with agents.
<!-- FNXC:NativeStructureEmbed 2026-07-19-19:30: Document the shared chat reference contract so agents and operators can use an unambiguous token rather than relying on title matching. -->
- Chat recognizes native structure references in both assistant and user messages using the explicit `fusion://<kind>/<id>` form. Supported kinds are `mission`, `milestone`, `roadmap-item`, `research-finding`, `eval-result`, and `goal`. Use a bare token such as `fusion://mission/M-001` in either message type, or an assistant Markdown link such as `[Mission](fusion://mission/M-001)`. `roadmap-item` currently resolves to the shared unavailable card until its plugin supplies a preview adapter and dashboard destination.
<!-- FNXC:NativeStructureEmbed 2026-07-19-20:00: Roadmap-item references now resolve through the roadmap plugin's PostgreSQL-safe read adapter and open the restored hosted Roadmaps destination. -->
- Chat recognizes native structure references in both assistant and user messages using the explicit `fusion://<kind>/<id>` form. Supported kinds are `mission`, `milestone`, `roadmap-item`, `research-finding`, `eval-result`, and `goal`. Use a bare token such as `fusion://mission/M-001` in either message type, or an assistant Markdown link such as `[Mission](fusion://mission/M-001)`. `roadmap-item` previews the roadmap feature title and description when available; a missing feature or unavailable roadmap data layer renders the shared unavailable card.
- Recognized references render an inline preview card before you leave the conversation. Select **Open** on an available card to navigate to its owning dashboard view; missing, archived, or otherwise unavailable structures show a safe unavailable placeholder instead. Plain-text mode deliberately leaves reference text raw.
- Entering `/new` or `/clear` (exact match after trimming) in the composer starts a fresh thread for the current chat target instead of sending the literal command to the model
- On mobile, the New Chat and Delete Conversation dialogs use a compact inset treatment (centered, viewport-bounded, internally scrollable) instead of the app's default full-height mobile modal chrome.
@@ -1911,7 +1911,7 @@ The `check-no-node-only-core-imports-in-dashboard.mjs` guard runs before tests a
### Native structure previews
`NativeStructurePreview` is the shared compact card for mission, milestone, roadmap item, research-finding, eval-result, and goal references. It resolves `GET /api/native-structures/:kind/:id/preview` to a typed available or unavailable payload and uses a required consumer-supplied `onOpen(ref, payload)` callback. Chat is a consumer: it parses strict `fusion://<kind>/<id>` tokens/assistant Markdown links and dispatches the callback into its owning dashboard view. `openTarget` is a view-state descriptor, not a URL, because dashboard navigation is callback based. Until its plugin provides a backend-safe reader and dashboard destination, a `roadmap-item` uses the shared unavailable card.
`NativeStructurePreview` is the shared compact card for mission, milestone, roadmap item, research-finding, eval-result, and goal references. It resolves `GET /api/native-structures/:kind/:id/preview` to a typed available or unavailable payload and uses a required consumer-supplied `onOpen(ref, payload)` callback. Chat is a consumer: it parses strict `fusion://<kind>/<id>` tokens/assistant Markdown links and dispatches the callback into its owning dashboard view. `openTarget` is a view-state descriptor, not a URL, because dashboard navigation is callback based. A `roadmap-item` reads a roadmap feature through the roadmap plugin's PostgreSQL-safe adapter, is unavailable only when missing or that read layer is unavailable, and opens the hosted `roadmaps` destination. The Roadmaps destination is manifest-advertised, exposed through the plugin dashboard-view export, bundled-registered, and available in desktop and mobile plugin navigation when the roadmap plugin is enabled.
PR tab note: `PrPanel` cards use tokenized `.pr-card` grid spacing (`padding` + `gap`) and boxed token-based hint callouts for empty/loading states. Manual PR merges now show in-progress feedback (`Merging…` button state + status hint) until the merge call resolves.

View File

@@ -874,11 +874,11 @@ export function isReviewArtifactGenerationEligible(
/**
* FNXC:NativeStructureEmbed 2026-07-16-12:00:
* Chat and mail share this compact reference contract so their consumers never invent
* incompatible structure identifiers. `roadmap-item` remains a deferred future kind until
* its plugin exposes a PostgreSQL-safe read adapter and a restored dashboard destination.
* incompatible structure identifiers. `roadmap-item` is resolved through the roadmap plugin's
* PostgreSQL-safe adapter and is missing-only because roadmap entities have no soft-delete state.
*/
export interface NativeStructureRef {
kind: "mission" | "milestone" | "research-finding" | "eval-result" | "goal";
kind: "mission" | "milestone" | "research-finding" | "eval-result" | "goal" | "roadmap-item";
id: string;
projectId?: string;
}
@@ -887,11 +887,17 @@ export interface NativeStructureRef {
* FNXC:NativeStructureEmbed 2026-07-16-12:00:
* Dashboard destinations are callback/view-state based rather than HTML routes. Consumers use
* this stable descriptor with their navigation callback; it is intentionally not a URL.
*
* FNXC:NativeStructureEmbed 2026-07-19-12:30:
* Roadmap-item descriptors carry optional hierarchy context for the hosted `roadmaps` view;
* consumers pass this object to onOpen instead of manufacturing a deep-link URL.
*/
export interface NativeStructureOpenTarget {
view: "missions" | "insights" | "evals" | "goals";
view: "missions" | "insights" | "evals" | "goals" | "roadmaps";
id: string;
missionId?: string;
roadmapId?: string;
milestoneId?: string;
}
/**
@@ -6206,16 +6212,16 @@ export function validateMessageMetadata(metadata: MessageMetadata | undefined):
}
/*
FNXC:NativeStructureEmbed 2026-07-20-12:00:
Mail accepts only the shared five-kind NativeStructureRef union. Reject unsupported future
kinds at the persistence boundary so every stored attachment remains renderable by the shared
preview component; labels are optional attach-time fallbacks, not serialized preview snapshots.
FNXC:NativeStructureEmbed 2026-07-19-12:30:
Mail accepts only the shared six-kind NativeStructureRef union. The roadmap item uses the
plugin-owned read adapter at render time, so attachment metadata remains a ref rather than a
duplicated persistence snapshot; labels are optional attach-time fallbacks.
*/
if (metadata.nativeStructures !== undefined) {
if (!Array.isArray(metadata.nativeStructures)) {
throw new Error("metadata.nativeStructures must be an array");
}
const supportedKinds: NativeStructureRef["kind"][] = ["mission", "milestone", "research-finding", "eval-result", "goal"];
const supportedKinds: NativeStructureRef["kind"][] = ["mission", "milestone", "research-finding", "eval-result", "goal", "roadmap-item"];
for (const embed of metadata.nativeStructures) {
if (typeof embed !== "object" || embed === null || Array.isArray(embed)) {
throw new Error("metadata.nativeStructures entries must be objects");

View File

@@ -506,6 +506,12 @@ function AppInner() {
setGoalAnchorId(target.id);
return;
}
if (target.view === "roadmaps") {
// FNXC:NativeStructureEmbed 2026-07-19-12:45: Roadmap previews open the registered
// plugin destination through TaskView state, never a synthetic URL deep-link.
handleTaskViewChange("plugin:fusion-plugin-roadmap:roadmaps");
return;
}
handleTaskViewChange(target.view);
};
window.addEventListener(NATIVE_STRUCTURE_OPEN_EVENT, openNativeStructure);
@@ -797,15 +803,15 @@ function AppInner() {
[poppedOutTaskEntries, taskPopupsVisibleOnCurrentView],
);
const pluginDashboardViews = useMemo<PluginDashboardViewEntry[]>(() => {
/*
FNXC:RoadmapsNavigation 2026-06-22-18:50:
The roadmap app view and experimental toggle were removed from the dashboard surface. Filter any plugin-provided Roadmaps dashboard view here so an installed/persisted plugin cannot reintroduce the sidebar destination.
*/
return rawPluginDashboardViews.filter(
(entry) => !(entry.pluginId === "fusion-plugin-roadmap" && entry.view.viewId === "roadmaps"),
);
}, [rawPluginDashboardViews]);
/*
FNXC:RoadmapsNavigation 2026-07-19-12:00:
Preserve the plugin manifest's roadmaps entry now that the bundled host registers its view.
Native-structure roadmap-item previews use this as their callback-driven open destination.
*/
const pluginDashboardViews = useMemo<PluginDashboardViewEntry[]>(
() => rawPluginDashboardViews,
[rawPluginDashboardViews],
);
const { stats: agentStats } = useAgents(currentProject?.id);

View File

@@ -271,11 +271,13 @@ export function LeftSidebarNav({
const compoundPluginEntry = sortedPluginViews.find(
(entry) => entry.pluginId === "fusion-plugin-compound-engineering",
);
/*
FNXC:RoadmapsNavigation 2026-07-19-12:00:
The bundled registry now hosts the manifest-advertised roadmaps view. Keep it in the
normal plugin pool so roadmap-item previews have a live callback navigation destination.
*/
const remainingPluginViews = sortedPluginViews.filter(
(entry) =>
entry !== graphPluginEntry &&
entry !== compoundPluginEntry &&
!(entry.pluginId === "fusion-plugin-roadmap" && entry.view.viewId === "roadmaps"),
(entry) => entry !== graphPluginEntry && entry !== compoundPluginEntry,
);
/*
@@ -286,8 +288,6 @@ export function LeftSidebarNav({
Flag gates preserved verbatim from the prior layout: agents (showAgentsTab), goals (goalsView), insight (insights), research (researchView), ideation (ideationView), skills (showSkillsTab), memory (memoryView), evals (evalsView). graph and compound are skipped when their plugin view is absent.
FNXC:Navigation 2026-06-22-18:50:
Roadmaps is no longer a dashboard navigation destination. Keep filtering it out even if a persisted plugin dashboard-view row is present, while preserving other plugin views in their sorted fallback section.
*/
const navEntries: SidebarNavEntry[] = [
/*

View File

@@ -18,6 +18,7 @@ const icons = {
"research-finding": Lightbulb,
"eval-result": BarChart3,
goal: Target,
"roadmap-item": Map,
} satisfies Record<NativeStructureRef["kind"], typeof Map>;
function isSupportedKind(kind: string): kind is NativeStructureRef["kind"] {
@@ -33,6 +34,9 @@ function unavailableLabel(kind: string): string {
* Chat and mail use this one memoized renderer for compact structure cards. Navigation remains
* owned by each consumer through `onOpen` because dashboard views use callback/view state rather
* than URL routes; rendering an anchor here would create dead destinations.
*
* FNXC:NativeStructureEmbed 2026-07-19-12:45:
* Roadmap items join this shared renderer with the roadmap icon and callback-only open action.
*/
export const NativeStructurePreview = memo(function NativeStructurePreview({ ref, payload, capturedLabel, onOpen }: NativeStructurePreviewProps) {
const supportedKind = isSupportedKind(ref.kind);
@@ -46,9 +50,9 @@ export const NativeStructurePreview = memo(function NativeStructurePreview({ ref
useEffect(() => {
/*
FNXC:NativeStructureEmbed 2026-07-19-18:00:
Refs can arrive from persisted chat/mail content, so reject a future or malformed kind before
fetching. The five-kind route is the sole resolver contract; roadmap-item must not trigger a
plugin read or turn an invalid icon lookup into a render crash.
Refs can arrive from persisted chat/mail content, so reject a malformed kind before fetching.
The six-kind route owns resolution; an invalid icon lookup must not trigger a plugin read or
turn into a render crash.
*/
if (payload || !supportedKind) return;
let active = true;

View File

@@ -632,6 +632,7 @@ import { App, didEnterAwaitingApproval, didEnterDone } from "../../App";
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews, fetchDashboardHealth, fetchBoardWorkflows } from "../../api";
import { __resetShellHostContextForTests } from "../../shell-host";
import { __test_clearDashboardViewsCache } from "../../hooks/usePluginDashboardViews";
import * as apiNodeModule from "../../hooks/useRemoteNodeData";
async function waitForAppShell(): Promise<void> {
@@ -643,6 +644,7 @@ async function waitForAppShell(): Promise<void> {
beforeEach(() => {
vi.clearAllMocks();
__test_clearDashboardViewsCache();
/*
* FNXC:DashboardTests 2026-06-22-03:47:
* App.test.tsx runs beside other dashboard specs in the same Vitest process, so reset API mock implementations as well as call counts to prevent cross-file implementation leakage.
@@ -2568,17 +2570,18 @@ describe("App view switching", () => {
localStorage.removeItem("kb-dashboard-view-mode");
});
it("hides the removed Roadmaps destination even when settings and plugin API still mention it", async () => {
it("shows the hosted Roadmaps destination when the plugin API advertises it", async () => {
/*
FNXC:RoadmapsNavigation 2026-06-22-18:50:
Roadmaps was removed as an app view and experiment. Stale persisted flags and plugin dashboard rows must not expose the old sidebar destination.
FNXC:RoadmapsNavigation 2026-07-19-12:00:
Roadmap-item previews open through the restored hosted roadmaps destination, so plugin
dashboard rows must remain visible rather than being filtered as legacy navigation.
*/
mockUseViewportMode.mockReturnValue("desktop");
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { ...defaultSettings.experimentalFeatures, roadmap: true },
});
(fetchPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
(fetchPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValue([
{
pluginId: "fusion-plugin-roadmap",
view: {
@@ -2595,7 +2598,7 @@ describe("App view switching", () => {
render(<App />);
expect(await screen.findByTestId("sidebar-nav-missions")).toBeInTheDocument();
expect(screen.queryByTestId("sidebar-nav-plugin-fusion-plugin-roadmap-roadmaps")).toBeNull();
expect(await screen.findByTestId("sidebar-nav-plugin-fusion-plugin-roadmap-roadmaps")).toBeInTheDocument();
});
it("restores board and plugin routes when persisted taskView changes across remounts", async () => {

View File

@@ -360,7 +360,7 @@ describe("Header", () => {
expect(onChangeView).toHaveBeenCalledWith("plugin:fusion-plugin-dependency-graph:queue");
});
it("hides legacy roadmaps overflow item when roadmap plugin view is present", () => {
it("renders hosted roadmaps primary item when roadmap plugin view is present", () => {
renderHeader({
onChangeView: noop,
experimentalFeatures: {},
@@ -372,8 +372,7 @@ describe("Header", () => {
],
});
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.queryByTestId("view-overflow-roadmaps")).toBeNull();
expect(screen.getByTestId("view-toggle-plugin-fusion-plugin-roadmap-roadmaps")).toBeInTheDocument();
});
it("renders view overflow trigger when an experimental overflow feature is enabled", () => {

View File

@@ -388,7 +388,7 @@ describe("LeftSidebarNav", () => {
expect(screen.queryByRole("button", { name: /view$/i })).toBeNull();
});
it("filters the removed Roadmaps plugin destination when registered", () => {
it("renders the hosted Roadmaps plugin destination when registered", () => {
const roadmapView: PluginDashboardViewEntry = {
pluginId: "fusion-plugin-roadmap",
view: {
@@ -401,8 +401,7 @@ describe("LeftSidebarNav", () => {
};
renderSidebar({ pluginDashboardViews: [pluginViews[0], roadmapView, pluginViews[1]] });
// FNXC:Navigation 2026-06-22-18:50: Roadmaps was removed from dashboard navigation; plugin rows must not reintroduce it.
expect(screen.queryByTestId("sidebar-nav-plugin-fusion-plugin-roadmap-roadmaps")).toBeNull();
expect(screen.getByTestId("sidebar-nav-plugin-fusion-plugin-roadmap-roadmaps")).toBeInTheDocument();
expect(screen.getByTestId("sidebar-nav-plugin-fusion-plugin-primary-primary-view")).toBeInTheDocument();
expect(screen.getByTestId("sidebar-nav-plugin-fusion-plugin-overflow-overflow-view")).toBeInTheDocument();
});

View File

@@ -782,7 +782,7 @@ describe("MobileNavBar", () => {
expect(screen.queryByTestId("mobile-more-item-command-center")).toBeNull();
});
it("suppresses legacy roadmaps entries when roadmap plugin view is registered", () => {
it("renders the hosted roadmaps plugin entry when roadmap plugin view is registered", () => {
render(
<MobileNavBar
{...createDefaultProps()}
@@ -798,7 +798,7 @@ describe("MobileNavBar", () => {
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.queryByTestId("mobile-more-item-roadmaps")).toBeNull();
expect(screen.getByTestId("mobile-more-item-plugin-fusion-plugin-roadmap-roadmaps")).toBeInTheDocument();
});
it("shows insights in more sheet when experimentalFeatures.insights is true", () => {

View File

@@ -14,11 +14,19 @@ const refs: NativeStructureRef[] = [
{ kind: "research-finding", id: "INS-1" },
{ kind: "eval-result", id: "EV-1" },
{ kind: "goal", id: "G-1" },
{ kind: "roadmap-item", id: "RF-1" },
];
function payload(ref: NativeStructureRef): NativeStructurePreviewPayload {
const views = { mission: "missions", milestone: "missions", "research-finding": "insights", "eval-result": "evals", goal: "goals" } as const;
return { available: true, kind: ref.kind, kindLabel: ref.kind, title: `${ref.kind} title`, excerpt: "Compact excerpt", openTarget: { view: views[ref.kind], id: ref.id } };
const views = { mission: "missions", milestone: "missions", "research-finding": "insights", "eval-result": "evals", goal: "goals", "roadmap-item": "roadmaps" } as const;
return {
available: true,
kind: ref.kind,
kindLabel: ref.kind === "roadmap-item" ? "Roadmap item" : ref.kind,
title: `${ref.kind} title`,
excerpt: "Compact excerpt",
openTarget: { view: views[ref.kind], id: ref.id },
};
}
describe("NativeStructurePreview", () => {
@@ -74,12 +82,11 @@ describe("NativeStructurePreview", () => {
await waitFor(() => expect(screen.getByTestId("native-structure-preview-error")).toBeInTheDocument());
});
it("does not fetch or crash for a deferred runtime kind", () => {
fetchPreview.mockClear();
const deferredRef = { kind: "roadmap-item", id: "R-1" } as unknown as NativeStructureRef;
render(<NativeStructurePreview ref={deferredRef} onOpen={vi.fn()} />);
expect(screen.getByTestId("native-structure-preview-unavailable")).toBeInTheDocument();
expect(fetchPreview).not.toHaveBeenCalled();
it("renders the missing-only roadmap-item placeholder", () => {
const ref: NativeStructureRef = { kind: "roadmap-item", id: "RF-missing" };
render(<NativeStructurePreview ref={ref} payload={{ available: false, kind: "roadmap-item", id: ref.id, reason: "missing" }} onOpen={vi.fn()} />);
expect(screen.getByTestId("native-structure-preview-unavailable")).toHaveAttribute("data-reason", "missing");
expect(screen.getByText("roadmap item")).toBeInTheDocument();
});
it("lets a later caller-supplied payload replace a failed fetch", async () => {
@@ -93,8 +100,21 @@ describe("NativeStructurePreview", () => {
expect(screen.queryByTestId("native-structure-preview-error")).toBeNull();
});
it("keeps the open affordance inside the mobile layout contract", () => {
const { container } = render(<NativeStructurePreview ref={refs[0]} payload={payload(refs[0])} onOpen={vi.fn()} />);
it("dispatches roadmap-item navigation without a dead anchor", () => {
const ref: NativeStructureRef = { kind: "roadmap-item", id: "RF-1" };
const result = payload(ref);
const onOpen = vi.fn();
const { container } = render(<NativeStructurePreview ref={ref} payload={result} onOpen={onOpen} />);
fireEvent.click(screen.getByRole("button", { name: "Open Roadmap item: roadmap-item title" }));
expect(onOpen).toHaveBeenCalledWith(ref, expect.objectContaining({ openTarget: { view: "roadmaps", id: "RF-1" } }));
expect(container.querySelector("a[href]")).toBeNull();
});
it("keeps the roadmap-item open affordance inside the mobile layout contract", () => {
const ref: NativeStructureRef = { kind: "roadmap-item", id: "RF-1" };
const { container } = render(<NativeStructurePreview ref={ref} payload={payload(ref)} onOpen={vi.fn()} />);
expect(container.querySelector(".native-structure-preview__open")).toBeInTheDocument();
const css = loadAllAppCss();
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.native-structure-preview__open/);

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, beforeEach, vi } from "vitest";
import { createElement } from "react";
import { getPluginViewComponent, isPluginViewRegistered, __test_clearPluginViewRegistry } from "../pluginViewRegistry";
import { render, screen } from "@testing-library/react";
import { PluginDashboardViewHost, getPluginViewComponent, isPluginViewRegistered, __test_clearPluginViewRegistry } from "../pluginViewRegistry";
import {
__test_resetBundledPluginViewRegistration,
registerBundledPluginViews,
@@ -11,6 +12,7 @@ const MockCompoundEngineeringDashboardView = () => createElement("div", { "data-
const MockCliPrintingPressWizardView = () => createElement("div", { "data-testid": "cli-printing-press-view" });
const MockCliPrintingPressManageView = () => createElement("div", { "data-testid": "cli-printing-press-manage-view" });
const MockLinearImportView = () => createElement("div", { "data-testid": "linear-import-view" });
const MockRoadmapDashboardView = () => createElement("div", { "data-testid": "roadmaps-view" });
vi.mock("@fusion-plugin-examples/dependency-graph/dashboard-view", () => ({
DependencyGraphDashboardView: (...args: unknown[]) => MockDependencyGraphDashboardView(...args),
@@ -32,6 +34,10 @@ vi.mock("@fusion-plugin-examples/linear-import/dashboard-view", () => ({
LinearImportDashboardView: (...args: unknown[]) => MockLinearImportView(...args),
}));
vi.mock("@fusion-plugin-examples/roadmap/dashboard-view", () => ({
RoadmapDashboardView: (...args: unknown[]) => MockRoadmapDashboardView(...args),
}));
// The dashboard statically registers bundled views client-side, so these views can
// render even when engine-side PluginLoader startup failed and the persisted
// installation row is in an error state.
@@ -41,7 +47,7 @@ describe("registerBundledPluginViews", () => {
__test_resetBundledPluginViewRegistration();
});
it("registers dependency graph, compound engineering, cli printing press, and Linear bundled views", () => {
it("registers dependency graph, compound engineering, cli printing press, Linear, and roadmaps bundled views", () => {
registerBundledPluginViews();
// This registration is independent of engine-side plugin load success; the
@@ -50,13 +56,21 @@ describe("registerBundledPluginViews", () => {
expect(getPluginViewComponent("fusion-plugin-dependency-graph", "graph")).toBeTruthy();
expect(isPluginViewRegistered("fusion-plugin-compound-engineering", "compound-engineering")).toBe(true);
expect(getPluginViewComponent("fusion-plugin-compound-engineering", "compound-engineering")).toBeTruthy();
// FNXC:RoadmapsNavigation 2026-06-22-18:50: Roadmaps no longer registers as a dashboard view.
expect(getPluginViewComponent("fusion-plugin-roadmap", "roadmaps")).toBeNull();
expect(getPluginViewComponent("fusion-plugin-roadmap", "roadmaps")).toBeTruthy();
expect(getPluginViewComponent("fusion-plugin-cli-printing-press", "wizard")).toBeTruthy();
expect(getPluginViewComponent("fusion-plugin-cli-printing-press", "manage")).toBeTruthy();
expect(getPluginViewComponent("fusion-plugin-linear-import", "linear-import")).toBeTruthy();
});
it("hosts the bundled roadmaps view instead of the unavailable fallback", async () => {
registerBundledPluginViews();
render(<>{PluginDashboardViewHost({ viewId: "plugin:fusion-plugin-roadmap:roadmaps" })}</>);
expect(await screen.findByTestId("roadmaps-view")).toBeInTheDocument();
expect(screen.queryByTestId("plugin-view-unavailable")).toBeNull();
});
it("is idempotent when called more than once", () => {
registerBundledPluginViews();
const firstGraph = getPluginViewComponent("fusion-plugin-dependency-graph", "graph");
@@ -72,7 +86,7 @@ describe("registerBundledPluginViews", () => {
expect(isPluginViewRegistered("fusion-plugin-dependency-graph", "graph")).toBe(true);
expect(isPluginViewRegistered("fusion-plugin-compound-engineering", "compound-engineering")).toBe(true);
expect(isPluginViewRegistered("fusion-plugin-roadmap", "roadmaps")).toBe(false);
expect(isPluginViewRegistered("fusion-plugin-roadmap", "roadmaps")).toBe(true);
expect(isPluginViewRegistered("fusion-plugin-cli-printing-press", "wizard")).toBe(true);
expect(isPluginViewRegistered("fusion-plugin-cli-printing-press", "manage")).toBe(true);
expect(isPluginViewRegistered("fusion-plugin-linear-import", "linear-import")).toBe(true);

View File

@@ -86,6 +86,23 @@ FNXC:Quality 2026-07-14-21:50:
Static host registry for Quality hub. Literal import() so Vite can code-split;
do not use @vite-ignore (reports footgun).
*/
/*
FNXC:RoadmapsNavigation 2026-07-19-12:00:
Register the manifest-advertised roadmaps destination with a literal bundled import so
roadmap-item open targets resolve to RoadmapsView instead of the unavailable fallback.
*/
async function loadRoadmapView(): Promise<{ default: PluginViewComponent }> {
const moduleId = "@fusion-plugin-examples/roadmap/dashboard-view";
const exportName = "RoadmapDashboardView";
const mod = await import("@fusion-plugin-examples/roadmap/dashboard-view") as unknown as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
const component = mod[exportName];
if (!component) {
console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`);
return { default: createMissingPluginView(moduleId, exportName) };
}
return { default: component as PluginViewComponent };
}
async function loadQualityView(): Promise<{ default: PluginViewComponent }> {
const moduleId = "@fusion-plugin-examples/quality/dashboard-view";
const exportName = "QualityDashboardView";
@@ -137,6 +154,12 @@ export function registerBundledPluginViews(): void {
"quality",
lazy(loadQualityView),
);
registerPluginView(
"fusion-plugin-roadmap",
"roadmaps",
lazy(loadRoadmapView),
);
}
export function __test_resetBundledPluginViewRegistration(): void {

View File

@@ -1,3 +1,4 @@
import { createRoadmapStoreForTaskStore } from "@fusion-plugin-examples/roadmap";
import type {
NativeStructurePreviewPayload,
NativeStructurePreviewResult,
@@ -11,6 +12,7 @@ const KIND_LABELS: Record<NativeStructureRef["kind"], string> = {
"research-finding": "Research finding",
"eval-result": "Evaluation result",
goal: "Goal",
"roadmap-item": "Roadmap item",
};
const MAX_EXCERPT_LENGTH = 180;
@@ -40,7 +42,12 @@ function text(value: string | null | undefined, fallback: string): string {
* This is a read-only projection over the task-scoped stores; it never duplicates structure
* persistence. Existing archived/dismissed lifecycle status supplies `soft-deleted` because no
* target has a tombstone column. Missing and unavailable structures are returned, never thrown;
* eval results have no archive lifecycle and can only be missing.
* eval results and roadmap items have no archive lifecycle and can only be missing.
*
* FNXC:NativeStructureEmbed 2026-07-19-12:45:
* Roadmap-item is a project-scoped, read-only projection through the plugin's supported
* createRoadmapStoreForTaskStore seam. Adapter/layer failures degrade to missing; do not
* duplicate roadmap persistence or reach into plugin store internals.
*/
export async function resolveNativeStructurePreview(
store: TaskStore,
@@ -84,5 +91,21 @@ export async function resolveNativeStructurePreview(
if (goal.status === "archived") return unavailable(ref, "soft-deleted");
return preview(ref, goal.title, text(goal.description, `Status: ${goal.status}`), { view: "goals", id: goal.id });
}
case "roadmap-item": {
try {
const roadmapStore = createRoadmapStoreForTaskStore(store);
const feature = await roadmapStore.getFeature(ref.id);
if (!feature) return unavailable(ref, "missing");
const milestone = await roadmapStore.getMilestone(feature.milestoneId);
return preview(ref, feature.title, text(feature.description, ""), {
view: "roadmaps",
id: feature.id,
milestoneId: feature.milestoneId,
...(milestone ? { roadmapId: milestone.roadmapId } : {}),
});
} catch {
return unavailable(ref, "missing");
}
}
}
}

View File

@@ -12,6 +12,8 @@ const milestone = { id: "MS-1", missionId: "M-1", title: "Milestone", descriptio
const insight = { id: "INS-1", title: "Finding", content: "Finding content", status: "stale" };
const evaluation = { id: "EV-1", taskId: "FN-1", taskSnapshot: { title: "Evaluated task" }, overallScore: 8, maxScore: 10 };
const goal = { id: "G-1", title: "Goal", description: "Goal description", status: "active" };
const roadmapFeature = { id: "RF-1", milestoneId: "RM-1", title: "Roadmap feature", description: "Roadmap feature description", orderIndex: 0 };
const roadmapMilestone = { id: "RM-1", roadmapId: "R-1", title: "Roadmap milestone", orderIndex: 0 };
function store(overrides: Record<string, unknown> = {}): TaskStore {
return {
@@ -19,6 +21,10 @@ function store(overrides: Record<string, unknown> = {}): TaskStore {
getInsightStore: vi.fn(() => ({ getInsight: vi.fn(async (id) => id === insight.id ? insight : undefined) })),
getEvalStore: vi.fn(() => ({ getTaskResult: vi.fn(async (id) => id === evaluation.id ? evaluation : undefined) })),
getGoalStore: vi.fn(() => ({ getGoal: vi.fn(async (id) => id === goal.id ? goal : null) })),
getRoadmapStore: vi.fn(() => ({
getFeature: vi.fn(async (id) => id === roadmapFeature.id ? roadmapFeature : undefined),
getMilestone: vi.fn(async (id) => id === roadmapMilestone.id ? roadmapMilestone : undefined),
})),
getRootDir: vi.fn(() => process.cwd()),
...overrides,
} as unknown as TaskStore;
@@ -31,12 +37,13 @@ describe("resolveNativeStructurePreview", () => {
["research-finding", insight.id, { view: "insights", id: insight.id }],
["eval-result", evaluation.id, { view: "evals", id: evaluation.id }],
["goal", goal.id, { view: "goals", id: goal.id }],
["roadmap-item", roadmapFeature.id, { view: "roadmaps", id: roadmapFeature.id, milestoneId: roadmapFeature.milestoneId, roadmapId: roadmapMilestone.roadmapId }],
] as const)("projects %s into its owning view", async (kind, id, openTarget) => {
const result = await resolveNativeStructurePreview(store(), { kind, id });
expect(result).toMatchObject({ available: true, kind, openTarget });
});
it.each(["mission", "milestone", "research-finding", "eval-result", "goal"] as const)("returns missing for absent %s", async (kind) => {
it.each(["mission", "milestone", "research-finding", "eval-result", "goal", "roadmap-item"] as const)("returns missing for absent %s", async (kind) => {
const result = await resolveNativeStructurePreview(store(), { kind, id: "absent" });
expect(result).toEqual({ available: false, kind, id: "absent", reason: "missing" });
});
@@ -51,8 +58,17 @@ describe("resolveNativeStructurePreview", () => {
expect(result).toMatchObject({ available: false, reason: "soft-deleted" });
});
it("never maps a missing eval to soft-deleted", async () => {
it("never maps a missing eval or roadmap item to soft-deleted", async () => {
await expect(resolveNativeStructurePreview(store(), { kind: "eval-result", id: "absent" })).resolves.toMatchObject({ reason: "missing" });
await expect(resolveNativeStructurePreview(store(), { kind: "roadmap-item", id: "absent" })).resolves.toEqual({ available: false, kind: "roadmap-item", id: "absent", reason: "missing" });
});
it("degrades roadmap adapter and PostgreSQL layer failures to missing", async () => {
const unavailableLayer = store({ getRoadmapStore: undefined, getAsyncLayer: vi.fn(() => null) });
const throwingRead = store({ getRoadmapStore: vi.fn(() => { throw new Error("adapter unavailable"); }) });
await expect(resolveNativeStructurePreview(unavailableLayer, { kind: "roadmap-item", id: roadmapFeature.id })).resolves.toEqual({ available: false, kind: "roadmap-item", id: roadmapFeature.id, reason: "missing" });
await expect(resolveNativeStructurePreview(throwingRead, { kind: "roadmap-item", id: roadmapFeature.id })).resolves.toEqual({ available: false, kind: "roadmap-item", id: roadmapFeature.id, reason: "missing" });
});
it("normalizes and bounds long excerpts for compact cards", async () => {
@@ -77,6 +93,7 @@ describe("native structure preview route", () => {
["research-finding", insight.id, { view: "insights", id: insight.id }],
["eval-result", evaluation.id, { view: "evals", id: evaluation.id }],
["goal", goal.id, { view: "goals", id: goal.id }],
["roadmap-item", roadmapFeature.id, { view: "roadmaps", id: roadmapFeature.id, milestoneId: roadmapFeature.milestoneId, roadmapId: roadmapMilestone.roadmapId }],
] as const)("returns the %s preview projection", async (kind, id, openTarget) => {
const app = express();
app.use("/api", createApiRoutes(store()));
@@ -93,7 +110,15 @@ describe("native structure preview route", () => {
expect(res.body).toMatchObject({ available: false, reason: "missing" });
});
it.each(["roadmap-item", "unknown"])("rejects unsupported %s", async (kind) => {
it("returns roadmap-item missing payload with 200", async () => {
const app = express();
app.use("/api", createApiRoutes(store()));
const res = await REQUEST(app, "GET", "/api/native-structures/roadmap-item/absent/preview");
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: false, kind: "roadmap-item", id: "absent", reason: "missing" });
});
it.each(["unknown"])("rejects unsupported %s", async (kind) => {
const app = express();
app.use("/api", createApiRoutes(store()));
const res = await REQUEST(app, "GET", `/api/native-structures/${kind}/id/preview`);

View File

@@ -4011,8 +4011,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
router.get("/native-structures/:kind/:id/preview", async (req, res) => {
try {
const { kind, id } = req.params;
if (kind !== "mission" && kind !== "milestone" && kind !== "research-finding" && kind !== "eval-result" && kind !== "goal") {
throw badRequest("kind must be one of: mission, milestone, research-finding, eval-result, goal");
if (kind !== "mission" && kind !== "milestone" && kind !== "research-finding" && kind !== "eval-result" && kind !== "goal" && kind !== "roadmap-item") {
throw badRequest("kind must be one of: mission, milestone, research-finding, eval-result, goal, roadmap-item");
}
if (!id.trim()) throw badRequest("id must be non-empty");
const { store: scopedStore } = await getProjectContext(req);

View File

@@ -172,6 +172,10 @@ export default defineConfig({
__dirname,
"../../plugins/fusion-plugin-quality/src/dashboard-view.tsx",
),
"@fusion-plugin-examples/roadmap/dashboard-view": resolve(
__dirname,
"../../plugins/fusion-plugin-roadmap/src/dashboard-view.tsx",
),
"@fusion-plugin-examples/quality/qa-tab": resolve(
__dirname,
"../../plugins/fusion-plugin-quality/src/qa-tab.tsx",

View File

@@ -560,6 +560,10 @@ export default defineConfig({
__dirname,
"../../plugins/fusion-plugin-quality/src/dashboard-view.tsx",
),
"@fusion-plugin-examples/roadmap/dashboard-view": resolve(
__dirname,
"../../plugins/fusion-plugin-roadmap/src/dashboard-view.tsx",
),
"@fusion-plugin-examples/quality/qa-tab": resolve(
__dirname,
"../../plugins/fusion-plugin-quality/src/qa-tab.tsx",

View File

@@ -15,6 +15,11 @@
"source": "./src/server/index.ts",
"import": "./dist/server/index.js"
},
"./dashboard-view": {
"types": "./src/dashboard-view.tsx",
"source": "./src/dashboard-view.tsx",
"import": "./dist/dashboard-view.js"
},
"./roadmap-suggestions": {
"types": "./src/roadmap-suggestions.d.ts",
"source": "./src/roadmap-suggestions.ts",

View File

@@ -1,6 +1,11 @@
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
import { RoadmapsView } from "./dashboard/RoadmapsView.js";
/*
FNXC:RoadmapsNavigation 2026-07-19-12:00:
The dashboard host loads this stable wrapper for the manifest-advertised roadmaps destination.
Adapt only host context to RoadmapsView props; roadmap data remains plugin-owned.
*/
export function RoadmapDashboardView({ context }: { context?: PluginDashboardViewContext }) {
return <RoadmapsView projectId={context?.projectId} addToast={context?.addToast ?? (() => undefined)} />;
}

View File

@@ -14,9 +14,19 @@ const plugin = definePlugin({
onSchemaInit: ensureRoadmapSchema,
},
routes: createRoadmapPluginRoutes(),
dashboardViews: [{
viewId: "roadmaps",
label: "Roadmaps",
componentPath: "./dashboard-view",
icon: "Map",
placement: "primary",
order: 30,
}],
/*
FNXC:RoadmapsNavigation 2026-06-22-18:50:
The roadmap dashboard view was removed from the product surface. Keep the plugin's schema/routes/domain exports available for compatibility, but do not advertise a dashboardViews entry.
FNXC:RoadmapsNavigation 2026-07-19-12:00:
Native-structure roadmap-item previews require a real hosted `roadmaps` open target.
Advertise this bundled dashboard destination while leaving roadmap schema, routes, and
persistence ownership unchanged.
*/
});