feat(builder): Block menu redesign - part 3 (#10864)

### Changes 🏗️

#### Block Menu Redesign - Part 3

This PR continues the block menu redesign effort, implementing the new
content sections and improving the overall user experience. The changes
focus on better organization, pagination, error handling, and visual
consistency.

#### Key Features Implemented:

**1. New Content Organization**
- **All Blocks Content**: Complete listing of all available blocks with
category-based organization and infinite scroll support
(`AllBlocksContent/`)
- **My Agents Content**: Display and manage user's own agents with
pagination (`MyAgentsContent/`)
- **Marketplace Agents Content**: Browse and add marketplace agents with
improved loading states (`MarketplaceAgentsContent/`)
- **Integration Blocks**: Dedicated view for integration-specific blocks
with better filtering (`IntegrationBlocks/`)
- **Suggestion Content**: Smart suggestions based on user context and
search history (`SuggestionContent/`)
- **Integrations Content**: Browse available integrations in a dedicated
view (`IntegrationsContent/`)

**2. Enhanced UI Components**
- **Paginated Lists**: New pagination components for blocks and
integrations (`PaginatedBlocksContent/`, `PaginatedIntegrationList/`)
- **Block List**: Reusable block list component with consistent styling
(`BlockList/`)
- **Improved Error Handling**: Comprehensive error states with retry
functionality across all content types
- **Loading States**: Skeleton loaders for better perceived performance

**3. Infrastructure Improvements**
- **Centralized Styles**: New `style.ts` file for consistent styling
across components
- **Better State Management**: Enhanced context provider with improved
menu state handling
- **Mock Flag Support**: Added feature flags for testing new block
features
- **Default State Enum**: Refactored to use enums for menu default
states

**4. Visual Assets**
- Added 50+ new integration icons/logos for better visual representation
- Updated existing integration images for consistency

**5. Code Quality**
- Improved error handling with proper error cards and retry mechanisms
- Consistent formatting and import organization
- Enhanced TypeScript types and interfaces
- Better separation of concerns with dedicated hooks for each content
type

#### Technical Details:
- **Files Changed**: 96 files
- **Additions**: 1,380 lines
- **Deletions**: 162 lines
- **New Components**: 10+ new React components with dedicated hooks
- **Integration Icons**: 50+ new PNG images for various integrations

#### Breaking Changes:
None - All changes are backwards compatible

---

### Test Plan 📋

- [x] Create a new agent and verify all blocks are accessible
- [x] Test infinite scroll in "All Blocks" view
- [x] Verify pagination works correctly in marketplace agents view
- [x] Test error states by simulating network failures
- [x] Check that all new integration icons display correctly
- [x] Test adding agents from marketplace view
- [x] Ensure skeleton loaders appear during data fetching

> Generated by claude
This commit is contained in:
Abhimanyu Yadav
2025-09-10 17:28:07 +05:30
committed by GitHub
parent 34fbf4377f
commit 3bbce71678
96 changed files with 1377 additions and 164 deletions

View File

@@ -41,6 +41,26 @@ export default defineConfig({
useInfiniteQueryParam: "page",
},
},
"getV2Get builder blocks": {
query: {
useInfinite: true,
useInfiniteQueryParam: "page",
useQuery: true,
},
},
"getV2Get builder integration providers": {
query: {
useInfinite: true,
useInfiniteQueryParam: "page",
},
},
"getV2List store agents": {
query: {
useInfinite: true,
useInfiniteQueryParam: "page",
useQuery: true,
},
},
},
},
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -60,4 +60,4 @@ export const AiBlock: React.FC<Props> = ({
</div>
</Button>
);
};
};

View File

@@ -0,0 +1,112 @@
import React, { Fragment } from "react";
import { Block } from "../Block";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { beautifyString } from "@/lib/utils";
import { useAllBlockContent } from "./useAllBlockContent";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { blockMenuContainerStyle } from "../style";
export const AllBlocksContent = () => {
const {
data,
isLoading,
isError,
error,
handleRefetchBlocks,
isLoadingMore,
isErrorOnLoadingMore,
} = useAllBlockContent();
if (isLoading) {
return (
<div className={blockMenuContainerStyle}>
{[0, 1, 2, 3, 4].map((skeletonIndex) => (
<Block.Skeleton key={`skeleton-${skeletonIndex}`} />
))}
</div>
);
}
if (isError) {
return (
<div className="h-full p-4">
<ErrorCard
isSuccess={false}
responseError={error || undefined}
httpError={{
status: data?.status,
statusText: "Request failed",
message: (error?.detail as string) || "An error occurred",
}}
context="block menu"
onRetry={() => window.location.reload()}
/>
</div>
);
}
const categories = data?.categories;
return (
<div className={blockMenuContainerStyle}>
{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) => (
<Block
key={`${category.name}-${block.id}`}
title={block.name as string}
description={block.name as string}
/>
))}
{isLoadingMore(category.name) && (
<>
{[0, 1, 2].map((skeletonIndex) => (
<Block.Skeleton
key={`skeleton-${category.name}-${skeletonIndex}`}
/>
))}
</>
)}
{!isErrorOnLoadingMore && (
<ErrorCard
isSuccess={false}
responseError={{ message: "Error loading blocks" }}
context="blocks"
onRetry={() => handleRefetchBlocks(category.name)}
/>
)}
{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={isLoadingMore(category.name)}
onClick={() => handleRefetchBlocks(category.name)}
>
see all
</Button>
)}
</div>
</div>
</Fragment>
))}
</div>
);
};

View File

