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

View File

@@ -506,6 +506,12 @@ function AppInner() {
setGoalAnchorId(target.id); setGoalAnchorId(target.id);
return; 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); handleTaskViewChange(target.view);
}; };
window.addEventListener(NATIVE_STRUCTURE_OPEN_EVENT, openNativeStructure); window.addEventListener(NATIVE_STRUCTURE_OPEN_EVENT, openNativeStructure);
@@ -797,15 +803,15 @@ function AppInner() {
[poppedOutTaskEntries, taskPopupsVisibleOnCurrentView], [poppedOutTaskEntries, taskPopupsVisibleOnCurrentView],
); );
const pluginDashboardViews = useMemo<PluginDashboardViewEntry[]>(() => { /*
/* FNXC:RoadmapsNavigation 2026-07-19-12:00:
FNXC:RoadmapsNavigation 2026-06-22-18:50: Preserve the plugin manifest's roadmaps entry now that the bundled host registers its view.
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. Native-structure roadmap-item previews use this as their callback-driven open destination.
*/ */
return rawPluginDashboardViews.filter( const pluginDashboardViews = useMemo<PluginDashboardViewEntry[]>(
(entry) => !(entry.pluginId === "fusion-plugin-roadmap" && entry.view.viewId === "roadmaps"), () => rawPluginDashboardViews,
); [rawPluginDashboardViews],
}, [rawPluginDashboardViews]); );
const { stats: agentStats } = useAgents(currentProject?.id); const { stats: agentStats } = useAgents(currentProject?.id);

View File

