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:
@@ -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.
|
||||
@@ -585,8 +585,8 @@ The full **New Task** dialog includes a compact **GitHub issue or PR** picker ne
|
||||
|
||||
Chat view provides project-scoped conversations with agents.
|
||||
|
||||
<!-- FNXC:NativeStructureEmbed 2026-07-19-19:30: Document the shared chat reference contract so agents and operators can use an unambiguous token rather than relying on title matching. -->
|
||||
- Chat recognizes native structure references in both assistant and user messages using the explicit `fusion://<kind>/<id>` form. Supported kinds are `mission`, `milestone`, `roadmap-item`, `research-finding`, `eval-result`, and `goal`. Use a bare token such as `fusion://mission/M-001` in either message type, or an assistant Markdown link such as `[Mission](fusion://mission/M-001)`. `roadmap-item` currently resolves to the shared unavailable card until its plugin supplies a preview adapter and dashboard destination.
|
||||
<!-- FNXC:NativeStructureEmbed 2026-07-19-20:00: Roadmap-item references now resolve through the roadmap plugin's PostgreSQL-safe read adapter and open the restored hosted Roadmaps destination. -->
|
||||
- Chat recognizes native structure references in both assistant and user messages using the explicit `fusion://<kind>/<id>` form. Supported kinds are `mission`, `milestone`, `roadmap-item`, `research-finding`, `eval-result`, and `goal`. Use a bare token such as `fusion://mission/M-001` in either message type, or an assistant Markdown link such as `[Mission](fusion://mission/M-001)`. `roadmap-item` previews the roadmap feature title and description when available; a missing feature or unavailable roadmap data layer renders the shared unavailable card.
|
||||
- Recognized references render an inline preview card before you leave the conversation. Select **Open** on an available card to navigate to its owning dashboard view; missing, archived, or otherwise unavailable structures show a safe unavailable placeholder instead. Plain-text mode deliberately leaves reference text raw.
|
||||
- Entering `/new` or `/clear` (exact match after trimming) in the composer starts a fresh thread for the current chat target instead of sending the literal command to the model
|
||||
- On mobile, the New Chat and Delete Conversation dialogs use a compact inset treatment (centered, viewport-bounded, internally scrollable) instead of the app's default full-height mobile modal chrome.
|
||||
@@ -1911,7 +1911,7 @@ The `check-no-node-only-core-imports-in-dashboard.mjs` guard runs before tests a
|
||||
|
||||
### Native structure previews
|
||||
|
||||
`NativeStructurePreview` is the shared compact card for mission, milestone, roadmap item, research-finding, eval-result, and goal references. It resolves `GET /api/native-structures/:kind/:id/preview` to a typed available or unavailable payload and uses a required consumer-supplied `onOpen(ref, payload)` callback. Chat is a consumer: it parses strict `fusion://<kind>/<id>` tokens/assistant Markdown links and dispatches the callback into its owning dashboard view. `openTarget` is a view-state descriptor, not a URL, because dashboard navigation is callback based. Until its plugin provides a backend-safe reader and dashboard destination, a `roadmap-item` uses the shared unavailable card.
|
||||
`NativeStructurePreview` is the shared compact card for mission, milestone, roadmap item, research-finding, eval-result, and goal references. It resolves `GET /api/native-structures/:kind/:id/preview` to a typed available or unavailable payload and uses a required consumer-supplied `onOpen(ref, payload)` callback. Chat is a consumer: it parses strict `fusion://<kind>/<id>` tokens/assistant Markdown links and dispatches the callback into its owning dashboard view. `openTarget` is a view-state descriptor, not a URL, because dashboard navigation is callback based. A `roadmap-item` reads a roadmap feature through the roadmap plugin's PostgreSQL-safe adapter, is unavailable only when missing or that read layer is unavailable, and opens the hosted `roadmaps` destination. The Roadmaps destination is manifest-advertised, exposed through the plugin dashboard-view export, bundled-registered, and available in desktop and mobile plugin navigation when the roadmap plugin is enabled.
|
||||
|
||||
PR tab note: `PrPanel` cards use tokenized `.pr-card` grid spacing (`padding` + `gap`) and boxed token-based hint callouts for empty/loading states. Manual PR merges now show in-progress feedback (`Merging…` button state + status hint) until the merge call resolves.
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -506,6 +506,12 @@ function AppInner() {
|
||||
setGoalAnchorId(target.id);
|
||||
return;
|
||||
}
|
||||
if (target.view === "roadmaps") {
|
||||
// FNXC:NativeStructureEmbed 2026-07-19-12:45: Roadmap previews open the registered
|
||||
// plugin destination through TaskView state, never a synthetic URL deep-link.
|
||||
handleTaskViewChange("plugin:fusion-plugin-roadmap:roadmaps");
|
||||
return;
|
||||
}
|
||||
handleTaskViewChange(target.view);
|
||||
};
|
||||
window.addEventListener(NATIVE_STRUCTURE_OPEN_EVENT, openNativeStructure);
|
||||
@@ -797,15 +803,15 @@ function AppInner() {
|
||||
[poppedOutTaskEntries, taskPopupsVisibleOnCurrentView],
|
||||
);
|
||||
|
||||
const pluginDashboardViews = useMemo<PluginDashboardViewEntry[]>(() => {
|
||||
/*
|
||||
FNXC:RoadmapsNavigation 2026-06-22-18:50:
|
||||
The roadmap app view and experimental toggle were removed from the dashboard surface. Filter any plugin-provided Roadmaps dashboard view here so an installed/persisted plugin cannot reintroduce the sidebar destination.
|
||||
*/
|
||||
return rawPluginDashboardViews.filter(
|
||||
(entry) => !(entry.pluginId === "fusion-plugin-roadmap" && entry.view.viewId === "roadmaps"),
|
||||
);
|
||||
}, [rawPluginDashboardViews]);
|
||||
/*
|
||||
FNXC:RoadmapsNavigation 2026-07-19-12:00:
|
||||
Preserve the plugin manifest's roadmaps entry now that the bundled host registers its view.
|
||||
Native-structure roadmap-item previews use this as their callback-driven open destination.
|
||||
*/
|
||||
const pluginDashboardViews = useMemo<PluginDashboardViewEntry[]>(
|
||||
() => rawPluginDashboardViews,
|
||||
[rawPluginDashboardViews],
|
||||
);
|
||||
|
||||
const { stats: agentStats } = useAgents(currentProject?.id);
|
||||
|
||||
|
||||
@@ -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[] = [
|
||||
/*
|
||||
|
||||
@@ -18,6 +18,7 @@ const icons = {
|
||||
"research-finding": Lightbulb,
|
||||
"eval-result": BarChart3,
|
||||
goal: Target,
|
||||
"roadmap-item": Map,
|
||||
} satisfies Record<NativeStructureRef["kind"], typeof Map>;
|
||||
|
||||
function isSupportedKind(kind: string): kind is NativeStructureRef["kind"] {
|
||||
@@ -33,6 +34,9 @@ function unavailableLabel(kind: string): string {
|
||||
* Chat and mail use this one memoized renderer for compact structure cards. Navigation remains
|
||||
* owned by each consumer through `onOpen` because dashboard views use callback/view state rather
|
||||
* than URL routes; rendering an anchor here would create dead destinations.
|
||||
*
|
||||
* FNXC:NativeStructureEmbed 2026-07-19-12:45:
|
||||
* Roadmap items join this shared renderer with the roadmap icon and callback-only open action.
|
||||
*/
|
||||
export const NativeStructurePreview = memo(function NativeStructurePreview({ ref, payload, capturedLabel, onOpen }: NativeStructurePreviewProps) {
|
||||
const supportedKind = isSupportedKind(ref.kind);
|
||||
@@ -46,9 +50,9 @@ export const NativeStructurePreview = memo(function NativeStructurePreview({ ref
|
||||
useEffect(() => {
|
||||
/*
|
||||
FNXC:NativeStructureEmbed 2026-07-19-18:00:
|
||||
Refs can arrive from persisted chat/mail content, so reject a future or malformed kind before
|
||||
fetching. The five-kind route is the sole resolver contract; roadmap-item must not trigger a
|
||||
plugin read or turn an invalid icon lookup into a render crash.
|
||||
Refs can arrive from persisted chat/mail content, so reject a malformed kind before fetching.
|
||||
The six-kind route owns resolution; an invalid icon lookup must not trigger a plugin read or
|
||||
turn into a render crash.
|
||||
*/
|
||||
if (payload || !supportedKind) return;
|
||||
let active = true;
|
||||
|
||||
@@ -632,6 +632,7 @@ import { App, didEnterAwaitingApproval, didEnterDone } from "../../App";
|
||||
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth";
|
||||
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews, fetchDashboardHealth, fetchBoardWorkflows } from "../../api";
|
||||
import { __resetShellHostContextForTests } from "../../shell-host";
|
||||
import { __test_clearDashboardViewsCache } from "../../hooks/usePluginDashboardViews";
|
||||
import * as apiNodeModule from "../../hooks/useRemoteNodeData";
|
||||
|
||||
async function waitForAppShell(): Promise<void> {
|
||||
@@ -643,6 +644,7 @@ async function waitForAppShell(): Promise<void> {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__test_clearDashboardViewsCache();
|
||||
/*
|
||||
* FNXC:DashboardTests 2026-06-22-03:47:
|
||||
* App.test.tsx runs beside other dashboard specs in the same Vitest process, so reset API mock implementations as well as call counts to prevent cross-file implementation leakage.
|
||||
@@ -2568,17 +2570,18 @@ describe("App view switching", () => {
|
||||
localStorage.removeItem("kb-dashboard-view-mode");
|
||||
});
|
||||
|
||||
it("hides the removed Roadmaps destination even when settings and plugin API still mention it", async () => {
|
||||
it("shows the hosted Roadmaps destination when the plugin API advertises it", async () => {
|
||||
/*
|
||||
FNXC:RoadmapsNavigation 2026-06-22-18:50:
|
||||
Roadmaps was removed as an app view and experiment. Stale persisted flags and plugin dashboard rows must not expose the old sidebar destination.
|
||||
FNXC:RoadmapsNavigation 2026-07-19-12:00:
|
||||
Roadmap-item previews open through the restored hosted roadmaps destination, so plugin
|
||||
dashboard rows must remain visible rather than being filtered as legacy navigation.
|
||||
*/
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: { ...defaultSettings.experimentalFeatures, roadmap: true },
|
||||
});
|
||||
(fetchPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
(fetchPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
view: {
|
||||
@@ -2595,7 +2598,7 @@ describe("App view switching", () => {
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByTestId("sidebar-nav-missions")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("sidebar-nav-plugin-fusion-plugin-roadmap-roadmaps")).toBeNull();
|
||||
expect(await screen.findByTestId("sidebar-nav-plugin-fusion-plugin-roadmap-roadmaps")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores board and plugin routes when persisted taskView changes across remounts", async () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -782,7 +782,7 @@ describe("MobileNavBar", () => {
|
||||
expect(screen.queryByTestId("mobile-more-item-command-center")).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses legacy roadmaps entries when roadmap plugin view is registered", () => {
|
||||
it("renders the hosted roadmaps plugin entry when roadmap plugin view is registered", () => {
|
||||
render(
|
||||
<MobileNavBar
|
||||
{...createDefaultProps()}
|
||||
@@ -798,7 +798,7 @@ describe("MobileNavBar", () => {
|
||||
|
||||
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||
expect(screen.queryByTestId("mobile-more-item-roadmaps")).toBeNull();
|
||||
expect(screen.getByTestId("mobile-more-item-plugin-fusion-plugin-roadmap-roadmaps")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows insights in more sheet when experimentalFeatures.insights is true", () => {
|
||||
|
||||
@@ -14,11 +14,19 @@ const refs: NativeStructureRef[] = [
|
||||
{ kind: "research-finding", id: "INS-1" },
|
||||
{ kind: "eval-result", id: "EV-1" },
|
||||
{ kind: "goal", id: "G-1" },
|
||||
{ kind: "roadmap-item", id: "RF-1" },
|
||||
];
|
||||
|
||||
function payload(ref: NativeStructureRef): NativeStructurePreviewPayload {
|
||||
const views = { mission: "missions", milestone: "missions", "research-finding": "insights", "eval-result": "evals", goal: "goals" } as const;
|
||||
return { available: true, kind: ref.kind, kindLabel: ref.kind, title: `${ref.kind} title`, excerpt: "Compact excerpt", openTarget: { view: views[ref.kind], id: ref.id } };
|
||||
const views = { mission: "missions", milestone: "missions", "research-finding": "insights", "eval-result": "evals", goal: "goals", "roadmap-item": "roadmaps" } as const;
|
||||
return {
|
||||
available: true,
|
||||
kind: ref.kind,
|
||||
kindLabel: ref.kind === "roadmap-item" ? "Roadmap item" : ref.kind,
|
||||
title: `${ref.kind} title`,
|
||||
excerpt: "Compact excerpt",
|
||||
openTarget: { view: views[ref.kind], id: ref.id },
|
||||
};
|
||||
}
|
||||
|
||||
describe("NativeStructurePreview", () => {
|
||||
@@ -74,12 +82,11 @@ describe("NativeStructurePreview", () => {
|
||||
await waitFor(() => expect(screen.getByTestId("native-structure-preview-error")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("does not fetch or crash for a deferred runtime kind", () => {
|
||||
fetchPreview.mockClear();
|
||||
const deferredRef = { kind: "roadmap-item", id: "R-1" } as unknown as NativeStructureRef;
|
||||
render(<NativeStructurePreview ref={deferredRef} onOpen={vi.fn()} />);
|
||||
expect(screen.getByTestId("native-structure-preview-unavailable")).toBeInTheDocument();
|
||||
expect(fetchPreview).not.toHaveBeenCalled();
|
||||
it("renders the missing-only roadmap-item placeholder", () => {
|
||||
const ref: NativeStructureRef = { kind: "roadmap-item", id: "RF-missing" };
|
||||
render(<NativeStructurePreview ref={ref} payload={{ available: false, kind: "roadmap-item", id: ref.id, reason: "missing" }} onOpen={vi.fn()} />);
|
||||
expect(screen.getByTestId("native-structure-preview-unavailable")).toHaveAttribute("data-reason", "missing");
|
||||
expect(screen.getByText("roadmap item")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets a later caller-supplied payload replace a failed fetch", async () => {
|
||||
@@ -93,8 +100,21 @@ describe("NativeStructurePreview", () => {
|
||||
expect(screen.queryByTestId("native-structure-preview-error")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the open affordance inside the mobile layout contract", () => {
|
||||
const { container } = render(<NativeStructurePreview ref={refs[0]} payload={payload(refs[0])} onOpen={vi.fn()} />);
|
||||
it("dispatches roadmap-item navigation without a dead anchor", () => {
|
||||
const ref: NativeStructureRef = { kind: "roadmap-item", id: "RF-1" };
|
||||
const result = payload(ref);
|
||||
const onOpen = vi.fn();
|
||||
const { container } = render(<NativeStructurePreview ref={ref} payload={result} onOpen={onOpen} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open Roadmap item: roadmap-item title" }));
|
||||
|
||||
expect(onOpen).toHaveBeenCalledWith(ref, expect.objectContaining({ openTarget: { view: "roadmaps", id: "RF-1" } }));
|
||||
expect(container.querySelector("a[href]")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the roadmap-item open affordance inside the mobile layout contract", () => {
|
||||
const ref: NativeStructureRef = { kind: "roadmap-item", id: "RF-1" };
|
||||
const { container } = render(<NativeStructurePreview ref={ref} payload={payload(ref)} onOpen={vi.fn()} />);
|
||||
expect(container.querySelector(".native-structure-preview__open")).toBeInTheDocument();
|
||||
const css = loadAllAppCss();
|
||||
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.native-structure-preview__open/);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -86,6 +86,23 @@ FNXC:Quality 2026-07-14-21:50:
|
||||
Static host registry for Quality hub. Literal import() so Vite can code-split;
|
||||
do not use @vite-ignore (reports footgun).
|
||||
*/
|
||||
/*
|
||||
FNXC:RoadmapsNavigation 2026-07-19-12:00:
|
||||
Register the manifest-advertised roadmaps destination with a literal bundled import so
|
||||
roadmap-item open targets resolve to RoadmapsView instead of the unavailable fallback.
|
||||
*/
|
||||
async function loadRoadmapView(): Promise<{ default: PluginViewComponent }> {
|
||||
const moduleId = "@fusion-plugin-examples/roadmap/dashboard-view";
|
||||
const exportName = "RoadmapDashboardView";
|
||||
const mod = await import("@fusion-plugin-examples/roadmap/dashboard-view") as unknown as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
|
||||
const component = mod[exportName];
|
||||
if (!component) {
|
||||
console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`);
|
||||
return { default: createMissingPluginView(moduleId, exportName) };
|
||||
}
|
||||
return { default: component as PluginViewComponent };
|
||||
}
|
||||
|
||||
async function loadQualityView(): Promise<{ default: PluginViewComponent }> {
|
||||
const moduleId = "@fusion-plugin-examples/quality/dashboard-view";
|
||||
const exportName = "QualityDashboardView";
|
||||
@@ -137,6 +154,12 @@ export function registerBundledPluginViews(): void {
|
||||
"quality",
|
||||
lazy(loadQualityView),
|
||||
);
|
||||
|
||||
registerPluginView(
|
||||
"fusion-plugin-roadmap",
|
||||
"roadmaps",
|
||||
lazy(loadRoadmapView),
|
||||
);
|
||||
}
|
||||
|
||||
export function __test_resetBundledPluginViewRegistration(): void {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createRoadmapStoreForTaskStore } from "@fusion-plugin-examples/roadmap";
|
||||
import type {
|
||||
NativeStructurePreviewPayload,
|
||||
NativeStructurePreviewResult,
|
||||
@@ -11,6 +12,7 @@ const KIND_LABELS: Record<NativeStructureRef["kind"], string> = {
|
||||
"research-finding": "Research finding",
|
||||
"eval-result": "Evaluation result",
|
||||
goal: "Goal",
|
||||
"roadmap-item": "Roadmap item",
|
||||
};
|
||||
const MAX_EXCERPT_LENGTH = 180;
|
||||
|
||||
@@ -40,7 +42,12 @@ function text(value: string | null | undefined, fallback: string): string {
|
||||
* This is a read-only projection over the task-scoped stores; it never duplicates structure
|
||||
* persistence. Existing archived/dismissed lifecycle status supplies `soft-deleted` because no
|
||||
* target has a tombstone column. Missing and unavailable structures are returned, never thrown;
|
||||
* eval results have no archive lifecycle and can only be missing.
|
||||
* eval results and roadmap items have no archive lifecycle and can only be missing.
|
||||
*
|
||||
* FNXC:NativeStructureEmbed 2026-07-19-12:45:
|
||||
* Roadmap-item is a project-scoped, read-only projection through the plugin's supported
|
||||
* createRoadmapStoreForTaskStore seam. Adapter/layer failures degrade to missing; do not
|
||||
* duplicate roadmap persistence or reach into plugin store internals.
|
||||
*/
|
||||
export async function resolveNativeStructurePreview(
|
||||
store: TaskStore,
|
||||
@@ -84,5 +91,21 @@ export async function resolveNativeStructurePreview(
|
||||
if (goal.status === "archived") return unavailable(ref, "soft-deleted");
|
||||
return preview(ref, goal.title, text(goal.description, `Status: ${goal.status}`), { view: "goals", id: goal.id });
|
||||
}
|
||||
case "roadmap-item": {
|
||||
try {
|
||||
const roadmapStore = createRoadmapStoreForTaskStore(store);
|
||||
const feature = await roadmapStore.getFeature(ref.id);
|
||||
if (!feature) return unavailable(ref, "missing");
|
||||
const milestone = await roadmapStore.getMilestone(feature.milestoneId);
|
||||
return preview(ref, feature.title, text(feature.description, ""), {
|
||||
view: "roadmaps",
|
||||
id: feature.id,
|
||||
milestoneId: feature.milestoneId,
|
||||
...(milestone ? { roadmapId: milestone.roadmapId } : {}),
|
||||
});
|
||||
} catch {
|
||||
return unavailable(ref, "missing");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ const milestone = { id: "MS-1", missionId: "M-1", title: "Milestone", descriptio
|
||||
const insight = { id: "INS-1", title: "Finding", content: "Finding content", status: "stale" };
|
||||
const evaluation = { id: "EV-1", taskId: "FN-1", taskSnapshot: { title: "Evaluated task" }, overallScore: 8, maxScore: 10 };
|
||||
const goal = { id: "G-1", title: "Goal", description: "Goal description", status: "active" };
|
||||
const roadmapFeature = { id: "RF-1", milestoneId: "RM-1", title: "Roadmap feature", description: "Roadmap feature description", orderIndex: 0 };
|
||||
const roadmapMilestone = { id: "RM-1", roadmapId: "R-1", title: "Roadmap milestone", orderIndex: 0 };
|
||||
|
||||
function store(overrides: Record<string, unknown> = {}): TaskStore {
|
||||
return {
|
||||
@@ -19,6 +21,10 @@ function store(overrides: Record<string, unknown> = {}): TaskStore {
|
||||
getInsightStore: vi.fn(() => ({ getInsight: vi.fn(async (id) => id === insight.id ? insight : undefined) })),
|
||||
getEvalStore: vi.fn(() => ({ getTaskResult: vi.fn(async (id) => id === evaluation.id ? evaluation : undefined) })),
|
||||
getGoalStore: vi.fn(() => ({ getGoal: vi.fn(async (id) => id === goal.id ? goal : null) })),
|
||||
getRoadmapStore: vi.fn(() => ({
|
||||
getFeature: vi.fn(async (id) => id === roadmapFeature.id ? roadmapFeature : undefined),
|
||||
getMilestone: vi.fn(async (id) => id === roadmapMilestone.id ? roadmapMilestone : undefined),
|
||||
})),
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
@@ -31,12 +37,13 @@ describe("resolveNativeStructurePreview", () => {
|
||||
["research-finding", insight.id, { view: "insights", id: insight.id }],
|
||||
["eval-result", evaluation.id, { view: "evals", id: evaluation.id }],
|
||||
["goal", goal.id, { view: "goals", id: goal.id }],
|
||||
["roadmap-item", roadmapFeature.id, { view: "roadmaps", id: roadmapFeature.id, milestoneId: roadmapFeature.milestoneId, roadmapId: roadmapMilestone.roadmapId }],
|
||||
] as const)("projects %s into its owning view", async (kind, id, openTarget) => {
|
||||
const result = await resolveNativeStructurePreview(store(), { kind, id });
|
||||
expect(result).toMatchObject({ available: true, kind, openTarget });
|
||||
});
|
||||
|
||||
it.each(["mission", "milestone", "research-finding", "eval-result", "goal"] as const)("returns missing for absent %s", async (kind) => {
|
||||
it.each(["mission", "milestone", "research-finding", "eval-result", "goal", "roadmap-item"] as const)("returns missing for absent %s", async (kind) => {
|
||||
const result = await resolveNativeStructurePreview(store(), { kind, id: "absent" });
|
||||
expect(result).toEqual({ available: false, kind, id: "absent", reason: "missing" });
|
||||
});
|
||||
@@ -51,8 +58,17 @@ describe("resolveNativeStructurePreview", () => {
|
||||
expect(result).toMatchObject({ available: false, reason: "soft-deleted" });
|
||||
});
|
||||
|
||||
it("never maps a missing eval to soft-deleted", async () => {
|
||||
it("never maps a missing eval or roadmap item to soft-deleted", async () => {
|
||||
await expect(resolveNativeStructurePreview(store(), { kind: "eval-result", id: "absent" })).resolves.toMatchObject({ reason: "missing" });
|
||||
await expect(resolveNativeStructurePreview(store(), { kind: "roadmap-item", id: "absent" })).resolves.toEqual({ available: false, kind: "roadmap-item", id: "absent", reason: "missing" });
|
||||
});
|
||||
|
||||
it("degrades roadmap adapter and PostgreSQL layer failures to missing", async () => {
|
||||
const unavailableLayer = store({ getRoadmapStore: undefined, getAsyncLayer: vi.fn(() => null) });
|
||||
const throwingRead = store({ getRoadmapStore: vi.fn(() => { throw new Error("adapter unavailable"); }) });
|
||||
|
||||
await expect(resolveNativeStructurePreview(unavailableLayer, { kind: "roadmap-item", id: roadmapFeature.id })).resolves.toEqual({ available: false, kind: "roadmap-item", id: roadmapFeature.id, reason: "missing" });
|
||||
await expect(resolveNativeStructurePreview(throwingRead, { kind: "roadmap-item", id: roadmapFeature.id })).resolves.toEqual({ available: false, kind: "roadmap-item", id: roadmapFeature.id, reason: "missing" });
|
||||
});
|
||||
|
||||
it("normalizes and bounds long excerpts for compact cards", async () => {
|
||||
@@ -77,6 +93,7 @@ describe("native structure preview route", () => {
|
||||
["research-finding", insight.id, { view: "insights", id: insight.id }],
|
||||
["eval-result", evaluation.id, { view: "evals", id: evaluation.id }],
|
||||
["goal", goal.id, { view: "goals", id: goal.id }],
|
||||
["roadmap-item", roadmapFeature.id, { view: "roadmaps", id: roadmapFeature.id, milestoneId: roadmapFeature.milestoneId, roadmapId: roadmapMilestone.roadmapId }],
|
||||
] as const)("returns the %s preview projection", async (kind, id, openTarget) => {
|
||||
const app = express();
|
||||
app.use("/api", createApiRoutes(store()));
|
||||
@@ -93,7 +110,15 @@ describe("native structure preview route", () => {
|
||||
expect(res.body).toMatchObject({ available: false, reason: "missing" });
|
||||
});
|
||||
|
||||
it.each(["roadmap-item", "unknown"])("rejects unsupported %s", async (kind) => {
|
||||
it("returns roadmap-item missing payload with 200", async () => {
|
||||
const app = express();
|
||||
app.use("/api", createApiRoutes(store()));
|
||||
const res = await REQUEST(app, "GET", "/api/native-structures/roadmap-item/absent/preview");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ available: false, kind: "roadmap-item", id: "absent", reason: "missing" });
|
||||
});
|
||||
|
||||
it.each(["unknown"])("rejects unsupported %s", async (kind) => {
|
||||
const app = express();
|
||||
app.use("/api", createApiRoutes(store()));
|
||||
const res = await REQUEST(app, "GET", `/api/native-structures/${kind}/id/preview`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
|
||||
import { RoadmapsView } from "./dashboard/RoadmapsView.js";
|
||||
|
||||
/*
|
||||
FNXC:RoadmapsNavigation 2026-07-19-12:00:
|
||||
The dashboard host loads this stable wrapper for the manifest-advertised roadmaps destination.
|
||||
Adapt only host context to RoadmapsView props; roadmap data remains plugin-owned.
|
||||
*/
|
||||
export function RoadmapDashboardView({ context }: { context?: PluginDashboardViewContext }) {
|
||||
return <RoadmapsView projectId={context?.projectId} addToast={context?.addToast ?? (() => undefined)} />;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user