Compare commits

...

8 Commits

Author SHA1 Message Date
Zamil Majdy
577b1de835 test(frontend/builder): add NodeHeader component tests for title display and editing 2026-04-17 21:40:47 +07:00
Zamil Majdy
e47e04c1ac fix(frontend/builder): guard handleTitleEdit no-op save, add getNodeDisplayName tests 2026-04-17 21:19:28 +07:00
Zamil Majdy
0887c7a858 refactor(frontend): DRY up getNodeDisplayName by delegating to getNodeDisplayTitle
getNodeDisplayName duplicated the 3-tier fallback logic already in
getNodeDisplayTitle. Delegate to the canonical helper to keep one
source of truth.
2026-04-17 21:12:04 +07:00
Zamil Majdy
5c72ee8225 fix(frontend/builder): address PR review — extract shared title helper, guard useEffect, add tests
- Extract getNodeDisplayTitle/formatNodeDisplayTitle into CustomNode/helpers.ts
  with 3-tier fallback: customized_name > agent_name+version > block title
- Add isEditingTitle guard to useEffect so edits aren't reset mid-typing
- Extract displayTitle const to remove duplicated ternary in text/tooltip
- Update GraphContent, BuilderChatPanel/helpers, useGraphMenuSearchBar to use
  the agent_name fallback so all title consumers are consistent
- Add Vitest tests covering all 3 tiers and formatting behavior (10 tests)
- Add code comments explaining customized_name vs agent_name distinction
2026-04-17 21:03:50 +07:00
Zamil Majdy
a85925782b Merge remote-tracking branch 'origin/dev' into fix/agent-executor-name-display 2026-04-17 20:57:11 +07:00
Joe Munene
5feee01450 style(frontend/builder): fix Prettier formatting in NodeHeader 2026-04-15 23:04:57 +03:00
Joe Munene
3223cc1ed8 fix(frontend/builder): address review — sync editedTitle state and scope Block suffix removal 2026-04-15 22:45:38 +03:00
Joe Munene
f5ef508334 fix(frontend/builder): preserve agent name in AgentExecutor node title after reload
When an AgentExecutorBlock node was saved and the page reloaded, the
node title reverted to the generic "Agent Executor" because NodeHeader
always used data.title (the block name). This reads agent_name and
graph_version from hardcodedValues (persisted via input_default) and
uses them as the display title, falling back to data.title for
non-agent blocks.

Closes #11041
2026-04-15 22:16:35 +03:00
8 changed files with 347 additions and 19 deletions

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { serializeGraphForChat } from "../helpers";
import { getNodeDisplayName, serializeGraphForChat } from "../helpers";
import type { CustomNode } from "../../FlowEditor/nodes/CustomNode/CustomNode";
describe("serializeGraphForChat XML injection prevention", () => {
@@ -53,3 +53,53 @@ describe("serializeGraphForChat XML injection prevention", () => {
expect(result).toContain("<injection>");
});
});
function makeNode(overrides: Partial<CustomNode["data"]> = {}): CustomNode {
return {
id: "node-1",
data: {
title: "AgentExecutorBlock",
description: "",
hardcodedValues: {},
inputSchema: {},
outputSchema: {},
uiType: "agent",
block_id: "b1",
costs: [],
categories: [],
...overrides,
},
type: "custom" as const,
position: { x: 0, y: 0 },
} as unknown as CustomNode;
}
describe("getNodeDisplayName", () => {
it("returns fallback when node is undefined", () => {
expect(getNodeDisplayName(undefined, "fallback-id")).toBe("fallback-id");
});
it("returns customized_name when set", () => {
const node = makeNode({
metadata: { customized_name: "My Agent" } as any,
});
expect(getNodeDisplayName(node, "fallback")).toBe("My Agent");
});
it("returns agent_name with version via getNodeDisplayTitle delegation", () => {
const node = makeNode({
hardcodedValues: { agent_name: "Researcher", graph_version: 3 },
});
expect(getNodeDisplayName(node, "fallback")).toBe("Researcher v3");
});
it("returns block title when no custom or agent name", () => {
const node = makeNode({ title: "SomeBlock" });
expect(getNodeDisplayName(node, "fallback")).toBe("SomeBlock");
});
it("returns fallback when title is empty", () => {
const node = makeNode({ title: "" });
expect(getNodeDisplayName(node, "fallback")).toBe("fallback");
});
});

View File

