♻️ refactor: replace per-item Editing components with singleton EditingPopover (#12327)

* ♻️ refactor: replace per-item Editing components with singleton EditingPopover

Eliminate 3 duplicate Editing components (AgentItem, AgentGroupItem, Group)
in favor of a single imperative EditingPopover using @lobehub/ui Popover atoms.
Anchor elements are passed via React state (useState + callback ref) instead
of DOM queries. Removes agentRenamingId/groupRenamingId from homeStore.

* fix: edit group agent avaar

Signed-off-by: Innei <tukon479@gmail.com>

*  test(e2e): update rename popover selectors and allow console in tests

Support both antd Popover and @lobehub/ui Popover atoms selectors.
Use save button click instead of click-outside for non-Enter rename flow.
Disable no-console rule for e2e and test files.

*  test(e2e): fix rename popover input detection with data-testid

Add data-testid="editing-popover" to PopoverPopup. Simplify inputNewName
to use single combined selector instead of sequential try-catch loop that
caused 8s+ timeout. Support both @lobehub/ui and antd Popover.

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-02-16 18:17:59 +08:00
committed by GitHub
parent abbf53feda
commit b3e87f6cd4
21 changed files with 441 additions and 1061 deletions

View File

@@ -10,7 +10,7 @@ import { Given, Then, When } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
import { TEST_USER } from '../../support/seedTestUser';
import { CustomWorld, WAIT_TIMEOUT } from '../../support/world';
import { type CustomWorld, WAIT_TIMEOUT } from '../../support/world';
// ============================================
// Helper Functions
@@ -23,67 +23,29 @@ async function inputNewName(
): Promise<void> {
await this.page.waitForTimeout(300);
// Try to find the popover input
const popoverInputSelectors = [
'.ant-popover-inner input',
'.ant-popover-content input',
'.ant-popover input',
];
// Primary: find input inside EditingPopover (data-testid) or antd Popover
const renameInput = this.page
.locator('[data-testid="editing-popover"] input, .ant-popover input')
.first();
let renameInput = null;
await renameInput.waitFor({ state: 'visible', timeout: 5000 });
await renameInput.click();
await renameInput.clear();
await renameInput.fill(newName);
for (const selector of popoverInputSelectors) {
try {
const locator = this.page.locator(selector).first();
await locator.waitFor({ state: 'visible', timeout: 2000 });
renameInput = locator;
break;
} catch {
// Try next selector
}
}
if (!renameInput) {
// Fallback: find any visible input
const allInputs = this.page.locator('input:visible');
const count = await allInputs.count();
for (let i = 0; i < count; i++) {
const input = allInputs.nth(i);
const placeholder = (await input.getAttribute('placeholder').catch(() => '')) || '';
if (placeholder.includes('Search') || placeholder.includes('搜索')) continue;
const isInPopover = await input.evaluate((el) => {
return el.closest('.ant-popover') !== null || el.closest('[class*="popover"]') !== null;
});
if (isInPopover || count <= 2) {
renameInput = input;
break;
}
}
}
if (renameInput) {
await renameInput.click();
await renameInput.clear();
await renameInput.fill(newName);
if (pressEnter) {
await renameInput.press('Enter');
} else {
await this.page.click('body', { position: { x: 10, y: 10 } });
}
if (pressEnter) {
await renameInput.press('Enter');
} else {
// Keyboard fallback
await this.page.keyboard.press('Meta+A');
await this.page.waitForTimeout(50);
await this.page.keyboard.type(newName, { delay: 20 });
if (pressEnter) {
await this.page.keyboard.press('Enter');
} else {
await this.page.click('body', { position: { x: 10, y: 10 } });
// Click the save button (ActionIcon with Check icon) next to the input
const saveButton = this.page
.locator('[data-testid="editing-popover"] svg.lucide-check, .ant-popover svg.lucide-check')
.first();
try {
await saveButton.waitFor({ state: 'visible', timeout: 2000 });
await saveButton.click();
} catch {
// Fallback: press Enter to save
await renameInput.press('Enter');
}
}

View File

@@ -1,14 +1,4 @@
{
"src/app/(backend)/api/agent/route.ts": {
"object-shorthand": {
"count": 1
}
},
"src/app/(backend)/api/agent/run/route.ts": {
"object-shorthand": {
"count": 1
}
},
"src/app/(backend)/api/dev/memory-user-memory/benchmark-locomo/route.ts": {
"no-console": {
"count": 6
@@ -54,124 +44,31 @@
"count": 1
}
},
"src/app/[variants]/(main)/agent/profile/features/Header/AgentPublishButton/useMarketPublish.ts": {
"object-shorthand": {
"count": 3
}
},
"src/app/[variants]/(main)/community/(detail)/agent/features/AgentForkTag.tsx": {
"no-console": {
"count": 1
}
},
"src/app/[variants]/(main)/community/(detail)/agent/features/Details/Capabilities/PluginItem.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/app/[variants]/(main)/community/(detail)/agent/features/Header.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/app/[variants]/(main)/community/(detail)/agent/features/Sidebar/ActionButton/index.tsx": {
"object-shorthand": {
"count": 2
}
},
"src/app/[variants]/(main)/community/(detail)/group_agent/features/Sidebar/ActionButton/index.tsx": {
"object-shorthand": {
"count": 2
}
},
"src/app/[variants]/(main)/community/(detail)/user/features/UserAgentCard.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/app/[variants]/(main)/community/(detail)/user/features/UserAgentList.tsx": {
"no-console": {
"count": 1
}
},
"src/app/[variants]/(main)/community/(detail)/user/features/UserFavoriteAgents.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/app/[variants]/(main)/community/(detail)/user/features/UserFavoritePlugins.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/app/[variants]/(main)/community/(detail)/user/features/UserGroupCard.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/app/[variants]/(main)/community/(list)/(home)/loading.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/app/[variants]/(main)/community/(list)/mcp/features/List/Item.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/app/[variants]/(main)/community/(list)/provider/features/List/Item.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/app/[variants]/(main)/community/components/VirtuosoGridList/index.tsx": {
"@eslint-react/no-nested-component-definitions": {
"count": 2
}
},
"src/app/[variants]/(main)/group/profile/features/Header/AgentPublishButton/useMarketPublish.ts": {
"object-shorthand": {
"count": 4
}
},
"src/app/[variants]/(main)/home/_layout/Body/Agent/Modals/ConfigGroupModal/index.tsx": {
"@typescript-eslint/consistent-type-imports": {
"count": 1
},
"simple-import-sort/imports": {
"count": 1
}
},
"src/app/[variants]/(main)/home/features/RecentPage/Item.tsx": {
"regexp/no-super-linear-backtracking": {
"count": 1
}
},
"src/app/[variants]/(main)/home/features/components/GroupSkeleton.tsx": {
"object-shorthand": {
"count": 1
}
},
"src/app/[variants]/(main)/home/features/index.tsx": {
"@eslint-react/no-nested-component-definitions": {
"count": 1
}
},
"src/app/[variants]/(main)/image/_layout/ConfigPanel/utils/__tests__/imageValidation.test.ts": {
"object-shorthand": {
"count": 1
}
},
"src/app/[variants]/(main)/image/_layout/TopicSidebar.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/app/[variants]/(main)/image/features/GenerationFeed/index.tsx": {
"object-shorthand": {
"count": 1
}
},
"src/app/[variants]/(main)/memory/features/GridView/index.tsx": {
"@eslint-react/no-nested-component-definitions": {
"count": 1
@@ -187,16 +84,6 @@
"count": 1
}
},
"src/app/[variants]/(main)/resource/library/_layout/Header/LibraryHead.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/app/[variants]/(main)/settings/provider/ProviderMenu/List.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/app/[variants]/(main)/settings/provider/detail/ollama/CheckError.tsx": {
"regexp/no-dupe-characters-character-class": {
"count": 1
@@ -205,11 +92,6 @@
"count": 1
}
},
"src/app/[variants]/(main)/settings/provider/features/ProviderConfig/index.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/app/[variants]/(main)/settings/skill/features/KlavisSkillItem.tsx": {
"no-console": {
"count": 2
@@ -220,21 +102,6 @@
"count": 1
}
},
"src/app/[variants]/(main)/settings/stats/features/usage/UsageCards/ActiveModels/ModelTable.tsx": {
"object-shorthand": {
"count": 1
}
},
"src/app/[variants]/(main)/video/_layout/TopicSidebar.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/app/[variants]/(main)/video/features/GenerationFeed/index.tsx": {
"object-shorthand": {
"count": 1
}
},
"src/app/[variants]/onboarding/components/KlavisServerList/hooks/useKlavisOAuth.ts": {
"no-console": {
"count": 1
@@ -255,11 +122,6 @@
"count": 3
}
},
"src/components/ChatGroupWizard/ChatGroupWizard.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/components/ChatGroupWizard/index.ts": {
"sort-keys-fix/sort-keys-fix": {
"count": 1
@@ -283,26 +145,11 @@
"count": 1
}
},
"src/components/MCPStdioCommandInput/index.tsx": {
"object-shorthand": {
"count": 1
}
},
"src/components/MaxTokenSlider.tsx": {
"object-shorthand": {
"count": 1
}
},
"src/components/ModelSelect/index.tsx": {
"no-shadow-restricted-names": {
"count": 1
}
},
"src/components/mdx/index.tsx": {
"object-shorthand": {
"count": 1
}
},
"src/config/featureFlags/schema.ts": {
"sort-keys-fix/sort-keys-fix": {
"count": 1
@@ -319,9 +166,6 @@
}
},
"src/envs/auth.ts": {
"perfectionist/sort-interfaces": {
"count": 19
},
"sort-keys-fix/sort-keys-fix": {
"count": 1
},
@@ -355,11 +199,6 @@
"count": 1
}
},
"src/features/ChatInput/ActionBar/Model/ReasoningTokenSlider.tsx": {
"object-shorthand": {
"count": 1
}
},
"src/features/ChatInput/ActionBar/Tools/KlavisServerItem.tsx": {
"no-console": {
"count": 2
@@ -383,36 +222,6 @@
"count": 1
}
},
"src/features/Conversation/Messages/Assistant/Actions/index.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/features/Conversation/Messages/AssistantGroup/Actions/index.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/features/Conversation/Messages/Supervisor/Actions/index.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/features/Conversation/Messages/Supervisor/components/ContentBlock.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/features/Conversation/components/Reaction/ReactionPicker.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/features/Conversation/store/slices/message/action/index.ts": {
"object-shorthand": {
"count": 1
}
},
"src/features/Conversation/store/slices/message/action/sendMessage.ts": {
"no-console": {
"count": 1
@@ -433,41 +242,6 @@
"count": 1
}
},
"src/features/GenerationTopicPanel/index.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/features/LibraryModal/CreateNew/CreateForm.tsx": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"src/features/LibraryModal/CreateNew/index.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/features/MCP/Scores.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/features/MCPPluginDetail/Header.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/features/PluginDevModal/MCPManifestForm/QuickImportSection.tsx": {
"object-shorthand": {
"count": 2
}
},
"src/features/PluginDevModal/MCPManifestForm/utils.ts": {
"object-shorthand": {
"count": 1
}
},
"src/features/PluginsUI/Render/utils/iframeOnReady.test.ts": {
"unicorn/no-invalid-remove-event-listener": {
"count": 1
@@ -484,44 +258,18 @@
}
},
"src/features/ProtocolUrlHandler/InstallPlugin/CustomPluginInstallModal.tsx": {
"object-shorthand": {
"count": 1
},
"prefer-const": {
"count": 1
}
},
"src/features/ResourceManager/components/Explorer/Header/SearchInput.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/features/ResourceManager/components/Explorer/ListView/index.tsx": {
"@eslint-react/no-nested-component-definitions": {
"count": 1
}
},
"src/features/ResourceManager/components/Explorer/MasonryView/MasonryItem/DefaultFileItem.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/features/ResourceManager/components/Explorer/MasonryView/MasonryItem/ImageFileItem.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/features/ResourceManager/components/Explorer/MasonryView/MasonryItem/MarkdownFileItem.tsx": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/features/ResourceManager/components/Explorer/MasonryView/MasonryItem/NoteFileItem.tsx": {
"regexp/no-super-linear-backtracking": {
"count": 1
},
"simple-import-sort/imports": {
"count": 1
}
},
"src/features/ShareModal/ShareJSON/generateFullExport.ts": {
@@ -534,21 +282,11 @@
"count": 1
}
},
"src/helpers/toolEngineering/index.ts": {
"object-shorthand": {
"count": 2
}
},
"src/hooks/useAgentOwnershipCheck.ts": {
"no-console": {
"count": 6
}
},
"src/hooks/useFetchAiVideoConfig.ts": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/hooks/useHotkeys/useHotkeyById.ts": {
"no-console": {
"count": 1
@@ -570,17 +308,9 @@
"count": 1
}
},
"src/libs/better-auth/sso/index.ts": {
"object-shorthand": {
"count": 1
}
},
"src/libs/next/proxy/define-config.ts": {
"no-console": {
"count": 1
},
"object-shorthand": {
"count": 2
}
},
"src/libs/observability/traceparent.test.ts": {
@@ -594,14 +324,6 @@
},
"@typescript-eslint/no-unsafe-function-type": {
"count": 1
},
"object-shorthand": {
"count": 1
}
},
"src/libs/oidc-provider/jwt.ts": {
"object-shorthand": {
"count": 1
}
},
"src/libs/swr/localStorageProvider.ts": {
@@ -627,21 +349,6 @@
"count": 1
}
},
"src/server/manifest.ts": {
"object-shorthand": {
"count": 3
}
},
"src/server/modules/AgentRuntime/RuntimeExecutors.ts": {
"object-shorthand": {
"count": 4
}
},
"src/server/modules/KeyVaultsEncrypt/index.ts": {
"object-shorthand": {
"count": 2
}
},
"src/server/modules/Mecha/ContextEngineering/index.ts": {
"sort-keys-fix/sort-keys-fix": {
"count": 1
@@ -652,19 +359,6 @@
"count": 1
}
},
"src/server/modules/ModelRuntime/trace.ts": {
"object-shorthand": {
"count": 1
}
},
"src/server/modules/S3/index.ts": {
"object-shorthand": {
"count": 3
},
"simple-import-sort/imports": {
"count": 1
}
},
"src/server/routers/lambda/__tests__/integration/aiAgent.createClientGroupAgentTaskThread.integration.test.ts": {
"@typescript-eslint/no-non-null-asserted-optional-chain": {
"count": 2
@@ -675,11 +369,6 @@
"count": 2
}
},
"src/server/routers/lambda/__tests__/integration/message.integration.test.ts": {
"object-shorthand": {
"count": 1
}
},
"src/server/routers/lambda/agent.ts": {
"no-console": {
"count": 1
@@ -725,21 +414,11 @@
"count": 2
}
},
"src/server/routers/tools/_helpers/scheduleToolCallReport.test.ts": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/server/routers/tools/mcp.ts": {
"sort-keys-fix/sort-keys-fix": {
"count": 1
}
},
"src/server/services/chunk/index.ts": {
"object-shorthand": {
"count": 1
}
},
"src/server/services/comfyui/config/fluxModelRegistry.ts": {
"sort-keys-fix/sort-keys-fix": {
"count": 2
@@ -833,86 +512,6 @@
"count": 2
}
},
"src/server/services/mcp/index.ts": {
"object-shorthand": {
"count": 1
}
},
"src/server/services/memory/userMemory/extract.ts": {
"object-shorthand": {
"count": 2
}
},
"src/server/services/message/__tests__/index.integration.test.ts": {
"object-shorthand": {
"count": 1
}
},
"src/server/services/oidc/index.ts": {
"object-shorthand": {
"count": 2
}
},
"src/server/services/search/impls/anspire/index.ts": {
"object-shorthand": {
"count": 1
}
},
"src/server/services/search/impls/bocha/index.ts": {
"object-shorthand": {
"count": 1
}
},
"src/server/services/search/impls/brave/index.ts": {
"object-shorthand": {
"count": 1
}
},
"src/server/services/search/impls/exa/index.ts": {
"object-shorthand": {
"count": 1
}
},
"src/server/services/search/impls/firecrawl/index.ts": {
"object-shorthand": {
"count": 1
}
},
"src/server/services/search/impls/google/index.ts": {
"object-shorthand": {
"count": 1
}
},
"src/server/services/search/impls/jina/index.ts": {
"object-shorthand": {
"count": 1
}
},
"src/server/services/search/impls/kagi/index.ts": {
"object-shorthand": {
"count": 1
}
},
"src/server/services/search/impls/search1api/index.ts": {
"object-shorthand": {
"count": 1
}
},
"src/server/services/search/impls/tavily/index.ts": {
"object-shorthand": {
"count": 1
}
},
"src/server/services/search/index.ts": {
"object-shorthand": {
"count": 5
}
},
"src/server/services/usage/index.test.ts": {
"object-shorthand": {
"count": 11
}
},
"src/server/services/webhookUser/index.test.ts": {
"unicorn/no-thenable": {
"count": 7
@@ -928,11 +527,6 @@
"count": 1
}
},
"src/services/chat/index.ts": {
"object-shorthand": {
"count": 1
}
},
"src/services/models.ts": {
"no-console": {
"count": 1
@@ -963,15 +557,7 @@
"count": 1
}
},
"src/store/chat/agents/__tests__/createAgentExecutors/call-tool.test.ts": {
"object-shorthand": {
"count": 1
}
},
"src/store/chat/agents/createAgentExecutors.ts": {
"object-shorthand": {
"count": 1
},
"prefer-const": {
"count": 1
},
@@ -995,16 +581,6 @@
"count": 1
}
},
"src/store/chat/slices/aiChat/actions/__tests__/StreamingHandler.test.ts": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"src/store/chat/slices/aiChat/actions/__tests__/streamingExecutor.test.ts": {
"object-shorthand": {
"count": 8
}
},
"src/store/chat/slices/aiChat/actions/conversationControl.ts": {
"no-unused-private-class-members": {
"count": 1
@@ -1063,11 +639,6 @@
"count": 1
}
},
"src/store/chat/slices/plugin/action.test.ts": {
"object-shorthand": {
"count": 3
}
},
"src/store/chat/slices/plugin/actions/internals.ts": {
"no-unused-private-class-members": {
"count": 2
@@ -1212,11 +783,6 @@
"count": 1
}
},
"src/store/file/slices/document/action.ts": {
"object-shorthand": {
"count": 1
}
},
"src/store/file/slices/tts/action.ts": {
"no-unused-private-class-members": {
"count": 1
@@ -1225,9 +791,6 @@
"src/store/file/slices/upload/action.ts": {
"no-unused-private-class-members": {
"count": 2
},
"object-shorthand": {
"count": 1
}
},
"src/store/global/actions/workspacePane.ts": {
@@ -1250,14 +813,6 @@
"count": 1
}
},
"src/store/image/slices/generationConfig/action.ts": {
"no-useless-rename": {
"count": 4
},
"object-shorthand": {
"count": 1
}
},
"src/store/image/slices/generationConfig/initialState.ts": {
"sort-keys-fix/sort-keys-fix": {
"count": 1
@@ -1276,11 +831,6 @@
"count": 1
}
},
"src/store/page/slices/crud/action.ts": {
"object-shorthand": {
"count": 1
}
},
"src/store/serverConfig/action.ts": {
"no-unused-private-class-members": {
"count": 1
@@ -1292,9 +842,6 @@
}
},
"src/store/session/slices/session/action.ts": {
"object-shorthand": {
"count": 1
},
"typescript-sort-keys/interface": {
"count": 1
}
@@ -1315,9 +862,6 @@
"src/store/tool/slices/mcpStore/action.ts": {
"no-console": {
"count": 1
},
"object-shorthand": {
"count": 3
}
},
"src/store/tool/slices/oldStore/initialState.ts": {
@@ -1330,16 +874,6 @@
"count": 1
}
},
"src/store/video/initialState.ts": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/store/video/store.ts": {
"simple-import-sort/imports": {
"count": 1
}
},
"src/styles/antdOverride.ts": {
"unicorn/no-anonymous-default-export": {
"count": 1

View File

@@ -92,4 +92,11 @@ export default eslint(
'unicorn/prefer-top-level-await': 0,
},
},
// E2E and test files - allow console.log for debugging
{
files: ['e2e/**/*', '**/*.test.ts', '**/*.test.tsx'],
rules: {
'no-console': 0,
},
},
);

