diff --git a/src/common/JobTree.jsx b/src/common/JobTree.jsx
new file mode 100644
index 000000000..65bf99f1b
--- /dev/null
+++ b/src/common/JobTree.jsx
@@ -0,0 +1,446 @@
+import {
+ useState,
+ useCallback,
+ useMemo,
+ createContext,
+ useContext,
+ useImperativeHandle,
+} from "react";
+import { AnimatePresence, motion } from "framer-motion";
+
+// Tree Selection Context
+const TreeSelectionContext = createContext({
+ selectedNodes: new Set(),
+ isSelectable: false,
+ selectNode: () => {},
+ isSelected: () => false,
+});
+
+// Tree Selection Provider
+const TreeSelectionProvider = ({
+ children,
+ selectable = false,
+ onSelectNodes,
+}) => {
+ const [selectedNodes, setSelectedNodes] = useState(new Set());
+
+ const selectNode = (nodeData, isSelected, clearOthers = false) => {
+ if (!selectable) return;
+
+ setSelectedNodes((prevSelected) => {
+ const newSelected = clearOthers ? new Set() : new Set(prevSelected);
+
+ if (isSelected) {
+ newSelected.add(nodeData);
+ } else {
+ newSelected.delete(nodeData);
+ }
+
+ // Trigger callback with array of selected node IDs
+ if (onSelectNodes) {
+ onSelectNodes(Array.from(newSelected));
+ }
+
+ return newSelected;
+ });
+ };
+
+ const isSelected = (node) => {
+ return selectedNodes.has(node);
+ };
+
+ const contextValue = useMemo(
+ () => ({
+ selectedNodes,
+ isSelectable: selectable,
+ selectNode,
+ isSelected,
+ }),
+ [selectedNodes, selectable, selectNode, isSelected]
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+// Hook to use tree selection context
+const useTreeSelection = () => {
+ const context = useContext(TreeSelectionContext);
+ if (!context) {
+ throw new Error(
+ "useTreeSelection must be used within a TreeSelectionProvider"
+ );
+ }
+ return context;
+};
+
+// Utility function to count leaf nodes in a subtree
+const countLeafNodes = (node) => {
+ if (!node.children || node.children.length === 0) {
+ return 1; // This is a leaf node
+ }
+
+ return node.children.reduce((count, child) => {
+ return count + countLeafNodes(child);
+ }, 0);
+};
+
+// Default title renderer - handles different data types gracefully
+const _defaultNodeTitleRender = ({ data }) => {
+ if (typeof data === "string") {
+ return {data};
+ }
+ if (data && typeof data === "object") {
+ // Display name, title, or id if available, otherwise fallback to JSON
+ const displayText =
+ data.name || data.title || data.label || data.id || JSON.stringify(data);
+ return {displayText};
+ }
+ return {String(data)};
+};
+
+const TitleTreeNode = ({
+ data,
+ titleRenderFn = _defaultNodeTitleRender,
+ firstIcon,
+ secondIcon,
+ onClick,
+ onDoubleClick,
+}) => {
+ const { isSelectable, selectNode, isSelected } = useTreeSelection();
+ const nodeIsSelected = isSelected(data);
+
+ const handleClick = (e) => {
+ if (isSelectable && (e.ctrlKey || e.metaKey)) {
+ // Multi-select with Ctrl/Cmd key
+ e.stopPropagation();
+ selectNode(data, !nodeIsSelected);
+ } else if (isSelectable && e.shiftKey) {
+ // Select with Shift key
+ // TODO: Range selection could be implemented later
+ e.stopPropagation();
+ selectNode(data, !nodeIsSelected);
+ } else if (isSelectable && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
+ // Single select
+ e.stopPropagation();
+ selectNode(data, true, true); // true for clearOthers
+ }
+
+ onClick && onClick(e);
+ };
+
+ return (
+
+ {firstIcon}
+ {secondIcon}
+
+ {titleRenderFn({ data })}
+
+
+ );
+};
+
+// Leaf node component for nodes without children
+const LeafTreeNode = ({ data, titleRenderFn = _defaultNodeTitleRender }) => {
+ return (
+
+ >}
+ secondIcon={}
+ />
+
+ );
+};
+
+// Component for nodes that support lazy loading of children
+const LazyExpandableTreeNode = ({
+ data,
+ titleRenderFn = _defaultNodeTitleRender,
+ onLoadChildren,
+}) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [children, setChildren] = useState(data.children || []);
+ const [loadError, setLoadError] = useState(null);
+ const [hasAttemptedLoad, setHasAttemptedLoad] = useState(false);
+
+ const toggleExpand = useCallback(
+ async (e) => {
+ e.stopPropagation();
+ if (!isExpanded) {
+ // Expanding - load children if needed
+ if (
+ !hasAttemptedLoad &&
+ onLoadChildren &&
+ (!data.children || data.children.length === 0)
+ ) {
+ setIsLoading(true);
+ setLoadError(null);
+ try {
+ const loadedChildren = await onLoadChildren(data);
+ setChildren(loadedChildren || []);
+ setHasAttemptedLoad(true);
+ } catch (error) {
+ setLoadError(error.message || "Failed to load children");
+ console.error("Error loading children:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ }
+ setIsExpanded(!isExpanded);
+ },
+ [isExpanded, onLoadChildren, data, hasAttemptedLoad]
+ );
+
+ const hasChildren = children && children.length > 0;
+ const canExpand = hasChildren || (onLoadChildren && !hasAttemptedLoad);
+
+ return (
+
+
+
+ {canExpand ? (
+
+ ) : (
+
+ )}
+
+ {titleRenderFn({ data })}
+ {isLoading && (
+
+
+
+ )}
+
+
+ {isExpanded && (
+
+ {loadError ? (
+ -
+
+ {loadError}
+
+ ) : (
+ children.map((child) => (
+
+ ))
+ )}
+
+ )}
+
+ );
+};
+
+// Regular expandable node for nodes with pre-loaded children
+const ExpandableTreeNode = ({
+ data,
+ titleRenderFn = _defaultNodeTitleRender,
+ onLoadChildren,
+}) => {
+ const [isExpanded, setIsExpanded] = useState(data?.expanded);
+
+ const toggleExpand = (e) => {
+ e.stopPropagation();
+ setIsExpanded(!isExpanded);
+ };
+
+ const hasChildren = useMemo(
+ () => data.children && data.children.length > 0,
+ [data]
+ );
+
+ if (!hasChildren) {
+ return ;
+ }
+
+ // Calculate leaf count for this subtree
+ const leafCount = useMemo(() => countLeafNodes(data), [data]);
+
+ return (
+
+
+ }
+ secondIcon={
+
+
+ {leafCount < 100 ? leafCount : "99+"}
+
+
+ }
+ />
+
+
+ {isExpanded && (
+
+ {data.children.map((child) => (
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+// Generic tree node that determines which type of node to render
+const TreeNode = ({ data, titleRenderFn, onLoadChildren }) => {
+ // Check if this node supports lazy loading
+ const supportsLazyLoading = useMemo(() => {
+ return (
+ data.hasChildren ||
+ data.loadChildren ||
+ (onLoadChildren &&
+ (data.children === undefined || data.children === null))
+ );
+ }, [data, onLoadChildren]);
+
+ // Check if this node has children or can have children
+ const hasChildren = useMemo(
+ () => data.children && data.children.length > 0,
+ [data]
+ );
+ const canHaveChildren = hasChildren || supportsLazyLoading;
+
+ if (!canHaveChildren) {
+ return ;
+ }
+
+ if (supportsLazyLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+// Internal Tree component that renders the actual tree
+const TreeContent = ({
+ nodes,
+ titleRenderFn = _defaultNodeTitleRender,
+ onLoadChildren,
+}) => {
+ if (!nodes || nodes.length === 0) {
+ return (
+ No items to display
+ );
+ }
+
+ return (
+
+ {nodes.map((node) => (
+
+ ))}
+
+ );
+};
+
+// Main Tree component
+const Tree = ({
+ ref,
+ nodes,
+ selectable = false,
+ onSelectNodes,
+ titleRenderFn = _defaultNodeTitleRender,
+ onLoadChildren,
+}) => {
+ const expandAll = () => {
+ // Logic to expand/collapse all nodes
+ };
+ const collapseAll = () => {
+ // Logic to expand/collapse all nodes
+ };
+ // Expose a method to expand/collapse all nodes could be added here
+ useImperativeHandle(ref, () => ({
+ expandAll,
+ collapseAll,
+ }));
+
+ return (
+
+
+
+ );
+};
+
+export default Tree;
diff --git a/src/layout/Router.jsx b/src/layout/Router.jsx
index 2696f4842..55795799e 100644
--- a/src/layout/Router.jsx
+++ b/src/layout/Router.jsx
@@ -28,6 +28,7 @@ import { useEffect } from "react";
import Footer from "./Footer";
import TopAnnouncement from "./TopAnnouncement";
import UserMetricsPage from "../pages/UserMetricsPage";
+import ExperimentTreeAlpha from "../pages/ExperimentTreeAlpha";
const router = createBrowserRouter(
[
@@ -79,6 +80,10 @@ const router = createBrowserRouter(
path: "/experiment/:expid/tree",
element: ,
},
+ {
+ path: "/experiment/:expid/tree-alpha",
+ element: ,
+ },
{
path: "/experiment/:expid/graph",
element: ,
diff --git a/src/pages/ExperimentTreeAlpha.jsx b/src/pages/ExperimentTreeAlpha.jsx
new file mode 100644
index 000000000..8a750c5df
--- /dev/null
+++ b/src/pages/ExperimentTreeAlpha.jsx
@@ -0,0 +1,106 @@
+import { useParams } from "react-router-dom";
+import { autosubmitApiV3 } from "../services/autosubmitApiV3";
+import Tree from "../common/JobTree";
+import { useEffect, useMemo, useState } from "react";
+
+const _customTitleRender = ({ data }) => {
+ return (
+
+ );
+};
+
+const ExperimentTreeAlpha = () => {
+ const routeParams = useParams();
+ const [filter, setFilter] = useState("");
+ const [treeData, setTreeData] = useState(null);
+
+ const { data, isFetching, refetch, isError } =
+ autosubmitApiV3.endpoints.getExperimentTreeView.useQuery({
+ expid: routeParams.expid,
+ runId: null,
+ });
+
+ useEffect(() => {
+ if (data && data.tree) {
+ if (filter && filter.length > 0) {
+ // Apply filter to the tree data
+ const applyFilter = (nodes) => {
+ return nodes
+ .map((node) => {
+ if (node.children) {
+ const filteredChildren = applyFilter(node.children);
+ if (
+ filteredChildren.length > 0 ||
+ node.title.includes(filter)
+ ) {
+ return { ...node, children: filteredChildren };
+ }
+ return null;
+ } else if (node.title.includes(filter)) {
+ return node;
+ }
+ return null;
+ })
+ .filter((node) => node !== null);
+ };
+ const filteredTree = applyFilter(data.tree);
+ setTreeData(filteredTree);
+ return;
+ }
+ setTreeData(data.tree);
+ return;
+ }
+ setTreeData(null);
+ return;
+ }, [data, filter]);
+
+ const handleNodeSelect = (selectedNodeIds) => {
+ console.log("Selected node IDs:", selectedNodeIds);
+ };
+
+ const handleModify = () => {
+ setTreeData([
+ { refKey: Date.now(), title: "New node" },
+ ...treeData,
+ // { refKey: Date.now(), title: "New Node" },
+ ]);
+ };
+ return (
+
+
+
+ {isFetching && (
+
Loading...
+ )}
+ {isError && (
+
Error loading tree data.
+ )}
+ {treeData && (
+
+ )}
+
+ );
+};
+export default ExperimentTreeAlpha;