From d940bf3d5cabf5be3bc0846f19124a429a1e0920 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 19 Jul 2026 15:45:45 -0700 Subject: [PATCH] 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) --- ...8-roadmap-item-native-structure-preview.md | 7 ++++ docs/dashboard-guide.md | 6 +-- packages/core/src/types.ts | 24 ++++++----- packages/dashboard/app/App.tsx | 24 ++++++----- .../app/components/LeftSidebarNav.tsx | 12 +++--- .../app/components/NativeStructurePreview.tsx | 10 +++-- .../app/components/__tests__/App.test.tsx | 13 +++--- .../app/components/__tests__/Header.test.tsx | 5 +-- .../__tests__/LeftSidebarNav.test.tsx | 5 +-- .../__tests__/MobileNavBar.test.tsx | 4 +- .../__tests__/NativeStructurePreview.test.tsx | 40 ++++++++++++++----- .../registerBundledPluginViews.test.tsx | 24 ++++++++--- .../app/plugins/registerBundledPluginViews.ts | 23 +++++++++++ .../dashboard/src/native-structure-preview.ts | 25 +++++++++++- .../native-structure-preview-routes.test.ts | 31 ++++++++++++-- .../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 +++ .../src/dashboard-view.tsx | 5 +++ plugins/fusion-plugin-roadmap/src/index.ts | 14 ++++++- 21 files changed, 223 insertions(+), 66 deletions(-) create mode 100644 .changeset/fn-8358-roadmap-item-native-structure-preview.md diff --git a/.changeset/fn-8358-roadmap-item-native-structure-preview.md b/.changeset/fn-8358-roadmap-item-native-structure-preview.md new file mode 100644 index 0000000000..616087eac8 --- /dev/null +++ b/.changeset/fn-8358-roadmap-item-native-structure-preview.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 994e4966cf..69d3597d46 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 recognizes native structure references in both assistant and user messages using the explicit `fusion:///` 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:///` 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:///` 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:///` 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. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c6f899ddd4..89f783c4fc 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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"); diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 792301c233..15b5eadd70 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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(() => { - /* - 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( + () => rawPluginDashboardViews, + [rawPluginDashboardViews], + ); const { stats: agentStats } = useAgents(currentProject?.id); diff --git a/packages/dashboard/app/components/LeftSidebarNav.tsx b/packages/dashboard/app/components/LeftSidebarNav.tsx index 5312a2f5b4..d615ff7fbc 100644 --- a/packages/dashboard/app/components/LeftSidebarNav.tsx +++ b/packages/dashboard/app/components/LeftSidebarNav.tsx @@ -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[] = [ /* diff --git a/packages/dashboard/app/components/NativeStructurePreview.tsx b/packages/dashboard/app/components/NativeStructurePreview.tsx index cc6d87300b..0ff6ff68a7 100644 --- a/packages/dashboard/app/components/NativeStructurePreview.tsx +++ b/packages/dashboard/app/components/NativeStructurePreview.tsx @@ -18,6 +18,7 @@ const icons = { "research-finding": Lightbulb, "eval-result": BarChart3, goal: Target, + "roadmap-item": Map, } satisfies Record; 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; diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx index 9d8426b11b..5f0f732f89 100644 --- a/packages/dashboard/app/components/__tests__/App.test.tsx +++ b/packages/dashboard/app/components/__tests__/App.test.tsx @@ -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 { @@ -643,6 +644,7 @@ async function waitForAppShell(): Promise { 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).mockResolvedValueOnce({ ...defaultSettings, experimentalFeatures: { ...defaultSettings.experimentalFeatures, roadmap: true }, }); - (fetchPluginDashboardViews as ReturnType).mockResolvedValueOnce([ + (fetchPluginDashboardViews as ReturnType).mockResolvedValue([ { pluginId: "fusion-plugin-roadmap", view: { @@ -2595,7 +2598,7 @@ describe("App view switching", () => { render(); 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 () => { diff --git a/packages/dashboard/app/components/__tests__/Header.test.tsx b/packages/dashboard/app/components/__tests__/Header.test.tsx index e508796d61..7c6470b5b3 100644 --- a/packages/dashboard/app/components/__tests__/Header.test.tsx +++ b/packages/dashboard/app/components/__tests__/Header.test.tsx @@ -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", () => { diff --git a/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx b/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx index d0004f59d6..7df527ed7a 100644 --- a/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx +++ b/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx @@ -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(); }); diff --git a/packages/dashboard/app/components/__tests__/MobileNavBar.test.tsx b/packages/dashboard/app/components/__tests__/MobileNavBar.test.tsx index a669e1f4cd..409c219bdc 100644 --- a/packages/dashboard/app/components/__tests__/MobileNavBar.test.tsx +++ b/packages/dashboard/app/components/__tests__/MobileNavBar.test.tsx @@ -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( { 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", () => { diff --git a/packages/dashboard/app/components/__tests__/NativeStructurePreview.test.tsx b/packages/dashboard/app/components/__tests__/NativeStructurePreview.test.tsx index 067f577141..589c674736 100644 --- a/packages/dashboard/app/components/__tests__/NativeStructurePreview.test.tsx +++ b/packages/dashboard/app/components/__tests__/NativeStructurePreview.test.tsx @@ -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(); - 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(); + 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(); + 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(); + + 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(); expect(container.querySelector(".native-structure-preview__open")).toBeInTheDocument(); const css = loadAllAppCss(); expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.native-structure-preview__open/); diff --git a/packages/dashboard/app/plugins/__tests__/registerBundledPluginViews.test.tsx b/packages/dashboard/app/plugins/__tests__/registerBundledPluginViews.test.tsx index f112bf88de..a38fbe89e4 100644 --- a/packages/dashboard/app/plugins/__tests__/registerBundledPluginViews.test.tsx +++ b/packages/dashboard/app/plugins/__tests__/registerBundledPluginViews.test.tsx @@ -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); diff --git a/packages/dashboard/app/plugins/registerBundledPluginViews.ts b/packages/dashboard/app/plugins/registerBundledPluginViews.ts index 75ce57eb72..964cad6c3c 100644 --- a/packages/dashboard/app/plugins/registerBundledPluginViews.ts +++ b/packages/dashboard/app/plugins/registerBundledPluginViews.ts @@ -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>; + 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 { diff --git a/packages/dashboard/src/native-structure-preview.ts b/packages/dashboard/src/native-structure-preview.ts index 4ecade7739..39a87ef7ad 100644 --- a/packages/dashboard/src/native-structure-preview.ts +++ b/packages/dashboard/src/native-structure-preview.ts @@ -1,3 +1,4 @@ +import { createRoadmapStoreForTaskStore } from "@fusion-plugin-examples/roadmap"; import type { NativeStructurePreviewPayload, NativeStructurePreviewResult, @@ -11,6 +12,7 @@ const KIND_LABELS: Record = { "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"); + } + } } } diff --git a/packages/dashboard/src/routes/__tests__/native-structure-preview-routes.test.ts b/packages/dashboard/src/routes/__tests__/native-structure-preview-routes.test.ts index 77efbd5d85..011b291c8d 100644 --- a/packages/dashboard/src/routes/__tests__/native-structure-preview-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/native-structure-preview-routes.test.ts @@ -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 = {}): TaskStore { return { @@ -19,6 +21,10 @@ function store(overrides: Record = {}): 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`); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 15b7c8cbd7..c6d70ad25b 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -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); diff --git a/packages/dashboard/vite.config.ts b/packages/dashboard/vite.config.ts index de0077c6d4..f5de60e987 100644 --- a/packages/dashboard/vite.config.ts +++ b/packages/dashboard/vite.config.ts @@ -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", diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 445e0075fb..1851ae9425 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -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", diff --git a/plugins/fusion-plugin-roadmap/package.json b/plugins/fusion-plugin-roadmap/package.json index 67d3937caa..4dba6711fc 100644 --- a/plugins/fusion-plugin-roadmap/package.json +++ b/plugins/fusion-plugin-roadmap/package.json @@ -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", diff --git a/plugins/fusion-plugin-roadmap/src/dashboard-view.tsx b/plugins/fusion-plugin-roadmap/src/dashboard-view.tsx index 3b3b77a9ff..5a30056aa6 100644 --- a/plugins/fusion-plugin-roadmap/src/dashboard-view.tsx +++ b/plugins/fusion-plugin-roadmap/src/dashboard-view.tsx @@ -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 undefined)} />; } diff --git a/plugins/fusion-plugin-roadmap/src/index.ts b/plugins/fusion-plugin-roadmap/src/index.ts index 60a7631df7..0e5f278cba 100644 --- a/plugins/fusion-plugin-roadmap/src/index.ts +++ b/plugins/fusion-plugin-roadmap/src/index.ts @@ -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. */ });