feat(frontend): Add graph search functionality to builder (#10776)

## Summary
- Added search functionality to find nodes in the graph by block type,
node ID, and input/output names
- Search icon added to both new and old control panels
- Implemented node highlighting on hover and navigation on click


https://github.com/user-attachments/assets/8cc69186-5582-446d-b2cd-601de992144f



## Changes
- Created `GraphSearchMenu` component for the new control panel
- Created `GraphSearchControl` component for the old control panel  
- Added `GraphSearchContent` component with search UI similar to
BlockMenu
- Implemented `useGraphSearch` hook with fuzzy search logic
- Added node highlighting without viewport movement on hover
- Added node navigation with centering and highlighting on selection

## Features
- Search by block type name, node ID, or input/output field names
- Real-time filtering with keyboard navigation support
- Visual feedback with node highlighting on hover
- Click to navigate and center on selected node
- Consistent styling with BlockMenu including category colors
- Works in both old and new control panels

## Test plan
- [x] Test search functionality in both old and new control panels
- [x] Verify search by block type name works
- [x] Verify search by node ID works  
- [x] Verify search by input/output names works
- [x] Test keyboard navigation (arrow keys and enter)
- [x] Verify node highlighting on hover
- [x] Verify node navigation on click
- [x] Check popover alignment with control panel top
This commit is contained in:
Swifty
2025-09-01 20:42:13 +02:00
committed by GitHub
parent 417ee7f0e1
commit 916d0adabb
16 changed files with 745 additions and 19 deletions

View File

@@ -61,24 +61,27 @@ poetry run pytest path/to/test.py --snapshot-update
```bash
# Install dependencies
cd frontend && npm install
cd frontend && pnpm i
# Start development server
npm run dev
pnpm dev
# Run E2E tests
npm run test
pnpm test
# Run Storybook for component development
npm run storybook
pnpm storybook
# Build production
npm run build
pnpm build
# Type checking
npm run types
pnpm types
```
We have a components library in autogpt_platform/frontend/src/components/atoms that should be used when adding new pages and components.
## Architecture Overview
### Backend Architecture

View File

@@ -12,9 +12,9 @@ import { BlockMenuStateProvider } from "../block-menu-provider";
interface BlockMenuProps {
pinBlocksPopover: boolean;
blockMenuSelected: "save" | "block" | "";
blockMenuSelected: "save" | "block" | "search" | "";
setBlockMenuSelected: React.Dispatch<
React.SetStateAction<"" | "save" | "block">
React.SetStateAction<"" | "save" | "block" | "search">
>;
}

View File

@@ -3,7 +3,7 @@ import { useState } from "react";
interface useBlockMenuProps {
pinBlocksPopover: boolean;
setBlockMenuSelected: React.Dispatch<
React.SetStateAction<"" | "save" | "block">
React.SetStateAction<"" | "save" | "block" | "search">
>;
}

View File

@@ -0,0 +1,74 @@
import React from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { MagnifyingGlassIcon } from "@phosphor-icons/react";
import { GraphSearchContent } from "../GraphMenuContent/GraphContent";
import { ControlPanelButton } from "../ControlPanelButton";
import { CustomNode } from "@/components/CustomNode";
import { useGraphMenu } from "./useGraphMenu";
interface GraphSearchMenuProps {
nodes: CustomNode[];
blockMenuSelected: "save" | "block" | "search" | "";
setBlockMenuSelected: React.Dispatch<
React.SetStateAction<"" | "save" | "block" | "search">
>;
onNodeSelect: (nodeId: string) => void;
onNodeHover?: (nodeId: string | null) => void;
}
export const GraphSearchMenu: React.FC<GraphSearchMenuProps> = ({
nodes,
blockMenuSelected,
setBlockMenuSelected,
onNodeSelect,
onNodeHover,
}) => {
const {
open,
searchQuery,
setSearchQuery,
filteredNodes,
handleNodeSelect,
handleOpenChange,
} = useGraphMenu({
nodes,
blockMenuSelected,
setBlockMenuSelected,
onNodeSelect,
});
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger className="hover:cursor-pointer">
<ControlPanelButton
data-id="graph-search-control-popover-trigger"
data-testid="graph-search-control-button"
selected={blockMenuSelected === "search"}
className="rounded-none"
>
<MagnifyingGlassIcon className="h-5 w-6" strokeWidth={2} />
</ControlPanelButton>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
sideOffset={16}
className="absolute h-[75vh] w-[46.625rem] overflow-hidden rounded-[1rem] border-none p-0 shadow-[0_2px_6px_0_rgba(0,0,0,0.05)]"
data-id="graph-search-popover-content"
>
<GraphSearchContent
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
filteredNodes={filteredNodes}
onNodeSelect={handleNodeSelect}
onNodeHover={onNodeHover}
/>
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,41 @@
import { useGraphSearch } from "../GraphMenuSearchBar/useGraphMenuSearchBar";
import { CustomNode } from "@/components/CustomNode";
interface UseGraphMenuProps {
nodes: CustomNode[];
blockMenuSelected: "save" | "block" | "search" | "";
setBlockMenuSelected: React.Dispatch<
React.SetStateAction<"" | "save" | "block" | "search">
>;
onNodeSelect: (nodeId: string) => void;
}
export const useGraphMenu = ({
nodes,
setBlockMenuSelected,
onNodeSelect,
}: UseGraphMenuProps) => {
const { open, setOpen, searchQuery, setSearchQuery, filteredNodes } =
useGraphSearch(nodes);
const handleNodeSelect = (nodeId: string) => {
onNodeSelect(nodeId);
setOpen(false);
setSearchQuery("");
setBlockMenuSelected("");
};
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
setBlockMenuSelected(newOpen ? "search" : "");
};
return {
open,
searchQuery,
setSearchQuery,
filteredNodes,
handleNodeSelect,
handleOpenChange,
};
};

View File

@@ -0,0 +1,131 @@
import React from "react";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { beautifyString, getPrimaryCategoryColor } from "@/lib/utils";
import { SearchableNode } from "../GraphMenuSearchBar/useGraphMenuSearchBar";
import { TextRenderer } from "@/components/ui/render";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "@/components/ui/tooltip";
import { GraphMenuSearchBar } from "../GraphMenuSearchBar/GraphMenuSearchBar";
import { useGraphContent } from "./useGraphContent";
interface GraphSearchContentProps {
searchQuery: string;
onSearchChange: (query: string) => void;
filteredNodes: SearchableNode[];
onNodeSelect: (nodeId: string) => void;
onNodeHover?: (nodeId: string | null) => void;
}
export const GraphSearchContent: React.FC<GraphSearchContentProps> = ({
searchQuery,
onSearchChange,
filteredNodes,
onNodeSelect,
onNodeHover,
}) => {
const {
selectedIndex,
setSelectedIndex,
handleKeyDown,
getNodeInputOutputSummary,
} = useGraphContent({
searchQuery,
filteredNodes,
onNodeSelect,
});
return (
<div className="flex h-full w-full flex-col">
{/* Search Bar */}
<GraphMenuSearchBar
searchQuery={searchQuery}
onSearchChange={onSearchChange}
onKeyDown={handleKeyDown}
/>
<Separator className="h-[1px] w-full text-zinc-300" />
{/* Search Results */}
<div className="flex-1 overflow-hidden">
{searchQuery && (
<div className="px-4 py-2 text-xs text-gray-500">
Found {filteredNodes.length} node{filteredNodes.length !== 1 ? "s" : ""}
</div>
)}
<ScrollArea className="h-full w-full">
{filteredNodes.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400">
{searchQuery ? "No nodes found matching your search" : "Start typing to search nodes"}
</div>
) : (
filteredNodes.map((node, index) => {
// Safety check for node data
if (!node || !node.data) {
return null;
}
const nodeTitle = node.data?.metadata?.customized_name ||
beautifyString(node.data?.blockType || "").replace(/ Block$/, "");
const nodeType = beautifyString(node.data?.blockType || "").replace(/ Block$/, "");
return (
<TooltipProvider key={node.id}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<div
className={`mx-4 my-2 flex h-20 cursor-pointer rounded-lg border border-zinc-200 bg-white ${
index === selectedIndex
? "border-zinc-400 shadow-md"
: "hover:border-zinc-300 hover:shadow-sm"
}`}
onClick={() => onNodeSelect(node.id)}
onMouseEnter={() => {
setSelectedIndex(index);
onNodeHover?.(node.id);
}}
onMouseLeave={() => onNodeHover?.(null)}
>
<div
className={`h-full w-3 rounded-l-[7px] ${getPrimaryCategoryColor(node.data?.categories)}`}
/>
<div className="mx-3 flex flex-1 items-center justify-between">
<div className="mr-2 min-w-0">
<span className="block truncate pb-1 text-sm font-semibold text-zinc-800">
<TextRenderer
value={nodeTitle}
truncateLengthLimit={45}
/>
</span>
<span className="block break-all text-xs font-normal text-zinc-500">
<TextRenderer
value={getNodeInputOutputSummary(node) || node.data?.description || ""}
truncateLengthLimit={165}
/>
</span>
</div>
</div>
</div>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<div className="space-y-1">
<div className="font-semibold">Node Type: {nodeType}</div>
{node.data?.metadata?.customized_name && (
<div className="text-xs text-gray-500">Custom Name: {node.data.metadata.customized_name}</div>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
})
)}
</ScrollArea>
</div>
</div>
);
};

View File

@@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
import { SearchableNode } from "../GraphMenuSearchBar/useGraphMenuSearchBar";
interface UseGraphContentProps {
searchQuery: string;
filteredNodes: SearchableNode[];
onNodeSelect: (nodeId: string) => void;
}
export const useGraphContent = ({
searchQuery,
filteredNodes,
onNodeSelect,
}: UseGraphContentProps) => {
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
setSelectedIndex(0);
}, [searchQuery]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, filteredNodes.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Enter" && filteredNodes.length > 0) {
e.preventDefault();
onNodeSelect(filteredNodes[selectedIndex].id);
}
};
const getNodeInputOutputSummary = (node: SearchableNode) => {
// Safety check for node data
if (!node || !node.data) {
return "";
}
const inputs = Object.keys(node.data?.inputSchema?.properties || {});
const outputs = Object.keys(node.data?.outputSchema?.properties || {});
const parts = [];
if (inputs.length > 0) {
parts.push(`Inputs: ${inputs.slice(0, 3).join(", ")}${inputs.length > 3 ? "..." : ""}`);
}
if (outputs.length > 0) {
parts.push(`Outputs: ${outputs.slice(0, 3).join(", ")}${outputs.length > 3 ? "..." : ""}`);
}
return parts.join(" | ");
};
return {
selectedIndex,
setSelectedIndex,
handleKeyDown,
getNodeInputOutputSummary,
};
};

