Implement new block menu and improve UI components

The commit refactors the block menu and UI components with improved styling and functionality, including:

- Add new block menu system with search, filters and categories
- Implement dedicated components for different block types
- Create context provider for block menu state management
- Add support for marketplace agents and integrations
- Update control panel styling and layout
- Improve accessibility and user interactions
This commit is contained in:
Abhimanyu Yadav
2025-06-09 16:08:54 +05:30
25 changed files with 1993 additions and 91 deletions

View File

@@ -28,6 +28,7 @@ import "@xyflow/react/dist/style.css";
import { CustomNode } from "./CustomNode";
import "./flow.css";
import {
Block,
BlockUIType,
formatEdgeID,
GraphExecutionID,
@@ -53,6 +54,7 @@ import OttoChatWidget from "@/components/OttoChatWidget";
import { useToast } from "@/components/ui/use-toast";
import { useCopyPaste } from "../hooks/useCopyPaste";
import { CronScheduler } from "./cronScheduler";
import { BlockMenu } from "./builder/block-menu/BlockMenu";
// This is for the history, this is the minimum distance a block must move before it is logged
// It helps to prevent spamming the history with small movements especially when pressing on a input in a block
@@ -103,7 +105,6 @@ const FlowEditor: React.FC<{
setAgentDescription,
savedAgent,
availableNodes,
availableFlows,
getOutputType,
requestSave,
requestSaveAndRun,
@@ -138,6 +139,10 @@ const FlowEditor: React.FC<{
// State to control if save popover should be pinned open
const [pinSavePopover, setPinSavePopover] = useState(false);
const [blockMenuSelected, setBlockMenuSelected] = useState<
"save" | "block" | ""
>("");
const runnerUIRef = useRef<RunnerUIWrapperRef>(null);
const [openCron, setOpenCron] = useState(false);
@@ -471,13 +476,7 @@ const FlowEditor: React.FC<{
}, [nodes, setViewport, x, y]);
const addNode = useCallback(
(blockId: string, nodeType: string, hardcodedValues: any = {}) => {
const nodeSchema = availableNodes.find((node) => node.id === blockId);
if (!nodeSchema) {
console.error(`Schema not found for block ID: ${blockId}`);
return;
}
(block: Block) => {
/*
Calculate a position to the right of the newly added block, allowing for some margin.
If adding to the right side causes the new block to collide with an existing block, attempt to place it at the bottom or left.
@@ -494,7 +493,7 @@ const FlowEditor: React.FC<{
? // we will get all the dimension of nodes, then store
findNewlyAddedBlockCoordinates(
nodeDimensions,
nodeSchema.uiType == BlockUIType.NOTE ? 300 : 500,
block.uiType == BlockUIType.NOTE ? 300 : 500,
60,
1.0,
)
@@ -509,19 +508,19 @@ const FlowEditor: React.FC<{
type: "custom",
position: viewportCoordinates, // Set the position to the calculated viewport center
data: {
blockType: nodeType,
blockCosts: nodeSchema.costs,
title: `${nodeType} ${nodeId}`,
description: nodeSchema.description,
categories: nodeSchema.categories,
inputSchema: nodeSchema.inputSchema,
outputSchema: nodeSchema.outputSchema,
hardcodedValues: hardcodedValues,
blockType: block.name,
blockCosts: block.costs,
title: `${block.name} ${nodeId}`,
description: block.description,
categories: block.categories,
inputSchema: block.inputSchema,
outputSchema: block.outputSchema,
hardcodedValues: block.hardcodedValues || {},
connections: [],
isOutputOpen: false,
block_id: blockId,
isOutputStatic: nodeSchema.staticOutput,
uiType: nodeSchema.uiType,
block_id: block.id,
isOutputStatic: block.staticOutput,
uiType: block.uiType,
},
};
@@ -550,7 +549,6 @@ const FlowEditor: React.FC<{
[
nodeId,
setViewport,
availableNodes,
addNodes,
nodeDimensions,
deleteElements,
@@ -632,12 +630,12 @@ const FlowEditor: React.FC<{
const editorControls: Control[] = [
{
label: "Undo",
icon: <IconUndo2 />,
icon: <IconUndo2 className="h-5 w-5" strokeWidth={2} />,
onClick: handleUndo,
},
{
label: "Redo",
icon: <IconRedo2 />,
icon: <IconRedo2 className="h-5 w-5" strokeWidth={2} />,
onClick: handleRedo,
},
];
@@ -685,15 +683,13 @@ const FlowEditor: React.FC<{
<Controls />
<Background className="dark:bg-slate-800" />
<ControlPanel
className="absolute z-20"
controls={editorControls}
topChildren={
<BlocksControl
pinBlocksPopover={pinBlocksPopover} // Pass the state to BlocksControl
blocks={availableNodes}
addBlock={addNode}
flows={availableFlows}
nodes={nodes}
<BlockMenu
pinBlocksPopover={pinBlocksPopover}
addNode={addNode}
blockMenuSelected={blockMenuSelected}
setBlockMenuSelected={setBlockMenuSelected}
/>
}
botChildren={
@@ -706,6 +702,8 @@ const FlowEditor: React.FC<{
agentName={agentName}
onNameChange={setAgentName}
pinSavePopover={pinSavePopover}
blockMenuSelected={blockMenuSelected}
setBlockMenuSelected={setBlockMenuSelected}
/>
}
></ControlPanel>

View File

@@ -0,0 +1,182 @@
"use client";
import {
Block,
CredentialsProviderName,
LibraryAgent,
Provider,
StoreAgent,
} from "@/lib/autogpt-server-api";
import { createContext, ReactNode, useContext, useState } from "react";
import { convertLibraryAgentIntoBlock } from "@/lib/utils";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
export type SearchItem = Block | Provider | LibraryAgent | StoreAgent;
export type DefaultStateType =
| "suggestion"
| "all_blocks"
| "input_blocks"
| "action_blocks"
| "output_blocks"
| "integrations"
| "marketplace_agents"
| "my_agents";
export type CategoryKey =
| "blocks"
| "integrations"
| "marketplace_agents"
| "my_agents";
export interface Filters {
categories: {
blocks: boolean;
integrations: boolean;
marketplace_agents: boolean;
my_agents: boolean;
providers: boolean;
};
createdBy: string[];
}
export type CategoryCounts = Record<CategoryKey, number>;
interface BlockMenuContextType {
defaultState: DefaultStateType;
setDefaultState: React.Dispatch<React.SetStateAction<DefaultStateType>>;
integration: CredentialsProviderName | null;
setIntegration: React.Dispatch<
React.SetStateAction<CredentialsProviderName | null>
>;
searchQuery: string;
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
searchId: string | undefined;
setSearchId: React.Dispatch<React.SetStateAction<string | undefined>>;
filters: Filters;
setFilters: React.Dispatch<React.SetStateAction<Filters>>;
searchData: SearchItem[];
setSearchData: React.Dispatch<React.SetStateAction<SearchItem[]>>;
categoryCounts: CategoryCounts;
setCategoryCounts: React.Dispatch<React.SetStateAction<CategoryCounts>>;
addNode: (block: Block) => void;
handleAddStoreAgent: ({
creator_name,
slug,
}: {
creator_name: string;
slug: string;
}) => Promise<void>;
loadingSlug: string | null;
setLoadingSlug: React.Dispatch<React.SetStateAction<string | null>>;
}
export const BlockMenuContext = createContext<BlockMenuContextType>(
{} as BlockMenuContextType,
);
interface BlockMenuStateProviderProps {
children: ReactNode;
addNode: (block: Block) => void;
}
export function BlockMenuStateProvider({
children,
addNode,
}: BlockMenuStateProviderProps) {
const [defaultState, setDefaultState] =
useState<DefaultStateType>("suggestion");
const [integration, setIntegration] =
useState<CredentialsProviderName | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [filters, setFilters] = useState<Filters>({
categories: {
blocks: false,
integrations: false,
marketplace_agents: false,
my_agents: false,
providers: false,
},
createdBy: [],
});
const [searchData, setSearchData] = useState<SearchItem[]>([]);
const [searchId, setSearchId] = useState<string | undefined>(undefined);
const [categoryCounts, setCategoryCounts] = useState<CategoryCounts>({
blocks: 0,
integrations: 0,
marketplace_agents: 0,
my_agents: 0,
});
const [loadingSlug, setLoadingSlug] = useState<string | null>(null);
const api = useBackendAPI();
const handleAddStoreAgent = async ({
creator_name,
slug,
}: {
creator_name: string;
slug: string;
}) => {
try {
setLoadingSlug(slug);
const details = await api.getStoreAgent(creator_name, slug);
if (!details.active_version_id) {
console.error(
"Cannot add store agent to library: active version ID is missing or undefined",
);
return;
}
const libraryAgent = await api.addMarketplaceAgentToLibrary(
details.active_version_id,
);
const block = convertLibraryAgentIntoBlock(libraryAgent);
addNode(block);
} catch (error) {
console.error("Failed to add store agent:", error);
} finally {
setLoadingSlug(null);
}
};
return (
<BlockMenuContext.Provider
value={{
defaultState,
setDefaultState,
integration,
setIntegration,
searchQuery,
setSearchQuery,
searchId,
setSearchId,
filters,
setFilters,
searchData,
setSearchData,
categoryCounts,
setCategoryCounts,
addNode,
handleAddStoreAgent,
loadingSlug,
setLoadingSlug,
}}
>
{children}
</BlockMenuContext.Provider>
);
}
export function useBlockMenuContext(): BlockMenuContextType {
const context = useContext(BlockMenuContext);
if (!context) {
throw new Error("Error in context of Block");
}
return context;
}

View File

@@ -0,0 +1,8 @@
import React from "react";
import PaginatedBlocksContent from "./PaginatedBlocksContent";
const ActionBlocksContent: React.FC = () => {
return <PaginatedBlocksContent blockRequest={{ type: "action" }} />;
};
export default ActionBlocksContent;

View File

@@ -0,0 +1,163 @@
import React, { useState, useEffect, Fragment, useCallback } from "react";
import Block from "../Block";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { BlockCategoryResponse } from "@/lib/autogpt-server-api";
import { useBlockMenuContext } from "../block-menu-provider";
import ErrorState from "../ErrorState";
import { beautifyString } from "@/lib/utils";
const AllBlocksContent: React.FC = () => {
const { addNode } = useBlockMenuContext();
const [categories, setCategories] = useState<BlockCategoryResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [loadingCategories, setLoadingCategories] = useState<Set<string>>(
new Set(),
);
const api = useBackendAPI();
const fetchBlocks = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await api.getBlockCategories();
setCategories(response);
} catch (err) {
console.error("Failed to fetch block categories:", err);
setError(
err instanceof Error ? err.message : "Failed to load block categories",
);
} finally {
setLoading(false);
}
}, [api]);
useEffect(() => {
fetchBlocks();
}, [api, fetchBlocks]);
const fetchMoreBlockOfACategory = async (category: string) => {
try {
setLoadingCategories((prev) => new Set(prev).add(category));
const response = await api.getBuilderBlocks({ category: category });
const updatedCategories = categories.map((cat) => {
if (cat.name === category) {
return {
...cat,
blocks: [...response.blocks],
};
}
return cat;
});
setCategories(updatedCategories);
} catch (error) {
console.error(`Failed to fetch blocks for category ${category}:`, error);
} finally {
setLoadingCategories((prev) => {
const newSet = new Set(prev);
newSet.delete(category);
return newSet;
});
}
};
if (loading) {
return (
<div className="scrollbar-thumb-rounded h-full overflow-y-auto pt-4 transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-transparent hover:scrollbar-thumb-zinc-200">
<div className="w-full space-y-3 px-4 pb-4">
{Array.from({ length: 3 }).map((_, categoryIndex) => (
<Fragment key={categoryIndex}>
{categoryIndex > 0 && (
<Skeleton className="my-4 h-[1px] w-full text-zinc-100" />
)}
{[0, 1, 2].map((blockIndex) => (
<Block.Skeleton key={`${categoryIndex}-${blockIndex}`} />
))}
</Fragment>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="h-full p-4">
<ErrorState
title="Failed to load blocks"
error={error}
onRetry={fetchBlocks}
/>
</div>
);
}
return (
<div className="scrollbar-thumb-rounded h-full overflow-y-auto pt-4 transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-transparent hover:scrollbar-thumb-zinc-200">
<div className="w-full space-y-3 px-4 pb-4">
{categories.map((category, index) => (
<Fragment key={category.name}>
{index > 0 && (
<Separator className="h-[1px] w-full text-zinc-300" />
)}
{/* Category Section */}
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
{category.name && beautifyString(category.name)}
</p>
<span className="rounded-full bg-zinc-100 px-[0.375rem] font-sans text-sm leading-[1.375rem] text-zinc-600">
{category.total_blocks}
</span>
</div>
<div className="space-y-2">
{category.blocks.map((block, idx) => (
<Block
key={`${category.name}-${idx}`}
title={block.name}
description={block.name}
onClick={() => {
addNode(block);
}}
/>
))}
{loadingCategories.has(category.name) && (
<>
{[0, 1, 2, 3, 4].map((skeletonIndex) => (
<Block.Skeleton
key={`skeleton-${category.name}-${skeletonIndex}`}
/>
))}
</>
)}
{category.total_blocks > category.blocks.length && (
<Button
variant={"link"}
className="px-0 font-sans text-sm leading-[1.375rem] text-zinc-600 underline hover:text-zinc-800"
disabled={loadingCategories.has(category.name)}
onClick={() => {
fetchMoreBlockOfACategory(category.name);
}}
>
see all
</Button>
)}
</div>
</div>
</Fragment>
))}
</div>
</div>
);
};
export default AllBlocksContent;

