feat: add duplicate button to LLM profile menu (#1372)

- Add 'Duplicate' menu item in profile actions menu (positioned after Rename, before Set as Active)
- Duplicate creates new profile with same settings but name '{old-name}-copy'
- Auto-increment suffix if name exists: '{old-name}-copy-1', '{old-name}-copy-2', etc.
- Preserve encrypted API keys when duplicating profiles
- Add i18n translations for BUTTON$DUPLICATE and SETTINGS$PROFILE_DUPLICATED in 15 languages
- Update component tests to include onDuplicate prop and test coverage

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Juan Michelini
2026-06-16 15:37:34 -03:00
committed by GitHub
co-authored by openhands
parent e207df2c23
commit 998b673f10
8 changed files with 139 additions and 5 deletions
@@ -9,6 +9,7 @@ vi.mock("react-i18next", () => ({
const translations: Record<string, string> = {
"SETTINGS$PROFILE_EDIT": "Edit",
"BUTTON$RENAME": "Rename",
"BUTTON$DUPLICATE": "Duplicate",
"SETTINGS$PROFILE_SET_ACTIVE": "Set as active",
"BUTTON$DELETE": "Delete",
};
@@ -20,6 +21,7 @@ vi.mock("react-i18next", () => ({
const defaultProps = {
onEdit: vi.fn(),
onRename: vi.fn(),
onDuplicate: vi.fn(),
onSetActive: vi.fn(),
onDelete: vi.fn(),
isActive: false,
@@ -28,15 +30,52 @@ const defaultProps = {
};
describe("ProfileActionsMenu", () => {
it("renders Edit, Rename, Set Active, and Delete buttons", () => {
it("renders Edit, Rename, Duplicate, Set Active, and Delete buttons", () => {
render(<ProfileActionsMenu {...defaultProps} />);
expect(screen.getByTestId("profile-edit")).toHaveTextContent("Edit");
expect(screen.getByTestId("profile-rename")).toHaveTextContent("Rename");
expect(screen.getByTestId("profile-duplicate")).toHaveTextContent(
"Duplicate",
);
expect(screen.getByTestId("profile-set-active")).toHaveTextContent("Set as active");
expect(screen.getByTestId("profile-delete")).toHaveTextContent("Delete");
});
it("renders Duplicate between Rename and Set as active", () => {
render(<ProfileActionsMenu {...defaultProps} />);
const items = screen.getAllByRole("menuitem");
const testIds = items.map((item) => item.getAttribute("data-testid"));
expect(testIds).toEqual([
"profile-edit",
"profile-rename",
"profile-duplicate",
"profile-set-active",
"profile-delete",
]);
});
it("calls onDuplicate and onClose when Duplicate is clicked", async () => {
const user = userEvent.setup();
const handleDuplicate = vi.fn();
const handleClose = vi.fn();
render(
<ProfileActionsMenu
{...defaultProps}
onDuplicate={handleDuplicate}
onClose={handleClose}
/>,
);
await user.click(screen.getByTestId("profile-duplicate"));
expect(handleDuplicate).toHaveBeenCalledTimes(1);
expect(handleClose).toHaveBeenCalledTimes(1);
});
it("calls onEdit and onClose when Edit is clicked", async () => {
const user = userEvent.setup();
const handleEdit = vi.fn();
@@ -184,7 +223,7 @@ describe("ProfileActionsMenu", () => {
expect(menu).toHaveAttribute("aria-orientation", "vertical");
const menuItems = screen.getAllByRole("menuitem");
expect(menuItems).toHaveLength(4);
expect(menuItems).toHaveLength(5);
});
it("styles Delete like other menu items", () => {
@@ -236,6 +275,7 @@ describe("ProfileActionsMenu", () => {
// Navigate to the last item (Delete)
await user.keyboard("{ArrowDown}"); // Rename
await user.keyboard("{ArrowDown}"); // Duplicate
await user.keyboard("{ArrowDown}"); // Set Active
await user.keyboard("{ArrowDown}"); // Delete
expect(screen.getByTestId("profile-delete")).toHaveFocus();
@@ -33,6 +33,7 @@ const defaultProps = {
onActivate: vi.fn(),
onEdit: vi.fn(),
onRename: vi.fn(),
onDuplicate: vi.fn(),
onDelete: vi.fn(),
isActivating: false,
};
@@ -44,6 +44,7 @@ const defaultProps = {
onActivate: vi.fn(),
onEdit: vi.fn(),
onRename: vi.fn(),
onDuplicate: vi.fn(),
onDelete: vi.fn(),
isActivating: false,
};
@@ -4,9 +4,13 @@ import { BrandButton } from "#/components/features/settings/brand-button";
import { RenameProfileModal } from "./rename-profile-modal";
import { DeleteProfileModal } from "./delete-profile-modal";
import { ProfilesBody } from "./profiles-body";
import { ProfileInfo } from "#/api/profiles-service/profiles-service.api";
import ProfilesService, {
ProfileInfo,
type SaveProfileRequest,
} from "#/api/profiles-service/profiles-service.api";
import { useLlmProfiles } from "#/hooks/query/use-llm-profiles";
import { useActivateLlmProfile } from "#/hooks/mutation/use-activate-llm-profile";
import { useSaveLlmProfile } from "#/hooks/mutation/use-save-llm-profile";
import {
displayErrorToast,
displaySuccessToast,
@@ -25,6 +29,7 @@ export function LlmProfilesManager({
const { t } = useTranslation("openhands");
const { data, isLoading, error } = useLlmProfiles();
const activateProfile = useActivateLlmProfile();
const saveProfile = useSaveLlmProfile();
const [profileToRename, setProfileToRename] = useState<ProfileInfo | null>(
null,
);
@@ -49,6 +54,41 @@ export function LlmProfilesManager({
onEditProfile?.(profile);
};
const handleDuplicate = async (profile: ProfileInfo) => {
try {
// Fetch the full config with encrypted secrets so the API key is
// preserved on the duplicate (same approach as the edit flow).
const detail = await ProfilesService.getProfile(
profile.name,
"encrypted",
);
// Find an available name: "{name}-copy", then "{name}-copy-1", etc.
const existingNames = new Set(profiles.map((p) => p.name));
let newName = `${profile.name}-copy`;
let counter = 1;
while (existingNames.has(newName)) {
newName = `${profile.name}-copy-${counter}`;
counter += 1;
}
await saveProfile.mutateAsync({
name: newName,
request: {
llm: detail.config as SaveProfileRequest["llm"],
include_secrets: true,
},
});
displaySuccessToast(
t(I18nKey.SETTINGS$PROFILE_DUPLICATED, { name: newName }),
);
} catch (err) {
console.error("Failed to duplicate profile:", err);
displayErrorToast(t(I18nKey.ERROR$GENERIC));
}
};
return (
<>
<div className="flex flex-col gap-4">
@@ -77,6 +117,7 @@ export function LlmProfilesManager({
onActivate={handleActivate}
onEdit={handleEdit}
onRename={setProfileToRename}
onDuplicate={handleDuplicate}
onDelete={setProfileToDelete}
isActivating={activateProfile.isPending}
/>
@@ -7,7 +7,7 @@ import {
} from "react";
import ReactDOM from "react-dom";
import { useTranslation } from "react-i18next";
import { TextCursor } from "lucide-react";
import { TextCursor, Copy } from "lucide-react";
import { cn } from "#/utils/utils";
import { dropdownMenuListClassName } from "#/utils/dropdown-classes";
import { I18nKey } from "#/i18n/declaration";
@@ -63,6 +63,7 @@ function MenuItem({
interface ProfileActionsMenuProps {
onEdit: () => void;
onRename: () => void;
onDuplicate: () => void;
onSetActive: () => void;
onDelete: () => void;
isActive: boolean;
@@ -80,6 +81,7 @@ interface ProfileActionsMenuProps {
export function ProfileActionsMenu({
onEdit,
onRename,
onDuplicate,
onSetActive,
onDelete,
isActive,
@@ -208,6 +210,15 @@ export function ProfileActionsMenu({
/>
<MenuItem
index={2}
icon={<Copy aria-hidden className="size-4" strokeWidth={2} />}
label={t(I18nKey.BUTTON$DUPLICATE)}
onClick={() => handleAction(onDuplicate)}
onKeyDown={handleKeyDown}
menuItemsRef={menuItemsRef}
testId="profile-duplicate"
/>
<MenuItem
index={3}
icon={<CheckCircleIcon width={16} height={16} />}
label={t(I18nKey.SETTINGS$PROFILE_SET_ACTIVE)}
onClick={() => handleAction(onSetActive)}
@@ -219,7 +230,7 @@ export function ProfileActionsMenu({
{/* The active profile can be deleted: useEnsureActiveProfile then promotes
another remaining profile so a profile is always active in local mode. */}
<MenuItem
index={3}
index={4}
icon={<DeleteIcon width={16} height={16} />}
label={t(I18nKey.BUTTON$DELETE)}
onClick={() => handleAction(onDelete)}
@@ -17,6 +17,7 @@ interface ProfileRowProps {
onActivate: (name: string) => void;
onEdit: (profile: ProfileInfo) => void;
onRename: (profile: ProfileInfo) => void;
onDuplicate: (profile: ProfileInfo) => void;
onDelete: (profile: ProfileInfo) => void;
isActivating: boolean;
}
@@ -27,6 +28,7 @@ export function ProfileRow({
onActivate,
onEdit,
onRename,
onDuplicate,
onDelete,
isActivating,
}: ProfileRowProps) {
@@ -76,6 +78,7 @@ export function ProfileRow({
anchorRef={triggerRef}
onEdit={() => onEdit(profile)}
onRename={() => onRename(profile)}
onDuplicate={() => onDuplicate(profile)}
onSetActive={() => onActivate(profile.name)}
onDelete={() => onDelete(profile)}
isActive={isActive}
@@ -18,6 +18,7 @@ interface ProfilesBodyProps {
onActivate: (name: string) => void;
onEdit: (profile: ProfileInfo) => void;
onRename: (profile: ProfileInfo) => void;
onDuplicate: (profile: ProfileInfo) => void;
onDelete: (profile: ProfileInfo) => void;
isActivating: boolean;
}
@@ -30,6 +31,7 @@ export function ProfilesBody({
onActivate,
onEdit,
onRename,
onDuplicate,
onDelete,
isActivating,
}: ProfilesBodyProps) {
@@ -84,6 +86,7 @@ export function ProfilesBody({
onActivate={onActivate}
onEdit={onEdit}
onRename={onRename}
onDuplicate={onDuplicate}
onDelete={onDelete}
isActivating={isActivating}
/>
+34
View File
@@ -22116,6 +22116,23 @@
"uk": "Перейменувати",
"ca": "Canvia el nom"
},
"BUTTON$DUPLICATE": {
"en": "Duplicate",
"ja": "複製",
"zh-CN": "复制",
"zh-TW": "複製",
"ko-KR": "복제",
"no": "Dupliser",
"it": "Duplica",
"pt": "Duplicar",
"es": "Duplicar",
"ar": "تكرار",
"fr": "Dupliquer",
"tr": "Çoğalt",
"de": "Duplizieren",
"uk": "Дублювати",
"ca": "Duplica"
},
"COMMON$APP": {
"en": "App",
"ja": "アプリ",
@@ -28746,6 +28763,23 @@
"uk": "Профіль перейменовано на \"{{name}}\"",
"ca": "Perfil reanomenat a \"{{name}}\""
},
"SETTINGS$PROFILE_DUPLICATED": {
"en": "Profile duplicated as \"{{name}}\"",
"ja": "プロファイルを「{{name}}」として複製しました",
"zh-CN": "配置文件已复制为「{{name}}」",
"zh-TW": "設定檔已複製為「{{name}}」",
"ko-KR": "프로필이 \"{{name}}\"(으)로 복제됨",
"no": "Profilen ble duplisert som \"{{name}}\"",
"it": "Profilo duplicato come \"{{name}}\"",
"pt": "Perfil duplicado como \"{{name}}\"",
"es": "Perfil duplicado como \"{{name}}\"",
"ar": "تم تكرار الملف الشخصي باسم \"{{name}}\"",
"fr": "Profil dupliqué en \"{{name}}\"",
"tr": "Profil \"{{name}}\" olarak çoğaltıldı",
"de": "Profil dupliziert als \"{{name}}\"",
"uk": "Профіль дубльовано як \"{{name}}\"",
"ca": "Perfil duplicat com a \"{{name}}\""
},
"SETTINGS$PROFILES_LOAD_ERROR": {
"en": "Failed to load profiles",
"ja": "プロファイルの読み込みに失敗しました",