View File

@@ -0,0 +1,60 @@
import { cn } from "@/lib/utils";
import React from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { MagnifyingGlassIcon, XIcon } from "@phosphor-icons/react";
import { useGraphMenuSearchBarComponent } from "./useGraphMenuSearchBarComponent";
interface GraphMenuSearchBarProps {
className?: string;
searchQuery: string;
onSearchChange: (query: string) => void;
onKeyDown?: (e: React.KeyboardEvent) => void;
}
export const GraphMenuSearchBar: React.FC<GraphMenuSearchBarProps> = ({
className = "",
searchQuery,
onSearchChange,
onKeyDown,
}) => {
const { inputRef, handleClear } = useGraphMenuSearchBarComponent({
onSearchChange,
});
return (
<div
className={cn(
"flex min-h-[3.5625rem] items-center gap-2.5 px-4",
className,
)}
>
<div className="flex h-6 w-6 items-center justify-center">
<MagnifyingGlassIcon className="h-6 w-6 text-zinc-700" strokeWidth={2} />
</div>
<Input
ref={inputRef}
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={"Search your graph for nodes, inputs, outputs..."}
className={cn(
"m-0 border-none p-0 font-sans text-base font-normal text-zinc-800 shadow-none outline-none",
"placeholder:text-zinc-400 focus:shadow-none focus:outline-none focus:ring-0",
)}
autoFocus
/>
{searchQuery.length > 0 && (
<Button
variant="ghost"
size={"sm"}
onClick={handleClear}
className="p-0 hover:bg-transparent"
>
<XIcon className="h-6 w-6 text-zinc-700" strokeWidth={2} />
</Button>
)}
</div>
);
};