View File

@@ -0,0 +1,16 @@
import React from "react";
import BlockMenuSidebar from "./BlockMenuSidebar";
import { Separator } from "@/components/ui/separator";
import BlockMenuDefaultContent from "./BlockMenuDefaultContent";
const BlockMenuDefault: React.FC = () => {
return (
<div className="flex flex-1 overflow-y-auto">
<BlockMenuSidebar />
<Separator className="h-full w-[1px] text-zinc-300" />
<BlockMenuDefaultContent />
</div>
);
};
export default BlockMenuDefault;

View File

@@ -0,0 +1,41 @@
import React from "react";
import SuggestionContent from "./SuggestionContent";
import AllBlocksContent from "./AllBlocksContent";
import IntegrationsContent from "./IntegrationsContent";
import MarketplaceAgentsContent from "./MarketplaceAgentsContent";
import MyAgentsContent from "./MyAgentsContent";
import ActionBlocksContent from "./ActionBlocksContent";
import InputBlocksContent from "./InputBlocksContent";
import OutputBlocksContent from "./OutputBlocksContent";
import { useBlockMenuContext } from "../block-menu-provider";
export interface ActionBlock {
id: number;
title: string;
description: string;
}
export interface BlockListType {
id: number;
title: string;
description: string;
}
const BlockMenuDefaultContent: React.FC = ({}) => {
const { defaultState } = useBlockMenuContext();
return (
<div className="h-full flex-1 overflow-hidden">
{defaultState == "suggestion" && <SuggestionContent />}
{defaultState == "all_blocks" && <AllBlocksContent />}
{defaultState == "input_blocks" && <InputBlocksContent />}
{defaultState == "action_blocks" && <ActionBlocksContent />}
{defaultState == "output_blocks" && <OutputBlocksContent />}
{defaultState == "integrations" && <IntegrationsContent />}
{defaultState == "marketplace_agents" && <MarketplaceAgentsContent />}
{defaultState == "my_agents" && <MyAgentsContent />}
</div>
);
};
export default BlockMenuDefaultContent;