@@ -0,0 +1,105 @@
import {
getGetV2GetBuilderBlockCategoriesQueryKey,
getV2GetBuilderBlocks,
useGetV2GetBuilderBlockCategories,
} from "@/app/api/__generated__/endpoints/default/default";
import { BlockCategoryResponse } from "@/app/api/__generated__/models/blockCategoryResponse";
import { BlockResponse } from "@/app/api/__generated__/models/blockResponse";
import * as Sentry from "@sentry/nextjs";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { useState } from "react";
import { useToast } from "@/components/molecules/Toast/use-toast";
export const useAllBlockContent = () => {
const { toast } = useToast();
const [loadingCategories, setLoadingCategories] = useState<Set<string>>(
new Set(),
);
const [errorLoadingCategories, setErrorLoadingCategories] = useState<
Set<string>
>(new Set());
const { data, isLoading, isError, error } = useGetV2GetBuilderBlockCategories(
undefined,
{
query: {
select: (x) => {
return {
categories: x.data as BlockCategoryResponse[],
status: x.status,
};
},
},
},
);
const handleRefetchBlocks = async (targetCategory: string) => {
try {
setLoadingCategories((prev) => new Set(prev).add(targetCategory));
// Clear any previous error for this category
setErrorLoadingCategories((prev) => {
const newSet = new Set(prev);
newSet.delete(targetCategory);
return newSet;
});
const response = await getV2GetBuilderBlocks({
category: targetCategory,
});
const result = response.data as BlockResponse;
if (result.blocks) {
const categoriesQueryKey = getGetV2GetBuilderBlockCategoriesQueryKey();
const queryClient = getQueryClient();
queryClient.setQueryData(categoriesQueryKey, (old: any) => {
if (!old?.data) return old;
const categories = old.data as BlockCategoryResponse[];
const updatedCategories = categories.map((old_cat) => {
if (old_cat.name === targetCategory) {
return {
...old_cat,
blocks: result.blocks,
};
}
return old_cat;
});
return {
...old,
data: updatedCategories,
};
});
}
} catch (error) {
Sentry.captureException(error);
setErrorLoadingCategories((prev) => new Set(prev).add(targetCategory));
toast({
title: "Error loading blocks",
description: "Please try again later",
variant: "destructive",
});
} finally {
setLoadingCategories((prev) => {
const newSet = new Set(prev);
newSet.delete(targetCategory);
return newSet;
});
}
};
const isLoadingMore = (categoryName: string) =>
loadingCategories.has(categoryName);
const isErrorOnLoadingMore = (categoryName: string) =>
errorLoadingCategories.has(categoryName);
return {
data,
isLoading,
isError,
error,
handleRefetchBlocks,
isLoadingMore,
isErrorOnLoadingMore,
};
};

View File

@@ -1,10 +1,9 @@
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { beautifyString, cn } from "@/lib/utils";
import { Plus } from "lucide-react";
import React, { ButtonHTMLAttributes } from "react";import { highlightText } from "./helpers";
;
import React, { ButtonHTMLAttributes } from "react";
import { highlightText } from "./helpers";
import { PlusIcon } from "@phosphor-icons/react";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
title?: string;
description?: string;
@@ -56,7 +55,7 @@ export const Block: BlockComponent = ({
"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} />
<PlusIcon className="h-5 w-5 text-zinc-50" />
</div>
</Button>
);
@@ -74,4 +73,4 @@ const BlockSkeleton = () => {
);
};
Block.Skeleton = BlockSkeleton;
Block.Skeleton = BlockSkeleton;

View File

@@ -0,0 +1,35 @@
import React from "react";
import { Block } from "../Block";
import { blockMenuContainerStyle } from "../style";
export interface BlockType {
id: string;
name: string;
description: string;
category?: string;
type?: string;
provider?: string;
}
interface BlocksListProps {
blocks: BlockType[];
loading?: boolean;
}
export const BlocksList: React.FC<BlocksListProps> = ({
blocks,
loading = false,
}) => {
if (loading) {
return (
<div className={blockMenuContainerStyle}>
{Array.from({ length: 7 }).map((_, index) => (
<Block.Skeleton key={index} />
))}
</div>
);
}
return blocks.map((block) => (
<Block key={block.id} title={block.name} description={block.description} />
));
};

View File

@@ -4,11 +4,11 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ToyBrick } from "lucide-react";
import { BlockMenuContent } from "../BlockMenuContent/BlockMenuContent";
import { ControlPanelButton } from "../ControlPanelButton";
import { useBlockMenu } from "./useBlockMenu";
import { BlockMenuStateProvider } from "../block-menu-provider";
import { LegoIcon } from "@phosphor-icons/react";
interface BlockMenuProps {
pinBlocksPopover: boolean;
@@ -23,7 +23,10 @@ export const BlockMenu: React.FC<BlockMenuProps> = ({
blockMenuSelected,
setBlockMenuSelected,
}) => {
const {open, onOpen} = useBlockMenu({pinBlocksPopover, setBlockMenuSelected});
const { open, onOpen } = useBlockMenu({
pinBlocksPopover,
setBlockMenuSelected,
});
return (
<Popover open={pinBlocksPopover ? true : open} onOpenChange={onOpen}>
<PopoverTrigger className="hover:cursor-pointer">
@@ -33,8 +36,8 @@ export const BlockMenu: React.FC<BlockMenuProps> = ({
selected={blockMenuSelected === "block"}
className="rounded-none"
>
{/* Need to find phosphor icon alternative for this lucide icon */}
<ToyBrick className="h-5 w-6" strokeWidth={2} />
{/* Need to find phosphor icon alternative for this lucide icon */}
<LegoIcon className="h-6 w-6" />
</ControlPanelButton>
</PopoverTrigger>
@@ -42,14 +45,13 @@ export const BlockMenu: React.FC<BlockMenuProps> = ({
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)]"
className="absolute h-[80vh] w-[46.625rem] overflow-hidden rounded-[1rem] border-none p-0 shadow-[0_2px_6px_0_rgba(0,0,0,0.05)]"
data-id="blocks-control-popover-content"
>
<BlockMenuStateProvider>
<BlockMenuContent />
</BlockMenuStateProvider>
<BlockMenuStateProvider>
<BlockMenuContent />
</BlockMenuStateProvider>
</PopoverContent>
</Popover>
);
};
};

View File

@@ -1,23 +1,26 @@
import { useState } from "react";
interface useBlockMenuProps {
pinBlocksPopover: boolean;
setBlockMenuSelected: React.Dispatch<
pinBlocksPopover: boolean;
setBlockMenuSelected: React.Dispatch<
React.SetStateAction<"" | "save" | "block" | "search">
>;
}
export const useBlockMenu = ({pinBlocksPopover, setBlockMenuSelected}: useBlockMenuProps) => {
const [open, setOpen] = useState(false);
const onOpen = (newOpen: boolean) => {
if (!pinBlocksPopover) {
setOpen(newOpen);
setBlockMenuSelected(newOpen ? "block" : "");
}
};
export const useBlockMenu = ({
pinBlocksPopover,
setBlockMenuSelected,
}: useBlockMenuProps) => {
const [open, setOpen] = useState(false);
const onOpen = (newOpen: boolean) => {
if (!pinBlocksPopover) {
setOpen(newOpen);
setBlockMenuSelected(newOpen ? "block" : "");
}
};
return {
open,
onOpen,
};
};
return {
open,
onOpen,
};
};

View File

@@ -12,4 +12,4 @@ export const BlockMenuDefault = () => {
<BlockMenuDefaultContent />
</div>
);
};
};