View File

@@ -0,0 +1,146 @@
import { useState, useMemo, useDeferredValue } from "react";
import { CustomNode } from "@/components/CustomNode";
import { beautifyString } from "@/lib/utils";
import jaro from "jaro-winkler";
export type SearchableNode = CustomNode & {
searchScore?: number;
matchedFields?: string[];
};
export const useGraphSearch = (nodes: CustomNode[]) => {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const deferredSearchQuery = useDeferredValue(searchQuery);
const filteredNodes = useMemo(() => {
// Filter out invalid nodes
const validNodes = (nodes || []).filter(node => node && node.data);
if (!deferredSearchQuery.trim()) {
return validNodes.map(node => ({ ...node, searchScore: 1, matchedFields: [] }));
}
const query = deferredSearchQuery.toLowerCase().trim();
const queryWords = query.split(/\s+/);
return validNodes
.map((node): SearchableNode => {
const { score, matchedFields } = calculateNodeScore(node, query, queryWords);
return { ...node, searchScore: score, matchedFields };
})
.filter(node => node.searchScore! > 0)
.sort((a, b) => b.searchScore! - a.searchScore!);
}, [nodes, deferredSearchQuery]);
return {
open,
setOpen,
searchQuery,
setSearchQuery,
filteredNodes,
};
};
function calculateNodeScore(
node: CustomNode,
query: string,
queryWords: string[]
): { score: number; matchedFields: string[] } {
const matchedFields: string[] = [];
let score = 0;
// Safety check for node data
if (!node || !node.data) {
return { score: 0, matchedFields: [] };
}
// Prepare searchable text with defensive checks
const nodeTitle = (node.data?.title || "").toLowerCase(); // This includes the ID
const nodeId = (node.id || "").toLowerCase();
const nodeDescription = (node.data?.description || "").toLowerCase();
const blockType = (node.data?.blockType || "").toLowerCase();
const beautifiedBlockType = beautifyString(blockType).toLowerCase();
const customizedName = (node.data?.metadata?.customized_name || "").toLowerCase();
// Get input and output names with defensive checks
const inputNames = Object.keys(node.data?.inputSchema?.properties || {})
.map(key => key.toLowerCase());
const outputNames = Object.keys(node.data?.outputSchema?.properties || {})
.map(key => key.toLowerCase());
// 1. Check exact match in customized name, title (includes ID), node ID, or block type (highest priority)
if (customizedName.includes(query) || nodeTitle.includes(query) || nodeId.includes(query) || blockType.includes(query) || beautifiedBlockType.includes(query)) {
score = 4;
matchedFields.push("title");
}
// 2. Check all query words in customized name, title or block type
else if (queryWords.every(word => customizedName.includes(word) || nodeTitle.includes(word) || beautifiedBlockType.includes(word))) {
score = 3.5;
matchedFields.push("title");
}
// 3. Check exact match in input/output names
else if (inputNames.some(name => name.includes(query))) {
score = 3;
matchedFields.push("inputs");
}
else if (outputNames.some(name => name.includes(query))) {
score = 2.8;
matchedFields.push("outputs");
}
// 4. Check all query words in input/output names
else if (inputNames.some(name => queryWords.every(word => name.includes(word)))) {
score = 2.5;
matchedFields.push("inputs");
}
else if (outputNames.some(name => queryWords.every(word => name.includes(word)))) {
score = 2.3;
matchedFields.push("outputs");
}
// 5. Similarity matching using Jaro-Winkler
else {
const titleSimilarity = Math.max(
jaro(customizedName, query),
jaro(nodeTitle, query),
jaro(nodeId, query),
jaro(beautifiedBlockType, query)
);
if (titleSimilarity > 0.7) {
score = 1.5 + titleSimilarity;
matchedFields.push("title");
}
// Check similarity with input/output names
const inputSimilarity = Math.max(...inputNames.map(name => jaro(name, query)), 0);
const outputSimilarity = Math.max(...outputNames.map(name => jaro(name, query)), 0);
if (inputSimilarity > 0.7 && inputSimilarity > score) {
score = 1 + inputSimilarity;
matchedFields.push("inputs");
}
if (outputSimilarity > 0.7 && outputSimilarity > score) {
score = 0.8 + outputSimilarity;
matchedFields.push("outputs");
}
}
// 6. Check description (lower priority)
if (score === 0 && nodeDescription.includes(query)) {
score = 0.5;
matchedFields.push("description");
}
// 7. Check if all query words appear in description
if (score === 0 && queryWords.every(word => nodeDescription.includes(word))) {
score = 0.3;
matchedFields.push("description");
}
return { score, matchedFields };
}

