feat: validate duplicate tool names at deployment review step

Add early detection of duplicate WXO tool names before deployment,
preventing users from hitting a 409 error only at deploy time.

- Add `GET /snapshots/check-names` endpoint using SDK's
  `get_drafts_by_names()` for fast server-side lookup
- Add `check_tool_names_exist()` to WXO service adapter
- Show inline validation error with alert banner on the review step
  card when a tool name already exists in the provider
- Block deploy button while duplicate names exist
- Also detect batch duplicates (same name on multiple flows)
- Use `keepPreviousData` to prevent error flicker on re-validation
This commit is contained in:
Viktor Avelino
2026-04-13 16:52:16 -04:00
parent ac414d369a
commit 39b8e52937
7 changed files with 343 additions and 111 deletions

View File

@ -904,6 +904,46 @@ async def list_deployment_snapshots(
)
@router.get("/snapshots/check-names")
async def check_snapshot_names(
provider_id: DeploymentProviderAccountIdQuery,
session: DbSessionReadOnly,
current_user: CurrentActiveUser,
names: Annotated[list[str], Query(description="Tool names to check for existence.")],
):
"""Check which tool names already exist in the provider.
Returns a mapping of each queried name to a boolean indicating existence.
Uses the provider SDK's name-based lookup for efficient server-side filtering.
"""
if not names:
return {"existing_names": []}
provider_account = await get_owned_provider_account_or_404(
provider_id=provider_id,
user_id=current_user.id,
db=session,
)
deployment_adapter = resolve_deployment_adapter(provider_account.provider_key)
with (
handle_adapter_errors(mapper=get_deployment_mapper(provider_account.provider_key)),
deployment_provider_scope(
provider_account.id,
),
):
try:
existing = await deployment_adapter.check_tool_names_exist(
user_id=current_user.id,
names=names,
db=session,
)
except Exception: # noqa: BLE001
# If the adapter doesn't support name-check, return empty.
existing = []
return {"existing_names": existing}
@router.patch(
"/snapshots/{provider_snapshot_id}",
response_model=SnapshotUpdateResponse,

View File

@ -824,6 +824,23 @@ class WatsonxOrchestrateDeploymentService(BaseDeploymentService):
)
return snapshots
async def check_tool_names_exist(
self,
*,
user_id: IdLike,
names: list[str],
db: AsyncSession,
) -> list[str]:
"""Return the subset of *names* that already exist as tools in the provider."""
if not names:
return []
clients = await self._get_provider_clients(user_id=user_id, db=db)
try:
tools = await asyncio.to_thread(clients.tool.get_drafts_by_names, names)
except Exception: # noqa: BLE001
return []
return [str(tool.get("name") or "") for tool in (tools or []) if isinstance(tool, dict) and tool.get("name")]
async def verify_credentials(
self,
*,

View File

@ -216,6 +216,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -1081,6 +1082,7 @@
"resolved": "https://registry.npmjs.org/@chakra-ui/styled-system/-/styled-system-2.9.2.tgz",
"integrity": "sha512-To/Z92oHpIE+4nk11uVMWqo2GGRS86coeMmjxtpnErmWRdLcp1WVCVRAvn+ZwpLiNR+reWFr2FFqJRsREuZdAg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@chakra-ui/shared-utils": "2.0.5",
"csstype": "^3.1.2",
@ -1092,6 +1094,7 @@
"resolved": "https://registry.npmjs.org/@chakra-ui/system/-/system-2.6.2.tgz",
"integrity": "sha512-EGtpoEjLrUu4W1fHD+a62XR+hzC5YfsWm+6lO0Kybcga3yYEij9beegO0jZgug27V+Rf7vns95VPVP6mFd/DEQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@chakra-ui/color-mode": "2.2.0",
"@chakra-ui/object-utils": "2.1.0",
@ -1198,6 +1201,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz",
"integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
@ -1234,6 +1238,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz",
"integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
@ -1243,6 +1248,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.15.tgz",
"integrity": "sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@ -1338,6 +1344,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -1361,6 +1368,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -6039,6 +6047,7 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.25"
@ -6409,6 +6418,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@ -7120,6 +7130,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -7130,6 +7141,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -7145,7 +7157,8 @@
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
@ -7770,6 +7783,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -8456,6 +8470,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -8848,6 +8863,7 @@
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@ -9288,6 +9304,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -9864,6 +9881,7 @@
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@ -10134,6 +10152,7 @@
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@ -11263,6 +11282,7 @@
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@ -11415,6 +11435,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6"
},
@ -11973,6 +11994,7 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@ -13561,6 +13583,7 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@ -13610,6 +13633,7 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@ -13649,6 +13673,7 @@
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
"integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 10.16.0"
}
@ -16144,6 +16169,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -16563,6 +16589,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -16635,6 +16662,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -16665,6 +16693,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -17326,6 +17355,7 @@
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -17844,7 +17874,8 @@
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/source-map": {
"version": "0.5.7",
@ -18023,6 +18054,7 @@
"integrity": "sha512-heMfJjOfbHvL+wlCAwFZlSxcakyJ5yQDam6e9k2RRArB1veJhRnsjO6lO1hOXjJYrqxfHA/ldIugbBVlCDqfvQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/icons": "^2.0.1",
@ -18392,6 +18424,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@ -18993,6 +19026,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -19505,6 +19539,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -20489,6 +20524,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -1,3 +1,4 @@
export * from "./use-check-tool-names";
export * from "./use-delete-deployment";
export * from "./use-get-deployment";
export * from "./use-get-deployment-attachments";