@@ -271,11 +271,13 @@ export function LeftSidebarNav({
const compoundPluginEntry = sortedPluginViews.find( const compoundPluginEntry = sortedPluginViews.find(
(entry) => entry.pluginId === "fusion-plugin-compound-engineering", (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( const remainingPluginViews = sortedPluginViews.filter(
(entry) => (entry) => entry !== graphPluginEntry && entry !== compoundPluginEntry,
entry !== graphPluginEntry &&
entry !== compoundPluginEntry &&
!(entry.pluginId === "fusion-plugin-roadmap" && entry.view.viewId === "roadmaps"),
); );
/* /*
@@ -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. 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[] = [ const navEntries: SidebarNavEntry[] = [
/* /*

View File

@@ -18,6 +18,7 @@ const icons = {
"research-finding": Lightbulb, "research-finding": Lightbulb,
"eval-result": BarChart3, "eval-result": BarChart3,
goal: Target, goal: Target,
"roadmap-item": Map,
} satisfies Record<NativeStructureRef["kind"], typeof Map>; } satisfies Record<NativeStructureRef["kind"], typeof Map>;
function isSupportedKind(kind: string): kind is NativeStructureRef["kind"] { 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 * 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 * 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. * 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) { export const NativeStructurePreview = memo(function NativeStructurePreview({ ref, payload, capturedLabel, onOpen }: NativeStructurePreviewProps) {
const supportedKind = isSupportedKind(ref.kind); const supportedKind = isSupportedKind(ref.kind);
@@ -46,9 +50,9 @@ export const NativeStructurePreview = memo(function NativeStructurePreview({ ref
useEffect(() => { useEffect(() => {
/* /*
FNXC:NativeStructureEmbed 2026-07-19-18:00: FNXC:NativeStructureEmbed 2026-07-19-18:00:
Refs can arrive from persisted chat/mail content, so reject a future or malformed kind before Refs can arrive from persisted chat/mail content, so reject a malformed kind before fetching.
fetching. The five-kind route is the sole resolver contract; roadmap-item must not trigger a The six-kind route owns resolution; an invalid icon lookup must not trigger a plugin read or
plugin read or turn an invalid icon lookup into a render crash. turn into a render crash.
*/ */
if (payload || !supportedKind) return; if (payload || !supportedKind) return;
let active = true; let active = true;

View File

@@ -632,6 +632,7 @@ import { App, didEnterAwaitingApproval, didEnterDone } from "../../App";
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth"; import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews, fetchDashboardHealth, fetchBoardWorkflows } from "../../api"; import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews, fetchDashboardHealth, fetchBoardWorkflows } from "../../api";
import { __resetShellHostContextForTests } from "../../shell-host"; import { __resetShellHostContextForTests } from "../../shell-host";
import { __test_clearDashboardViewsCache } from "../../hooks/usePluginDashboardViews";
import * as apiNodeModule from "../../hooks/useRemoteNodeData"; import * as apiNodeModule from "../../hooks/useRemoteNodeData";
async function waitForAppShell(): Promise<void> { async function waitForAppShell(): Promise<void> {
@@ -643,6 +644,7 @@ async function waitForAppShell(): Promise<void> {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
__test_clearDashboardViewsCache();
/* /*
* FNXC:DashboardTests 2026-06-22-03:47: * 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. * 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"); 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: FNXC:RoadmapsNavigation 2026-07-19-12:00:
Roadmaps was removed as an app view and experiment. Stale persisted flags and plugin dashboard rows must not expose the old sidebar destination. 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"); mockUseViewportMode.mockReturnValue("desktop");
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ (fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings, ...defaultSettings,
experimentalFeatures: { ...defaultSettings.experimentalFeatures, roadmap: true }, experimentalFeatures: { ...defaultSettings.experimentalFeatures, roadmap: true },
}); });
(fetchPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValueOnce([ (fetchPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValue([
{ {
pluginId: "fusion-plugin-roadmap", pluginId: "fusion-plugin-roadmap",
view: { view: {
@@ -2595,7 +2598,7 @@ describe("App view switching", () => {
render(<App />); render(<App />);
expect(await screen.findByTestId("sidebar-nav-missions")).toBeInTheDocument(); 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 () => { 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"); 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({ renderHeader({
onChangeView: noop, onChangeView: noop,
experimentalFeatures: {}, experimentalFeatures: {},
@@ -372,8 +372,7 @@ describe("Header", () => {
], ],
}); });
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); expect(screen.getByTestId("view-toggle-plugin-fusion-plugin-roadmap-roadmaps")).toBeInTheDocument();
expect(screen.queryByTestId("view-overflow-roadmaps")).toBeNull();
}); });
it("renders view overflow trigger when an experimental overflow feature is enabled", () => { 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(); 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 = { const roadmapView: PluginDashboardViewEntry = {
pluginId: "fusion-plugin-roadmap", pluginId: "fusion-plugin-roadmap",
view: { view: {
@@ -401,8 +401,7 @@ describe("LeftSidebarNav", () => {
}; };
renderSidebar({ pluginDashboardViews: [pluginViews[0], roadmapView, pluginViews[1]] }); 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.getByTestId("sidebar-nav-plugin-fusion-plugin-roadmap-roadmaps")).toBeInTheDocument();
expect(screen.queryByTestId("sidebar-nav-plugin-fusion-plugin-roadmap-roadmaps")).toBeNull();
expect(screen.getByTestId("sidebar-nav-plugin-fusion-plugin-primary-primary-view")).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(); 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(); 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( render(
<MobileNavBar <MobileNavBar
{...createDefaultProps()} {...createDefaultProps()}
@@ -798,7 +798,7 @@ describe("MobileNavBar", () => {
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull(); expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
fireEvent.click(screen.getByTestId("mobile-nav-tab-more")); 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", () => { 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: "research-finding", id: "INS-1" },
{ kind: "eval-result", id: "EV-1" }, { kind: "eval-result", id: "EV-1" },
{ kind: "goal", id: "G-1" }, { kind: "goal", id: "G-1" },
{ kind: "roadmap-item", id: "RF-1" },
]; ];
function payload(ref: NativeStructureRef): NativeStructurePreviewPayload { function payload(ref: NativeStructureRef): NativeStructurePreviewPayload {
const views = { mission: "missions", milestone: "missions", "research-finding": "insights", "eval-result": "evals", goal: "goals" } as const; 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, title: `${ref.kind} title`, excerpt: "Compact excerpt", openTarget: { view: views[ref.kind], id: ref.id } }; 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", () => { describe("NativeStructurePreview", () => {
@@ -74,12 +82,11 @@ describe("NativeStructurePreview", () => {
await waitFor(() => expect(screen.getByTestId("native-structure-preview-error")).toBeInTheDocument()); await waitFor(() => expect(screen.getByTestId("native-structure-preview-error")).toBeInTheDocument());
}); });
it("does not fetch or crash for a deferred runtime kind", () => { it("renders the missing-only roadmap-item placeholder", () => {
fetchPreview.mockClear(); const ref: NativeStructureRef = { kind: "roadmap-item", id: "RF-missing" };
const deferredRef = { kind: "roadmap-item", id: "R-1" } as unknown as NativeStructureRef; render(<NativeStructurePreview ref={ref} payload={{ available: false, kind: "roadmap-item", id: ref.id, reason: "missing" }} onOpen={vi.fn()} />);
render(<NativeStructurePreview ref={deferredRef} onOpen={vi.fn()} />); expect(screen.getByTestId("native-structure-preview-unavailable")).toHaveAttribute("data-reason", "missing");
expect(screen.getByTestId("native-structure-preview-unavailable")).toBeInTheDocument(); expect(screen.getByText("roadmap item")).toBeInTheDocument();
expect(fetchPreview).not.toHaveBeenCalled();
}); });
it("lets a later caller-supplied payload replace a failed fetch", async () => { 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(); expect(screen.queryByTestId("native-structure-preview-error")).toBeNull();
}); });
it("keeps the open affordance inside the mobile layout contract", () => { it("dispatches roadmap-item navigation without a dead anchor", () => {
const { container } = render(<NativeStructurePreview ref={refs[0]} payload={payload(refs[0])} onOpen={vi.fn()} />); 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(); expect(container.querySelector(".native-structure-preview__open")).toBeInTheDocument();
const css = loadAllAppCss(); const css = loadAllAppCss();
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.native-structure-preview__open/); 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 { describe, expect, it, beforeEach, vi } from "vitest";
import { createElement } from "react"; 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 { import {
__test_resetBundledPluginViewRegistration, __test_resetBundledPluginViewRegistration,
registerBundledPluginViews, registerBundledPluginViews,
@@ -11,6 +12,7 @@ const MockCompoundEngineeringDashboardView = () => createElement("div", { "data-
const MockCliPrintingPressWizardView = () => createElement("div", { "data-testid": "cli-printing-press-view" }); const MockCliPrintingPressWizardView = () => createElement("div", { "data-testid": "cli-printing-press-view" });
const MockCliPrintingPressManageView = () => createElement("div", { "data-testid": "cli-printing-press-manage-view" }); const MockCliPrintingPressManageView = () => createElement("div", { "data-testid": "cli-printing-press-manage-view" });
const MockLinearImportView = () => createElement("div", { "data-testid": "linear-import-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", () => ({ vi.mock("@fusion-plugin-examples/dependency-graph/dashboard-view", () => ({
DependencyGraphDashboardView: (...args: unknown[]) => MockDependencyGraphDashboardView(...args), DependencyGraphDashboardView: (...args: unknown[]) => MockDependencyGraphDashboardView(...args),
@@ -32,6 +34,10 @@ vi.mock("@fusion-plugin-examples/linear-import/dashboard-view", () => ({
LinearImportDashboardView: (...args: unknown[]) => MockLinearImportView(...args), 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 // The dashboard statically registers bundled views client-side, so these views can
// render even when engine-side PluginLoader startup failed and the persisted // render even when engine-side PluginLoader startup failed and the persisted
// installation row is in an error state. // installation row is in an error state.
@@ -41,7 +47,7 @@ describe("registerBundledPluginViews", () => {
__test_resetBundledPluginViewRegistration(); __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(); registerBundledPluginViews();
// This registration is independent of engine-side plugin load success; the // 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(getPluginViewComponent("fusion-plugin-dependency-graph", "graph")).toBeTruthy();
expect(isPluginViewRegistered("fusion-plugin-compound-engineering", "compound-engineering")).toBe(true); expect(isPluginViewRegistered("fusion-plugin-compound-engineering", "compound-engineering")).toBe(true);
expect(getPluginViewComponent("fusion-plugin-compound-engineering", "compound-engineering")).toBeTruthy(); 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")).toBeTruthy();
expect(getPluginViewComponent("fusion-plugin-roadmap", "roadmaps")).toBeNull();
expect(getPluginViewComponent("fusion-plugin-cli-printing-press", "wizard")).toBeTruthy(); expect(getPluginViewComponent("fusion-plugin-cli-printing-press", "wizard")).toBeTruthy();
expect(getPluginViewComponent("fusion-plugin-cli-printing-press", "manage")).toBeTruthy(); expect(getPluginViewComponent("fusion-plugin-cli-printing-press", "manage")).toBeTruthy();
expect(getPluginViewComponent("fusion-plugin-linear-import", "linear-import")).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", () => { it("is idempotent when called more than once", () => {
registerBundledPluginViews(); registerBundledPluginViews();
const firstGraph = getPluginViewComponent("fusion-plugin-dependency-graph", "graph"); 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-dependency-graph", "graph")).toBe(true);
expect(isPluginViewRegistered("fusion-plugin-compound-engineering", "compound-engineering")).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", "wizard")).toBe(true);
expect(isPluginViewRegistered("fusion-plugin-cli-printing-press", "manage")).toBe(true); expect(isPluginViewRegistered("fusion-plugin-cli-printing-press", "manage")).toBe(true);
expect(isPluginViewRegistered("fusion-plugin-linear-import", "linear-import")).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; Static host registry for Quality hub. Literal import() so Vite can code-split;
do not use @vite-ignore (reports footgun). 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 }> { async function loadQualityView(): Promise<{ default: PluginViewComponent }> {
const moduleId = "@fusion-plugin-examples/quality/dashboard-view"; const moduleId = "@fusion-plugin-examples/quality/dashboard-view";
const exportName = "QualityDashboardView"; const exportName = "QualityDashboardView";
@@ -137,6 +154,12 @@ export function registerBundledPluginViews(): void {
"quality", "quality",
lazy(loadQualityView), lazy(loadQualityView),
); );
registerPluginView(
"fusion-plugin-roadmap",
"roadmaps",
lazy(loadRoadmapView),
);
} }
export function __test_resetBundledPluginViewRegistration(): void { export function __test_resetBundledPluginViewRegistration(): void {

View File

@@ -1,3 +1,4 @@
import { createRoadmapStoreForTaskStore } from "@fusion-plugin-examples/roadmap";
import type { import type {
NativeStructurePreviewPayload, NativeStructurePreviewPayload,
NativeStructurePreviewResult, NativeStructurePreviewResult,
@@ -11,6 +12,7 @@ const KIND_LABELS: Record<NativeStructureRef["kind"], string> = {
"research-finding": "Research finding", "research-finding": "Research finding",
"eval-result": "Evaluation result", "eval-result": "Evaluation result",
goal: "Goal", goal: "Goal",
"roadmap-item": "Roadmap item",
}; };
const MAX_EXCERPT_LENGTH = 180; 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 * 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 * persistence. Existing archived/dismissed lifecycle status supplies `soft-deleted` because no
* target has a tombstone column. Missing and unavailable structures are returned, never thrown; * 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( export async function resolveNativeStructurePreview(
store: TaskStore, store: TaskStore,
@@ -84,5 +91,21 @@ export async function resolveNativeStructurePreview(
if (goal.status === "archived") return unavailable(ref, "soft-deleted"); 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 }); 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 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 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 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 { function store(overrides: Record<string, unknown> = {}): TaskStore {
return { 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) })), 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) })), 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) })), 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()), getRootDir: vi.fn(() => process.cwd()),
...overrides, ...overrides,
} as unknown as TaskStore; } as unknown as TaskStore;
@@ -31,12 +37,13 @@ describe("resolveNativeStructurePreview", () => {
["research-finding", insight.id, { view: "insights", id: insight.id }], ["research-finding", insight.id, { view: "insights", id: insight.id }],
["eval-result", evaluation.id, { view: "evals", id: evaluation.id }], ["eval-result", evaluation.id, { view: "evals", id: evaluation.id }],
["goal", goal.id, { view: "goals", id: goal.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) => { ] as const)("projects %s into its owning view", async (kind, id, openTarget) => {
const result = await resolveNativeStructurePreview(store(), { kind, id }); const result = await resolveNativeStructurePreview(store(), { kind, id });
expect(result).toMatchObject({ available: true, kind, openTarget }); 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" }); const result = await resolveNativeStructurePreview(store(), { kind, id: "absent" });
expect(result).toEqual({ available: false, kind, id: "absent", reason: "missing" }); expect(result).toEqual({ available: false, kind, id: "absent", reason: "missing" });
}); });
@@ -51,8 +58,17 @@ describe("resolveNativeStructurePreview", () => {
expect(result).toMatchObject({ available: false, reason: "soft-deleted" }); 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: "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 () => { 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 }], ["research-finding", insight.id, { view: "insights", id: insight.id }],
["eval-result", evaluation.id, { view: "evals", id: evaluation.id }], ["eval-result", evaluation.id, { view: "evals", id: evaluation.id }],
["goal", goal.id, { view: "goals", id: goal.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) => { ] as const)("returns the %s preview projection", async (kind, id, openTarget) => {
const app = express(); const app = express();
app.use("/api", createApiRoutes(store())); app.use("/api", createApiRoutes(store()));
@@ -93,7 +110,15 @@ describe("native structure preview route", () => {
expect(res.body).toMatchObject({ available: false, reason: "missing" }); 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(); const app = express();
app.use("/api", createApiRoutes(store())); app.use("/api", createApiRoutes(store()));
const res = await REQUEST(app, "GET", `/api/native-structures/${kind}/id/preview`); 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) => { router.get("/native-structures/:kind/:id/preview", async (req, res) => {
try { try {
const { kind, id } = req.params; const { kind, id } = req.params;
if (kind !== "mission" && kind !== "milestone" && kind !== "research-finding" && kind !== "eval-result" && kind !== "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"); 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"); if (!id.trim()) throw badRequest("id must be non-empty");
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);

View File

@@ -172,6 +172,10 @@ export default defineConfig({
__dirname, __dirname,
"../../plugins/fusion-plugin-quality/src/dashboard-view.tsx", "../../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( "@fusion-plugin-examples/quality/qa-tab": resolve(
__dirname, __dirname,
"../../plugins/fusion-plugin-quality/src/qa-tab.tsx", "../../plugins/fusion-plugin-quality/src/qa-tab.tsx",

View File

@@ -560,6 +560,10 @@ export default defineConfig({
__dirname, __dirname,
"../../plugins/fusion-plugin-quality/src/dashboard-view.tsx", "../../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( "@fusion-plugin-examples/quality/qa-tab": resolve(
__dirname, __dirname,
"../../plugins/fusion-plugin-quality/src/qa-tab.tsx", "../../plugins/fusion-plugin-quality/src/qa-tab.tsx",

View File

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

View File

@@ -1,6 +1,11 @@
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types"; import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
import { RoadmapsView } from "./dashboard/RoadmapsView.js"; 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 }) { export function RoadmapDashboardView({ context }: { context?: PluginDashboardViewContext }) {
return <RoadmapsView projectId={context?.projectId} addToast={context?.addToast ?? (() => undefined)} />; return <RoadmapsView projectId={context?.projectId} addToast={context?.addToast ?? (() => undefined)} />;
} }

View File

@@ -14,9 +14,19 @@ const plugin = definePlugin({
onSchemaInit: ensureRoadmapSchema, onSchemaInit: ensureRoadmapSchema,
}, },
routes: createRoadmapPluginRoutes(), routes: createRoadmapPluginRoutes(),
dashboardViews: [{
viewId: "roadmaps",
label: "Roadmaps",
componentPath: "./dashboard-view",
icon: "Map",
placement: "primary",
order: 30,
}],
/* /*
FNXC:RoadmapsNavigation 2026-06-22-18:50: FNXC:RoadmapsNavigation 2026-07-19-12:00:
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. 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.
*/ */
}); });