View File

@@ -0,0 +1,119 @@
import React, { useEffect, useState } from "react";
import MenuItem from "../MenuItem";
import { DefaultStateType, useBlockMenuContext } from "../block-menu-provider";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { CountResponse } from "@/lib/autogpt-server-api";
const BlockMenuSidebar: React.FC = ({}) => {
const { defaultState, setDefaultState, setIntegration } =
useBlockMenuContext();
const [blockCounts, setBlockCounts] = useState<CountResponse | undefined>(
undefined,
);
const api = useBackendAPI();
useEffect(() => {
const fetchBlockCounts = async () => {
try {
const counts = await api.getBlockCounts();
setBlockCounts(counts);
} catch (error) {
console.error("Failed to fetch block counts:", error);
}
};
fetchBlockCounts();
}, [api]);
const topLevelMenuItems = [
{
name: "Suggestion",
type: "suggestion",
},
{
name: "All blocks",
type: "all_blocks",
number: blockCounts?.all_blocks,
},
];
const subMenuItems = [
{
name: "Input blocks",
type: "input_blocks",
number: blockCounts?.input_blocks,
},
{
name: "Action blocks",
type: "action_blocks",
number: blockCounts?.action_blocks,
},
{
name: "Output blocks",
type: "output_blocks",
number: blockCounts?.output_blocks,
},
];
const bottomMenuItems = [
{
name: "Integrations",
type: "integrations",
number: blockCounts?.integrations,
onClick: () => {
setIntegration(null);
setDefaultState("integrations");
},
},
{
name: "Marketplace Agents",
type: "marketplace_agents",
number: blockCounts?.marketplace_agents,
},
{
name: "My Agents",
type: "my_agents",
number: blockCounts?.my_agents,
},
];
return (
<div className="w-fit space-y-2 px-4 pt-4">
{topLevelMenuItems.map((item) => (
<MenuItem
key={item.type}
name={item.name}
number={item.number}
selected={defaultState === item.type}
onClick={() => setDefaultState(item.type as DefaultStateType)}
/>
))}
<div className="ml-[0.5365rem] space-y-2 border-l border-black/10 pl-[0.75rem]">
{subMenuItems.map((item) => (
<MenuItem
key={item.type}
name={item.name}
number={item.number}
className="max-w-[11.5339rem]"
selected={defaultState === item.type}
onClick={() => setDefaultState(item.type as DefaultStateType)}
/>
))}
</div>
{bottomMenuItems.map((item) => (
<MenuItem
key={item.type}
name={item.name}
number={item.number}
selected={defaultState === item.type}
onClick={
item.onClick ||
(() => setDefaultState(item.type as DefaultStateType))
}
/>
))}
</div>
);
};
export default BlockMenuSidebar;

View File

@@ -0,0 +1,33 @@
import React from "react";
import Block from "../Block";
import { Block as BlockType } from "@/lib/autogpt-server-api";
import { useBlockMenuContext } from "../block-menu-provider";
interface BlocksListProps {
blocks: BlockType[];
loading?: boolean;
}
const BlocksList: React.FC<BlocksListProps> = ({ blocks, loading = false }) => {
const { addNode } = useBlockMenuContext();
return (
<div className="w-full space-y-3 px-4 pb-4">
{loading
? Array.from({ length: 7 }).map((_, index) => (
<Block.Skeleton key={index} />
))
: blocks.map((block) => (
<Block
key={block.id}
title={block.name}
description={block.description}
onClick={() => {
addNode(block);
}}
/>
))}
</div>
);
};
export default BlocksList;

View File

@@ -0,0 +1,8 @@
import React from "react";
import PaginatedBlocksContent from "./PaginatedBlocksContent";
const InputBlocksContent: React.FC = () => {
return <PaginatedBlocksContent blockRequest={{ type: "input" }} />;
};
export default InputBlocksContent;

View File