View File

@ -0,0 +1,38 @@
import type { useQueryFunctionType } from "@/types/api";
import { api } from "../../api";
import { getURL } from "../../helpers/constants";
import { UseRequestProcessor } from "../../services/request-processor";
interface CheckToolNamesResponse {
existing_names: string[];
}
interface CheckToolNamesParams {
providerId: string;
names: string[];
}
export const useCheckToolNames: useQueryFunctionType<
CheckToolNamesParams,
CheckToolNamesResponse
> = ({ providerId, names }, options) => {
const { query } = UseRequestProcessor();
const fn = async (): Promise<CheckToolNamesResponse> => {
const { data } = await api.get<CheckToolNamesResponse>(
`${getURL("DEPLOYMENTS")}/snapshots/check-names`,
{
params: { provider_id: providerId, names },
paramsSerializer: { indexes: null },
},
);
return data;
};
const sortedNames = [...names].sort();
return query(
["useCheckToolNames", { providerId, names: sortedNames }],
fn,
options,
);
};

View File

@ -1,12 +1,18 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { keepPreviousData } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { useCheckToolNames } from "@/controllers/API/queries/deployments";
import { useGetRefreshFlowsQuery } from "@/controllers/API/queries/flows/use-get-refresh-flows-query";
import { useFolderStore } from "@/stores/foldersStore";
import { useDeploymentStepper } from "../contexts/deployment-stepper-context";
function normalizeWxoName(s: string): string {
return s.replace(/[\s-]/g, "_").replace(/[^a-zA-Z0-9_]/g, "");
}
function EditableToolName({
value,
placeholder,
@ -98,6 +104,9 @@ export default function StepReview() {
setToolNameByFlow,
attachedConnectionByFlow,
removedFlowIds,
selectedInstance,
preExistingFlowIds,
setHasToolNameErrors,
} = useDeploymentStepper();
const { folderId } = useParams();
@ -144,6 +153,69 @@ export default function StepReview() {
},
);
// Collect normalized tool names to check against the provider.
const toolNamesToCheck = useMemo(() => {
const names: string[] = [];
for (const item of reviewFlows) {
// Skip pre-existing flows in edit mode — those already have tools in provider.
if (isEditMode && preExistingFlowIds.has(item.flowId)) continue;
const normalized = normalizeWxoName(item.toolName);
if (normalized) names.push(normalized);
}
return names;
}, [reviewFlows, isEditMode, preExistingFlowIds]);
const { data: checkNamesData } = useCheckToolNames(
{ providerId: selectedInstance?.id ?? "", names: toolNamesToCheck },
{
enabled: !!selectedInstance?.id && toolNamesToCheck.length > 0,
placeholderData: keepPreviousData,
},
);
const existingToolNames = useMemo(() => {
if (!checkNamesData?.existing_names) return new Set<string>();
return new Set(checkNamesData.existing_names.map((n) => n.toLowerCase()));
}, [checkNamesData]);
const toolNameErrors = useMemo(() => {
const errors = new Map<string, string>();
const batchNames = new Map<string, string>(); // normalized -> flowId (first seen)
for (const item of reviewFlows) {
const normalized = normalizeWxoName(item.toolName).toLowerCase();
if (!normalized) continue;
// Check batch duplicates (two flows with same tool name in this deployment)
const firstFlowId = batchNames.get(normalized);
if (firstFlowId) {
errors.set(item.flowId, "Duplicate tool name within this deployment");
if (!errors.has(firstFlowId)) {
errors.set(firstFlowId, "Duplicate tool name within this deployment");
}
} else {
batchNames.set(normalized, item.flowId);
}
// Check against existing provider tools (skip for pre-existing flows in edit mode)
if (!errors.has(item.flowId) && existingToolNames.has(normalized)) {
if (!(isEditMode && preExistingFlowIds.has(item.flowId))) {
errors.set(item.flowId, "Tool name already exists in the provider");
}
}
}
return errors;
}, [reviewFlows, existingToolNames, isEditMode, preExistingFlowIds]);
useEffect(() => {
setHasToolNameErrors(toolNameErrors.size > 0);
}, [toolNameErrors, setHasToolNameErrors]);
useEffect(() => {
return () => setHasToolNameErrors(false);
}, [setHasToolNameErrors]);
return (
<div className="flex flex-col gap-4 py-3">
<div>
@ -226,122 +298,137 @@ export default function StepReview() {
{/* Configuration section scoped per flow */}
{reviewFlows.length > 0 && (
<div className="flex flex-col gap-3">
{reviewFlows.map((item) => (
<div
key={item.flowId}
className="rounded-xl border border-border bg-background p-4"
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ForwardedIconComponent
name="Wrench"
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
<EditableToolName
value={toolNameByFlow.get(item.flowId)?.trim() ?? ""}
placeholder={item.flowName}
onSave={(name) => {
setToolNameByFlow((prev) => {
const next = new Map(prev);
if (name.trim()) {
next.set(item.flowId, name.trim());
} else {
next.delete(item.flowId);
}
return next;
});
}}
/>
{reviewFlows.map((item) => {
const toolError = toolNameErrors.get(item.flowId);
return (
<div
key={item.flowId}
className={`rounded-xl border bg-background p-4 ${toolError ? "border-destructive/50" : "border-border"}`}
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ForwardedIconComponent
name="Wrench"
className={`h-3.5 w-3.5 shrink-0 ${toolError ? "text-destructive" : "text-muted-foreground"}`}
/>
<EditableToolName
value={toolNameByFlow.get(item.flowId)?.trim() ?? ""}
placeholder={item.flowName}
onSave={(name) => {
setToolNameByFlow((prev) => {
const next = new Map(prev);
if (name.trim()) {
next.set(item.flowId, name.trim());
} else {
next.delete(item.flowId);
}
return next;
});
}}
/>
</div>
<div className="flex items-center gap-2 pl-5">
<ForwardedIconComponent
name="Workflow"
className="h-3 w-3 shrink-0 text-muted-foreground"
/>
<span className="text-xs text-muted-foreground">
{item.flowName}
</span>
<Badge
variant="secondaryStatic"
size="tag"
className="bg-accent-purple-muted text-accent-purple-muted-foreground"
>
{item.versionLabel}
</Badge>
</div>
</div>
<div className="flex items-center gap-2 pl-5">
<ForwardedIconComponent
name="Workflow"
className="h-3 w-3 shrink-0 text-muted-foreground"
/>
<span className="text-xs text-muted-foreground">
{item.flowName}
</span>
<Badge
variant="secondaryStatic"
size="tag"
className="bg-accent-purple-muted text-accent-purple-muted-foreground"
>
{item.versionLabel}
</Badge>
</div>
</div>
{item.connectionDetails.length > 0 &&
(() => {
const newConns = item.connectionDetails.filter(
(c) => c.isNew,
);
const existingConns = item.connectionDetails.filter(
(c) => !c.isNew,
);
return (
<div className="flex flex-col gap-4">
{existingConns.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
Existing Connections
</span>
{existingConns.map((conn) => (
<span
key={conn.name}
className="text-xs font-medium text-foreground"
>
{conn.name}
{item.connectionDetails.length > 0 &&
(() => {
const newConns = item.connectionDetails.filter(
(c) => c.isNew,
);
const existingConns = item.connectionDetails.filter(
(c) => !c.isNew,
);
return (
<div className="flex flex-col gap-4">
{existingConns.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
Existing Connections
</span>
))}
</div>
)}
{newConns.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
New Connections
</span>
{newConns.map((conn) => (
<div
key={conn.name}
className="flex flex-col gap-1.5"
>
<span className="text-xs font-medium text-foreground">
{existingConns.map((conn) => (
<span
key={conn.name}
className="text-xs font-medium text-foreground"
>
{conn.name}
</span>
{conn.envVars.length > 0 && (
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-md border border-border">
{conn.envVars.map(({ key, masked }) => (
<div
key={key}
className="flex items-center justify-between bg-muted/40 px-3 py-1.5"
>
<span className="font-mono text-xs text-foreground">
{key}
</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">
=
</span>
<span className="font-mono text-xs text-muted-foreground">
{masked}
))}
</div>
)}
{newConns.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
New Connections
</span>
{newConns.map((conn) => (
<div
key={conn.name}
className="flex flex-col gap-1.5"
>
<span className="text-xs font-medium text-foreground">
{conn.name}
</span>
{conn.envVars.length > 0 && (
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-md border border-border">
{conn.envVars.map(({ key, masked }) => (
<div
key={key}
className="flex items-center justify-between bg-muted/40 px-3 py-1.5"
>
<span className="font-mono text-xs text-foreground">
{key}
</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">
=
</span>
<span className="font-mono text-xs text-muted-foreground">
{masked}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
);
})()}
))}
</div>
)}
</div>
))}
</div>
)}
</div>
);
})()}
{toolError && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2">
<ForwardedIconComponent
name="AlertTriangle"
className="h-3.5 w-3.5 shrink-0 text-destructive"
/>
<span className="text-xs text-destructive">
{toolError}
</span>
</div>
)}
</div>
</div>
</div>
))}
);
})}
</div>
)}

View File

@ -100,6 +100,10 @@ interface DeploymentStepperContextType {
handleRemoveAttachedFlow: (flowId: string) => void;
handleUndoRemoveFlow: (flowId: string) => void;
// Tool name validation
hasToolNameErrors: boolean;
setHasToolNameErrors: Dispatch<SetStateAction<boolean>>;
// Deploy / Update
needsProviderAccountCreation: boolean;
buildProviderAccountPayload: () => ProviderAccountCreateRequest | null;
@ -161,6 +165,8 @@ export function DeploymentStepperProvider({
Map<string, string[]>
>(initialState?.initialConnectionsByFlow ?? new Map());
const [hasToolNameErrors, setHasToolNameErrors] = useState(false);
// Edit mode: track which pre-existing flows the user wants to detach.
const [removedFlowIds, setRemovedFlowIds] = useState<Set<string>>(new Set());
// Cache removed flow data so undo can restore it.
@ -251,6 +257,9 @@ export function DeploymentStepperProvider({
// In edit mode, user can proceed without new attachments (may just change desc/LLM).
return isEditMode || selectedVersionByFlow.size > 0;
}
if (logical === 4) {
return !hasToolNameErrors;
}
return true;
}, [
currentStep,
@ -262,6 +271,7 @@ export function DeploymentStepperProvider({
selectedLlm,
selectedVersionByFlow,
isEditMode,
hasToolNameErrors,
]);
const handleNext = useCallback(() => {
@ -546,6 +556,8 @@ export function DeploymentStepperProvider({
removedFlowIds,
handleRemoveAttachedFlow,
handleUndoRemoveFlow,
hasToolNameErrors,
setHasToolNameErrors,
needsProviderAccountCreation,
buildProviderAccountPayload,
buildDeploymentPayload,
@ -577,6 +589,7 @@ export function DeploymentStepperProvider({
removedFlowIds,
handleRemoveAttachedFlow,
handleUndoRemoveFlow,
hasToolNameErrors,
needsProviderAccountCreation,
buildProviderAccountPayload,
buildDeploymentPayload,