mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:19:05 +08:00
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:
committed by
GitHub
co-authored by
openhands
parent
e207df2c23
commit
998b673f10
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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": "プロファイルの読み込みに失敗しました",
|
||||
|
||||
Reference in New Issue
Block a user