feat(frontend): replace legacy builder with new flow editor

(#12081)

### Changes 🏗️

This PR completes the migration from the legacy builder to the new Flow
editor by removing all legacy code and feature flags.

**Removed:**
- Old builder view toggle functionality (`BuilderViewTabs.tsx`)
- Legacy debug panel (`RightSidebar.tsx`)
- Feature flags: `NEW_FLOW_EDITOR` and `BUILDER_VIEW_SWITCH`
- `useBuilderView` hook and related view-switching logic

**Updated:**
- Simplified `build/page.tsx` to always render the new Flow editor
- Added CSS styling (`flow.css`) to properly render Phosphor icons in
React Flow handles

**Tests:**
- Skipped e2e test suite in `build.spec.ts` (legacy builder tests)
- Follow-up PR (#12082) will add new e2e tests for the Flow editor

### Checklist 📋

#### For code changes:

- [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] Create a new flow and verify it loads correctly
    - [x] Add nodes and connections to verify basic functionality works
    - [x] Verify that node handles render correctly with the new CSS
- [x] Check that the UI is clean without the old debug panel or view
toggles

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
This commit is contained in:
Abhimanyu Yadav
2026-02-12 16:46:01 +05:30
committed by GitHub
parent a78145505b
commit d09f1532a4
10 changed files with 61 additions and 262 deletions

View File

@@ -1,31 +0,0 @@
"use client";
import { Tabs, TabsList, TabsTrigger } from "@/components/__legacy__/ui/tabs";
export type BuilderView = "old" | "new";
export function BuilderViewTabs({
value,
onChange,
}: {
value: BuilderView;
onChange: (value: BuilderView) => void;
}) {
return (
<div className="pointer-events-auto fixed right-4 top-20 z-50">
<Tabs
value={value}
onValueChange={(v: string) => onChange(v as BuilderView)}
>
<TabsList className="w-fit bg-zinc-900">
<TabsTrigger value="old" className="text-gray-100">
Old
</TabsTrigger>
<TabsTrigger value="new" className="text-gray-100">
New
</TabsTrigger>
</TabsList>
</Tabs>
</div>
);
}

View File

@@ -23,6 +23,9 @@ import { useCopyPaste } from "./useCopyPaste";
import { useFlow } from "./useFlow";
import { useFlowRealtime } from "./useFlowRealtime";
import "@xyflow/react/dist/style.css";
import "./flow.css";
export const Flow = () => {
const [{ flowID, flowExecutionID }] = useQueryStates({
flowID: parseAsString,

View File

@@ -0,0 +1,9 @@
/* Reset default xyflow handle styles so custom Phosphor icon handles render correctly */
.react-flow__handle {
background: transparent;
width: auto;
height: auto;
border: 0;
position: relative;
transform: none;
}

View File

@@ -1,83 +0,0 @@
import { useMemo } from "react";
import { Link } from "@/app/api/__generated__/models/link";
import { useEdgeStore } from "../stores/edgeStore";
import { useNodeStore } from "../stores/nodeStore";
import { scrollbarStyles } from "@/components/styles/scrollbars";
import { cn } from "@/lib/utils";
import { customEdgeToLink } from "./helper";
export const RightSidebar = () => {
const edges = useEdgeStore((s) => s.edges);
const nodes = useNodeStore((s) => s.nodes);
const backendLinks: Link[] = useMemo(
() => edges.map(customEdgeToLink),
[edges],
);
return (
<div
className={cn(
"flex h-full w-full flex-col border-l border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-900",
scrollbarStyles,
)}
>
<div className="mb-4">
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-200">
Graph Debug Panel
</h2>
</div>
<div className="flex-1 overflow-y-auto">
<h3 className="mb-2 text-sm font-semibold text-slate-700 dark:text-slate-200">
Nodes ({nodes.length})
</h3>
<div className="mb-6 space-y-3">
{nodes.map((n) => (
<div
key={n.id}
className="rounded border p-2 text-xs dark:border-slate-700"
>
<div className="mb-1 font-medium">
#{n.id} {n.data?.title ? ` ${n.data.title}` : ""}
</div>
<div className="text-slate-500 dark:text-slate-400">
hardcodedValues
</div>
<pre className="mt-1 max-h-40 overflow-auto rounded bg-slate-50 p-2 dark:bg-slate-800">
{JSON.stringify(n.data?.hardcodedValues ?? {}, null, 2)}
</pre>
</div>
))}
</div>
<h3 className="mb-2 text-sm font-semibold text-slate-700 dark:text-slate-200">
Links ({backendLinks.length})
</h3>
<div className="mb-6 space-y-3">
{backendLinks.map((l) => (
<div
key={l.id}
className="rounded border p-2 text-xs dark:border-slate-700"
>
<div className="font-medium">
{l.source_id}[{l.source_name}] {l.sink_id}[{l.sink_name}]
</div>
<div className="mt-1 text-slate-500 dark:text-slate-400">
edge.id: {l.id}
</div>
</div>
))}
</div>
<h4 className="mb-2 text-xs font-semibold text-slate-600 dark:text-slate-300">
Backend Links JSON
</h4>
<pre className="max-h-64 overflow-auto rounded bg-slate-50 p-2 text-[11px] dark:bg-slate-800">
{JSON.stringify(backendLinks, null, 2)}
</pre>
</div>
</div>
);
};

View File

@@ -1,64 +1,13 @@
"use client";
import FlowEditor from "@/app/(platform)/build/components/legacy-builder/Flow/Flow";
import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
// import LoadingBox from "@/components/__legacy__/ui/loading";
import { GraphID } from "@/lib/autogpt-server-api/types";
import { ReactFlowProvider } from "@xyflow/react";
import { useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { BuilderViewTabs } from "./components/BuilderViewTabs/BuilderViewTabs";
import { Flow } from "./components/FlowEditor/Flow/Flow";
import { useBuilderView } from "./useBuilderView";
function BuilderContent() {
const query = useSearchParams();
const { completeStep } = useOnboarding();
useEffect(() => {
completeStep("BUILDER_OPEN");
}, [completeStep]);
const _graphVersion = query.get("flowVersion");
const graphVersion = _graphVersion ? parseInt(_graphVersion) : undefined;
return (
<FlowEditor
className="flex h-full w-full"
flowID={(query.get("flowID") as GraphID | null) ?? undefined}
flowVersion={graphVersion}
/>
);
}
export default function BuilderPage() {
const {
isSwitchEnabled,
selectedView,
setSelectedView,
isNewFlowEditorEnabled,
} = useBuilderView();
// Switch is temporary, we will remove it once our new flow editor is ready
if (isSwitchEnabled) {
return (
<div className="relative h-full w-full">
<BuilderViewTabs value={selectedView} onChange={setSelectedView} />
{selectedView === "new" ? (
<ReactFlowProvider>
<Flow />
</ReactFlowProvider>
) : (
<BuilderContent />
)}
</div>
);
}
return isNewFlowEditorEnabled ? (
<ReactFlowProvider>
<Flow />
</ReactFlowProvider>
) : (
<BuilderContent />
return (
<div className="relative h-full w-full">
<ReactFlowProvider>
<Flow />
</ReactFlowProvider>
</div>
);
}

View File

@@ -1,44 +0,0 @@
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo } from "react";
import { BuilderView } from "./components/BuilderViewTabs/BuilderViewTabs";
export function useBuilderView() {
const isNewFlowEditorEnabled = useGetFlag(Flag.NEW_FLOW_EDITOR);
const isBuilderViewSwitchEnabled = useGetFlag(Flag.BUILDER_VIEW_SWITCH);
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const currentView = searchParams.get("view");
const defaultView = "old";
const selectedView = useMemo<BuilderView>(() => {
if (currentView === "new" || currentView === "old") return currentView;
return defaultView;
}, [currentView, defaultView]);
useEffect(() => {
if (isBuilderViewSwitchEnabled === true) {
if (currentView !== "new" && currentView !== "old") {
const params = new URLSearchParams(searchParams);
params.set("view", defaultView);
router.replace(`${pathname}?${params.toString()}`);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isBuilderViewSwitchEnabled, defaultView, pathname, router, searchParams]);
const setSelectedView = (value: BuilderView) => {
const params = new URLSearchParams(searchParams);
params.set("view", value);
router.push(`${pathname}?${params.toString()}`);
};
return {
isSwitchEnabled: isBuilderViewSwitchEnabled === true,
selectedView,
setSelectedView,
isNewFlowEditorEnabled: Boolean(isNewFlowEditorEnabled),
} as const;
}

View File

@@ -10,8 +10,6 @@ export enum Flag {
NEW_AGENT_RUNS = "new-agent-runs",
GRAPH_SEARCH = "graph-search",
ENABLE_ENHANCED_OUTPUT_HANDLING = "enable-enhanced-output-handling",
NEW_FLOW_EDITOR = "new-flow-editor",
BUILDER_VIEW_SWITCH = "builder-view-switch",
SHARE_EXECUTION_RESULTS = "share-execution-results",
AGENT_FAVORITING = "agent-favoriting",
MARKETPLACE_SEARCH_TERMS = "marketplace-search-terms",
@@ -27,8 +25,6 @@ const defaultFlags = {
[Flag.NEW_AGENT_RUNS]: false,
[Flag.GRAPH_SEARCH]: false,
[Flag.ENABLE_ENHANCED_OUTPUT_HANDLING]: false,
[Flag.NEW_FLOW_EDITOR]: false,
[Flag.BUILDER_VIEW_SWITCH]: false,
[Flag.SHARE_EXECUTION_RESULTS]: false,
[Flag.AGENT_FAVORITING]: false,
[Flag.MARKETPLACE_SEARCH_TERMS]: DEFAULT_SEARCH_TERMS,

View File

@@ -11,24 +11,18 @@ test.beforeEach(async ({ page }) => {
const buildPage = new BuildPage(page);
const testUser = await getTestUser();
const { getId } = getSelectors(page);
await page.goto("/login");
await loginPage.login(testUser.email, testUser.password);
await hasUrl(page, "/marketplace");
await page.goto("/build");
await buildPage.closeTutorial();
await buildPage.openBlocksPanel();
const [dictionaryBlock] = await buildPage.getFilteredBlocksFromAPI(
(block) => block.name === "AddToDictionaryBlock",
);
const blockCard = getId(`block-name-${dictionaryBlock.id}`);
await blockCard.click();
const blockInEditor = getId(dictionaryBlock.id).first();
expect(blockInEditor).toBeAttached();
await buildPage.addBlock(dictionaryBlock);
await buildPage.saveAgent("Test Agent", "Test Description");
await test

View File

@@ -1,3 +1,6 @@
// TODO: These tests were written for the old (legacy) builder.
// They need to be updated to work with the new flow editor.
// Note: all the comments with //(number)! are for the docs
//ignore them when reading the code, but if you change something,
//make sure to update the docs! Your autoformmater will break this page,
@@ -12,7 +15,7 @@ import { getTestUser } from "./utils/auth";
// Reason Ignore: admonishment is in the wrong place visually with correct prettier rules
// prettier-ignore
test.describe("Build", () => { //(1)!
test.describe.skip("Build", () => { //(1)!
let buildPage: BuildPage; //(2)!
// Reason Ignore: admonishment is in the wrong place visually with correct prettier rules

View File

@@ -1,7 +1,6 @@
import { expect, Locator, Page } from "@playwright/test";
import { Locator, Page } from "@playwright/test";
import { Block as APIBlock } from "../../lib/autogpt-server-api/types";
import { beautifyString } from "../../lib/utils";
import { isVisible } from "../utils/assertion";
import { BasePage } from "./base.page";
export interface Block {
@@ -27,32 +26,39 @@ export class BuildPage extends BasePage {
try {
await this.page
.getByRole("button", { name: "Skip Tutorial", exact: true })
.click();
} catch (error) {
console.info("Error closing tutorial:", error);
.click({ timeout: 3000 });
} catch (_error) {
console.info("Tutorial not shown or already dismissed");
}
}
async openBlocksPanel(): Promise<void> {
const isPanelOpen = await this.page
.getByTestId("blocks-control-blocks-label")
.isVisible();
const popoverContent = this.page.locator(
'[data-id="blocks-control-popover-content"]',
);
const isPanelOpen = await popoverContent.isVisible();
if (!isPanelOpen) {
await this.page.getByTestId("blocks-control-blocks-button").click();
await popoverContent.waitFor({ state: "visible", timeout: 5000 });
}
}
async closeBlocksPanel(): Promise<void> {
await this.page.getByTestId("profile-popout-menu-trigger").click();
const popoverContent = this.page.locator(
'[data-id="blocks-control-popover-content"]',
);
if (await popoverContent.isVisible()) {
await this.page.getByTestId("blocks-control-blocks-button").click();
}
}
async saveAgent(
name: string = "Test Agent",
description: string = "",
): Promise<void> {
console.log(`💾 Saving agent '${name}' with description '${description}'`);
await this.page.getByTestId("blocks-control-save-button").click();
console.log(`Saving agent '${name}' with description '${description}'`);
await this.page.getByTestId("save-control-save-button").click();
await this.page.getByTestId("save-control-name-input").fill(name);
await this.page
.getByTestId("save-control-description-input")
@@ -107,32 +113,34 @@ export class BuildPage extends BasePage {
await this.openBlocksPanel();
const searchInput = this.page.locator(
'[data-id="blocks-control-search-input"]',
'[data-id="blocks-control-search-bar"] input[type="text"]',
);
const displayName = this.getDisplayName(block.name);
await searchInput.clear();
await searchInput.fill(displayName);
const blockCard = this.page.getByTestId(`block-name-${block.id}`);
const blockCardId = block.id.replace(/[^a-zA-Z0-9]/g, "");
const blockCard = this.page.locator(
`[data-id="block-card-${blockCardId}"]`,
);
try {
// Wait for the block card to be visible with a reasonable timeout
await blockCard.waitFor({ state: "visible", timeout: 10000 });
await blockCard.click();
const blockInEditor = this.page.getByTestId(block.id).first();
expect(blockInEditor).toBeAttached();
} catch (error) {
console.log(
`❌ ❌ Block ${block.name} (display: ${displayName}) returned from the API but not found in block list`,
`Block ${block.name} (display: ${displayName}) returned from the API but not found in block list`,
);
console.log(`Error: ${error}`);
}
}
async hasBlock(block: Block) {
const blockInEditor = this.page.getByTestId(block.id).first();
await blockInEditor.isVisible();
async hasBlock(_block: Block) {
// In the new flow editor, verify a node exists on the canvas
const node = this.page.locator('[data-id^="custom-node-"]').first();
await node.isVisible();
}
async getBlockInputs(blockId: string): Promise<string[]> {
@@ -159,7 +167,7 @@ export class BuildPage extends BasePage {
// Clear any existing search to ensure we see all blocks in the category
const searchInput = this.page.locator(
'[data-id="blocks-control-search-input"]',
'[data-id="blocks-control-search-bar"] input[type="text"]',
);
await searchInput.clear();
@@ -391,13 +399,13 @@ export class BuildPage extends BasePage {
async isRunButtonEnabled(): Promise<boolean> {
console.log(`checking if run button is enabled`);
const runButton = this.page.getByTestId("primary-action-run-agent");
const runButton = this.page.locator('[data-id="run-graph-button"]');
return await runButton.isEnabled();
}
async runAgent(): Promise<void> {
console.log(`clicking run button`);
const runButton = this.page.getByTestId("primary-action-run-agent");
const runButton = this.page.locator('[data-id="run-graph-button"]');
await runButton.click();
await this.page.waitForTimeout(1000);
await runButton.click();
@@ -424,7 +432,7 @@ export class BuildPage extends BasePage {
async waitForSaveButton(): Promise<void> {
console.log(`waiting for save button`);
await this.page.waitForSelector(
'[data-testid="blocks-control-save-button"]:not([disabled])',
'[data-testid="save-control-save-button"]:not([disabled])',
);
}
@@ -526,27 +534,22 @@ export class BuildPage extends BasePage {
async createDummyAgent() {
await this.closeTutorial();
await this.openBlocksPanel();
const dictionaryBlock = await this.getDictionaryBlockDetails();
const searchInput = this.page.locator(
'[data-id="blocks-control-search-input"]',
'[data-id="blocks-control-search-bar"] input[type="text"]',
);
const displayName = this.getDisplayName(dictionaryBlock.name);
await searchInput.clear();
await searchInput.fill("Add to Dictionary");
await isVisible(this.page.getByText("Output"));
await searchInput.fill(displayName);
const blockCard = this.page.getByTestId(`block-name-${dictionaryBlock.id}`);
if (await blockCard.isVisible()) {
const blockCard = this.page.locator('[data-id^="block-card-"]').first();
try {
await blockCard.waitFor({ state: "visible", timeout: 10000 });
await blockCard.click();
const blockInEditor = this.page.getByTestId(dictionaryBlock.id).first();
expect(blockInEditor).toBeAttached();
} catch (error) {
console.log("Could not find Add to Dictionary block:", error);
}
await this.saveAgent("Test Agent", "Test Description");
await expect(this.isRunButtonEnabled()).resolves.toBeTruthy();
}
}