Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/portal/src/pages/CriteriaGraphView.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof CriteriaGraphView>;

export default meta;
type Story = StoryObj<typeof meta>;

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");
},
};
19 changes: 18 additions & 1 deletion apps/portal/src/pages/CriteriaGraphView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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"], [
Expand Down
53 changes: 48 additions & 5 deletions apps/portal/src/pages/CriteriaGraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<line>` geometry used for acyclic DAGs. */
straight: boolean;
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -261,8 +280,8 @@ export function CriteriaGraphView() {
<div>
<h1 className="text-3xl font-bold tracking-tight">Criteria Graph</h1>
<p className="text-muted-foreground">
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`}
</p>
</div>
<div className="flex items-center gap-2">
Expand All @@ -280,7 +299,25 @@ export function CriteriaGraphView() {
</div>
);

if (!layout || filteredGraph!.nodes.length === 0) {
const independentList = separated!.independentNodes.length > 0 && (
<Card>
<CardContent className="p-4">
<h2 className="mb-3 text-sm font-semibold">
Independent criteria ({separated!.independentNodes.length})
</h2>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{separated!.independentNodes.map((node) => (
<Link key={node.id} to={`/criteria/${node.id}`} className="min-w-0 rounded-md border p-3 hover:bg-accent">
<span className="block truncate font-mono text-sm font-medium">{node.id}</span>
<span className="block truncate text-xs text-muted-foreground" title={node.prompt}>{node.prompt}</span>
</Link>
))}
</div>
</CardContent>
</Card>
);

if (filteredGraph!.nodes.length === 0) {
return (
<div className="space-y-6">
{header}
Expand All @@ -292,6 +329,10 @@ export function CriteriaGraphView() {
);
}

if (!layout) {
return <div className="space-y-6">{header}{gateFilterBar}{independentList}</div>;
}

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}`;
Expand Down Expand Up @@ -514,6 +555,8 @@ export function CriteriaGraphView() {
</CardContent>
</Card>

{independentList}

{/* Legend */}
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
Expand Down
5 changes: 5 additions & 0 deletions docs/architecture/app-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down