View File

@@ -1,14 +1,33 @@
import { Text } from "@/components/atoms/Text/Text";
import React from "react";
import { DefaultStateType, useBlockMenuContext } from "../block-menu-provider";
import { AllBlocksContent } from "../AllBlocksContent/AllBlocksContent";
import { PaginatedBlocksContent } from "../PaginatedBlocksContent/PaginatedBlocksContent";
import { IntegrationsContent } from "../IntegrationsContent/IntegrationsContent";
import { MarketplaceAgentsContent } from "../MarketplaceAgentsContent/MarketplaceAgentsContent";
import { MyAgentsContent } from "../MyAgentsContent/MyAgentsContent";
import { SuggestionContent } from "../SuggestionContent/SuggestionContent";
export const BlockMenuDefaultContent = () => {
const { defaultState } = useBlockMenuContext();
return (
<div className="h-full flex-1 overflow-hidden flex items-center justify-center">
{/* I have added temporary content here, will fillup it in follow up prs */}
<Text variant="body" className="text-green-300">
This is the block menu default content
</Text>
<div className="h-full flex-1 overflow-hidden">
{defaultState == DefaultStateType.SUGGESTION && <SuggestionContent />}
{defaultState == DefaultStateType.ALL_BLOCKS && <AllBlocksContent />}
{defaultState == DefaultStateType.INPUT_BLOCKS && (
<PaginatedBlocksContent type="input" />
)}
{defaultState == DefaultStateType.ACTION_BLOCKS && (
<PaginatedBlocksContent type="action" />
)}
{defaultState == DefaultStateType.OUTPUT_BLOCKS && (
<PaginatedBlocksContent type="output" />
)}
{defaultState == DefaultStateType.INTEGRATIONS && <IntegrationsContent />}
{defaultState == DefaultStateType.MARKETPLACE_AGENTS && (
<MarketplaceAgentsContent />
)}
{defaultState == DefaultStateType.MY_AGENTS && <MyAgentsContent />}
</div>
);
};
};

View File

@@ -3,10 +3,10 @@ import { Text } from "@/components/atoms/Text/Text";
export const BlockMenuSearch = () => {
return (
// This is just a temporary text, will content inside in it [in follow-up prs]
<div className="flex items-center justify-center h-full w-full">
<div className="flex h-full w-full items-center justify-center">
<Text variant="h3" className="text-green-300">
This is the block menu search
</Text>
</div>
);
};
};

View File

@@ -12,7 +12,13 @@ interface BlockMenuSearchBarProps {
export const BlockMenuSearchBar: React.FC<BlockMenuSearchBarProps> = ({
className = "",
}) => {
const { handleClear, inputRef, localQuery, setLocalQuery, debouncedSetSearchQuery } = useBlockMenuSearchBar();
const {
handleClear,
inputRef,
localQuery,
setLocalQuery,
debouncedSetSearchQuery,
} = useBlockMenuSearchBar();
return (
<div
@@ -22,7 +28,10 @@ export const BlockMenuSearchBar: React.FC<BlockMenuSearchBarProps> = ({
)}
>
<div className="flex h-6 w-6 items-center justify-center">
<MagnifyingGlassIcon className="h-6 w-6 text-zinc-700" strokeWidth={2} />
<MagnifyingGlassIcon
className="h-6 w-6 text-zinc-700"
strokeWidth={2}
/>
</div>
<Input
ref={inputRef}
@@ -50,4 +59,4 @@ export const BlockMenuSearchBar: React.FC<BlockMenuSearchBarProps> = ({
)}
</div>
);
};
};

View File

@@ -1,46 +1,49 @@
import { debounce } from "lodash";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useBlockMenuContext } from "../block-menu-provider";
const SEARCH_DEBOUNCE_MS = 300;
export const useBlockMenuSearchBar = () => {
const inputRef = useRef<HTMLInputElement>(null);
const [localQuery, setLocalQuery] = useState("");
const { setSearchQuery, setSearchId, searchId } = useBlockMenuContext();
const searchIdRef = useRef(searchId);
useEffect(() => {
searchIdRef.current = searchId;
}, [searchId]);
const inputRef = useRef<HTMLInputElement>(null);
const [localQuery, setLocalQuery] = useState("");
const { setSearchQuery, setSearchId, searchId } = useBlockMenuContext();
const debouncedSetSearchQuery = debounce((value: string) => {
setSearchQuery(value);
if (value.length === 0) {
setSearchId(undefined);
} else if (!searchIdRef.current) {
setSearchId(crypto.randomUUID());
}
}, SEARCH_DEBOUNCE_MS);
useEffect(() => {
return () => {
debouncedSetSearchQuery.cancel();
};
}, [debouncedSetSearchQuery]);
const handleClear = () => {
setLocalQuery("");
setSearchQuery("");
setSearchId(undefined);
const searchIdRef = useRef(searchId);
useEffect(() => {
searchIdRef.current = searchId;
}, [searchId]);
const debouncedSetSearchQuery = useCallback(
debounce((value: string) => {
setSearchQuery(value);
if (value.length === 0) {
setSearchId(undefined);
} else if (!searchIdRef.current) {
setSearchId(crypto.randomUUID());
}
}, SEARCH_DEBOUNCE_MS),
[setSearchQuery, setSearchId],
);
useEffect(() => {
return () => {
debouncedSetSearchQuery.cancel();
};
}, [debouncedSetSearchQuery]);
return {
handleClear,
inputRef,
localQuery,
setLocalQuery,
debouncedSetSearchQuery,
}
};
const handleClear = () => {
setLocalQuery("");
setSearchQuery("");
setSearchId(undefined);
debouncedSetSearchQuery.cancel();
};
return {
handleClear,
inputRef,
localQuery,
setLocalQuery,
debouncedSetSearchQuery,
};
};

