diff --git a/apps/portal/src/pages/CriteriaGraphView.stories.tsx b/apps/portal/src/pages/CriteriaGraphView.stories.tsx new file mode 100644 index 000000000..9b14fbf93 --- /dev/null +++ b/apps/portal/src/pages/CriteriaGraphView.stories.tsx @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { expect } from "storybook/test"; + +import { CriteriaGraphView } from "./CriteriaGraphView"; + +const graph = { + nodes: [ + { id: "root", prompt: "The project builds", dependsOn: [] }, + { id: "child", prompt: "Tests pass", dependsOn: ["root"] }, + { id: "standalone", prompt: "The README is clear", dependsOn: [] }, + ], + edges: [{ source: "root", target: "child" }], +}; + +const meta = { + component: CriteriaGraphView, + parameters: { + msw: { handlers: [http.get("*/api/v1/criteria/graph", () => HttpResponse.json(graph))] }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const WithIndependentCriterion: Story = { + play: async ({ canvas }) => { + await expect(await canvas.findByText("Independent criteria (1)")).toBeVisible(); + await expect(canvas.getByRole("link", { name: /standalone/ })).toHaveAttribute("href", "/criteria/standalone"); + }, +}; diff --git a/apps/portal/src/pages/CriteriaGraphView.test.tsx b/apps/portal/src/pages/CriteriaGraphView.test.tsx index 5e9616f12..7e1867233 100644 --- a/apps/portal/src/pages/CriteriaGraphView.test.tsx +++ b/apps/portal/src/pages/CriteriaGraphView.test.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { describe, expect, it } from "vitest"; -import { layoutGraph, edgePath } from "./CriteriaGraphView"; +import { layoutGraph, edgePath, separateIndependentCriteria } from "./CriteriaGraphView"; import type { CriteriaGraphData } from "@/types"; function node(id: string) { @@ -16,6 +16,23 @@ function graphOf(ids: string[], edges: Array<[string, string]>): CriteriaGraphDa } as unknown as CriteriaGraphData; } +describe("separateIndependentCriteria", () => { + it("keeps only connected criteria in the graph and lists isolated criteria", () => { + const graph = graphOf(["root", "child", "standalone"], [["root", "child"]]); + const { linkedGraph, independentNodes } = separateIndependentCriteria(graph); + + expect(linkedGraph.nodes.map((node) => node.id)).toEqual(["root", "child"]); + expect(linkedGraph.edges).toEqual(graph.edges); + expect(independentNodes.map((node) => node.id)).toEqual(["standalone"]); + }); + + it("supports a selection with only independent criteria", () => { + const { linkedGraph, independentNodes } = separateIndependentCriteria(graphOf(["a", "b"], [])); + expect(linkedGraph.nodes).toEqual([]); + expect(independentNodes.map((node) => node.id)).toEqual(["a", "b"]); + }); +}); + describe("layoutGraph", () => { it("lays out an acyclic graph with all nodes positioned", () => { const graph = graphOf(["a", "b", "c"], [ diff --git a/apps/portal/src/pages/CriteriaGraphView.tsx b/apps/portal/src/pages/CriteriaGraphView.tsx index a15235463..d6b346529 100644 --- a/apps/portal/src/pages/CriteriaGraphView.tsx +++ b/apps/portal/src/pages/CriteriaGraphView.tsx @@ -111,6 +111,18 @@ export function layoutGraph(graph: CriteriaGraphData) { return { nodeMap, positions, nodeWidths, NODE_MIN_W, NODE_H, totalW, totalH, minX, edges, cycleEdges, cycleNodes, cycleGroups }; } +/** Keep criteria with no visible dependency edge out of the graph layout. */ +export function separateIndependentCriteria(graph: CriteriaGraphData) { + const connectedIds = new Set(graph.edges.flatMap((edge) => [edge.source, edge.target])); + return { + linkedGraph: { + nodes: graph.nodes.filter((node) => connectedIds.has(node.id)), + edges: graph.edges, + }, + independentNodes: graph.nodes.filter((node) => !connectedIds.has(node.id)), + }; +} + export interface EdgeRenderSpec { /** Straight edges keep the exact `` geometry used for acyclic DAGs. */ straight: boolean; @@ -211,8 +223,15 @@ export function CriteriaGraphView() { return { nodes, edges }; }, [graph, selectedGates]); - // Compute layout - const layout = useMemo(() => (filteredGraph ? layoutGraph(filteredGraph) : null), [filteredGraph]); + // Split after gate filtering so a criterion with no visible edge is listed separately. + const separated = useMemo( + () => (filteredGraph ? separateIndependentCriteria(filteredGraph) : null), + [filteredGraph], + ); + const layout = useMemo( + () => (separated?.linkedGraph.nodes.length ? layoutGraph(separated.linkedGraph) : null), + [separated], + ); if (isLoading) { return ( @@ -261,8 +280,8 @@ export function CriteriaGraphView() {

Criteria Graph

- Dependency DAG — {filteredGraph!.nodes.length} - {selectedGates.length > 0 ? ` of ${graph.nodes.length}` : ""} criteria, {filteredGraph!.edges.length} edges + Dependency DAG — {separated!.linkedGraph.nodes.length} connected criteria, {filteredGraph!.edges.length} edges + {separated!.independentNodes.length > 0 && ` · ${separated!.independentNodes.length} independent`}

@@ -280,7 +299,25 @@ export function CriteriaGraphView() {
); - if (!layout || filteredGraph!.nodes.length === 0) { + const independentList = separated!.independentNodes.length > 0 && ( + + +

+ Independent criteria ({separated!.independentNodes.length}) +

+
+ {separated!.independentNodes.map((node) => ( + + {node.id} + {node.prompt} + + ))} +
+
+
+ ); + + if (filteredGraph!.nodes.length === 0) { return (
{header} @@ -292,6 +329,10 @@ export function CriteriaGraphView() { ); } + if (!layout) { + return
{header}{gateFilterBar}{independentList}
; + } + const { nodeMap, positions, nodeWidths, NODE_MIN_W, NODE_H, totalW, totalH, minX, edges, cycleEdges, cycleNodes, cycleGroups } = layout; const PADDING = 60; const viewBox = `${minX - PADDING} ${-PADDING} ${totalW + PADDING * 2} ${totalH + PADDING * 2 + NODE_H}`; @@ -514,6 +555,8 @@ export function CriteriaGraphView() { + {independentList} + {/* Legend */}
diff --git a/docs/architecture/app-design.md b/docs/architecture/app-design.md index f1a243262..de757808b 100644 --- a/docs/architecture/app-design.md +++ b/docs/architecture/app-design.md @@ -647,6 +647,11 @@ reusable badge components provide a consistent **hover-to-preview + click-to-nav | `components/TaskPromptBadge.tsx` | Task prompt (any type) | `/task-prompts/:id` | Type label, text snippet, list of detected features, created date, **Open details** button | | `components/AgentBadge.tsx` | Coding agent | `/agents/:id` | Registry name, internal ID, deleted state, version, **View agent** button | +The Criteria management graph lays out only criteria with a visible dependency +edge. Criteria without a parent or child in the current gate filter appear in a +linked **Independent criteria** list below the graph, so they remain accessible +without widening the DAG canvas. + Both follow the same rules: - **Self-contained tooltip.** Each wraps its trigger in a Radix `Tooltip` (a local