diff --git a/web/app/components/base/app-icon-picker/index.tsx b/web/app/components/base/app-icon-picker/index.tsx index f8cb056ecda..67ff538474d 100644 --- a/web/app/components/base/app-icon-picker/index.tsx +++ b/web/app/components/base/app-icon-picker/index.tsx @@ -179,6 +179,7 @@ function AppIconPickerContent({ return ( ({ onSaved, }: { open: boolean - onSaved: (binding: { - agent_id?: string | null - binding_type: 'inline_agent' | 'roster_agent' - current_snapshot_id?: string | null - id: string - node_id: string - workflow_id: string - }) => void + onSaved: (agentId: string) => void }) => open ? (
-
diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx index 97a0ff5323c..2e6a40f8560 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx @@ -1,5 +1,5 @@ import type { AgentComposerAgentResponse } from '@dify/contracts/api/console/apps/types.gen' -import { render, screen, within } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { FlowType } from '@/types/common' import { SaveInlineAgentToRosterDialog } from '../save-inline-agent-to-roster-dialog' @@ -112,7 +112,6 @@ const renderDialog = (agent: AgentComposerAgentResponse = inlineAgent) => { { { }), ) }) + + it('keeps one source snapshot while open and uses the latest agent after reopening', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const onSaved = vi.fn() + const updatedInlineAgent = { + ...inlineAgent, + description: 'Updated source description.', + icon: '🦊', + icon_background: '#FFEDD5', + role: 'Updated source role', + } + const { rerender } = render( + , + ) + + rerender( + , + ) + + let dialog = screen.getByRole('dialog', { + name: 'agentV2.roster.saveToRosterDialog.title', + }) + expect( + within(dialog).getByRole('textbox', { + name: 'agentV2.roster.createForm.roleLabel common.label.optional', + }), + ).toHaveValue('Tender Analyst') + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.saveToRosterForm.changeIcon', + }), + ) + expect(screen.getByText('🤖:#F5F3FF')).toBeInTheDocument() + + rerender( + , + ) + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + rerender( + , + ) + dialog = screen.getByRole('dialog', { + name: 'agentV2.roster.saveToRosterDialog.title', + }) + expect( + within(dialog).getByRole('textbox', { + name: 'agentV2.roster.createForm.roleLabel common.label.optional', + }), + ).toHaveValue('Updated source role') + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.saveToRosterForm.changeIcon', + }), + ) + expect(screen.getByText('🦊:#FFEDD5')).toBeInTheDocument() + }) + + it('returns only the saved roster agent id after a successful save', async () => { + const user = userEvent.setup() + const { onOpenChange, onSaved } = renderDialog() + + const dialog = screen.getByRole('dialog', { + name: 'agentV2.roster.saveToRosterDialog.title', + }) + await user.type( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + 'Roster Tender Agent', + ) + await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) + + const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1] + mutationOptions.onSuccess({ + binding: { + agent_id: 'roster-agent-1', + binding_type: 'roster_agent', + }, + }) + + expect(onSaved).toHaveBeenCalledWith('roster-agent-1') + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(toastMock.success).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx b/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx index 318d9be15cb..69f4f72b89d 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx @@ -1,9 +1,9 @@ 'use client' import type { AgentComposerAgentResponse, - AgentComposerBindingResponse, WorkflowAgentComposerResponse, } from '@dify/contracts/api/console/apps/types.gen' +import type { Ref } from 'react' import type { AgentFormValues, AgentIconSelection, @@ -18,34 +18,100 @@ import { } from '@langgenius/dify-ui/dialog' import { Form } from '@langgenius/dify-ui/form' import { IconButton } from '@langgenius/dify-ui/icon-button' -import { toast } from '@langgenius/dify-ui/toast' import { useMutation } from '@tanstack/react-query' -import { useState } from 'react' +import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIconPicker from '@/app/components/base/app-icon-picker' -import { - createAgentIconSelection, - defaultAgentIcon, -} from '@/features/agent-v2/roster/components/agent-form' +import { createAgentIconSelection } from '@/features/agent-v2/roster/components/agent-form' import { AgentFormFields } from '@/features/agent-v2/roster/components/agent-form-fields' import { consoleQuery } from '@/service/client' import { FlowType } from '@/types/common' type SaveInlineAgentToRosterDialogProps = { - flowId?: string - flowType?: FlowType - formKey: number - initialAgent?: AgentComposerAgentResponse | null + flowId: string + flowType: FlowType.appFlow | FlowType.snippet + initialAgent: AgentComposerAgentResponse nodeId: string open: boolean onOpenChange: (open: boolean) => void - onSaved: (binding: AgentComposerBindingResponse) => void + onSaved: (agentId: string) => void +} + +type SaveInlineAgentToRosterFormSessionProps = { + initialAgent: AgentComposerAgentResponse + nameInputRef: Ref + pending: boolean + onCancel: () => void + onSubmit: (formValues: AgentFormValues, agentIcon: AgentIconSelection) => void +} + +function SaveInlineAgentToRosterFormSession({ + initialAgent, + nameInputRef, + pending, + onCancel, + onSubmit, +}: SaveInlineAgentToRosterFormSessionProps) { + const { t } = useTranslation('agentV2') + const { t: tCommon } = useTranslation('common') + const [initialValues] = useState(() => ({ + fields: { + description: initialAgent.description ?? '', + name: '', + role: initialAgent.role ?? '', + } satisfies AgentFormValues, + icon: createAgentIconSelection(initialAgent), + })) + const [agentIcon, setAgentIcon] = useState(initialValues.icon) + const [iconPickerOpen, setIconPickerOpen] = useState(false) + + return ( + <> +
+ + {t(($) => $['roster.saveToRosterDialog.title'])} + + + {t(($) => $['roster.saveToRosterDialog.description'])} + +
+ + className="flex min-h-0 flex-1 flex-col" + onFormSubmit={(formValues) => onSubmit(formValues, agentIcon)} + > + $['roster.saveToRosterForm.changeIcon'])} + onIconClick={() => setIconPickerOpen(true)} + /> +
+ + +
+ + + + ) } export function SaveInlineAgentToRosterDialog({ flowId, flowType, - formKey, initialAgent, nodeId, open, @@ -53,14 +119,7 @@ export function SaveInlineAgentToRosterDialog({ onSaved, }: SaveInlineAgentToRosterDialogProps) { const { t } = useTranslation('agentV2') - const { t: tCommon } = useTranslation('common') - const [name, setName] = useState('') - const [description, setDescription] = useState(initialAgent?.description ?? '') - const [role, setRole] = useState(initialAgent?.role ?? '') - const [iconPickerOpen, setIconPickerOpen] = useState(false) - const [agentIcon, setAgentIcon] = useState(() => - initialAgent ? createAgentIconSelection(initialAgent) : defaultAgentIcon, - ) + const nameInputRef = useRef(null) const appSaveToRosterMutation = useMutation( consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions(), ) @@ -69,33 +128,20 @@ export function SaveInlineAgentToRosterDialog({ ) const isSavingToRoster = appSaveToRosterMutation.isPending || snippetSaveToRosterMutation.isPending - const handleOpenChange = (nextOpen: boolean) => { - if (nextOpen) { - setName('') - setDescription(initialAgent?.description ?? '') - setRole(initialAgent?.role ?? '') - setAgentIcon(initialAgent ? createAgentIconSelection(initialAgent) : defaultAgentIcon) - } else { - setIconPickerOpen(false) - } + if (!nextOpen && isSavingToRoster) return onOpenChange(nextOpen) } - const handleSubmit = (formValues: AgentFormValues) => { + const handleSubmit = (formValues: AgentFormValues, agentIcon: AgentIconSelection) => { if (isSavingToRoster) return - if (!flowId) return - - const trimmedName = formValues.name?.trim() ?? '' - const trimmedRole = formValues.role?.trim() ?? '' - const body = { variant: 'workflow' as const, save_strategy: 'save_to_roster' as const, - new_agent_name: trimmedName, - description: formValues.description?.trim() ?? '', - role: trimmedRole, + new_agent_name: formValues.name.trim(), + description: formValues.description.trim(), + role: formValues.role.trim(), icon_type: agentIcon.type, icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon, icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined, @@ -105,9 +151,8 @@ export function SaveInlineAgentToRosterDialog({ const binding = composerState.binding if (binding?.binding_type !== 'roster_agent' || !binding.agent_id) return - toast.success(t(($) => $['roster.saveToRosterSuccess'])) - onSaved(binding) - handleOpenChange(false) + onSaved(binding.agent_id) + onOpenChange(false) }, } @@ -142,75 +187,31 @@ export function SaveInlineAgentToRosterDialog({ return ( <> - + $['operation.close'], { ns: 'common' })} size="lg" - className="absolute inset-e-6 top-6" + className="absolute inset-e-5 top-5" > } /> -
- - {t(($) => $['roster.saveToRosterDialog.title'])} - - - {t(($) => $['roster.saveToRosterDialog.description'])} - -
- - key={formKey} - className="min-h-0 flex-1" - onFormSubmit={handleSubmit} - > - $['roster.saveToRosterForm.changeIcon'])} - name={name} - role={role} - onDescriptionChange={setDescription} - onIconClick={() => setIconPickerOpen(true)} - onNameChange={setName} - onRoleChange={setRole} - /> -
- - -
- + onOpenChange(false)} + onSubmit={handleSubmit} + />
- { - setAgentIcon(icon) - }} - /> ) } diff --git a/web/app/components/workflow/nodes/agent-v2/panel.tsx b/web/app/components/workflow/nodes/agent-v2/panel.tsx index ee5c6b9e6b5..1c12d645377 100644 --- a/web/app/components/workflow/nodes/agent-v2/panel.tsx +++ b/web/app/components/workflow/nodes/agent-v2/panel.tsx @@ -131,7 +131,6 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { requestKey: number } | null>(null) const [isOutputVariablesCollapsed, setIsOutputVariablesCollapsed] = useState(true) - const [saveToRosterSessionKey, setSaveToRosterSessionKey] = useState(0) const { handleNodeDataUpdate, handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate() const openInlineAgentPanelNodeId = useStore((state) => state.openInlineAgentPanelNodeId) const setOpenInlineAgentPanelNodeId = useStore((state) => state.setOpenInlineAgentPanelNodeId) @@ -186,7 +185,12 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { const isAgentBindingPending = isInlineAgentPending || isInlineAgentWaitingForCreation || isCreatingInlineAgent const canStartFromScratch = inputs.agent_binding?.binding_type !== 'inline_agent' - const canSaveInlineToRoster = isInlineAgentReady && !!inlineAgent + const saveToRosterTarget = + configsMap?.flowId && + (configsMap.flowType === FlowType.appFlow || configsMap.flowType === FlowType.snippet) + ? { flowId: configsMap.flowId, flowType: configsMap.flowType } + : null + const canSaveInlineToRoster = isInlineAgentReady && !!inlineAgent && !!saveToRosterTarget const inlineComposerStateForPanel = inlineAgentQuery.data const displayedAgent = rosterAgentQuery.data ?? @@ -378,14 +382,11 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { ]) const handleSaveInlineToRosterOpen = useCallback(() => { - setSaveToRosterSessionKey((key) => key + 1) setIsSaveToRosterDialogOpen(true) }, []) const handleInlineSavedToRoster = useCallback( - (binding: AgentComposerBindingResponse) => { - if (binding.binding_type !== 'roster_agent' || !binding.agent_id) return - + (agentId: string) => { setOpenInlineAgentPanelNodeId(undefined) setIsInlineAgentPanelOpenedFromTrigger(false) setIsRosterAgentPanelOpen(true) @@ -395,7 +396,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { delete draft._openInlineAgentPanel draft.agent_binding = { binding_type: 'roster_agent', - agent_id: binding.agent_id!, + agent_id: agentId, } }) inputsRef.current = newInputs @@ -698,17 +699,17 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { onSaveInlineToRoster={canSaveInlineToRoster ? handleSaveInlineToRosterOpen : undefined} onStartFromScratch={canStartFromScratch ? handleStartFromScratch : undefined} /> - + {saveToRosterTarget && inlineAgent && ( + + )}
+type AgentDetailSidebarActionAgent = AgentFormSource & Pick export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebarActionAgent }) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') const { t: tApp } = useTranslation('app') const [isEditOpen, setIsEditOpen] = useState(false) - const [editSessionKey, setEditSessionKey] = useState(0) const [isDuplicateOpen, setIsDuplicateOpen] = useState(false) - const [duplicateSessionKey, setDuplicateSessionKey] = useState(0) const [isDeleteOpen, setIsDeleteOpen] = useState(false) const { exportAppDsl, isExporting } = useExportAppDsl() const router = useRouter() - const dialogAgent: AgentAppPartial = { - description: agent.description, - icon: agent.icon, - icon_background: agent.icon_background, - icon_type: agent.icon_type, - icon_url: agent.icon_url, - id: agent.id, - mode: agent.mode, - name: agent.name, - role: agent.role, - } - const handleEditOpen = () => { - setEditSessionKey((key) => key + 1) setIsEditOpen(true) } const handleDuplicateOpen = () => { - setDuplicateSessionKey((key) => key + 1) setIsDuplicateOpen(true) } @@ -112,15 +85,9 @@ export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebar - + diff --git a/web/features/agent-v2/roster/components/__tests__/agent-form.spec.ts b/web/features/agent-v2/roster/components/__tests__/agent-form.spec.ts new file mode 100644 index 00000000000..d083bc288ae --- /dev/null +++ b/web/features/agent-v2/roster/components/__tests__/agent-form.spec.ts @@ -0,0 +1,17 @@ +import { createAgentIconSelection } from '../agent-form' + +describe('createAgentIconSelection', () => { + it('uses the resolved image URL while preserving the uploaded file id', () => { + expect( + createAgentIconSelection({ + icon: 'uploaded-file-id', + icon_type: 'image', + icon_url: 'https://example.com/resolved-agent-icon.png', + }), + ).toEqual({ + type: 'image', + fileId: 'uploaded-file-id', + url: 'https://example.com/resolved-agent-icon.png', + }) + }) +}) diff --git a/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx b/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx index 7423faf5c41..4890cc5a745 100644 --- a/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx @@ -112,7 +112,7 @@ describe('CreateAgentDialog', () => { mutationOptions.onSuccess({ id: 'agent-1' }) }) - expect(toastMock.success).toHaveBeenCalledWith('agentV2.roster.createSuccess') + expect(toastMock.success).not.toHaveBeenCalled() expect(trackCreateAppMock).toHaveBeenCalledWith({ source: 'studio_blank', appMode: 'agent-v2', @@ -141,6 +141,44 @@ describe('CreateAgentDialog', () => { expect(mutationMock.mutate).not.toHaveBeenCalled() }) + it('focuses the name field when opened and resets native form values after closing', async () => { + const user = userEvent.setup() + render() + + const trigger = screen.getByRole('button', { name: /agentV2\.roster\.createAgent/ }) + await user.click(trigger) + + let dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' }) + const nameInput = within(dialog).getByRole('textbox', { + name: 'agentV2.roster.createForm.nameLabel', + }) + const descriptionInput = within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.descriptionLabel/, + }) + expect(nameInput).toHaveFocus() + expect(descriptionInput).toHaveAttribute('maxlength', '400') + + await user.type(nameInput, 'Temporary Agent') + await user.type(descriptionInput, 'Temporary description') + await user.click(within(dialog).getByRole('button', { name: 'common.operation.cancel' })) + await waitFor(() => { + expect( + screen.queryByRole('dialog', { name: 'agentV2.roster.createDialog.title' }), + ).not.toBeInTheDocument() + }) + + await user.click(trigger) + dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' }) + expect( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + ).toHaveValue('') + expect( + within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.descriptionLabel/, + }), + ).toHaveValue('') + }) + it('marks role and description as optional', async () => { const user = userEvent.setup() render() diff --git a/web/features/agent-v2/roster/components/__tests__/duplicate-agent-dialog.spec.tsx b/web/features/agent-v2/roster/components/__tests__/duplicate-agent-dialog.spec.tsx new file mode 100644 index 00000000000..6087ddb4e97 --- /dev/null +++ b/web/features/agent-v2/roster/components/__tests__/duplicate-agent-dialog.spec.tsx @@ -0,0 +1,151 @@ +import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { DuplicateAgentDialog } from '../duplicate-agent-dialog' + +const queryDataMock = vi.hoisted(() => vi.fn()) +const mutationMock = vi.hoisted(() => ({ + isPending: false, + mutate: vi.fn(), +})) + +vi.mock('@tanstack/react-query', () => ({ + useMutation: () => mutationMock, + useQueryClient: () => ({ + getQueryData: queryDataMock, + }), +})) + +vi.mock('@/app/components/base/app-icon-picker', () => ({ + __esModule: true, + default: ({ + initialEmoji, + open, + }: { + initialEmoji?: { icon: string; background: string } + open: boolean + }) => (open ? {`${initialEmoji?.icon}:${initialEmoji?.background}`} : null), +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + agent: { + byAgentId: { + copy: { + post: { + mutationOptions: vi.fn(() => ({})), + }, + }, + get: { + queryKey: vi.fn(() => ['agent']), + }, + }, + }, + }, +})) + +const createAgent = (overrides: Partial = {}): AgentAppPartial => ({ + description: 'Original description', + icon: '🧸', + icon_background: '#F5F3FF', + icon_type: 'emoji', + icon_url: null, + id: 'agent-1', + mode: 'agent', + name: 'Research Agent', + role: 'Research Assistant', + ...overrides, +}) + +describe('DuplicateAgentDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mutationMock.isPending = false + queryDataMock.mockReturnValue(undefined) + }) + + it('keeps one form snapshot while open and creates a new session after closing', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const updatedAgent = createAgent({ + icon: '🦊', + icon_background: '#FFEDD5', + name: 'Updated Agent', + role: 'Updated Role', + }) + const { rerender } = render( + , + ) + + rerender() + + let dialog = screen.getByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' }) + expect( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + ).toHaveValue('Research Agent copy') + await user.click( + within(dialog).getByRole('button', { + name: /agentV2\.roster\.duplicateForm\.changeIcon.*Research Agent/, + }), + ) + expect(screen.getByText('🧸:#F5F3FF')).toBeInTheDocument() + + rerender() + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + rerender() + dialog = screen.getByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' }) + expect( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + ).toHaveValue('Updated Agent copy') + await user.click( + within(dialog).getByRole('button', { + name: /agentV2\.roster\.duplicateForm\.changeIcon.*Updated Agent/, + }), + ) + expect(screen.getByText('🦊:#FFEDD5')).toBeInTheDocument() + }) + + it('starts a new form session when the agent identity changes', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const secondAgent = createAgent({ + description: 'Second description', + id: 'agent-2', + name: 'Second Agent', + role: 'Second Role', + }) + const { rerender } = render( + , + ) + + rerender() + + const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' }) + expect( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + ).toHaveValue('Second Agent copy') + await user.click(within(dialog).getByRole('button', { name: 'common.operation.duplicate' })) + + expect(mutationMock.mutate).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-2', + }, + body: { + name: 'Second Agent copy', + description: 'Second description', + role: 'Second Role', + icon_type: 'emoji', + icon: '🧸', + icon_background: '#F5F3FF', + }, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) + }) +}) diff --git a/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx b/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx index 8cee962675c..632e1fce60a 100644 --- a/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx @@ -1,5 +1,5 @@ import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen' -import { render, screen, within } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { EditAgentDialog } from '../edit-agent-dialog' @@ -27,19 +27,24 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ vi.mock('@/app/components/base/app-icon-picker', () => ({ __esModule: true, default: ({ + initialEmoji, onSelect, open, }: { + initialEmoji?: { icon: string; background: string } onSelect: (payload: { type: 'emoji'; icon: string; background: string }) => void open: boolean }) => open ? ( - +
+ {`${initialEmoji?.icon}:${initialEmoji?.background}`} + +
) : null, })) @@ -71,9 +76,9 @@ const createAgent = (overrides: Partial = {}): AgentAppPartial const renderDialog = (agent = createAgent()) => { const onOpenChange = vi.fn() - render() + const renderResult = render() - return { onOpenChange } + return { ...renderResult, onOpenChange } } describe('EditAgentDialog', () => { @@ -154,12 +159,35 @@ describe('EditAgentDialog', () => { expect(mutationOptions).not.toHaveProperty('onError') }) + it('closes without a redundant success toast after updating', async () => { + const user = userEvent.setup() + const { onOpenChange } = renderDialog() + + const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + const roleInput = within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.roleLabel/, + }) + await user.clear(roleInput) + await user.type(roleInput, 'Market Analyst') + await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) + + const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1] + mutationOptions.onSuccess() + + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(toastMock.success).not.toHaveBeenCalled() + }) + it('submits selected icon fields when the roster icon changes', async () => { const user = userEvent.setup() renderDialog() const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) - await user.click(within(dialog).getByRole('button', { name: /agentV2\.roster\.editAgent/ })) + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.createForm.changeIcon', + }), + ) await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' })) await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) @@ -185,6 +213,159 @@ describe('EditAgentDialog', () => { expect(mutationOptions).not.toHaveProperty('onError') }) + it('keeps the original form snapshot when the agent source changes while open', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const agent = createAgent() + const { rerender } = render() + + let dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toBeDisabled() + + rerender( + , + ) + + dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toBeDisabled() + expect( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + ).toHaveValue('Research Agent') + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.createForm.changeIcon', + }), + ) + expect(screen.getByText('🧸:#F5F3FF')).toBeInTheDocument() + }) + + it('keeps a user-selected icon when the agent source changes during the session', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const { rerender } = render( + , + ) + + const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.createForm.changeIcon', + }), + ) + await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' })) + + rerender( + , + ) + + expect(screen.getByText('🧠:#E0F2FE')).toBeInTheDocument() + expect( + within(screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' })).getByRole( + 'button', + { name: 'common.operation.save' }, + ), + ).not.toBeDisabled() + }) + + it('creates a fresh form session from the latest agent after closing', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const agent = createAgent() + const { rerender } = render() + + const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + await user.click( + within(dialog).getByRole('button', { + name: 'agentV2.roster.createForm.changeIcon', + }), + ) + await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' })) + + rerender() + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + rerender( + , + ) + const reopenedDialog = screen.getByRole('dialog', { + name: 'agentV2.roster.editDialog.title', + }) + await user.click( + within(reopenedDialog).getByRole('button', { + name: 'agentV2.roster.createForm.changeIcon', + }), + ) + + expect(screen.getByText('🦊:#FFEDD5')).toBeInTheDocument() + }) + + it('starts a new form session when the agent identity changes', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const { rerender } = render( + , + ) + + rerender( + , + ) + + const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) + const nameInput = within(dialog).getByRole('textbox', { + name: 'agentV2.roster.createForm.nameLabel', + }) + expect(nameInput).toHaveValue('Second Agent') + expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toBeDisabled() + + await user.clear(nameInput) + await user.type(nameInput, 'Renamed Second Agent') + await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) + + expect(mutationMock.mutate).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-2', + }, + body: { + name: 'Renamed Second Agent', + description: 'Second description', + role: 'Second Role', + icon_type: 'emoji', + icon: '🦊', + icon_background: '#FFEDD5', + }, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) + }) + it('shows a field error when saving with an empty name', async () => { const user = userEvent.setup() renderDialog() diff --git a/web/features/agent-v2/roster/components/agent-form-fields.tsx b/web/features/agent-v2/roster/components/agent-form-fields.tsx index ff1b5caaf03..7203f173256 100644 --- a/web/features/agent-v2/roster/components/agent-form-fields.tsx +++ b/web/features/agent-v2/roster/components/agent-form-fields.tsx @@ -1,4 +1,5 @@ -import type { AgentIconSelection } from './agent-form' +import type { Ref } from 'react' +import type { AgentFormValues, AgentIconSelection } from './agent-form' import { Field, FieldError, FieldLabel } from '@langgenius/dify-ui/field' import { Input } from '@langgenius/dify-ui/input' import { Textarea } from '@langgenius/dify-ui/textarea' @@ -6,34 +7,26 @@ import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' type AgentFormFieldsProps = { - description: string + defaultValues: AgentFormValues icon: AgentIconSelection iconAriaLabel: string - name: string - onDescriptionChange: (description: string) => void onIconClick: () => void - onNameChange: (name: string) => void - onRoleChange: (role: string) => void - role: string + ref: Ref } export function AgentFormFields({ - description, + defaultValues, icon, iconAriaLabel, - name, - onDescriptionChange, onIconClick, - onNameChange, - onRoleChange, - role, + ref, }: AgentFormFieldsProps) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') return ( -
-
+
+
-
+
{ if (typeof value === 'string' && value.length > 0 && !value.trim()) return t(($) => $['roster.createForm.nameRequired']) @@ -63,23 +56,19 @@ export function AgentFormFields({ > {t(($) => $['roster.createForm.nameLabel'])} $['roster.createForm.namePlaceholder'])} required - value={name} /> -
- - {t(($) => $['roster.createForm.nameRequired'])} - - -
+ + {t(($) => $['roster.createForm.nameRequired'])} + +
- + {t(($) => $['roster.createForm.roleLabel'])} @@ -88,10 +77,9 @@ export function AgentFormFields({ $['roster.createForm.rolePlaceholder'])} - value={role} />
@@ -106,9 +94,9 @@ export function AgentFormFields({