View File

@@ -1,152 +0,0 @@
import { type GroupMemberAvatar } from '@lobechat/types';
import { Avatar, Block, Flexbox, Input, stopPropagation } from '@lobehub/ui';
import { type InputRef, message, Popover } from 'antd';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import EmojiPicker from '@/components/EmojiPicker';
import GroupAvatar from '@/features/GroupAvatar';
import { useIsDark } from '@/hooks/useIsDark';
import { useFileStore } from '@/store/file';
import { useGlobalStore } from '@/store/global';
import { globalGeneralSelectors } from '@/store/global/selectors';
import { useHomeStore } from '@/store/home';
const MAX_AVATAR_SIZE = 1024 * 1024;
interface EditingProps {
avatar?: string;
id: string;
memberAvatars?: GroupMemberAvatar[];
title: string;
toggleEditing: (visible?: boolean) => void;
}
const Editing = memo<EditingProps>(({ id, title, avatar, memberAvatars, toggleEditing }) => {
const { t } = useTranslation('setting');
const locale = useGlobalStore(globalGeneralSelectors.currentLanguage);
const isDarkMode = useIsDark();
const editing = useHomeStore((s) => s.groupRenamingId === id);
const uploadWithProgress = useFileStore((s) => s.uploadWithProgress);
const [newTitle, setNewTitle] = useState(title);
const [newAvatar, setNewAvatar] = useState<string | null | undefined>(avatar);
const [uploading, setUploading] = useState(false);
const inputRef = useRef<InputRef>(null);
useEffect(() => {
if (editing) {
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}, [editing]);
const handleUpdate = useCallback(async () => {
const hasChanges = (newTitle && title !== newTitle) || newAvatar !== avatar;
if (hasChanges) {
try {
useHomeStore.getState().setGroupUpdatingId(id);
await useHomeStore
.getState()
.renameAgentGroup(id, newTitle || title, newAvatar !== avatar ? newAvatar : undefined);
} finally {
useHomeStore.getState().setGroupUpdatingId(null);
}
}
toggleEditing(false);
}, [newTitle, newAvatar, title, avatar, id, toggleEditing]);
const handleAvatarUpload = useCallback(
async (file: File) => {
if (file.size > MAX_AVATAR_SIZE) {
message.error(t('settingAgent.avatar.sizeExceeded'));
return;
}
setUploading(true);
try {
const result = await uploadWithProgress({ file });
if (result?.url) {
setNewAvatar(result.url);
}
} finally {
setUploading(false);
}
},
[uploadWithProgress, t],
);
const handleAvatarDelete = useCallback(() => {
setNewAvatar(null);
}, []);
return (
<Popover
arrow={false}
open={editing}
overlayInnerStyle={{ padding: 4 }}
placement="bottomLeft"
trigger="click"
content={
<Flexbox horizontal gap={4} style={{ width: 320 }} onClick={stopPropagation}>
<EmojiPicker
allowUpload
allowDelete={!!newAvatar}
loading={uploading}
locale={locale}
shape={'square'}
value={newAvatar ?? undefined}
customRender={(avatarValue) => (
<Block
clickable
align={'center'}
height={36}
justify={'center'}
variant={isDarkMode ? 'filled' : 'outlined'}
width={36}
onClick={(e) => e.stopPropagation()}
>
{avatarValue ? (
<Avatar
emojiScaleWithBackground
avatar={avatarValue}
shape={'square'}
size={32}
/>
) : (
<GroupAvatar avatars={memberAvatars || []} size={32} />
)}
</Block>
)}
onChange={setNewAvatar}
onDelete={handleAvatarDelete}
onUpload={handleAvatarUpload}
/>
<Input
defaultValue={title}
ref={inputRef}
style={{ flex: 1 }}
onChange={(e) => setNewTitle(e.target.value)}
onPressEnter={() => handleUpdate()}
onKeyDown={(e) => {
if (e.key === 'Escape') toggleEditing(false);
}}
/>
</Flexbox>
}
onOpenChange={(open) => {
if (!open) handleUpdate();
}}
>
<div />
</Popover>
);
});
export default Editing;

View File

@@ -4,7 +4,7 @@ import { ActionIcon, Icon } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { Loader2, PinIcon } from 'lucide-react';
import { type CSSProperties, type DragEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
@@ -14,7 +14,6 @@ import { useGlobalStore } from '@/store/global';
import { useHomeStore } from '@/store/home';
import Actions from '../Item/Actions';
import Editing from './Editing';
import { useGroupDropdownMenu } from './useDropdownMenu';
interface GroupItemProps {
@@ -24,16 +23,13 @@ interface GroupItemProps {
}
const GroupItem = memo<GroupItemProps>(({ item, style, className }) => {
const { id, avatar, backgroundColor, groupAvatar, title, pinned } = item;
const { id, avatar, backgroundColor, title, pinned } = item;
const { t } = useTranslation('chat');
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const openAgentInNewWindow = useGlobalStore((s) => s.openAgentInNewWindow);
// Get UI state from homeStore (editing, updating)
const [editing, isUpdating] = useHomeStore((s) => [
s.groupRenamingId === id,
s.groupUpdatingId === id,
]);
const isUpdating = useHomeStore((s) => s.groupUpdatingId === id);
// Get display title with fallback
const displayTitle = title || t('untitledAgent');
@@ -62,13 +58,6 @@ const GroupItem = memo<GroupItemProps>(({ item, style, className }) => {
[id, openAgentInNewWindow],
);
const toggleEditing = useCallback(
(visible?: boolean) => {
useHomeStore.getState().setGroupRenamingId(visible ? id : null);
},
[id],
);
// Memoize pin icon
const pinIcon = useMemo(
() =>
@@ -99,39 +88,36 @@ const GroupItem = memo<GroupItemProps>(({ item, style, className }) => {
);
}, [isUpdating, avatar, backgroundColor]);
const customAvatar = typeof avatar === 'string' ? avatar : undefined;
const memberAvatars = Array.isArray(avatar) ? avatar : [];
const dropdownMenu = useGroupDropdownMenu({
anchor,
avatar: customAvatar,
id,
memberAvatars,
pinned: pinned ?? false,
toggleEditing,
title: displayTitle,
});
return (
<>
<Link aria-label={id} to={groupUrl}>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
className={className}
contextMenuItems={dropdownMenu}
disabled={editing || isUpdating}
draggable={!editing && !isUpdating}
extra={pinIcon}
icon={avatarIcon}
key={id}
style={style}
title={displayTitle}
onDoubleClick={handleDoubleClick}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
/>
</Link>
<Editing
avatar={groupAvatar || undefined}
id={id}
memberAvatars={Array.isArray(avatar) ? avatar : undefined}
<Link aria-label={id} ref={setAnchor} to={groupUrl}>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
className={className}
contextMenuItems={dropdownMenu}
disabled={isUpdating}
draggable={!isUpdating}
extra={pinIcon}
icon={avatarIcon}
key={id}
style={style}
title={displayTitle}
toggleEditing={toggleEditing}
onDoubleClick={handleDoubleClick}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
/>
</>
</Link>
);
});

View File

@@ -5,19 +5,26 @@ import { LucideCopy, Pen, PictureInPicture2Icon, Pin, PinOff, Trash } from 'luci
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { openEditingPopover } from '@/features/EditingPopover/store';
import { useGlobalStore } from '@/store/global';
import { useHomeStore } from '@/store/home';
interface UseGroupDropdownMenuParams {
anchor: HTMLElement | null;
avatar?: string;
id: string;
memberAvatars?: { avatar?: string; background?: string }[];
pinned: boolean;
toggleEditing: (visible?: boolean) => void;
title: string;
}
export const useGroupDropdownMenu = ({
anchor,
avatar,
id,
memberAvatars,
pinned,
toggleEditing,
title,
}: UseGroupDropdownMenuParams): (() => MenuProps['items']) => {
const { t } = useTranslation('chat');
const { modal, message } = App.useApp();
@@ -44,7 +51,9 @@ export const useGroupDropdownMenu = ({
label: t('rename', { ns: 'common' }),
onClick: (info: any) => {
info.domEvent?.stopPropagation();
toggleEditing(true);
if (anchor) {
openEditingPopover({ anchor, avatar, id, memberAvatars, title, type: 'agentGroup' });
}
},
},
{
@@ -86,11 +95,14 @@ export const useGroupDropdownMenu = ({
},
] as MenuProps['items'],
[
anchor,
avatar,
memberAvatars,
t,
pinned,
pinAgentGroup,
id,
toggleEditing,
title,
duplicateAgentGroup,
openAgentInNewWindow,
modal,

View File

@@ -1,134 +0,0 @@
import { DEFAULT_AVATAR } from '@lobechat/const';
import { Flexbox, Input, stopPropagation } from '@lobehub/ui';
import { type InputRef, message, Popover } from 'antd';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import EmojiPicker from '@/components/EmojiPicker';
import { useAgentStore } from '@/store/agent';
import { useFileStore } from '@/store/file';
import { useGlobalStore } from '@/store/global';
import { globalGeneralSelectors } from '@/store/global/selectors';
import { useHomeStore } from '@/store/home';
const MAX_AVATAR_SIZE = 1024 * 1024;
interface EditingProps {
avatar?: string;
id: string;
title: string;
toggleEditing: (visible?: boolean) => void;
}
const Editing = memo<EditingProps>(({ id, title, avatar, toggleEditing }) => {
const { t } = useTranslation('setting');
const locale = useGlobalStore(globalGeneralSelectors.currentLanguage);
const editing = useHomeStore((s) => s.agentRenamingId === id);
const uploadWithProgress = useFileStore((s) => s.uploadWithProgress);
const currentAvatar = avatar || DEFAULT_AVATAR;
const [newTitle, setNewTitle] = useState(title);
const [newAvatar, setNewAvatar] = useState(currentAvatar);
const [uploading, setUploading] = useState(false);
const inputRef = useRef<InputRef>(null);
useEffect(() => {
if (editing) {
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}, [editing]);
const handleUpdate = useCallback(async () => {
const hasChanges =
(newTitle && title !== newTitle) || (newAvatar && currentAvatar !== newAvatar);
if (hasChanges) {
try {
useHomeStore.getState().setAgentUpdatingId(id);
const updates: { avatar?: string; title?: string } = {};
if (newTitle && title !== newTitle) updates.title = newTitle;
if (newAvatar && currentAvatar !== newAvatar) updates.avatar = newAvatar;
await useAgentStore.getState().optimisticUpdateAgentMeta(id, updates);
await useHomeStore.getState().refreshAgentList();
} finally {
useHomeStore.getState().setAgentUpdatingId(null);
}
}
toggleEditing(false);
}, [newTitle, newAvatar, title, currentAvatar, id, toggleEditing]);
const handleAvatarUpload = useCallback(
async (file: File) => {
if (file.size > MAX_AVATAR_SIZE) {
message.error(t('settingAgent.avatar.sizeExceeded'));
return;
}
setUploading(true);
try {
const result = await uploadWithProgress({ file });
if (result?.url) {
setNewAvatar(result.url);
}
} finally {
setUploading(false);
}
},
[uploadWithProgress, t],
);
const handleAvatarDelete = useCallback(() => {
setNewAvatar(DEFAULT_AVATAR);
}, []);
return (
<Popover
arrow={false}
open={editing}
overlayInnerStyle={{ padding: 4 }}
placement="bottomLeft"
trigger="click"
content={
<Flexbox horizontal gap={4} style={{ width: 320 }} onClick={stopPropagation}>
<EmojiPicker
allowUpload
allowDelete={!!newAvatar && newAvatar !== DEFAULT_AVATAR}
loading={uploading}
locale={locale}
shape={'square'}
size={36}
value={newAvatar}
onChange={setNewAvatar}
onDelete={handleAvatarDelete}
onUpload={handleAvatarUpload}
/>
<Input
defaultValue={title}
ref={inputRef}
style={{ flex: 1 }}
onChange={(e) => setNewTitle(e.target.value)}
onPressEnter={() => handleUpdate()}
onKeyDown={(e) => {
if (e.key === 'Escape') toggleEditing(false);
}}
/>
</Flexbox>
}
onOpenChange={(open) => {
if (!open) handleUpdate();
}}
>
<div />
</Popover>
);
});
export default Editing;

View File

@@ -4,7 +4,7 @@ import { ActionIcon, Icon } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { Loader2, PinIcon } from 'lucide-react';
import { type CSSProperties, type DragEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
@@ -17,7 +17,6 @@ import { useHomeStore } from '@/store/home';
import { useAgentModal } from '../../ModalProvider';
import Actions from '../Item/Actions';
import Avatar from './Avatar';
import Editing from './Editing';
import { useAgentDropdownMenu } from './useDropdownMenu';
interface AgentItemProps {
@@ -30,13 +29,10 @@ const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
const { id, avatar, title, pinned } = item;
const { t } = useTranslation('chat');
const { openCreateGroupModal } = useAgentModal();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const openAgentInNewWindow = useGlobalStore((s) => s.openAgentInNewWindow);
// Get UI state from homeStore (editing, updating)
const [editing, isUpdating] = useHomeStore((s) => [
s.agentRenamingId === id,
s.agentUpdatingId === id,
]);
const isUpdating = useHomeStore((s) => s.agentUpdatingId === id);
// Separate loading state from chat store - only show loading for this specific agent
const isLoading = useChatStore(operationSelectors.isAgentRunning(id));
@@ -72,13 +68,6 @@ const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
openCreateGroupModal(id);
}, [id, openCreateGroupModal]);
const toggleEditing = useCallback(
(visible?: boolean) => {
useHomeStore.getState().setAgentRenamingId(visible ? id : null);
},
[id],
);
// Memoize pin icon
const pinIcon = useMemo(
() =>
@@ -98,41 +87,34 @@ const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
}, [isUpdating, avatar]);
const dropdownMenu = useAgentDropdownMenu({
anchor,
avatar: typeof avatar === 'string' ? avatar : undefined,
group: undefined, // TODO: pass group from parent if needed
id,
openCreateGroupModal: handleOpenCreateGroupModal,
pinned: pinned ?? false,
toggleEditing,
title: displayTitle,
});
return (
<>
<Link aria-label={displayTitle} to={agentUrl}>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
className={className}
contextMenuItems={dropdownMenu}
disabled={editing || isUpdating}
draggable={!editing && !isUpdating}
extra={pinIcon}
icon={avatarIcon}
key={id}
loading={isLoading}
style={style}
title={displayTitle}
onDoubleClick={handleDoubleClick}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
/>
</Link>
<Editing
avatar={typeof avatar === 'string' ? avatar : undefined}
id={id}
<Link aria-label={displayTitle} ref={setAnchor} to={agentUrl}>
<NavItem
actions={<Actions dropdownMenu={dropdownMenu} />}
className={className}
contextMenuItems={dropdownMenu}
disabled={isUpdating}
draggable={!isUpdating}
extra={pinIcon}
icon={avatarIcon}
key={id}
loading={isLoading}
style={style}
title={displayTitle}
toggleEditing={toggleEditing}
onDoubleClick={handleDoubleClick}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
/>
</>
</Link>
);
});

View File

@@ -17,24 +17,29 @@ import {
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { openEditingPopover } from '@/features/EditingPopover/store';
import { useGlobalStore } from '@/store/global';
import { useHomeStore } from '@/store/home';
import { homeAgentListSelectors } from '@/store/home/selectors';
interface UseAgentDropdownMenuParams {
anchor: HTMLElement | null;
avatar?: string;
group: string | undefined;
id: string;
openCreateGroupModal: () => void;
pinned: boolean;
toggleEditing: (visible?: boolean) => void;
title: string;
}
export const useAgentDropdownMenu = ({
anchor,
avatar,
group,
id,
openCreateGroupModal,
pinned,
toggleEditing,
title,
}: UseAgentDropdownMenuParams): (() => MenuProps['items']) => {
const { t } = useTranslation('chat');
const { modal, message } = App.useApp();
@@ -65,7 +70,9 @@ export const useAgentDropdownMenu = ({
label: t('rename', { ns: 'common' }),
onClick: (info: any) => {
info.domEvent?.stopPropagation();
toggleEditing(true);
if (anchor) {
openEditingPopover({ anchor, avatar, id, title, type: 'agent' });
}
},
},
{
@@ -137,9 +144,11 @@ export const useAgentDropdownMenu = ({
},
] as MenuProps['items'],
[
anchor,
pinned,
id,
toggleEditing,
avatar,
title,
sessionCustomGroups,
group,
isDefault,

View File

@@ -1,69 +0,0 @@
import { Input, stopPropagation } from '@lobehub/ui';
import { type InputRef, Popover } from 'antd';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useHomeStore } from '@/store/home';
interface EditingProps {
id: string;
name: string;
toggleEditing: (visible?: boolean) => void;
}
const Editing = memo<EditingProps>(({ id, name, toggleEditing }) => {
const [newName, setNewName] = useState(name);
const [editing, updateGroupName] = useHomeStore((s) => [
s.groupRenamingId === id,
s.updateGroupName,
]);
const inputRef = useRef<InputRef>(null);
useEffect(() => {
if (editing) {
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}, [editing]);
const handleUpdate = useCallback(async () => {
if (newName && name !== newName) {
try {
useHomeStore.getState().setGroupUpdatingId(id);
await updateGroupName(id, newName);
} finally {
useHomeStore.getState().setGroupUpdatingId(null);
}
}
toggleEditing(false);
}, [newName, name, id, updateGroupName, toggleEditing]);
return (
<Popover
arrow={false}
open={editing}
overlayInnerStyle={{ padding: 4, width: 320 }}
placement="bottomLeft"
trigger="click"
content={
<Input
defaultValue={name}
ref={inputRef}
onBlur={() => handleUpdate()}
onChange={(e) => setNewName(e.target.value)}
onClick={stopPropagation}
onPressEnter={() => handleUpdate()}
onKeyDown={(e) => {
if (e.key === 'Escape') toggleEditing(false);
}}
/>
}
>
<div />
</Popover>
);
});
export default Editing;

View File

@@ -2,7 +2,7 @@ import { type SidebarGroup } from '@lobechat/types';
import { AccordionItem, ContextMenuTrigger, Flexbox, Icon, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { HashIcon, Loader2 } from 'lucide-react';
import React, { memo, useCallback, useMemo } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { useHomeStore } from '@/store/home';
@@ -10,7 +10,6 @@ import { useCreateMenuItems } from '../../../../hooks';
import { useAgentModal } from '../../ModalProvider';
import SessionList from '../List';
import Actions from './Actions';
import Editing from './Editing';
import { useGroupDropdownMenu } from './useDropdownMenu';
const styles = createStaticStyles(({ css }) => ({
@@ -20,10 +19,8 @@ const styles = createStaticStyles(({ css }) => ({
}));
const GroupItem = memo<SidebarGroup>(({ items, id, name }) => {
const [editing, isUpdating] = useHomeStore((s) => [
s.groupRenamingId === id,
s.groupUpdatingId === id,
]);
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const isUpdating = useHomeStore((s) => s.groupUpdatingId === id);
// Modal management
const { openConfigGroupModal } = useAgentModal();
@@ -31,22 +28,16 @@ const GroupItem = memo<SidebarGroup>(({ items, id, name }) => {
// Create menu items
const { isLoading } = useCreateMenuItems();
const toggleEditing = useCallback(
(visible?: boolean) => {
useHomeStore.getState().setGroupRenamingId(visible ? id : null);
},
[id],
);
const handleOpenConfigGroupModal = useCallback(() => {
openConfigGroupModal();
}, [openConfigGroupModal]);
const dropdownMenu = useGroupDropdownMenu({
anchor,
id,
isCustomGroup: true,
name,
openConfigGroupModal: handleOpenConfigGroupModal,
toggleEditing,
});
const groupIcon = useMemo(() => {
@@ -59,13 +50,15 @@ const GroupItem = memo<SidebarGroup>(({ items, id, name }) => {
return (
<AccordionItem
action={<Actions dropdownMenu={dropdownMenu} isLoading={isLoading} />}
disabled={editing || isUpdating}
disabled={isUpdating}
itemKey={id}
key={id}
paddingBlock={4}
paddingInline={'8px 4px'}
headerWrapper={(header) => (
<ContextMenuTrigger items={dropdownMenu}>{header}</ContextMenuTrigger>
<ContextMenuTrigger items={dropdownMenu}>
<div ref={setAnchor}>{header}</div>
</ContextMenuTrigger>
)}
title={
<Flexbox horizontal align="center" gap={6} style={{ overflow: 'hidden' }}>
@@ -76,7 +69,6 @@ const GroupItem = memo<SidebarGroup>(({ items, id, name }) => {
</Flexbox>
}
>
<Editing id={id} name={name} toggleEditing={toggleEditing} />
<SessionList dataSource={items} groupId={id} itemClassName={styles.item} />
</AccordionItem>
);

View File

@@ -4,18 +4,20 @@ import { useMemo } from 'react';
import { useCreateMenuItems, useSessionGroupMenuItems } from '../../../../hooks';
interface GroupDropdownMenuProps {
anchor: HTMLElement | null;
id?: string;
isCustomGroup?: boolean;
isPinned?: boolean;
name?: string;
openConfigGroupModal: () => void;
toggleEditing?: (visible?: boolean) => void;
}
export const useGroupDropdownMenu = ({
anchor,
id,
isCustomGroup,
isPinned,
toggleEditing,
name,
openConfigGroupModal,
}: GroupDropdownMenuProps): MenuProps['items'] => {
// Session group menu items
@@ -29,7 +31,7 @@ export const useGroupDropdownMenu = ({
const createAgentItem = createAgentMenuItem({ groupId: id, isPinned });
const createGroupChatItem = createGroupChatMenuItem({ groupId: id });
const configItem = configGroupMenuItem(openConfigGroupModal);
const renameItem = toggleEditing ? renameGroupMenuItem(toggleEditing) : null;
const renameItem = id && name ? renameGroupMenuItem(id, name, anchor) : null;
const deleteItem = id ? deleteGroupMenuItem(id) : null;
return [
@@ -41,10 +43,11 @@ export const useGroupDropdownMenu = ({
: [configItem]),
].filter(Boolean) as MenuProps['items'];
}, [
anchor,
isCustomGroup,
id,
isPinned,
toggleEditing,
name,
createAgentMenuItem,
createGroupChatMenuItem,
configGroupMenuItem,

View File

@@ -5,6 +5,7 @@ import { createContext, memo, use, useMemo, useState } from 'react';
import { ChatGroupWizard } from '@/components/ChatGroupWizard';
import { MemberSelectionModal } from '@/components/MemberSelectionModal';
import EditingPopover from '@/features/EditingPopover';
import ConfigGroupModal from './Modals/ConfigGroupModal';
import CreateGroupModal from './Modals/CreateGroupModal';
@@ -139,6 +140,8 @@ export const AgentModalProvider = memo<AgentModalProviderProps>(({ children }) =
await memberSelectionCallbacks.onConfirm?.(selectedAgents);
}}
/>
<EditingPopover />
</AgentModalContext>
);
});

View File

@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import { useGroupTemplates } from '@/components/ChatGroupWizard/templates';
import { DEFAULT_CHAT_GROUP_CHAT_CONFIG } from '@/const/settings';
import { openEditingPopover } from '@/features/EditingPopover/store';
import { useAgentStore } from '@/store/agent';
import { useAgentGroupStore } from '@/store/agentGroup';
import { useHomeStore } from '@/store/home';
@@ -38,7 +39,7 @@ export const useSessionGroupMenuItems = () => {
* Rename group menu item
*/
const renameGroupMenuItem = useCallback(
(onToggleEdit: (visible?: boolean) => void): ItemType => {
(groupId: string, groupName: string, anchor: HTMLElement | null): ItemType => {
const iconElement = <Icon icon={FolderPenIcon} />;
return {
icon: iconElement,
@@ -46,7 +47,9 @@ export const useSessionGroupMenuItems = () => {
label: t('sessionGroup.rename'),
onClick: (info: any) => {
info.domEvent?.stopPropagation();
onToggleEdit(true);
if (anchor) {
openEditingPopover({ anchor, id: groupId, title: groupName, type: 'group' });
}
},
};
},

View File

@@ -0,0 +1,92 @@
import { ActionIcon, Avatar, Block, Flexbox, Input, stopPropagation } from '@lobehub/ui';
import { type InputRef } from 'antd';
import { Check } from 'lucide-react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import EmojiPicker from '@/components/EmojiPicker';
import { useIsDark } from '@/hooks/useIsDark';
import { useAgentStore } from '@/store/agent';
import { useGlobalStore } from '@/store/global';
import { globalGeneralSelectors } from '@/store/global/selectors';
import { useHomeStore } from '@/store/home';
interface AgentContentProps {
avatar?: string;
id: string;
onClose: () => void;
title: string;
}
const AgentContent = memo<AgentContentProps>(({ id, title, avatar, onClose }) => {
const locale = useGlobalStore(globalGeneralSelectors.currentLanguage);
const isDarkMode = useIsDark();
const currentAvatar = avatar || '';
const [newTitle, setNewTitle] = useState(title);
const [newAvatar, setNewAvatar] = useState(currentAvatar);
const handleUpdate = useCallback(async () => {
const hasChanges =
(newTitle && title !== newTitle) || (newAvatar && currentAvatar !== newAvatar);
if (hasChanges) {
try {
useHomeStore.getState().setAgentUpdatingId(id);
const updates: { avatar?: string; title?: string } = {};
if (newTitle && title !== newTitle) updates.title = newTitle;
if (newAvatar && currentAvatar !== newAvatar) updates.avatar = newAvatar;
await useAgentStore.getState().optimisticUpdateAgentMeta(id, updates);
await useHomeStore.getState().refreshAgentList();
} finally {
useHomeStore.getState().setAgentUpdatingId(null);
}
}
onClose();
}, [newTitle, newAvatar, title, currentAvatar, id, onClose]);
const inputRef = useRef<InputRef>(null);
useEffect(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (inputRef.current) {
inputRef.current.focus();
}
});
});
}, []);
return (
<Flexbox horizontal align={'center'} gap={4} style={{ width: 320 }} onClick={stopPropagation}>
<EmojiPicker
locale={locale}
shape={'square'}
value={newAvatar}
customRender={(avatarValue) => (
<Block
clickable
align={'center'}
height={36}
justify={'center'}
variant={isDarkMode ? 'filled' : 'outlined'}
width={36}
onClick={stopPropagation}
>
<Avatar emojiScaleWithBackground avatar={avatarValue} shape={'square'} size={32} />
</Block>
)}
onChange={setNewAvatar}
/>
<Input
defaultValue={title}
ref={inputRef}
style={{ flex: 1 }}
onChange={(e) => setNewTitle(e.target.value)}
onPressEnter={handleUpdate}
/>
<ActionIcon icon={Check} size={'small'} onClick={handleUpdate} />
</Flexbox>
);
});
export default AgentContent;

View File

@@ -0,0 +1,146 @@
import { ActionIcon, Avatar, Block, Flexbox, Input, stopPropagation } from '@lobehub/ui';
import { type InputRef, message } from 'antd';
import { Check } from 'lucide-react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import EmojiPicker from '@/components/EmojiPicker';
import GroupAvatar from '@/features/GroupAvatar';
import { useIsDark } from '@/hooks/useIsDark';
import { useFileStore } from '@/store/file';
import { useGlobalStore } from '@/store/global';
import { globalGeneralSelectors } from '@/store/global/selectors';
import { useHomeStore } from '@/store/home';
const MAX_AVATAR_SIZE = 1024 * 1024;
interface GroupContentProps {
avatar?: string;
id: string;
memberAvatars?: { avatar?: string; background?: string }[];
onClose: () => void;
title: string;
type: 'group' | 'agentGroup';
}
const GroupContent = memo<GroupContentProps>(
({ id, title, avatar, memberAvatars, type, onClose }) => {
const { t } = useTranslation('setting');
const locale = useGlobalStore(globalGeneralSelectors.currentLanguage);
const isDarkMode = useIsDark();
const uploadWithProgress = useFileStore((s) => s.uploadWithProgress);
const isAgentGroup = type === 'agentGroup';
const [newTitle, setNewTitle] = useState(title);
const [newAvatar, setNewAvatar] = useState<string | null | undefined>(avatar);
const [uploading, setUploading] = useState(false);
const handleUpdate = useCallback(async () => {
const titleChanged = newTitle && title !== newTitle;
const avatarChanged = isAgentGroup && newAvatar !== avatar;
if (titleChanged || avatarChanged) {
try {
useHomeStore.getState().setGroupUpdatingId(id);
if (type === 'group') {
await useHomeStore.getState().updateGroupName(id, newTitle);
} else {
await useHomeStore
.getState()
.renameAgentGroup(id, newTitle || title, avatarChanged ? newAvatar : undefined);
}
} finally {
useHomeStore.getState().setGroupUpdatingId(null);
}
}
onClose();
}, [newTitle, newAvatar, title, avatar, id, type, isAgentGroup, onClose]);
const handleAvatarUpload = useCallback(
async (file: File) => {
if (file.size > MAX_AVATAR_SIZE) {
message.error(t('settingAgent.avatar.sizeExceeded'));
return;
}
setUploading(true);
try {
const result = await uploadWithProgress({ file });
if (result?.url) {
setNewAvatar(result.url);
}
} finally {
setUploading(false);
}
},
[uploadWithProgress, t],
);
const handleAvatarDelete = useCallback(() => {
setNewAvatar(null);
}, []);
const inputRef = useRef<InputRef>(null);
useEffect(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (inputRef.current) {
inputRef.current.focus();
}
});
});
}, []);
return (
<Flexbox horizontal align={'center'} gap={4} style={{ width: 320 }} onClick={stopPropagation}>
{isAgentGroup && (
<EmojiPicker
allowUpload
allowDelete={!!newAvatar}
loading={uploading}
locale={locale}
shape={'square'}
value={newAvatar ?? undefined}
customRender={(avatarValue) => (
<Block
clickable
align={'center'}
height={36}
justify={'center'}
variant={isDarkMode ? 'filled' : 'outlined'}
width={36}
onClick={stopPropagation}
>
{avatarValue ? (
<Avatar
emojiScaleWithBackground
avatar={avatarValue}
shape={'square'}
size={32}
/>
) : (
<GroupAvatar avatars={memberAvatars || []} size={32} />
)}
</Block>
)}
onChange={setNewAvatar}
onDelete={handleAvatarDelete}
onUpload={handleAvatarUpload}
/>
)}
<Input
defaultValue={title}
ref={inputRef}
style={{ flex: 1 }}
onChange={(e) => setNewTitle(e.target.value)}
onPressEnter={handleUpdate}
/>
<ActionIcon icon={Check} size={'small'} onClick={handleUpdate} />
</Flexbox>
);
},
);
export default GroupContent;

View File

@@ -0,0 +1,47 @@
'use client';
import { PopoverPopup, PopoverPortal, PopoverPositioner, PopoverRoot } from '@lobehub/ui';
import AgentContent from './AgentContent';
import GroupContent from './GroupContent';
import { useEditingPopoverStore } from './store';
const EditingPopover = () => {
const target = useEditingPopoverStore((s) => s.target);
const close = useEditingPopoverStore((s) => s.close);
return (
<PopoverRoot
open={target !== null}
onOpenChange={(open) => {
if (!open) close();
}}
>
<PopoverPortal>
<PopoverPositioner anchor={target?.anchor ?? document.body} placement="bottomLeft">
<PopoverPopup data-testid="editing-popover" style={{ padding: 4 }}>
{target?.type === 'agent' ? (
<AgentContent
avatar={target.avatar}
id={target.id}
title={target.title}
onClose={close}
/>
) : target ? (
<GroupContent
avatar={target.avatar}
id={target.id}
memberAvatars={target.memberAvatars}
title={target.title}
type={target.type}
onClose={close}
/>
) : null}
</PopoverPopup>
</PopoverPositioner>
</PopoverPortal>
</PopoverRoot>
);
};
export default EditingPopover;

View File

@@ -0,0 +1,27 @@
import { create } from 'zustand';
export interface EditingTarget {
anchor: HTMLElement;
avatar?: string;
id: string;
memberAvatars?: { avatar?: string; background?: string }[];
title: string;
type: 'agent' | 'group' | 'agentGroup';
}
interface EditingPopoverState {
close: () => void;
open: (target: EditingTarget) => void;
target: EditingTarget | null;
}
export const useEditingPopoverStore = create<EditingPopoverState>((set) => ({
close: () => set({ target: null }),
open: (target) => set({ target }),
target: null,
}));
export const openEditingPopover = (target: EditingTarget) =>
useEditingPopoverStore.getState().open(target);
export const closeEditingPopover = () => useEditingPopoverStore.getState().close();

View File

@@ -317,32 +317,6 @@ describe('createSidebarUISlice', () => {
});
// ========== UI State Actions ==========
describe('setAgentRenamingId', () => {
it('should set agent renaming id', () => {
const { result } = renderHook(() => useHomeStore());
act(() => {
result.current.setAgentRenamingId('agent-123');
});
expect(result.current.agentRenamingId).toBe('agent-123');
});
it('should clear agent renaming id when set to null', () => {
const { result } = renderHook(() => useHomeStore());
act(() => {
result.current.setAgentRenamingId('agent-123');
});
act(() => {
result.current.setAgentRenamingId(null);
});
expect(result.current.agentRenamingId).toBeNull();
});
});
describe('setAgentUpdatingId', () => {
it('should set agent updating id', () => {
const { result } = renderHook(() => useHomeStore());
@@ -369,32 +343,6 @@ describe('createSidebarUISlice', () => {
});
});
describe('setGroupRenamingId', () => {
it('should set group renaming id', () => {
const { result } = renderHook(() => useHomeStore());
act(() => {
result.current.setGroupRenamingId('group-123');
});
expect(result.current.groupRenamingId).toBe('group-123');
});
it('should clear group renaming id when set to null', () => {
const { result } = renderHook(() => useHomeStore());
act(() => {
result.current.setGroupRenamingId('group-123');
});
act(() => {
result.current.setGroupRenamingId(null);
});
expect(result.current.groupRenamingId).toBeNull();
});
});
describe('setGroupUpdatingId', () => {
it('should set group updating id', () => {
const { result } = renderHook(() => useHomeStore());

View File

@@ -146,18 +146,10 @@ export class SidebarUIActionImpl {
await this.#get().refreshAgentList();
};
setAgentRenamingId = (id: string | null): void => {
this.#set({ agentRenamingId: id }, false, n('setAgentRenamingId'));
};
setAgentUpdatingId = (id: string | null): void => {
this.#set({ agentUpdatingId: id }, false, n('setAgentUpdatingId'));
};
setGroupRenamingId = (id: string | null): void => {
this.#set({ groupRenamingId: id }, false, n('setGroupRenamingId'));
};
setGroupUpdatingId = (id: string | null): void => {
this.#set({ groupUpdatingId: id }, false, n('setGroupUpdatingId'));
};

View File

@@ -1,16 +1,8 @@
export interface SidebarUIState {
/**
* ID of the agent currently being renamed
*/
agentRenamingId: string | null;
/**
* ID of the agent currently being updated
*/
agentUpdatingId: string | null;
/**
* ID of the group currently being renamed
*/
groupRenamingId: string | null;
/**
* ID of the group currently being updated
*/
@@ -18,8 +10,6 @@ export interface SidebarUIState {
}
export const initialSidebarUIState: SidebarUIState = {
agentRenamingId: null,
agentUpdatingId: null,
groupRenamingId: null,
groupUpdatingId: null,
};