mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(platform/builder): implement launchdarkly feature flag for block menu redesign (#10667)
I’ve added a new launch darkly flag to toggle between the new and old block menu in the builder. ### Changes 🏗️ - A new flag name `NEW_BLOCK_MENU` has been added. - A new block menu block has been created, which is a normal component. It will be expanded with more components in the future. Currently, it’s just a one-line component. - A new control panel has been created, which improves state localisation and has a new design according to the design files. <img width="1512" height="981" alt="Screenshot 2025-08-18 at 2 49 54 PM" src="https://github.com/user-attachments/assets/3deeefe3-9e42-4178-9cf9-77773ed7e172" /> ### Checklist 📋 - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Everything works perfectly on local.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ToyBrick } from "lucide-react";
|
||||
import { BlockMenuContent } from "../BlockMenuContent/BlockMenuContent";
|
||||
import { ControlPanelButton } from "../ControlButton/ControlPanelButton";
|
||||
import { useBlockMenu } from "./useBlockMenu";
|
||||
|
||||
interface BlockMenuProps {
|
||||
pinBlocksPopover: boolean;
|
||||
blockMenuSelected: "save" | "block" | "";
|
||||
setBlockMenuSelected: React.Dispatch<
|
||||
React.SetStateAction<"" | "save" | "block">
|
||||
>;
|
||||
}
|
||||
|
||||
export const BlockMenu: React.FC<BlockMenuProps> = ({
|
||||
pinBlocksPopover,
|
||||
blockMenuSelected,
|
||||
setBlockMenuSelected,
|
||||
}) => {
|
||||
const {open, onOpen} = useBlockMenu({pinBlocksPopover, setBlockMenuSelected});
|
||||
return (
|
||||
<Popover open={pinBlocksPopover ? true : open} onOpenChange={onOpen}>
|
||||
<PopoverTrigger className="hover:cursor-pointer">
|
||||
<ControlPanelButton
|
||||
data-id="blocks-control-popover-trigger"
|
||||
data-testid="blocks-control-blocks-button"
|
||||
selected={blockMenuSelected === "block"}
|
||||
className="rounded-none"
|
||||
>
|
||||
{/* Need to find phosphor icon alternative for this lucide icon */}
|
||||
<ToyBrick className="h-5 w-6" strokeWidth={2} />
|
||||
</ControlPanelButton>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={16}
|
||||
className="absolute h-[75vh] w-[46.625rem] overflow-hidden rounded-[1rem] border-none p-0 shadow-[0_2px_6px_0_rgba(0,0,0,0.05)]"
|
||||
data-id="blocks-control-popover-content"
|
||||
>
|
||||
<BlockMenuContent />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface useBlockMenuProps {
|
||||
pinBlocksPopover: boolean;
|
||||
setBlockMenuSelected: React.Dispatch<
|
||||
React.SetStateAction<"" | "save" | "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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
|
||||
export const BlockMenuContent = () => {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||
This is the block menu content
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
// BLOCK MENU TODO: We need a disable state in this, currently it's not in design.
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import React from "react";
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
selected?: boolean;
|
||||
children?: React.ReactNode; // For icon purpose
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ControlPanelButton: React.FC<Props> = ({
|
||||
selected = false,
|
||||
children,
|
||||
disabled,
|
||||
className,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
// Using div instead of button, because it's only for design purposes. We are using this to give design to PopoverTrigger.
|
||||
<div
|
||||
role="button"
|
||||
className={cn(
|
||||
"flex h-[4.25rem] w-[4.25rem] items-center justify-center whitespace-normal bg-white p-[1.38rem] text-zinc-800 shadow-none hover:cursor-pointer hover:bg-zinc-100 hover:text-zinc-950 focus:ring-0",
|
||||
selected &&
|
||||
"bg-violet-50 text-violet-700 hover:cursor-default hover:bg-violet-50 hover:text-violet-700 active:bg-violet-50 active:text-violet-700",
|
||||
disabled && "cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import React, { useMemo } from "react";
|
||||
import { BlockMenu } from "../BlockMenu/BlockMenu";
|
||||
import { useNewControlPanel } from "./useNewControlPanel";
|
||||
import { NewSaveControl } from "../SaveControl/NewSaveControl";
|
||||
import { GraphExecutionID } from "@/lib/autogpt-server-api";
|
||||
import { history } from "@/components/history";
|
||||
import { ControlPanelButton } from "../ControlButton/ControlPanelButton";
|
||||
import { ArrowUUpLeftIcon, ArrowUUpRightIcon } from "@phosphor-icons/react";
|
||||
|
||||
export type Control = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
interface ControlPanelProps {
|
||||
className?: string;
|
||||
flowExecutionID: GraphExecutionID | undefined;
|
||||
visualizeBeads: "no" | "static" | "animate";
|
||||
pinSavePopover: boolean;
|
||||
pinBlocksPopover: boolean;
|
||||
}
|
||||
|
||||
export const NewControlPanel = ({
|
||||
flowExecutionID,
|
||||
visualizeBeads,
|
||||
pinSavePopover,
|
||||
pinBlocksPopover,
|
||||
className,
|
||||
}: ControlPanelProps) => {
|
||||
const {
|
||||
blockMenuSelected,
|
||||
setBlockMenuSelected,
|
||||
agentDescription,
|
||||
setAgentDescription,
|
||||
saveAgent,
|
||||
agentName,
|
||||
setAgentName,
|
||||
savedAgent,
|
||||
isSaving,
|
||||
isRunning,
|
||||
isStopping,
|
||||
} = useNewControlPanel({ flowExecutionID, visualizeBeads });
|
||||
|
||||
const controls: Control[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "Undo",
|
||||
icon: <ArrowUUpLeftIcon size={20} weight="bold" />,
|
||||
onClick: history.undo,
|
||||
disabled: !history.canUndo(),
|
||||
},
|
||||
{
|
||||
label: "Redo",
|
||||
icon: <ArrowUUpRightIcon size={20} weight="bold" />,
|
||||
onClick: history.redo,
|
||||
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
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center rounded-[1rem] p-0">
|
||||
<BlockMenu
|
||||
pinBlocksPopover={pinBlocksPopover}
|
||||
blockMenuSelected={blockMenuSelected}
|
||||
setBlockMenuSelected={setBlockMenuSelected}
|
||||
/>
|
||||
<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]" />
|
||||
<NewSaveControl
|
||||
agentMeta={savedAgent}
|
||||
canSave={!isSaving && !isRunning && !isStopping}
|
||||
onSave={saveAgent}
|
||||
agentDescription={agentDescription}
|
||||
onDescriptionChange={setAgentDescription}
|
||||
agentName={agentName}
|
||||
onNameChange={setAgentName}
|
||||
pinSavePopover={pinSavePopover}
|
||||
blockMenuSelected={blockMenuSelected}
|
||||
setBlockMenuSelected={setBlockMenuSelected}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewControlPanel;
|
||||
@@ -0,0 +1,35 @@
|
||||
import useAgentGraph from "@/hooks/useAgentGraph";
|
||||
import { GraphExecutionID, GraphID } from "@/lib/autogpt-server-api";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface NewControlPanelProps {
|
||||
flowExecutionID: GraphExecutionID | undefined;
|
||||
visualizeBeads: "no" | "static" | "animate";
|
||||
}
|
||||
|
||||
export const useNewControlPanel = ({flowExecutionID, visualizeBeads}: NewControlPanelProps) => {
|
||||
const [blockMenuSelected, setBlockMenuSelected] = useState<
|
||||
"save" | "block" | ""
|
||||
>("");
|
||||
const query = useSearchParams();
|
||||
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")
|
||||
|
||||
return {
|
||||
blockMenuSelected,
|
||||
setBlockMenuSelected,
|
||||
agentDescription,
|
||||
setAgentDescription,
|
||||
saveAgent,
|
||||
agentName,
|
||||
setAgentName,
|
||||
savedAgent,
|
||||
isSaving,
|
||||
isRunning,
|
||||
isStopping,
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GraphMeta } from "@/lib/autogpt-server-api";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { IconSave } from "@/components/ui/icons";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { ControlPanelButton } from "../ControlButton/ControlPanelButton";
|
||||
|
||||
interface SaveControlProps {
|
||||
agentMeta: GraphMeta | null;
|
||||
agentName: string;
|
||||
agentDescription: string;
|
||||
canSave: boolean;
|
||||
onSave: () => void;
|
||||
onNameChange: (name: string) => void;
|
||||
onDescriptionChange: (description: string) => void;
|
||||
pinSavePopover: boolean;
|
||||
|
||||
blockMenuSelected: "save" | "block" | "";
|
||||
setBlockMenuSelected: React.Dispatch<
|
||||
React.SetStateAction<"" | "save" | "block">
|
||||
>;
|
||||
}
|
||||
|
||||
export const NewSaveControl = ({
|
||||
agentMeta,
|
||||
canSave,
|
||||
onSave,
|
||||
agentName,
|
||||
onNameChange,
|
||||
agentDescription,
|
||||
onDescriptionChange,
|
||||
blockMenuSelected,
|
||||
setBlockMenuSelected,
|
||||
pinSavePopover,
|
||||
}: SaveControlProps) => {
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave();
|
||||
}, [onSave]);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
|
||||
event.preventDefault();
|
||||
handleSave();
|
||||
toast({
|
||||
duration: 2000,
|
||||
title: "All changes saved successfully!",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleSave, toast]);
|
||||
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
{/* Need to find phosphor icon alternative for this lucide icon */}
|
||||
<IconSave className="h-5 w-5" strokeWidth={2} />
|
||||
</ControlPanelButton>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
side="right"
|
||||
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">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="name" className="dark:text-gray-300">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Enter your agent name"
|
||||
className="col-span-3"
|
||||
value={agentName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
data-id="save-control-name-input"
|
||||
data-testid="save-control-name-input"
|
||||
maxLength={100}
|
||||
/>
|
||||
<Label htmlFor="description" className="dark:text-gray-300">
|
||||
Description
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
placeholder="Your agent description"
|
||||
className="col-span-3"
|
||||
value={agentDescription}
|
||||
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||
data-id="save-control-description-input"
|
||||
data-testid="save-control-description-input"
|
||||
maxLength={500}
|
||||
/>
|
||||
{agentMeta?.version && (
|
||||
<>
|
||||
<Label htmlFor="version" className="dark:text-gray-300">
|
||||
Version
|
||||
</Label>
|
||||
<Input
|
||||
id="version"
|
||||
placeholder="Version"
|
||||
className="col-span-3"
|
||||
value={agentMeta?.version || "-"}
|
||||
disabled
|
||||
data-testid="save-control-version-output"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-stretch gap-2">
|
||||
<Button
|
||||
className="w-full dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
onClick={handleSave}
|
||||
data-id="save-control-save-agent"
|
||||
data-testid="save-control-save-agent-button"
|
||||
disabled={!canSave}
|
||||
>
|
||||
Save Agent
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -57,6 +57,8 @@ import PrimaryActionBar from "@/components/PrimaryActionButton";
|
||||
import OttoChatWidget from "@/components/OttoChatWidget";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useCopyPaste } from "../hooks/useCopyPaste";
|
||||
import NewControlPanel from "@/app/(platform)/build/components/NewBlockMenu/NewControlPanel/NewControlPanel";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
|
||||
// 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
|
||||
@@ -99,6 +101,11 @@ const FlowEditor: React.FC<{
|
||||
const [flowExecutionID, setFlowExecutionID] = useState<
|
||||
GraphExecutionID | undefined
|
||||
>();
|
||||
// State to control if blocks menu should be pinned open
|
||||
const [pinBlocksPopover, setPinBlocksPopover] = useState(false);
|
||||
// State to control if save popover should be pinned open
|
||||
const [pinSavePopover, setPinSavePopover] = useState(false);
|
||||
|
||||
const {
|
||||
agentName,
|
||||
setAgentName,
|
||||
@@ -150,11 +157,6 @@ const FlowEditor: React.FC<{
|
||||
}>({});
|
||||
const isDragging = useRef(false);
|
||||
|
||||
// State to control if blocks menu should be pinned open
|
||||
const [pinBlocksPopover, setPinBlocksPopover] = useState(false);
|
||||
// State to control if save popover should be pinned open
|
||||
const [pinSavePopover, setPinSavePopover] = useState(false);
|
||||
|
||||
const runnerUIRef = useRef<RunnerUIWrapperRef>(null);
|
||||
|
||||
const { toast } = useToast();
|
||||
@@ -674,6 +676,8 @@ const FlowEditor: React.FC<{
|
||||
runnerUIRef.current?.openRunInputDialog();
|
||||
}, [isScheduling, savedAgent, toast, saveAgent]);
|
||||
|
||||
const isNewBlockEnabled = useGetFlag(Flag.NEW_BLOCK_MENU);
|
||||
|
||||
return (
|
||||
<FlowContext.Provider
|
||||
value={{ visualizeBeads, setIsAnyModalOpen, getNextNodeId }}
|
||||
@@ -698,31 +702,41 @@ 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={availableBlocks}
|
||||
addBlock={addNode}
|
||||
flows={availableFlows}
|
||||
nodes={nodes}
|
||||
/>
|
||||
}
|
||||
botChildren={
|
||||
<SaveControl
|
||||
agentMeta={savedAgent}
|
||||
canSave={!isSaving && !isRunning && !isStopping}
|
||||
onSave={saveAgent}
|
||||
agentDescription={agentDescription}
|
||||
onDescriptionChange={setAgentDescription}
|
||||
agentName={agentName}
|
||||
onNameChange={setAgentName}
|
||||
pinSavePopover={pinSavePopover}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{isNewBlockEnabled ? (
|
||||
<NewControlPanel
|
||||
flowExecutionID={flowExecutionID}
|
||||
visualizeBeads={visualizeBeads}
|
||||
pinSavePopover={pinSavePopover}
|
||||
pinBlocksPopover={pinBlocksPopover}
|
||||
/>
|
||||
) : (
|
||||
<ControlPanel
|
||||
className="absolute z-20"
|
||||
controls={editorControls}
|
||||
topChildren={
|
||||
<BlocksControl
|
||||
pinBlocksPopover={pinBlocksPopover} // Pass the state to BlocksControl
|
||||
blocks={availableBlocks}
|
||||
addBlock={addNode}
|
||||
flows={availableFlows}
|
||||
nodes={nodes}
|
||||
/>
|
||||
}
|
||||
botChildren={
|
||||
<SaveControl
|
||||
agentMeta={savedAgent}
|
||||
canSave={!isSaving && !isRunning && !isStopping}
|
||||
onSave={saveAgent}
|
||||
agentDescription={agentDescription}
|
||||
onDescriptionChange={setAgentDescription}
|
||||
agentName={agentName}
|
||||
onNameChange={setAgentName}
|
||||
pinSavePopover={pinSavePopover}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!graphHasWebhookNodes ? (
|
||||
<PrimaryActionBar
|
||||
className="absolute bottom-0 left-1/2 z-20 -translate-x-1/2"
|
||||
|
||||
@@ -3,11 +3,13 @@ import { useFlags } from "launchdarkly-react-client-sdk";
|
||||
export enum Flag {
|
||||
BETA_BLOCKS = "beta-blocks",
|
||||
AGENT_ACTIVITY = "agent-activity",
|
||||
NEW_BLOCK_MENU = "new-block-menu",
|
||||
}
|
||||
|
||||
export type FlagValues = {
|
||||
[Flag.BETA_BLOCKS]: string[];
|
||||
[Flag.AGENT_ACTIVITY]: boolean;
|
||||
[Flag.NEW_BLOCK_MENU]: boolean;
|
||||
};
|
||||
|
||||
const isTest = process.env.NEXT_PUBLIC_PW_TEST === "true";
|
||||
@@ -15,6 +17,7 @@ const isTest = process.env.NEXT_PUBLIC_PW_TEST === "true";
|
||||
const mockFlags = {
|
||||
[Flag.BETA_BLOCKS]: [],
|
||||
[Flag.AGENT_ACTIVITY]: true,
|
||||
[Flag.NEW_BLOCK_MENU]: false, // TODO: change to true when new block menu is ready
|
||||
};
|
||||
|
||||
export function useGetFlag<T extends Flag>(flag: T): FlagValues[T] | null {
|
||||
|
||||
Reference in New Issue
Block a user