@@ -0,0 +1,112 @@
import { Button } from "@/components/ui/button";
import React, { useState, useEffect, Fragment, useCallback } from "react";
import IntegrationBlock from "../IntegrationBlock";
import { useBlockMenuContext } from "../block-menu-provider";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { Block } from "@/lib/autogpt-server-api";
import ErrorState from "../ErrorState";
import { Skeleton } from "@/components/ui/skeleton";
const IntegrationBlocks: React.FC = ({}) => {
const { integration, setIntegration, addNode } = useBlockMenuContext();
const [blocks, setBlocks] = useState<Block[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const api = useBackendAPI();
const fetchBlocks = useCallback(async () => {
if (integration) {
try {
setLoading(true);
setError(null);
const response = await api.getBuilderBlocks({ provider: integration });
setBlocks(response.blocks);
} catch (err) {
console.error("Failed to fetch integration blocks:", err);
setError(
err instanceof Error
? err.message
: "Failed to load integration blocks",
);
} finally {
setLoading(false);
}
}
}, [api, integration]);
useEffect(() => {
fetchBlocks();
}, [api, integration, fetchBlocks]);
if (loading) {
return (
<div className="w-full space-y-3 p-4">
{Array.from({ length: 3 }).map((_, blockIndex) => (
<Fragment key={blockIndex}>
{blockIndex > 0 && (
<Skeleton className="my-4 h-[1px] w-full text-zinc-100" />
)}
{[0, 1, 2].map((index) => (
<IntegrationBlock.Skeleton key={`${blockIndex}-${index}`} />
))}
</Fragment>
))}
</div>
);
}
if (error) {
return (
<div className="h-full p-4">
<ErrorState
title="Failed to load integration blocks"
error={error}
onRetry={fetchBlocks}
/>
</div>
);
}
return (
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<Button
variant={"link"}
className="p-0 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800"
onClick={() => {
setIntegration(null);
}}
>
Integrations
</Button>
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
/
</p>
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
{integration}
</p>
</div>
<span className="flex h-[1.375rem] w-[1.6875rem] items-center justify-center rounded-[1.25rem] bg-[#f0f0f0] p-1.5 font-sans text-sm leading-[1.375rem] text-zinc-500 group-disabled:text-zinc-400">
{blocks.length}
</span>
</div>
<div className="space-y-3">
{blocks.map((block, index) => (
<IntegrationBlock
key={index}
title={block.name}
description={block.description}
icon_url={`/integrations/${integration}.png`}
onClick={() => {
addNode(block);
}}
/>
))}
</div>
</div>
);
};
export default IntegrationBlocks;

View File

@@ -0,0 +1,22 @@
import React from "react";
import PaginatedIntegrationList from "./PaginatedIntegrationList";
import IntegrationBlocks from "./IntegrationBlocks";
import { useBlockMenuContext } from "../block-menu-provider";
const IntegrationsContent: React.FC = () => {
const { integration } = useBlockMenuContext();
if (!integration) {
return <PaginatedIntegrationList />;
}
return (
<div className="scrollbar-thumb-rounded h-full overflow-y-auto pt-4 transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-transparent hover:scrollbar-thumb-zinc-200">
<div className="w-full px-4 pb-4">
<IntegrationBlocks />
</div>
</div>
);
};
export default IntegrationsContent;

View File

@@ -0,0 +1,84 @@
import React from "react";
import MarketplaceAgentBlock from "../MarketplaceAgentBlock";
import { usePagination } from "@/hooks/usePagination";
import ErrorState from "../ErrorState";
import { useBlockMenuContext } from "../block-menu-provider";
const MarketplaceAgentsContent: React.FC = () => {
const {
data: agents,
loading,
loadingMore,
hasMore,
error,
scrollRef,
refresh,
} = usePagination({
request: { apiType: "store-agents" },
pageSize: 10,
});
const { handleAddStoreAgent, loadingSlug } = useBlockMenuContext();
if (loading) {
return (
<div
ref={scrollRef}
className="scrollbar-thumb-rounded h-full overflow-y-auto pt-4 transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-transparent hover:scrollbar-thumb-zinc-200"
>
<div className="w-full space-y-3 px-4 pb-4">
{Array.from({ length: 5 }).map((_, index) => (
<MarketplaceAgentBlock.Skeleton key={index} />
))}
</div>
</div>
);
}
if (error) {
return (
<div className="h-full p-4">
<ErrorState
title="Failed to load marketplace agents"
error={error}
onRetry={refresh}
/>
</div>
);
}
return (
<div
ref={scrollRef}
className="scrollbar-thumb-rounded h-full overflow-y-auto pt-4 transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-transparent hover:scrollbar-thumb-zinc-200"
>
<div className="w-full space-y-3 px-4 pb-4">
{agents.map((agent) => (
<MarketplaceAgentBlock
key={agent.slug}
slug={agent.slug}
title={agent.agent_name}
image_url={agent.agent_image}
creator_name={agent.creator}
number_of_runs={agent.runs}
loading={loadingSlug === agent.slug}
onClick={() =>
handleAddStoreAgent({
creator_name: agent.creator,
slug: agent.slug,
})
}
/>
))}
{loadingMore && hasMore && (
<>
{Array.from({ length: 3 }).map((_, index) => (
<MarketplaceAgentBlock.Skeleton key={`loading-${index}`} />
))}
</>
)}
</div>
</div>
);
};
export default MarketplaceAgentsContent;

View File

@@ -0,0 +1,81 @@
import React from "react";
import UGCAgentBlock from "../UGCAgentBlock";
import { usePagination } from "@/hooks/usePagination";
import ErrorState from "../ErrorState";
import { useBlockMenuContext } from "../block-menu-provider";
import { convertLibraryAgentIntoBlock } from "@/lib/utils";
const MyAgentsContent: React.FC = () => {
const {
data: agents,
loading,
loadingMore,
hasMore,
error,
scrollRef,
refresh,
} = usePagination({
request: { apiType: "library-agents" },
pageSize: 10,
});
const { addNode } = useBlockMenuContext();
if (loading) {
return (
<div
ref={scrollRef}
className="scrollbar-thumb-rounded h-full overflow-y-auto pt-4 transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-transparent hover:scrollbar-thumb-zinc-200"
>
<div className="w-full space-y-3 px-4 pb-4">
{Array.from({ length: 5 }).map((_, index) => (
<UGCAgentBlock.Skeleton key={index} />
))}
</div>
</div>
);
}
if (error) {
return (
<div className="h-full p-4">
<ErrorState
title="Failed to load library agents"
error={error}
onRetry={refresh}
/>
</div>
);
}
return (
<div
ref={scrollRef}
className="scrollbar-thumb-rounded h-full overflow-y-auto pt-4 transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-transparent hover:scrollbar-thumb-zinc-200"
>
<div className="w-full space-y-3 px-4 pb-4">
{agents.map((agent) => (
<UGCAgentBlock
key={agent.id}
title={agent.name}
edited_time={agent.updated_at}
version={agent.graph_version}
image_url={agent.image_url}
onClick={() => {
const block = convertLibraryAgentIntoBlock(agent);
addNode(block);
}}
/>
))}
{loadingMore && hasMore && (
<>
{Array.from({ length: 3 }).map((_, index) => (
<UGCAgentBlock.Skeleton key={`loading-${index}`} />
))}
</>
)}
</div>
</div>
);
};
export default MyAgentsContent;

View File

@@ -0,0 +1,8 @@
import React from "react";
import PaginatedBlocksContent from "./PaginatedBlocksContent";
const OutputBlocksContent: React.FC = () => {
return <PaginatedBlocksContent blockRequest={{ type: "output" }} />;
};
export default OutputBlocksContent;

View File

@@ -0,0 +1,59 @@
import React, { Fragment } from "react";
import BlocksList from "./BlocksList";
import Block from "../Block";
import { BlockRequest } from "@/lib/autogpt-server-api";
import { usePagination } from "@/hooks/usePagination";
import ErrorState from "../ErrorState";
interface PaginatedBlocksContentProps {
blockRequest: BlockRequest;
pageSize?: number;
}
const PaginatedBlocksContent: React.FC<PaginatedBlocksContentProps> = ({
blockRequest,
pageSize = 10,
}) => {
const {
data: blocks,
loading,
loadingMore,
hasMore,
error,
scrollRef,
refresh,
} = usePagination({
request: { apiType: "blocks", ...blockRequest },
pageSize,
});
if (error) {
return (
<div className="h-full w-full px-4 pb-4">
<ErrorState
title="Failed to load blocks"
error={error}
onRetry={refresh}
/>
</div>
);
}
return (
<div
ref={scrollRef}
className="scrollbar-thumb-rounded h-full overflow-y-auto pt-4 transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-transparent hover:scrollbar-thumb-zinc-200"
>
<BlocksList blocks={blocks} loading={loading} />
{loadingMore && hasMore && (
<div className="w-full space-y-3 px-4 pb-4">
{Array.from({ length: 3 }).map((_, index) => (
<Block.Skeleton key={`loading-${index}`} />
))}
</div>
)}
</div>
);
};
export default PaginatedBlocksContent;

View File

@@ -0,0 +1,79 @@
import React from "react";
import Integration from "../Integration";
import { useBlockMenuContext } from "../block-menu-provider";
import { usePagination } from "@/hooks/usePagination";
import ErrorState from "../ErrorState";
const PaginatedIntegrationList: React.FC = () => {
const { setIntegration } = useBlockMenuContext();
const {
data: providers,
loading,
loadingMore,
hasMore,
error,
scrollRef,
refresh,
} = usePagination({
request: { apiType: "providers" },
pageSize: 10,
});
if (loading) {
return (
<div
ref={scrollRef}
className="scrollbar-thumb-rounded h-full overflow-y-auto pt-4 transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-transparent hover:scrollbar-thumb-zinc-200"
>
<div className="w-full space-y-3 px-4 pb-4">
{Array.from({ length: 6 }).map((_, integrationIndex) => (
<Integration.Skeleton key={integrationIndex} />
))}
</div>
</div>
);
}
if (error) {
return (
<div className="h-full p-4">
<ErrorState
title="Failed to load integrations"
error={error}
onRetry={refresh}
/>
</div>
);
}
return (
<div
ref={scrollRef}
className="scrollbar-thumb-rounded h-full overflow-y-auto pt-4 transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-transparent hover:scrollbar-thumb-zinc-200"
>
<div className="w-full px-4 pb-4">
<div className="space-y-3">
{providers.map((integration, index) => (
<Integration
key={index}
title={integration.name}
icon_url={`/integrations/${integration.name}.png`}
description={integration.description}
number_of_blocks={integration.integration_count}
onClick={() => setIntegration(integration.name)}
/>
))}
{loadingMore && hasMore && (
<>
{Array.from({ length: 3 }).map((_, index) => (
<Integration.Skeleton key={`loading-${index}`} />
))}
</>
)}
</div>
</div>
</div>
);
};
export default PaginatedIntegrationList;

View File

@@ -0,0 +1,160 @@
import React, { useCallback, useEffect, useState } from "react";
import SearchHistoryChip from "../SearchHistoryChip";
import IntegrationChip from "../IntegrationChip";
import Block from "../Block";
import { useBlockMenuContext } from "../block-menu-provider";
import {
CredentialsProviderName,
SuggestionsResponse,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import ErrorState from "../ErrorState";
const SuggestionContent: React.FC = () => {
const { setIntegration, setDefaultState, setSearchQuery, addNode } =
useBlockMenuContext();
const [suggestionsData, setSuggestionsData] =
useState<SuggestionsResponse | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const api = useBackendAPI();
const fetchSuggestions = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await api.getSuggestions();
setSuggestionsData(response);
} catch (err) {
console.error("Error fetching data:", err);
setError(
err instanceof Error ? err.message : "Failed to load suggestions",
);
} finally {
setLoading(false);
}
}, [api]);
useEffect(() => {
fetchSuggestions();
}, [api, fetchSuggestions]);
if (error) {
return (
<div className="h-full p-4">
<ErrorState
title="Failed to load suggestions"
error={error}
onRetry={fetchSuggestions}
/>
</div>
);
}
return (
<div className="scrollbar-thumb-rounded h-full overflow-y-auto pt-4 transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-transparent hover:scrollbar-thumb-zinc-200">
<div className="w-full space-y-6 pb-4">
{/* Recent Searches */}
{/* <div className="-mb-2 space-y-2.5">
<p className="px-4 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
Recent searches
</p>
<div className="scrollbar-thumb-rounded flex flex-nowrap gap-2 overflow-x-auto transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-transparent hover:scrollbar-thumb-zinc-200">
{" "}
{!loading && suggestionsData
? suggestionsData.recent_searches.map((search, index) => (
<SearchHistoryChip
key={`search-${index}`}
content={search}
className={index === 0 ? "ml-4" : ""}
onClick={() => setSearchQuery(search)}
/>
))
: Array(3)
.fill(0)
.map((_, index) => (
<SearchHistoryChip.Skeleton
key={`search-${index}`}
className={index === 0 ? "ml-4" : ""}
/>
))}
{!loading && suggestionsData
? suggestionsData.recent_searches.map((search, index) => (
<SearchHistoryChip
key={`search-${index}`}
content={search}
className={index === 0 ? "ml-4" : ""}
onClick={() => setSearchQuery(search)}
/>
))
: Array(3)
.fill(0)
.map((_, index) => (
<SearchHistoryChip.Skeleton
key={`search-${index}`}
className={index === 0 ? "ml-4" : ""}
/>
))}
</div>
</div> */}
{/* Integrations */}
<div className="space-y-2.5 px-4">
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
Integrations
</p>
<div className="grid grid-cols-3 grid-rows-2 gap-2">
{!loading && suggestionsData
? suggestionsData.providers.map((provider, index) => (
<IntegrationChip
key={`integration-${index}`}
icon_url={`/integrations/${provider}.png`}
name={provider}
onClick={() => {
setDefaultState("integrations");
setIntegration(provider as CredentialsProviderName);
}}
/>
))
: Array(6)
.fill(0)
.map((_, index) => (
<IntegrationChip.Skeleton
key={`integration-skeleton-${index}`}
/>
))}
</div>
</div>
{/* Top blocks */}
<div className="space-y-2.5 px-4">
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
Top blocks
</p>
<div className="space-y-2">
{!loading && suggestionsData
? suggestionsData.top_blocks.map((block, index) => (
<Block
key={`block-${index}`}
title={block.name}
description={block.description}
onClick={() => {
addNode(block);
}}
/>
))
: Array(3)
.fill(0)
.map((_, index) => (
<Block.Skeleton key={`block-skeleton-${index}`} />
))}
</div>
</div>
</div>
</div>
);
};
export default SuggestionContent;