View File

@@ -0,0 +1,21 @@
import { useRef } from "react";
interface UseGraphMenuSearchBarComponentProps {
onSearchChange: (query: string) => void;
}
export const useGraphMenuSearchBarComponent = ({
onSearchChange,
}: UseGraphMenuSearchBarComponentProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const handleClear = () => {
onSearchChange("");
inputRef.current?.focus();
};
return {
inputRef,
handleClear,
};
};

View File

@@ -8,6 +8,9 @@ import { GraphExecutionID } from "@/lib/autogpt-server-api";
import { history } from "@/components/history";
import { ControlPanelButton } from "../ControlPanelButton";
import { ArrowUUpLeftIcon, ArrowUUpRightIcon } from "@phosphor-icons/react";
import { GraphSearchMenu } from "../GraphMenu/GraphMenu";
import { CustomNode } from "@/components/CustomNode";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
export type Control = {
icon: React.ReactNode;
@@ -22,6 +25,9 @@ interface ControlPanelProps {
visualizeBeads: "no" | "static" | "animate";
pinSavePopover: boolean;
pinBlocksPopover: boolean;
nodes: CustomNode[];
onNodeSelect: (nodeId: string) => void;
onNodeHover?: (nodeId: string | null) => void;
}
export const NewControlPanel = ({
@@ -29,8 +35,13 @@ export const NewControlPanel = ({
visualizeBeads,
pinSavePopover,
pinBlocksPopover,
nodes,
onNodeSelect,
onNodeHover,
className,
}: ControlPanelProps) => {
const isGraphSearchEnabled = useGetFlag(Flag.GRAPH_SEARCH);
const {
blockMenuSelected,
setBlockMenuSelected,
@@ -77,6 +88,18 @@ export const NewControlPanel = ({
setBlockMenuSelected={setBlockMenuSelected}
/>
<Separator className="text-[#E1E1E1]" />
{isGraphSearchEnabled && (
<>
<GraphSearchMenu
nodes={nodes}
blockMenuSelected={blockMenuSelected}
setBlockMenuSelected={setBlockMenuSelected}
onNodeSelect={onNodeSelect}
onNodeHover={onNodeHover}
/>
<Separator className="text-[#E1E1E1]" />
</>
)}
{controls.map((control, index) => (
<ControlPanelButton
key={index}

View File

@@ -10,7 +10,7 @@ export interface NewControlPanelProps {
export const useNewControlPanel = ({flowExecutionID, visualizeBeads}: NewControlPanelProps) => {
const [blockMenuSelected, setBlockMenuSelected] = useState<
"save" | "block" | ""
"save" | "block" | "search" | ""
>("");
const query = useSearchParams();
const _graphVersion = query.get("flowVersion");

View File

@@ -23,9 +23,9 @@ interface SaveControlProps {
onDescriptionChange: (description: string) => void;
pinSavePopover: boolean;
blockMenuSelected: "save" | "block" | "";
blockMenuSelected: "save" | "block" | "search" | "";
setBlockMenuSelected: React.Dispatch<
React.SetStateAction<"" | "save" | "block">
React.SetStateAction<"" | "save" | "block" | "search">
>;
}

View File

@@ -48,6 +48,7 @@ import ConnectionLine from "./ConnectionLine";
import { Control, ControlPanel } from "@/components/edit/control/ControlPanel";
import { SaveControl } from "@/components/edit/control/SaveControl";
import { BlocksControl } from "@/components/edit/control/BlocksControl";
import { GraphSearchControl } from "@/components/edit/control/GraphSearchControl";
import { IconUndo2, IconRedo2 } from "@/components/ui/icons";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { startTutorial } from "./tutorial";
@@ -488,6 +489,74 @@ const FlowEditor: React.FC<{
});
}, [nodes, getViewport, setViewport]);
const navigateToNode = useCallback(
(nodeId: string) => {
const node = getNode(nodeId);
if (!node) return;
// Center the viewport on the selected node
const zoom = 1.2; // Slightly zoom in for better visibility
const nodeX = node.position.x + (node.width || 500) / 2;
const nodeY = node.position.y + (node.height || 400) / 2;
setViewport({
x: window.innerWidth / 2 - nodeX * zoom,
y: window.innerHeight / 2 - nodeY * zoom,
zoom: zoom,
});
// Add a temporary highlight effect to the node
updateNode(nodeId, {
style: {
...node.style,
boxShadow: "0 0 20px 5px rgba(59, 130, 246, 0.8)",
transition: "box-shadow 0.3s ease-in-out",
},
});
// Remove highlight after a delay
setTimeout(() => {
updateNode(nodeId, {
style: {
...node.style,
boxShadow: undefined,
},
});
}, 2000);
},
[getNode, setViewport, updateNode],
);
const highlightNode = useCallback(
(nodeId: string | null) => {
if (!nodeId) {
// Clear all highlights
nodes.forEach((node) => {
updateNode(node.id, {
style: {
...node.style,
boxShadow: undefined,
},
});
});
return;
}
const node = getNode(nodeId);
if (!node) return;
// Add highlight effect without moving view
updateNode(nodeId, {
style: {
...node.style,
boxShadow: "0 0 15px 3px rgba(59, 130, 246, 0.6)",
transition: "box-shadow 0.2s ease-in-out",
},
});
},
[getNode, updateNode, nodes],
);
const addNode = useCallback(
(blockId: string, nodeType: string, hardcodedValues: any = {}) => {
const nodeSchema = availableBlocks.find((node) => node.id === blockId);
@@ -682,6 +751,7 @@ const FlowEditor: React.FC<{
}, [isScheduling, savedAgent, toast, saveAgent]);
const isNewBlockEnabled = useGetFlag(Flag.NEW_BLOCK_MENU);
const isGraphSearchEnabled = useGetFlag(Flag.GRAPH_SEARCH);
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
@@ -794,19 +864,31 @@ const FlowEditor: React.FC<{
visualizeBeads={visualizeBeads}
pinSavePopover={pinSavePopover}
pinBlocksPopover={pinBlocksPopover}
nodes={nodes}
onNodeSelect={navigateToNode}
onNodeHover={highlightNode}
/>
) : (
<ControlPanel
className="absolute z-20"
controls={editorControls}
topChildren={
<BlocksControl
pinBlocksPopover={pinBlocksPopover} // Pass the state to BlocksControl
blocks={availableBlocks}
addBlock={addNode}
flows={availableFlows}
nodes={nodes}
/>
<>
<BlocksControl
pinBlocksPopover={pinBlocksPopover} // Pass the state to BlocksControl
blocks={availableBlocks}
addBlock={addNode}
flows={availableFlows}
nodes={nodes}
/>
{isGraphSearchEnabled && (
<GraphSearchControl
nodes={nodes}
onNodeSelect={navigateToNode}
onNodeHover={highlightNode}
/>
)}
</>
}
botChildren={
<SaveControl

View File

@@ -0,0 +1,82 @@
import React from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/atoms/Button/Button";
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { CustomNode } from "@/components/CustomNode";
import { GraphSearchContent } from "../../../app/(platform)/build/components/NewBlockMenu/GraphMenuContent/GraphContent";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useGraphMenu } from "../../../app/(platform)/build/components/NewBlockMenu/GraphMenu/useGraphMenu";
interface GraphSearchControlProps {
nodes: CustomNode[];
onNodeSelect: (nodeId: string) => void;
onNodeHover?: (nodeId: string | null) => void;
}
export function GraphSearchControl({
nodes,
onNodeSelect,
onNodeHover,
}: GraphSearchControlProps) {
// Use the same hook as GraphSearchMenu for consistency
const {
open,
searchQuery,
setSearchQuery,
filteredNodes,
handleNodeSelect,
handleOpenChange,
} = useGraphMenu({
nodes,
blockMenuSelected: "", // We don't need to track this in the old control panel
setBlockMenuSelected: () => {}, // Not needed in this context
onNodeSelect,
});
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
data-id="graph-search-control-trigger"
data-testid="graph-search-control-button"
name="Search"
className="dark:hover:bg-slate-800"
>
<MagnifyingGlassIcon className="h-5 w-5" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right">Search Graph</TooltipContent>
</Tooltip>
<PopoverContent
side="right"
sideOffset={22}
align="start"
alignOffset={-50} // Offset upward to align with control panel top
className="absolute -top-3 w-[17rem] rounded-xl border-none p-0 shadow-none md:w-[30rem]"
data-id="graph-search-popover-content"
>
<GraphSearchContent
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
filteredNodes={filteredNodes}
onNodeSelect={handleNodeSelect}
onNodeHover={onNodeHover}
/>
</PopoverContent>
</Popover>
);
}

View File

@@ -7,6 +7,7 @@ export enum Flag {
AGENT_ACTIVITY = "agent-activity",
NEW_BLOCK_MENU = "new-block-menu",
NEW_AGENT_RUNS = "new-agent-runs",
GRAPH_SEARCH = "graph-search",
}
export type FlagValues = {
@@ -14,6 +15,7 @@ export type FlagValues = {
[Flag.AGENT_ACTIVITY]: boolean;
[Flag.NEW_BLOCK_MENU]: boolean;
[Flag.NEW_AGENT_RUNS]: boolean;
[Flag.GRAPH_SEARCH]: boolean;
};
const isTest = process.env.NEXT_PUBLIC_PW_TEST === "true";
@@ -23,6 +25,7 @@ const mockFlags = {
[Flag.AGENT_ACTIVITY]: true,
[Flag.NEW_BLOCK_MENU]: false,
[Flag.NEW_AGENT_RUNS]: false,
[Flag.GRAPH_SEARCH]: true,
};
export function useGetFlag<T extends Flag>(flag: T): FlagValues[T] | null {