@@ -1,5 +1,6 @@
import type { CustomNode } from "../FlowEditor/nodes/CustomNode/CustomNode";
import type { CustomEdge } from "../FlowEditor/edges/CustomEdge";
import { getNodeDisplayTitle } from "../FlowEditor/nodes/CustomNode/helpers";
/** Maximum nodes serialized into the AI context to prevent token overruns. */
const MAX_NODES = 100;
@@ -144,18 +145,16 @@ export function getActionKey(action: GraphAction): string {
/**
* Resolves the display name for a node: prefers the user-customized name,
* falls back to the block title, then to the raw ID.
* then agent name from hardcodedValues, then block title, then fallback ID.
* Delegates to `getNodeDisplayTitle` for the 3-tier resolution logic.
* Shared between `serializeGraphForChat` and `ActionItem` to avoid duplication.
*/
export function getNodeDisplayName(
node: CustomNode | undefined,
fallback: string,
): string {
return (
(node?.data.metadata?.customized_name as string | undefined) ||
node?.data.title ||
fallback
);
if (!node) return fallback;
return getNodeDisplayTitle(node.data) || fallback;
}
/**

View File

@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import { getNodeDisplayTitle, formatNodeDisplayTitle } from "../helpers";
import { CustomNodeData } from "../CustomNode";
function makeNodeData(overrides: Partial<CustomNodeData> = {}): CustomNodeData {
return {
title: "AgentExecutorBlock",
description: "",
hardcodedValues: {},
inputSchema: {},
outputSchema: {},
uiType: "agent",
block_id: "block-1",
costs: [],
categories: [],
...overrides,
} as CustomNodeData;
}
describe("getNodeDisplayTitle", () => {
it("returns customized_name when set (tier 1)", () => {
const data = makeNodeData({
metadata: { customized_name: "My Custom Agent" } as any,
hardcodedValues: { agent_name: "Researcher", graph_version: 2 },
});
expect(getNodeDisplayTitle(data)).toBe("My Custom Agent");
});
it("returns agent_name with version when no customized_name (tier 2)", () => {
const data = makeNodeData({
hardcodedValues: { agent_name: "Researcher", graph_version: 2 },
});
expect(getNodeDisplayTitle(data)).toBe("Researcher v2");
});
it("returns agent_name without version when graph_version is undefined (tier 2)", () => {
const data = makeNodeData({
hardcodedValues: { agent_name: "Researcher" },
});
expect(getNodeDisplayTitle(data)).toBe("Researcher");
});
it("returns agent_name with version 0 (tier 2)", () => {
const data = makeNodeData({
hardcodedValues: { agent_name: "Researcher", graph_version: 0 },
});
expect(getNodeDisplayTitle(data)).toBe("Researcher v0");
});
it("returns generic block title when no custom or agent name (tier 3)", () => {
const data = makeNodeData({ title: "AgentExecutorBlock" });
expect(getNodeDisplayTitle(data)).toBe("AgentExecutorBlock");
});
it("prioritizes customized_name over agent_name", () => {
const data = makeNodeData({
metadata: { customized_name: "Renamed" } as any,
hardcodedValues: { agent_name: "Original Agent", graph_version: 1 },
});
expect(getNodeDisplayTitle(data)).toBe("Renamed");
});
});
describe("formatNodeDisplayTitle", () => {
it("returns custom name as-is without beautifying", () => {
const data = makeNodeData({
metadata: { customized_name: "my_custom_name" } as any,
});
expect(formatNodeDisplayTitle(data)).toBe("my_custom_name");
});
it("returns agent name as-is without beautifying", () => {
const data = makeNodeData({
hardcodedValues: { agent_name: "Blockchain Agent", graph_version: 1 },
});
expect(formatNodeDisplayTitle(data)).toBe("Blockchain Agent v1");
});
it("beautifies generic block title and strips Block suffix", () => {
const data = makeNodeData({ title: "AgentExecutorBlock" });
const result = formatNodeDisplayTitle(data);
expect(result).not.toContain("Block");
expect(result).toBe("Agent Executor");
});
it("does not corrupt agent names containing 'Block'", () => {
const data = makeNodeData({
hardcodedValues: { agent_name: "Blockchain Agent", graph_version: 2 },
});
expect(formatNodeDisplayTitle(data)).toBe("Blockchain Agent v2");
});
});

View File

@@ -6,9 +6,10 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { beautifyString, cn } from "@/lib/utils";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
import { CustomNodeData } from "../CustomNode";
import { formatNodeDisplayTitle, getNodeDisplayTitle } from "../helpers";
import { NodeBadges } from "./NodeBadges";
import { NodeContextMenu } from "./NodeContextMenu";
import { NodeCost } from "./NodeCost";
@@ -21,15 +22,24 @@ type Props = {
export const NodeHeader = ({ data, nodeId }: Props) => {
const updateNodeData = useNodeStore((state) => state.updateNodeData);
const title = (data.metadata?.customized_name as string) || data.title;
const title = getNodeDisplayTitle(data);
const displayTitle = formatNodeDisplayTitle(data);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editedTitle, setEditedTitle] = useState(title);
useEffect(() => {
if (!isEditingTitle) {
setEditedTitle(title);
}
}, [title, isEditingTitle]);
const handleTitleEdit = () => {
updateNodeData(nodeId, {
metadata: { ...data.metadata, customized_name: editedTitle },
});
if (editedTitle !== title) {
updateNodeData(nodeId, {
metadata: { ...data.metadata, customized_name: editedTitle },
});
}
setIsEditingTitle(false);
};
@@ -72,12 +82,12 @@ export const NodeHeader = ({ data, nodeId }: Props) => {
variant="large-semibold"
className="line-clamp-1 hover:cursor-text"
>
{beautifyString(title).replace("Block", "").trim()}
{displayTitle}
</Text>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{beautifyString(title).replace("Block", "").trim()}</p>
<p>{displayTitle}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@/tests/integrations/test-utils";
import { NodeHeader } from "../NodeHeader";
import { CustomNodeData } from "../../CustomNode";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
vi.mock("../NodeCost", () => ({
NodeCost: () => <div data-testid="node-cost" />,
}));
vi.mock("../NodeContextMenu", () => ({
NodeContextMenu: () => <div data-testid="node-context-menu" />,
}));
vi.mock("../NodeBadges", () => ({
NodeBadges: () => <div data-testid="node-badges" />,
}));
function makeData(overrides: Partial<CustomNodeData> = {}): CustomNodeData {
return {
title: "AgentExecutorBlock",
description: "",
hardcodedValues: {},
inputSchema: {},
outputSchema: {},
uiType: "agent",
block_id: "block-1",
costs: [],
categories: [],
...overrides,
} as CustomNodeData;
}
describe("NodeHeader", () => {
const mockUpdateNodeData = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
useNodeStore.setState({ updateNodeData: mockUpdateNodeData } as any);
});
it("renders beautified generic block title", () => {
render(<NodeHeader data={makeData()} nodeId="abc-123" />);
expect(screen.getByText("Agent Executor")).toBeTruthy();
});
it("renders agent name with version from hardcodedValues", () => {
const data = makeData({
hardcodedValues: { agent_name: "Researcher", graph_version: 2 },
});
render(<NodeHeader data={data} nodeId="abc-123" />);
expect(screen.getByText("Researcher v2")).toBeTruthy();
});
it("renders customized_name over agent name", () => {
const data = makeData({
metadata: { customized_name: "My Custom Node" } as any,
hardcodedValues: { agent_name: "Researcher", graph_version: 1 },
});
render(<NodeHeader data={data} nodeId="abc-123" />);
expect(screen.getByText("My Custom Node")).toBeTruthy();
});
it("shows node ID prefix", () => {
render(<NodeHeader data={makeData()} nodeId="abc-123" />);
expect(screen.getByText("#abc")).toBeTruthy();
});
it("enters edit mode on double-click and saves on blur", () => {
render(<NodeHeader data={makeData()} nodeId="node-1" />);
const titleEl = screen.getByText("Agent Executor");
fireEvent.doubleClick(titleEl);
const input = screen.getByDisplayValue("AgentExecutorBlock");
fireEvent.change(input, { target: { value: "New Name" } });
fireEvent.blur(input);
expect(mockUpdateNodeData).toHaveBeenCalledWith("node-1", {
metadata: { customized_name: "New Name" },
});
});
it("does not save when title is unchanged on blur", () => {
const data = makeData({
hardcodedValues: { agent_name: "Researcher", graph_version: 2 },
});
render(<NodeHeader data={data} nodeId="node-1" />);
const titleEl = screen.getByText("Researcher v2");
fireEvent.doubleClick(titleEl);
const input = screen.getByDisplayValue("Researcher v2");
fireEvent.blur(input);
expect(mockUpdateNodeData).not.toHaveBeenCalled();
});
it("saves on Enter key", () => {
render(<NodeHeader data={makeData()} nodeId="node-1" />);
fireEvent.doubleClick(screen.getByText("Agent Executor"));
const input = screen.getByDisplayValue("AgentExecutorBlock");
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(mockUpdateNodeData).toHaveBeenCalledWith("node-1", {
metadata: { customized_name: "Renamed" },
});
});
it("cancels edit on Escape key", () => {
render(<NodeHeader data={makeData()} nodeId="node-1" />);
fireEvent.doubleClick(screen.getByText("Agent Executor"));
const input = screen.getByDisplayValue("AgentExecutorBlock");
fireEvent.change(input, { target: { value: "Changed" } });
fireEvent.keyDown(input, { key: "Escape" });
expect(mockUpdateNodeData).not.toHaveBeenCalled();
expect(screen.getByText("Agent Executor")).toBeTruthy();
});
});

View File

@@ -1,6 +1,55 @@
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { NodeResolutionData } from "@/app/(platform)/build/stores/types";
import { beautifyString } from "@/lib/utils";
import { RJSFSchema } from "@rjsf/utils";
import { CustomNodeData } from "./CustomNode";
/**
* Resolves the display title for a node using a 3-tier fallback:
*
* 1. `customized_name` — the user's manual rename (highest priority)
* 2. `agent_name` (+ version) from `hardcodedValues` — the selected agent's
* display name, persisted by blocks like AgentExecutorBlock
* 3. `data.title` — the generic block name (e.g. "Agent Executor")
*
* `customized_name` is the user's explicit rename via double-click; it lives in
* node metadata. `agent_name` is the programmatic name of the agent graph
* selected in the block's input form; it lives in `hardcodedValues` alongside
* `graph_version`. These are distinct sources of truth — customized_name always
* wins because it reflects deliberate user intent.
*/
export function getNodeDisplayTitle(data: CustomNodeData): string {
if (data.metadata?.customized_name) {
return data.metadata.customized_name as string;
}
const agentName = data.hardcodedValues?.agent_name as string | undefined;
const graphVersion = data.hardcodedValues?.graph_version as
| number
| undefined;
if (agentName) {
return graphVersion != null ? `${agentName} v${graphVersion}` : agentName;
}
return data.title;
}
/**
* Returns the formatted display title for rendering.
* Agent names and custom names are shown as-is; generic block names get
* beautified and have the trailing " Block" suffix stripped.
*/
export function formatNodeDisplayTitle(data: CustomNodeData): string {
const title = getNodeDisplayTitle(data);
const isAgentOrCustom = !!(
data.metadata?.customized_name || data.hardcodedValues?.agent_name
);
return isAgentOrCustom
? title
: beautifyString(title)
.replace(/ Block$/, "")
.trim();
}
export const nodeStyleBasedOnStatus: Record<AgentExecutionStatus, string> = {
INCOMPLETE: "ring-slate-300 bg-slate-300",

View File

@@ -1,3 +1,4 @@
import { formatNodeDisplayTitle } from "@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/helpers";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { beautifyString, cn } from "@/lib/utils";
@@ -58,9 +59,7 @@ export function GraphSearchContent({
filteredNodes.map((node, index) => {
if (!node?.data) return null;
const nodeTitle =
(node.data.metadata?.customized_name as string) ||
beautifyString(node.data.title || "").replace(/ Block$/, "");
const nodeTitle = formatNodeDisplayTitle(node.data);
const nodeType = beautifyString(node.data.title || "").replace(
/ Block$/,
"",
@@ -70,7 +69,10 @@ export function GraphSearchContent({
node.data.description ||
"";
const hasCustomName = !!node.data.metadata?.customized_name;
const hasCustomName = !!(
node.data.metadata?.customized_name ||
node.data.hardcodedValues?.agent_name
);
return (
<div

View File

@@ -69,6 +69,9 @@ function calculateNodeScore(
const customizedName = String(
node.data?.metadata?.customized_name || "",
).toLowerCase();
const agentName = String(
node.data?.hardcodedValues?.agent_name || "",
).toLowerCase();
// Get input and output names with defensive checks
const inputNames = Object.keys(node.data?.inputSchema?.properties || {}).map(
@@ -81,6 +84,7 @@ function calculateNodeScore(
// 1. Check exact match in customized name, title (includes ID), node ID, or block type (highest priority)
if (
customizedName.includes(query) ||
agentName.includes(query) ||
nodeTitle.includes(query) ||
nodeID.includes(query) ||
blockType.includes(query) ||
@@ -95,6 +99,7 @@ function calculateNodeScore(
queryWords.every(
(word) =>
customizedName.includes(word) ||
agentName.includes(word) ||
nodeTitle.includes(word) ||
beautifiedBlockType.includes(word),
)