View File

@@ -0,0 +1,64 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Plus } from "lucide-react";
import { ButtonHTMLAttributes } from "react";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
title?: string;
description?: string;
ai_name?: string;
}
const AiBlock: React.FC<Props> = ({
title,
description,
className,
ai_name,
...rest
}) => {
return (
<Button
className={cn(
"group flex h-[5.625rem] w-full min-w-[7.5rem] items-center justify-start space-x-3 whitespace-normal rounded-[0.75rem] bg-zinc-50 px-[0.875rem] py-[0.625rem] text-start shadow-none",
"hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300 disabled:pointer-events-none",
)}
{...rest}
>
<div className="flex flex-1 flex-col items-start gap-1.5">
<div className="space-y-0.5">
<span
className={cn(
"line-clamp-1 font-sans text-sm font-medium leading-[1.375rem] text-zinc-700 group-disabled:text-zinc-400",
)}
>
{title}
</span>
<span
className={cn(
"line-clamp-1 font-sans text-xs font-normal leading-5 text-zinc-500 group-disabled:text-zinc-400",
)}
>
{description}
</span>
</div>
<span
className={cn(
"rounded-[0.75rem] bg-zinc-200 px-[0.5rem] font-sans text-xs leading-[1.25rem] text-zinc-500",
)}
>
Supports {ai_name}
</span>
</div>
<div
className={cn(
"flex h-7 w-7 items-center justify-center rounded-[0.5rem] bg-zinc-700 group-disabled:bg-zinc-400",
)}
>
<Plus className="h-5 w-5 text-zinc-50" strokeWidth={2} />
</div>
</Button>
);
};
export default AiBlock;