View File

@@ -1,13 +1,14 @@
import React from "react";
import { MenuItem } from "../MenuItem";
import { DefaultStateType } from "../block-menu-provider";
import { DefaultStateType, useBlockMenuContext } from "../block-menu-provider";
import { useBlockMenuSidebar } from "./useBlockMenuSidebar";
import { Skeleton } from "@/components/ui/skeleton";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
export const BlockMenuSidebar = () => {
const { blockCounts, setDefaultState, defaultState, isLoading, isError, error } = useBlockMenuSidebar();
const { data, setDefaultState, defaultState, isLoading, isError, error } =
useBlockMenuSidebar();
const { setIntegration } = useBlockMenuContext();
if (isLoading) {
return (
<div className="w-fit space-y-2 px-4 pt-4">
@@ -21,11 +22,25 @@ export const BlockMenuSidebar = () => {
);
}
if (isError) {
return <div className="w-fit space-y-2 px-4 pt-4">
<ErrorCard className="w-[12.875rem]" httpError={{status: 500, statusText: "Internal Server Error", message: error?.detail || 'An error occurred'}} />
return (
<div className="w-fit space-y-2 px-4 pt-4">
<ErrorCard
className="w-[12.875rem]"
isSuccess={false}
responseError={error || undefined}
context="block menu"
httpError={{
status: data?.status,
statusText: "Internal Server Error",
message: (error?.detail as string) || "An error occurred",
}}
/>
</div>
);
}
const blockCounts = data?.blockCounts;
const topLevelMenuItems = [
{
name: "Suggestion",
@@ -62,7 +77,8 @@ export const BlockMenuSidebar = () => {
type: "integrations",
number: blockCounts?.integrations,
onClick: () => {
setDefaultState("integrations");
setDefaultState(DefaultStateType.INTEGRATIONS);
setIntegration(undefined);
},
},
{
@@ -114,4 +130,4 @@ export const BlockMenuSidebar = () => {
))}
</div>
);
};
};

View File

@@ -5,20 +5,23 @@ import { CountResponse } from "@/app/api/__generated__/models/countResponse";
export const useBlockMenuSidebar = () => {
const { defaultState, setDefaultState } = useBlockMenuContext();
const { data: blockCounts, isLoading, isError, error} = useGetV2GetBuilderItemCounts({
query : {
select : (x) =>{
return x.data as CountResponse
}
}
const { data, isLoading, isError, error } = useGetV2GetBuilderItemCounts({
query: {
select: (x) => {
return {
blockCounts: x.data as CountResponse,
status: x.status,
};
},
},
});
return {
blockCounts,
data,
setDefaultState,
defaultState,
isLoading,
isError,
error,
}
};
};
};

View File

@@ -32,4 +32,4 @@ export const ControlPanelButton: React.FC<Props> = ({
{children}
</div>
);
};
};

View File

@@ -51,4 +51,4 @@ export const FilterChip: React.FC<Props> = ({
)}
</Button>
);
};
};

View File

@@ -85,4 +85,4 @@ const IntegrationSkeleton: React.FC<{ className?: string }> = ({
);
};
Integration.Skeleton = IntegrationSkeleton;
Integration.Skeleton = IntegrationSkeleton;

View File

@@ -0,0 +1,101 @@
import { Button } from "@/components/ui/button";
import React, { Fragment } from "react";
import { IntegrationBlock } from "../IntergrationBlock";
import { useBlockMenuContext } from "../block-menu-provider";
import { Skeleton } from "@/components/ui/skeleton";
import { useIntegrationBlocks } from "./useIntegrationBlocks";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
export const IntegrationBlocks = () => {
const { integration, setIntegration } = useBlockMenuContext();
const {
allBlocks,
status,
totalBlocks,
blocksLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
error,
refetch,
} = useIntegrationBlocks();
if (blocksLoading) {
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">
<ErrorCard
isSuccess={false}
responseError={error || undefined}
httpError={{
status: status,
statusText: "Request failed",
message: (error?.detail as string) || "An error occurred",
}}
context="block menu"
onRetry={() => refetch()}
/>
</div>
);
}
return (
<InfiniteScroll
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
>
<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(undefined);
}}
>
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">
{totalBlocks}
</span>
</div>
<div className="space-y-3">
{allBlocks.map((block) => (
<IntegrationBlock
key={block.id}
title={block.name}
description={block.description}
icon_url={`/integrations/${integration}.png`}
/>
))}
</div>
</div>
</InfiniteScroll>
);
};

View File

@@ -0,0 +1,61 @@
import { useGetV2GetBuilderBlocksInfinite } from "@/app/api/__generated__/endpoints/default/default";
import { BlockResponse } from "@/app/api/__generated__/models/blockResponse";
import { useBlockMenuContext } from "../block-menu-provider";
const PAGE_SIZE = 10;
export const useIntegrationBlocks = () => {
const { integration } = useBlockMenuContext();
const {
data: blocks,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading: blocksLoading,
error,
refetch,
} = useGetV2GetBuilderBlocksInfinite(
{
page: 1,
page_size: PAGE_SIZE,
provider: integration,
},
{
query: {
getNextPageParam: (lastPage) => {
const pagination = (lastPage.data as BlockResponse).pagination;
const isMore =
pagination.current_page * pagination.page_size <
pagination.total_items;
return isMore ? pagination.current_page + 1 : undefined;
},
},
},
);
const allBlocks =
blocks?.pages?.flatMap((page) => {
const response = page.data as BlockResponse;
return response.blocks;
}) ?? [];
const totalBlocks = blocks?.pages[0]
? (blocks.pages[0].data as BlockResponse).pagination.total_items
: 0;
const status = blocks?.pages[0]?.status;
return {
allBlocks,
totalBlocks,
status,
blocksLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
error,
refetch,
};
};

View File

@@ -57,4 +57,4 @@ const IntegrationChipSkeleton: React.FC = () => {
);
};
IntegrationChip.Skeleton = IntegrationChipSkeleton;
IntegrationChip.Skeleton = IntegrationChipSkeleton;

View File