View File

@@ -0,0 +1,147 @@
import React, { useEffect, useState, useCallback, useRef } from "react";
import FiltersList from "./FiltersList";
import SearchList from "./SearchList";
import { useBlockMenuContext } from "../block-menu-provider";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
const BlockMenuSearch: React.FC = ({}) => {
const {
searchData,
searchQuery,
searchId,
setSearchData,
filters,
setCategoryCounts,
} = useBlockMenuContext();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [hasMore, setHasMore] = useState<boolean>(true);
const [page, setPage] = useState<number>(1);
const [loadingMore, setLoadingMore] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const api = useBackendAPI();
const pageSize = 10;
const fetchSearchData = useCallback(
async (pageNum: number, isLoadMore: boolean = false) => {
if (isLoadMore) {
setLoadingMore(true);
} else {
setIsLoading(true);
}
try {
const activeCategories = Object.entries(filters.categories)
.filter(([_, isActive]) => isActive)
.map(([category, _]) => category)
.map(
(category) =>
category as
| "blocks"
| "integrations"
| "marketplace_agents"
| "my_agents",
);
const response = await api.searchBlocks({
search_query: searchQuery,
search_id: searchId,
page: pageNum,
page_size: pageSize,
filter: activeCategories.length > 0 ? activeCategories : undefined,
by_creator:
filters.createdBy.length > 0 ? filters.createdBy : undefined,
});
setCategoryCounts(response.total_items);
if (isLoadMore) {
setSearchData((prev) => [...prev, ...response.items]);
} else {
setSearchData(response.items);
}
setHasMore(response.more_pages);
setError(null);
} catch (error) {
console.error("Error fetching search data:", error);
setError(
error instanceof Error
? error.message
: "Failed to load search results",
);
if (!isLoadMore) {
setPage(1);
}
} finally {
setIsLoading(false);
setLoadingMore(false);
}
},
[
searchQuery,
searchId,
filters,
api,
setCategoryCounts,
setSearchData,
pageSize,
],
);
const handleScroll = useCallback(() => {
if (!scrollRef.current || loadingMore || !hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
if (scrollTop + clientHeight >= scrollHeight - 100) {
const nextPage = page + 1;
setPage(nextPage);
fetchSearchData(nextPage, true);
}
}, [loadingMore, hasMore, page, fetchSearchData]);
useEffect(() => {
const scrollElement = scrollRef.current;
if (scrollElement) {
scrollElement.addEventListener("scroll", handleScroll);
return () => scrollElement.removeEventListener("scroll", handleScroll);
}
}, [handleScroll]);
useEffect(() => {
if (searchQuery) {
setPage(1);
setHasMore(true);
setError(null);
fetchSearchData(1, false);
} else {
setSearchData([]);
setError(null);
setPage(1);
setHasMore(true);
}
}, [searchQuery, searchId, filters, fetchSearchData, setSearchData]);
return (
<div
ref={scrollRef}
className="scrollbar-thumb-rounded h-full space-y-4 overflow-y-auto py-4 transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-transparent hover:scrollbar-thumb-zinc-200"
>
{searchData.length !== 0 && <FiltersList />}
<SearchList
isLoading={isLoading}
loadingMore={loadingMore}
hasMore={hasMore}
error={error}
onRetry={() => {
setPage(1);
setError(null);
fetchSearchData(1, false);
}}
/>
</div>
);
};
export default BlockMenuSearch;

View File

@@ -0,0 +1,267 @@
import FilterChip from "../FilterChip";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
import { cn, getBlockType } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import {
CategoryKey,
Filters,
useBlockMenuContext,
} from "../block-menu-provider";
import { StoreAgent } from "@/lib/autogpt-server-api";
const INITIAL_CREATORS_TO_SHOW = 5;
export default function FilterSheet({
categories,
}: {
categories: Array<{ key: CategoryKey; name: string }>;
}) {
const { filters, setFilters, searchData } = useBlockMenuContext();
const [isOpen, setIsOpen] = useState(false);
const [isSheetVisible, setIsSheetVisible] = useState(false);
const [localFilters, setLocalFilters] = useState<Filters>(filters);
const [creators, setCreators] = useState<string[]>([]);
const [displayedCreatorsCount, setDisplayedCreatorsCount] = useState(
INITIAL_CREATORS_TO_SHOW,
);
useEffect(() => {
if (isOpen) {
setIsSheetVisible(true);
setLocalFilters(filters);
setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW); // Reset on open
const marketplaceAgents = (searchData?.filter(
(item) => getBlockType(item) === "store_agent",
) || []) as StoreAgent[];
const uniqueCreators = Array.from(
new Set(marketplaceAgents.map((agent) => agent.creator)),
);
setCreators(uniqueCreators);
} else {
const timer = setTimeout(() => {
setIsSheetVisible(false);
}, 300);
return () => clearTimeout(timer);
}
}, [isOpen, filters, searchData]);
const onCategoryChange = useCallback((category: CategoryKey) => {
setLocalFilters((prev) => ({
...prev,
categories: {
...prev.categories,
[category]: !prev.categories[category],
},
}));
}, []);
const onCreatorChange = useCallback((creator: string) => {
setLocalFilters((prev) => {
const updatedCreators = prev.createdBy.includes(creator)
? prev.createdBy.filter((c) => c !== creator)
: [...prev.createdBy, creator];
return {
...prev,
createdBy: updatedCreators,
};
});
}, []);
const handleApplyFilters = useCallback(() => {
setFilters(localFilters);
setIsOpen(false);
}, [localFilters, setFilters]);
const handleClearFilters = useCallback(() => {
const clearedFilters: Filters = {
categories: {
blocks: false,
integrations: false,
marketplace_agents: false,
my_agents: false,
providers: false,
},
createdBy: [],
};
setFilters(clearedFilters);
setIsOpen(false);
}, [setFilters]);
const hasLocalActiveFilters = useCallback(() => {
const hasCategoryFilter = Object.values(localFilters.categories).some(
(value) => value,
);
const hasCreatorFilter = localFilters.createdBy.length > 0;
return hasCategoryFilter || hasCreatorFilter;
}, [localFilters]);
const hasActiveFilters = useCallback(() => {
const hasCategoryFilter = Object.values(filters.categories).some(
(value) => value,
);
const hasCreatorFilter = filters.createdBy.length > 0;
return hasCategoryFilter || hasCreatorFilter;
}, [filters]);
const handleToggleShowMoreCreators = () => {
if (displayedCreatorsCount < creators.length) {
setDisplayedCreatorsCount(creators.length);
} else {
setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
}
};
const visibleCreators = creators.slice(0, displayedCreatorsCount);
return (
<div className="m-0 ml-4 inline w-fit p-0">
<Button
onClick={() => {
setIsSheetVisible(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsOpen(true);
});
});
}}
variant={"link"}
className="m-0 p-0 hover:no-underline"
>
<FilterChip
name={hasActiveFilters() ? "Edit filters" : "All filters"}
/>
</Button>
{isSheetVisible && (
<>
<div
className={cn(
"absolute bottom-2 left-2 top-2 z-20 w-3/4 max-w-[22.5rem] space-y-4 overflow-hidden rounded-[0.75rem] bg-white pb-4 shadow-[0_4px_12px_2px_rgba(0,0,0,0.1)] transition-all",
isOpen
? "translate-x-0 duration-300 ease-out"
: "-translate-x-full duration-300 ease-out",
)}
>
<div
className={cn(
"flex-1 space-y-4 pb-16",
"scrollbar-thumb-rounded h-full overflow-y-auto pt-4 transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-transparent hover:scrollbar-thumb-zinc-200",
)}
>
{/* Top */}
<div className="flex items-center justify-between px-5">
<p className="font-sans text-base text-[#040404]">Filters</p>
<Button
variant="ghost"
size="icon"
onClick={() => setIsOpen(false)}
>
<X className="h-5 w-5" />
</Button>
</div>
<Separator className="h-[1px] w-full text-zinc-300" />
{/* Categories */}
<div className="space-y-4 px-5">
<p className="font-sans text-base font-medium text-zinc-800">
Categories
</p>
<div className="space-y-2">
{categories.map((category) => (
<div
key={category.key}
className="flex items-center space-x-2"
>
<Checkbox
id={category.key}
checked={localFilters.categories[category.key]}
onCheckedChange={() => onCategoryChange(category.key)}
className="border border-[#D4D4D4] shadow-none data-[state=checked]:border-none data-[state=checked]:bg-violet-700 data-[state=checked]:text-white"
/>
<label
htmlFor={category.key}
className="font-sans text-sm leading-[1.375rem] text-zinc-600"
>
{category.name}
</label>
</div>
))}
</div>
</div>
<Separator className="h-[1px] w-full text-zinc-300" />
{/* Created By */}
<div className="space-y-4 px-5">
<p className="font-sans text-base font-medium text-zinc-800">
Created by
</p>
<div className="space-y-2">
{visibleCreators.map((creator) => (
<div key={creator} className="flex items-center space-x-2">
<Checkbox
id={`creator-${creator}`}
checked={localFilters.createdBy.includes(creator)}
onCheckedChange={() => onCreatorChange(creator)}
className="border border-[#D4D4D4] shadow-none data-[state=checked]:border-none data-[state=checked]:bg-violet-700 data-[state=checked]:text-white"
/>
<label
htmlFor={`creator-${creator}`}
className="font-sans text-sm leading-[1.375rem] text-zinc-600"
>
{creator}
</label>
</div>
))}
</div>
{creators.length > INITIAL_CREATORS_TO_SHOW && (
<Button
variant={"link"}
className="m-0 p-0 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800 underline hover:text-zinc-600"
onClick={handleToggleShowMoreCreators}
>
{displayedCreatorsCount < creators.length ? "More" : "Less"}
</Button>
)}
</div>
</div>
{/* Footer buttons */}
<div className="fixed bottom-0 flex w-full justify-between gap-3 border-t border-zinc-300 bg-white px-5 py-3">
<Button
className="min-w-[5rem] rounded-[0.5rem] border-none px-1.5 py-2 font-sans text-sm font-medium leading-[1.375rem] text-zinc-800 shadow-none ring-1 ring-zinc-400"
variant={"outline"}
onClick={handleClearFilters}
>
Clear
</Button>
<Button
className={cn(
"min-w-[6.25rem] rounded-[0.5rem] border-none px-1.5 py-2 font-sans text-sm font-medium leading-[1.375rem] text-white shadow-none ring-1 disabled:ring-0",
)}
onClick={handleApplyFilters}
disabled={!hasLocalActiveFilters()}
>
Apply filters
</Button>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { useCallback } from "react";
import FilterChip from "../FilterChip";
import FilterSheet from "./FilterSheet";
import { CategoryKey, useBlockMenuContext } from "../block-menu-provider";
const FiltersList = () => {
const { filters, setFilters, categoryCounts } = useBlockMenuContext();
const categories: Array<{ key: CategoryKey; name: string }> = [
{ key: "blocks", name: "Blocks" },
{ key: "integrations", name: "Integrations" },
{ key: "marketplace_agents", name: "Marketplace agents" },
{ key: "my_agents", name: "My agents" },
];
const handleCategoryFilter = (category: CategoryKey) => {
setFilters({
...filters,
categories: {
...filters.categories,
[category]: !filters.categories[category],
},
});
};
const handleCreatorFilter = useCallback(
(creator: string) => {
const updatedCreators = filters.createdBy.includes(creator)
? filters.createdBy.filter((c) => c !== creator)
: [...filters.createdBy, creator];
setFilters({
...filters,
createdBy: updatedCreators,
});
},
[filters, setFilters],
);
return (
<div className="flex flex-nowrap gap-3 overflow-x-auto scrollbar-hide">
<FilterSheet categories={categories} />
{filters.createdBy.map((creator) => (
<FilterChip
key={creator}
name={"Created by " + creator}
selected={true}
onClick={() => handleCreatorFilter(creator)}
/>
))}
{categories.map((category) => (
<FilterChip
key={category.key}
name={category.name}
number={categoryCounts[category.key]}
selected={filters.categories[category.key]}
onClick={() => handleCategoryFilter(category.key)}
/>
))}
</div>
);
};
export default FiltersList;

View File

@@ -0,0 +1,19 @@
import { Frown } from "lucide-react";
const NoSearchResult = () => {
return (
<div className="flex h-full w-full flex-col items-center justify-center text-center">
<Frown className="mb-10 h-16 w-16 text-zinc-400" strokeWidth={1} />
<div className="space-y-1">
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
No match found
</p>
<p className="font-sans text-sm font-normal leading-[1.375rem] text-zinc-600">
Try adjusting your search terms
</p>
</div>
</div>
);
};
export default NoSearchResult;

View File

@@ -0,0 +1,173 @@
import React from "react";
import MarketplaceAgentBlock from "../MarketplaceAgentBlock";
import Block from "../Block";
import UGCAgentBlock from "../UGCAgentBlock";
import AiBlock from "./AiBlock";
import IntegrationBlock from "../IntegrationBlock";
import { useBlockMenuContext } from "../block-menu-provider";
import NoSearchResult from "./NoSearchResult";
import { Button } from "@/components/ui/button";
import { convertLibraryAgentIntoBlock, getBlockType } from "@/lib/utils";
interface SearchListProps {
isLoading: boolean;
loadingMore: boolean;
hasMore: boolean;
error: string | null;
onRetry: () => void;
}
const SearchList: React.FC<SearchListProps> = ({
isLoading,
loadingMore,
hasMore,
error,
onRetry,
}) => {
const { searchQuery, addNode, loadingSlug, searchData, handleAddStoreAgent } =
useBlockMenuContext();
if (isLoading) {
return (
<div className="space-y-2.5 px-4">
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
Search results
</p>
{Array(6)
.fill(0)
.map((_, i) => (
<Block.Skeleton key={i} />
))}
</div>
);
}
if (error) {
return (
<div className="px-4">
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
<p className="mb-2 text-sm text-red-600">
Error loading search results: {error}
</p>
<Button
variant="outline"
size="sm"
onClick={onRetry}
className="h-7 text-xs"
>
Retry
</Button>
</div>
</div>
);
}
if (searchData.length === 0) {
return <NoSearchResult />;
}
return (
<div className="space-y-2.5 px-4">
<p className="font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
Search results
</p>
{searchData.map((item: any, index: number) => {
const blockType = getBlockType(item);
switch (blockType) {
case "store_agent":
return (
<MarketplaceAgentBlock
key={index}
slug={item.slug}
highlightedText={searchQuery}
title={item.agent_name}
image_url={item.agent_image}
creator_name={item.creator}
number_of_runs={item.runs}
loading={loadingSlug == item.slug}
onClick={() =>
handleAddStoreAgent({
creator_name: item.creator,
slug: item.slug,
})
}
/>
);
case "block":
return (
<Block
key={index}
title={item.name}
highlightedText={searchQuery}
description={item.description}
onClick={() => {
addNode(item);
}}
/>
);
case "provider":
return (
<IntegrationBlock
key={index}
title={item.name}
highlightedText={searchQuery}
icon_url={`/integrations/${item.name}.png`}
description={item.description}
onClick={() => {
addNode(item);
}}
/>
);
case "library_agent":
return (
<UGCAgentBlock
key={index}
title={item.name}
highlightedText={searchQuery}
image_url={item.image_url}
version={item.graph_version}
edited_time={item.updated_at}
onClick={() => {
const block = convertLibraryAgentIntoBlock(item);
addNode(block);
}}
/>
);
case "ai_agent":
return (
<AiBlock
key={index}
title={item.name}
description={item.description}
ai_name={item.inputSchema.properties.model.enum.find(
(model: string) =>
model
.toLowerCase()
.includes(searchQuery.toLowerCase().trim()),
)}
onClick={() => {
const block = convertLibraryAgentIntoBlock(item);
addNode(block);
}}
/>
);
default:
return null;
}
})}
{loadingMore && hasMore && (
<div className="space-y-2.5">
{Array(3)
.fill(0)
.map((_, i) => (
<Block.Skeleton key={`loading-more-${i}`} />
))}
</div>
)}
</div>
);
};
export default SearchList;

View File

@@ -1,13 +1,7 @@
import { Card, CardContent } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import React from "react";
import ControlPanelButton from "@/components/builder/block-menu/ControlPanelButton";
/**
* Represents a control element for the ControlPanel Component.
@@ -27,6 +21,7 @@ interface ControlPanelProps {
controls: Control[];
topChildren?: React.ReactNode;
botChildren?: React.ReactNode;
className?: string;
}
@@ -45,42 +40,31 @@ export const ControlPanel = ({
className,
}: ControlPanelProps) => {
return (
<Card className={cn("m-4 mt-24 w-14 dark:bg-slate-900", className)}>
<CardContent className="p-0">
<div className="flex flex-col items-center gap-3 rounded-xl py-3">
{topChildren}
<Separator className="dark:bg-slate-700" />
{controls.map((control, index) => (
<Tooltip key={index} delayDuration={500}>
<TooltipTrigger asChild>
<div>
<Button
variant="ghost"
size="icon"
onClick={() => control.onClick()}
data-id={`control-button-${index}`}
data-testid={`blocks-control-${control.label.toLowerCase()}-button`}
disabled={control.disabled || false}
className="dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
>
{control.icon}
<span className="sr-only">{control.label}</span>
</Button>
</div>
</TooltipTrigger>
<TooltipContent
side="right"
className="dark:bg-slate-800 dark:text-slate-100"
>
{control.label}
</TooltipContent>
</Tooltip>
))}
<Separator className="dark:bg-slate-700" />
{botChildren}
</div>
</CardContent>
</Card>
<section
className={cn(
"absolute left-4 top-24 z-10 w-[4.25rem] overflow-hidden rounded-[1rem] border-none bg-white p-0 shadow-[0_1px_5px_0_rgba(0,0,0,0.1)]",
className,
)}
>
<div className="flex flex-col items-center justify-center rounded-[1rem] p-0">
{topChildren}
<Separator className="text-[#E1E1E1]" />
{controls.map((control, index) => (
<ControlPanelButton
key={index}
onClick={() => control.onClick()}
data-id={`control-button-${index}`}
data-testid={`blocks-control-${control.label.toLowerCase()}-button`}
disabled={control.disabled || false}
className="rounded-none"
>
{control.icon}
</ControlPanelButton>
))}
<Separator className="text-[#E1E1E1]" />
{botChildren}
</div>
</section>
);
};
export default ControlPanel;

View File

@@ -16,6 +16,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useToast } from "@/components/ui/use-toast";
import ControlPanelButton from "@/components/builder/block-menu/ControlPanelButton";
interface SaveControlProps {
agentMeta: GraphMeta | null;
@@ -26,6 +27,11 @@ interface SaveControlProps {
onNameChange: (name: string) => void;
onDescriptionChange: (description: string) => void;
pinSavePopover: boolean;
blockMenuSelected: "save" | "block" | "";
setBlockMenuSelected: React.Dispatch<
React.SetStateAction<"" | "save" | "block">
>;
}
/**
@@ -48,6 +54,8 @@ export const SaveControl = ({
onNameChange,
agentDescription,
onDescriptionChange,
blockMenuSelected,
setBlockMenuSelected,
pinSavePopover,
}: SaveControlProps) => {
/**
@@ -82,27 +90,29 @@ export const SaveControl = ({
}, [handleSave, toast]);
return (
<Popover open={pinSavePopover ? true : undefined}>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
data-id="save-control-popover-trigger"
data-testid="blocks-control-save-button"
name="Save"
>
<IconSave className="dark:text-gray-300" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right">Save</TooltipContent>
</Tooltip>
<Popover
open={pinSavePopover ? true : undefined}
onOpenChange={(open) => open || setBlockMenuSelected("")}
>
<PopoverTrigger>
<ControlPanelButton
data-id="save-control-popover-trigger"
data-testid="blocks-control-save-button"
selected={blockMenuSelected === "save"}
onClick={() => {
setBlockMenuSelected("save");
}}
className="rounded-none"
>
<IconSave className="h-5 w-5" strokeWidth={2} />
</ControlPanelButton>
</PopoverTrigger>
<PopoverContent
side="right"
sideOffset={15}
sideOffset={16}
align="start"
className="w-[17rem] rounded-xl border-none p-0 shadow-none md:w-[30rem]"
data-id="save-control-popover-content"
>
<Card className="border-none shadow-none dark:bg-slate-900">