@@ -0,0 +1,27 @@
import React from "react";
import { useBlockMenuContext } from "../block-menu-provider";
import { scrollbarStyles } from "@/components/styles/scrollbars";
import { IntegrationBlocks } from "../IntegrationBlocks/IntegrationBlocks";
import { PaginatedIntegrationList } from "../PaginatedIntegrationList/PaginatedIntegrationList";
import { cn } from "@/lib/utils";
export const IntegrationsContent = () => {
const { integration } = useBlockMenuContext();
if (!integration) {
return <PaginatedIntegrationList />;
}
return (
<div
className={cn(
scrollbarStyles,
"h-full overflow-y-auto pt-4 transition-all duration-200",
)}
>
<div className="w-full px-4 pb-4">
<IntegrationBlocks />
</div>
</div>
);
};

View File

@@ -1,10 +1,10 @@
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { beautifyString, cn } from "@/lib/utils";
import { Plus } from "lucide-react";
import Image from "next/image";
import React, { ButtonHTMLAttributes } from "react";
import { highlightText } from "./helpers";
import { Button } from "@/components/atoms/Button/Button";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
title?: string;
@@ -17,8 +17,6 @@ interface IntegrationBlockComponent extends React.FC<Props> {
Skeleton: React.FC<{ className?: string }>;
}
export const IntegrationBlock: IntegrationBlockComponent = ({
title,
icon_url,
@@ -29,6 +27,7 @@ export const IntegrationBlock: IntegrationBlockComponent = ({
}) => {
return (
<Button
variant={"ghost"}
className={cn(
"group flex h-16 w-full min-w-[7.5rem] items-center justify-start gap-3 whitespace-normal rounded-[0.75rem] bg-zinc-50 px-[0.875rem] py-[0.625rem] text-start shadow-none",
"hover:cursor-default hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300 disabled:cursor-not-allowed",
@@ -96,4 +95,4 @@ const IntegrationBlockSkeleton = ({ className }: { className?: string }) => {
);
};
IntegrationBlock.Skeleton = IntegrationBlockSkeleton;
IntegrationBlock.Skeleton = IntegrationBlockSkeleton;

View File

@@ -1,11 +1,15 @@
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { ExternalLink, Loader2, Plus } from "lucide-react";
import Image from "next/image";
import React, { ButtonHTMLAttributes } from "react";
import Link from "next/link";
import { highlightText } from "./helpers";
import {
ArrowSquareOutIcon,
CircleNotchIcon,
PlusIcon,
} from "@phosphor-icons/react";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
title?: string;
@@ -89,7 +93,10 @@ export const MarketplaceAgentBlock: MarketplaceAgentBlockComponent = ({
<span className="font-sans text-xs leading-5 text-blue-700 underline">
Agent page
</span>
<ExternalLink className="h-4 w-4 text-blue-700" strokeWidth={1} />
<ArrowSquareOutIcon
className="h-4 w-4 text-blue-700"
strokeWidth={1}
/>
</Link>
</div>
</div>
@@ -99,9 +106,9 @@ export const MarketplaceAgentBlock: MarketplaceAgentBlockComponent = ({
)}
>
{!loading ? (
<Plus className="h-5 w-5 text-zinc-50" strokeWidth={2} />
<PlusIcon className="h-5 w-5 text-zinc-50" strokeWidth={2} />
) : (
<Loader2 className="h-5 w-5 animate-spin" />
<CircleNotchIcon className="h-5 w-5 animate-spin" />
)}
</div>
</Button>
@@ -132,4 +139,4 @@ const MarketplaceAgentBlockSkeleton: React.FC<{ className?: string }> = ({
);
};
MarketplaceAgentBlock.Skeleton = MarketplaceAgentBlockSkeleton;
MarketplaceAgentBlock.Skeleton = MarketplaceAgentBlockSkeleton;

View File

@@ -0,0 +1,88 @@
import React from "react";
import { MarketplaceAgentBlock } from "../MarketplaceAgentBlock";
import { useMarketplaceAgentsContent } from "./useMarketplaceAgentsContent";
import { scrollbarStyles } from "@/components/styles/scrollbars";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { cn } from "@/lib/utils";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
import { blockMenuContainerStyle } from "../style";
export const MarketplaceAgentsContent = () => {
const {
handleAddStoreAgent,
addingAgent,
isListStoreAgentsLoading,
isListStoreAgentsError,
listStoreAgentsError,
listStoreAgents,
refetchListStoreAgents,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useMarketplaceAgentsContent();
if (isListStoreAgentsLoading) {
return (
<div
className={cn(
scrollbarStyles,
"h-full overflow-y-auto pt-4 transition-all duration-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 (isListStoreAgentsError) {
return (
<div className="h-full p-4">
<ErrorCard
isSuccess={false}
context="block menu"
httpError={{
status: status,
statusText: "Request failed",
message:
(listStoreAgentsError?.detail as unknown as string) ||
"An error occurred",
}}
responseError={listStoreAgentsError || undefined}
onRetry={() => refetchListStoreAgents()}
/>
</div>
);
}
return (
<InfiniteScroll
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
className={blockMenuContainerStyle}
>
{listStoreAgents?.map((agent, index) => (
<MarketplaceAgentBlock
key={agent.slug + index}
slug={agent.slug}
title={agent.agent_name}
image_url={agent.agent_image}
creator_name={agent.creator}
number_of_runs={agent.runs}
loading={addingAgent === agent.slug}
onClick={() =>
handleAddStoreAgent({
creator_name: agent.creator,
slug: agent.slug,
})
}
/>
))}
</InfiniteScroll>
);
};

View File

@@ -0,0 +1,121 @@
import { getGetV2GetBuilderItemCountsQueryKey } from "@/app/api/__generated__/endpoints/default/default";
import {
getGetV2ListLibraryAgentsQueryKey,
usePostV2AddMarketplaceAgent,
} from "@/app/api/__generated__/endpoints/library/library";
import {
getV2GetSpecificAgent,
useGetV2ListStoreAgentsInfinite,
} from "@/app/api/__generated__/endpoints/store/store";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { StoreAgentsResponse } from "@/lib/autogpt-server-api";
import { getQueryClient } from "@/lib/react-query/queryClient";
import * as Sentry from "@sentry/nextjs";
import { useState } from "react";
export const useMarketplaceAgentsContent = () => {
const { toast } = useToast();
const [addingAgent, setAddingAgent] = useState<string | null>(null);
const {
data: listStoreAgents,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading: isListStoreAgentsLoading,
isError: isListStoreAgentsError,
error: listStoreAgentsError,
refetch: refetchListStoreAgents,
} = useGetV2ListStoreAgentsInfinite(
{
page: 1,
page_size: 10,
},
{
query: {
getNextPageParam: (lastPage) => {
const pagination = (lastPage.data as StoreAgentsResponse).pagination;
const isMore =
pagination.current_page * pagination.page_size <
pagination.total_items;
return isMore ? pagination.current_page + 1 : undefined;
},
},
},
);
const allAgents =
listStoreAgents?.pages?.flatMap((page) => {
const response = page.data as StoreAgentsResponse;
return response.agents;
}) ?? [];
const status = listStoreAgents?.pages[0]?.status;
const { mutate: addMarketplaceAgent } = usePostV2AddMarketplaceAgent({
mutation: {
onSuccess: () => {
const queryClient = getQueryClient();
queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryAgentsQueryKey(),
});
queryClient.refetchQueries({
queryKey: getGetV2GetBuilderItemCountsQueryKey(),
});
},
},
});
const handleAddStoreAgent = async ({
creator_name,
slug,
}: {
creator_name: string;
slug: string;
}) => {
try {
setAddingAgent(slug);
const { data: agent, status } = await getV2GetSpecificAgent(
creator_name,
slug,
);
if (status !== 200) {
Sentry.captureException("Store listing version not found");
throw new Error("Store listing version not found");
}
addMarketplaceAgent({
data: {
store_listing_version_id: agent?.store_listing_version_id,
},
});
// Need a way to convert the library agent into block
// then add the block in builder
} catch (error) {
Sentry.captureException(error);
toast({
title: "Error",
description: "Failed to add agent to library",
});
} finally {
setAddingAgent(null);
}
};
return {
handleAddStoreAgent,
listStoreAgents: allAgents,
status,
addingAgent,
isListStoreAgentsLoading,
isListStoreAgentsError,
listStoreAgentsError,
refetchListStoreAgents,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
};
};

View File

@@ -30,11 +30,11 @@ export const MenuItem: React.FC<Props> = ({
<span className="truncate font-sans text-sm font-medium leading-[1.375rem] text-zinc-800">
{name}
</span>
{number && (
{number !== undefined && (
<span className="font-sans text-sm font-normal leading-[1.375rem] text-zinc-600">
{number > 100 ? "100+" : number}
</span>
)}
</Button>
);
};
};

View File

@@ -0,0 +1,67 @@
import React from "react";
import { UGCAgentBlock } from "../UGCAgentBlock";
import { useMyAgentsContent } from "./useMyAgentsContent";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
import { blockMenuContainerStyle } from "../style";
export const MyAgentsContent = () => {
const {
allAgents,
agentLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
isError,
error,
status,
refetch,
} = useMyAgentsContent();
if (agentLoading) {
return (
<div className={blockMenuContainerStyle}>
{Array.from({ length: 5 }).map((_, index) => (
<UGCAgentBlock.Skeleton key={index} />
))}
</div>
);
}
if (isError) {
return (
<div className="h-full p-4">
<ErrorCard
isSuccess={false}
context="block menu"
responseError={error || undefined}
httpError={{
status: status,
statusText: "Request failed",
message: (error?.detail as string) || "An error occurred",
}}
onRetry={() => refetch()}
/>
</div>
);
}
return (
<InfiniteScroll
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
className={blockMenuContainerStyle}
>
{allAgents.map((agent) => (
<UGCAgentBlock
key={agent.id}
title={agent.name}
edited_time={agent.updated_at}
version={agent.graph_version}
image_url={agent.image_url}
/>
))}
</InfiniteScroll>
);
};

View File

@@ -0,0 +1,52 @@
import { useGetV2ListLibraryAgentsInfinite } from "@/app/api/__generated__/endpoints/library/library";
import { LibraryAgentResponse } from "@/app/api/__generated__/models/libraryAgentResponse";
export const useMyAgentsContent = () => {
const {
data: agents,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
isLoading: agentLoading,
refetch,
error,
} = useGetV2ListLibraryAgentsInfinite(
{
page: 1,
page_size: 10,
},
{
query: {
getNextPageParam: (lastPage) => {
const pagination = (lastPage.data as LibraryAgentResponse).pagination;
const isMore =
pagination.current_page * pagination.page_size <
pagination.total_items;
return isMore ? pagination.current_page + 1 : undefined;
},
},
},
);
const allAgents =
agents?.pages?.flatMap((page) => {
const response = page.data as LibraryAgentResponse;
return response.agents;
}) ?? [];
const status = agents?.pages[0]?.status;
return {
allAgents,
agentLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
isError,
refetch,
error,
status,
};
};

View File

@@ -41,7 +41,7 @@ export const NewControlPanel = ({
className,
}: ControlPanelProps) => {
const isGraphSearchEnabled = useGetFlag(Flag.GRAPH_SEARCH);
const {
blockMenuSelected,
setBlockMenuSelected,
@@ -71,14 +71,14 @@ export const NewControlPanel = ({
disabled: !history.canRedo(),
},
],
[]
[],
);
return (
<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
className,
)}
>
<div className="flex flex-col items-center justify-center rounded-[1rem] p-0">

View File

@@ -4,11 +4,14 @@ import { useSearchParams } from "next/navigation";
import { useState } from "react";
export interface NewControlPanelProps {
flowExecutionID: GraphExecutionID | undefined;
visualizeBeads: "no" | "static" | "animate";
flowExecutionID: GraphExecutionID | undefined;
visualizeBeads: "no" | "static" | "animate";
}
export const useNewControlPanel = ({flowExecutionID, visualizeBeads}: NewControlPanelProps) => {
export const useNewControlPanel = ({
flowExecutionID,
visualizeBeads,
}: NewControlPanelProps) => {
const [blockMenuSelected, setBlockMenuSelected] = useState<
"save" | "block" | "search" | ""
>("");
@@ -16,8 +19,23 @@ export const useNewControlPanel = ({flowExecutionID, visualizeBeads}: NewControl
const _graphVersion = query.get("flowVersion");
const graphVersion = _graphVersion ? parseInt(_graphVersion) : undefined;
const flowID = query.get("flowID") as GraphID | null ?? undefined;
const {agentDescription, setAgentDescription, saveAgent, agentName, setAgentName, savedAgent, isSaving, isRunning, isStopping} = useAgentGraph(flowID, graphVersion, flowExecutionID, visualizeBeads !== "no")
const flowID = (query.get("flowID") as GraphID | null) ?? undefined;
const {
agentDescription,
setAgentDescription,
saveAgent,
agentName,
setAgentName,
savedAgent,
isSaving,
isRunning,
isStopping,
} = useAgentGraph(
flowID,
graphVersion,
flowExecutionID,
visualizeBeads !== "no",
);
return {
blockMenuSelected,
@@ -31,5 +49,5 @@ export const useNewControlPanel = ({flowExecutionID, visualizeBeads}: NewControl
isSaving,
isRunning,
isStopping,
}
};
};
};

View File

@@ -14,4 +14,4 @@ export const NoSearchResult = () => {
</div>
</div>
);
};
};

View File

@@ -0,0 +1,56 @@
import React from "react";
import { BlocksList } from "../BlockList/BlockList";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
import { usePaginatedBlocks } from "./usePaginatedBlocks";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { blockMenuContainerStyle } from "../style";
interface PaginatedBlocksContentProps {
type?: "all" | "input" | "action" | "output" | null;
}
export const PaginatedBlocksContent: React.FC<PaginatedBlocksContentProps> = ({
type,
}) => {
const {
allBlocks: blocks,
status,
blocksLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
error,
refetch,
} = usePaginatedBlocks({
type,
});
if (error) {
return (
<div className="h-full px-4">
<ErrorCard
isSuccess={false}
httpError={{
status: status,
statusText: "Request failed",
message: (error?.detail as string) || "An error occurred",
}}
responseError={error || undefined}
context="block menu"
onRetry={() => refetch()}
/>
</div>
);
}
return (
<InfiniteScroll
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
className={blockMenuContainerStyle}
>
<BlocksList blocks={blocks} loading={blocksLoading} />
</InfiniteScroll>
);
};

View File

@@ -0,0 +1,56 @@
import { useGetV2GetBuilderBlocksInfinite } from "@/app/api/__generated__/endpoints/default/default";
import { BlockResponse } from "@/app/api/__generated__/models/blockResponse";
interface UsePaginatedBlocksProps {
type?: "all" | "input" | "action" | "output" | null;
}
const PAGE_SIZE = 10;
export const usePaginatedBlocks = ({ type }: UsePaginatedBlocksProps) => {
const {
data: blocks,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading: blocksLoading,
error,
refetch,
} = useGetV2GetBuilderBlocksInfinite(
{
page: 1,
page_size: PAGE_SIZE,
type,
},
{
query: {
getNextPageParam: (lastPage) => {
const pagination = (lastPage.data as BlockResponse).pagination;
const isMore =
pagination.current_page * pagination.page_size <
pagination.total_items;
return isMore ? pagination.current_page + 1 : undefined;
},
},
},
);
const allBlocks =
blocks?.pages?.flatMap((page) => {
const response = page.data as BlockResponse;
return response.blocks;
}) ?? [];
const status = blocks?.pages[0]?.status;
return {
allBlocks,
status,
blocksLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
error,
refetch,
};
};

View File

@@ -0,0 +1,68 @@
import React from "react";
import { Integration } from "../Integration";
import { useBlockMenuContext } from "../block-menu-provider";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
import { usePaginatedIntegrationList } from "./usePaginatedIntegrationList";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { blockMenuContainerStyle } from "../style";
export const PaginatedIntegrationList = () => {
const { setIntegration } = useBlockMenuContext();
const {
allProviders: providers,
providersLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
error,
status,
refetch,
} = usePaginatedIntegrationList();
if (error) {
return (
<div className="h-full px-4">
<ErrorCard
isSuccess={false}
responseError={error || undefined}
context="block menu"
httpError={{
status: status,
statusText: "Request failed",
message: (error?.detail as string) || "An error occurred",
}}
onRetry={() => refetch()}
/>
</div>
);
}
if (providersLoading && providers.length === 0) {
return (
<div className={blockMenuContainerStyle}>
{Array.from({ length: 6 }).map((_, integrationIndex) => (
<Integration.Skeleton key={integrationIndex} />
))}
</div>
);
}
return (
<InfiniteScroll
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
className={blockMenuContainerStyle}
>
{providers.map((integration, index) => (
<Integration
key={integration.name + index}
title={integration.name}
icon_url={`/integrations/${integration.name}.png`}
description={integration.description}
number_of_blocks={integration.integration_count}
onClick={() => setIntegration(integration.name)}
/>
))}
</InfiniteScroll>
);
};

View File

@@ -0,0 +1,52 @@
import { useGetV2GetBuilderIntegrationProvidersInfinite } from "@/app/api/__generated__/endpoints/default/default";
import { ProviderResponse } from "@/app/api/__generated__/models/providerResponse";
const PAGE_SIZE = 10;
export const usePaginatedIntegrationList = () => {
const {
data: providers,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading: providersLoading,
error,
refetch,
} = useGetV2GetBuilderIntegrationProvidersInfinite(
{
page: 1,
page_size: PAGE_SIZE,
},
{
query: {
getNextPageParam: (lastPage: any) => {
const pagination = (lastPage.data as ProviderResponse).pagination;
const isMore =
pagination.current_page * pagination.page_size <
pagination.total_items;
return isMore ? pagination.current_page + 1 : undefined;
},
},
},
);
const allProviders =
providers?.pages?.flatMap((page: any) => {
const response = page.data as ProviderResponse;
return response.providers;
}) ?? [];
const status = providers?.pages[0]?.status;
return {
allProviders,
providersLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
error,
refetch,
status,
};
};

View File

@@ -41,7 +41,6 @@ export const NewSaveControl = ({
setBlockMenuSelected,
pinSavePopover,
}: SaveControlProps) => {
const handleSave = useCallback(() => {
onSave();
}, [onSave]);
@@ -51,8 +50,8 @@ export const NewSaveControl = ({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
event.preventDefault();
handleSave();
event.preventDefault();
handleSave();
toast({
duration: 2000,
title: "All changes saved successfully!",
@@ -155,4 +154,4 @@ export const NewSaveControl = ({
</PopoverContent>
</Popover>
);
};
};

View File

@@ -44,4 +44,4 @@ const SearchHistoryChipSkeleton: React.FC<{ className?: string }> = ({
);
};
SearchHistoryChip.Skeleton = SearchHistoryChipSkeleton;
SearchHistoryChip.Skeleton = SearchHistoryChipSkeleton;

View File

@@ -0,0 +1,88 @@
import React from "react";
import { IntegrationChip } from "../IntegrationChip";
import { Block } from "../Block";
import { DefaultStateType, useBlockMenuContext } from "../block-menu-provider";
import { useSuggestionContent } from "./useSuggestionContent";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { blockMenuContainerStyle } from "../style";
export const SuggestionContent = () => {
const { setIntegration, setDefaultState } = useBlockMenuContext();
const { data, isLoading, isError, error, refetch } = useSuggestionContent();
if (isError) {
return (
<div className="h-full p-4">
<ErrorCard
isSuccess={false}
responseError={error || undefined}
httpError={{
status: data?.status,
statusText: "Request failed",
message: (error?.detail as string) || "An error occurred",
}}
context="block menu"
onRetry={() => refetch()}
/>
</div>
);
}
const suggestions = data?.suggestions;
return (
<div className={blockMenuContainerStyle}>
<div className="w-full space-y-6 pb-4">
{/* 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">
{!isLoading && suggestions
? suggestions.providers.map((provider, index) => (
<IntegrationChip
key={`integration-${index}`}
icon_url={`/integrations/${provider}.png`}
name={provider}
onClick={() => {
setDefaultState(DefaultStateType.INTEGRATIONS);
setIntegration(provider);
}}
/>
))
: 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">
{!isLoading && suggestions
? suggestions.top_blocks.map((block, index) => (
<Block
key={`block-${index}`}
title={block.name}
description={block.description}
/>
))
: Array(3)
.fill(0)
.map((_, index) => (
<Block.Skeleton key={`block-skeleton-${index}`} />
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,18 @@
import { useGetV2GetBuilderSuggestions } from "@/app/api/__generated__/endpoints/default/default";
import { SuggestionsResponse } from "@/app/api/__generated__/models/suggestionsResponse";
export const useSuggestionContent = () => {
const { data, isLoading, isError, error, refetch } =
useGetV2GetBuilderSuggestions({
query: {
select: (x) => {
return {
suggestions: x.data as SuggestionsResponse,
status: x.status,
};
},
},
});
return { data, isLoading, isError, error, refetch };
};

View File

@@ -11,7 +11,7 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
title?: string;
edited_time?: Date;
version?: number;
image_url?: string;
image_url: string | null;
highlightedText?: string;
}
@@ -114,4 +114,4 @@ const UGCAgentBlockSkeleton: React.FC<{ className?: string }> = ({
);
};
UGCAgentBlock.Skeleton = UGCAgentBlockSkeleton;
UGCAgentBlock.Skeleton = UGCAgentBlockSkeleton;

View File

@@ -2,16 +2,16 @@
import { createContext, ReactNode, useContext, useState } from "react";
export type DefaultStateType =
| "suggestion"
| "all_blocks"
| "input_blocks"
| "action_blocks"
| "output_blocks"
| "integrations"
| "marketplace_agents"
| "my_agents";
export enum DefaultStateType {
SUGGESTION = "suggestion",
ALL_BLOCKS = "all_blocks",
INPUT_BLOCKS = "input_blocks",
ACTION_BLOCKS = "action_blocks",
OUTPUT_BLOCKS = "output_blocks",
INTEGRATIONS = "integrations",
MARKETPLACE_AGENTS = "marketplace_agents",
MY_AGENTS = "my_agents",
}
interface BlockMenuContextType {
searchQuery: string;
@@ -20,7 +20,9 @@ interface BlockMenuContextType {
setSearchId: React.Dispatch<React.SetStateAction<string | undefined>>;
defaultState: DefaultStateType;
setDefaultState: React.Dispatch<React.SetStateAction<DefaultStateType>>;
}
integration: string | undefined;
setIntegration: React.Dispatch<React.SetStateAction<string | undefined>>;
}
export const BlockMenuContext = createContext<BlockMenuContextType>(
{} as BlockMenuContextType,
@@ -35,7 +37,10 @@ export function BlockMenuStateProvider({
}: BlockMenuStateProviderProps) {
const [searchQuery, setSearchQuery] = useState("");
const [searchId, setSearchId] = useState<string | undefined>(undefined);
const [defaultState, setDefaultState] = useState<DefaultStateType>("suggestion");
const [defaultState, setDefaultState] = useState<DefaultStateType>(
DefaultStateType.SUGGESTION,
);
const [integration, setIntegration] = useState<string | undefined>(undefined);
return (
<BlockMenuContext.Provider
@@ -46,6 +51,8 @@ export function BlockMenuStateProvider({
setSearchId,
defaultState,
setDefaultState,
integration,
setIntegration,
}}
>
{children}
@@ -61,4 +68,4 @@ export function useBlockMenuContext(): BlockMenuContextType {
);
}
return context;
}
}

View File

@@ -1,22 +1,22 @@
export const highlightText = (
text: string | undefined,
highlight: string | undefined,
) => {
if (!text || !highlight) return text;
function escapeRegExp(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const escaped = escapeRegExp(highlight);
const parts = text.split(new RegExp(`(${escaped})`, "gi"));
return parts.map((part, i) =>
part.toLowerCase() === highlight?.toLowerCase() ? (
<mark key={i} className="bg-transparent font-bold">
{part}
</mark>
) : (
part
),
);
};
text: string | undefined,
highlight: string | undefined,
) => {
if (!text || !highlight) return text;
function escapeRegExp(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const escaped = escapeRegExp(highlight);
const parts = text.split(new RegExp(`(${escaped})`, "gi"));
return parts.map((part, i) =>
part.toLowerCase() === highlight?.toLowerCase() ? (
<mark key={i} className="bg-transparent font-bold">
{part}
</mark>
) : (
part
),
);
};

View File

@@ -0,0 +1,2 @@
export const blockMenuContainerStyle =
"scrollbar-thin scrollbar-thumb-zinc-300 scrollbar-track-transparent w-full px-4 pb-4 space-y-3 h-full overflow-y-auto pt-4 transition-all duration-200";