feat(agent-v2): align roster create dialog

This commit is contained in:
yyh
2026-06-12 13:09:24 +08:00
parent ed0dbb8c02
commit 7ca985c7b3
4 changed files with 208 additions and 78 deletions

View File

@ -0,0 +1,59 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CreateAgentDialog } from '../create-agent-dialog'
const mutationMock = vi.hoisted(() => ({
isPending: false,
mutate: vi.fn(),
}))
vi.mock('@tanstack/react-query', () => ({
useMutation: () => ({
isPending: mutationMock.isPending,
mutate: mutationMock.mutate,
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
agents: {
post: {
mutationOptions: vi.fn(() => ({})),
},
},
},
}))
describe('CreateAgentDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mutationMock.isPending = false
})
it('submits roster role and default icon fields when creating an agent', async () => {
const user = userEvent.setup()
render(<CreateAgentDialog />)
await user.click(screen.getByRole('button', { name: /agentV2\.roster\.createAgent/ }))
const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' })
await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), ' Research Agent ')
await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel' }), ' Researcher ')
await user.type(within(dialog).getByPlaceholderText('agentV2.roster.createForm.descriptionPlaceholder'), ' Find and summarize market materials. ')
await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' }))
expect(mutationMock.mutate).toHaveBeenCalledWith({
body: {
name: 'Research Agent',
description: 'Find and summarize market materials.',
role: 'Researcher',
icon_type: 'emoji',
icon: '🧸',
icon_background: '#F5F3FF',
},
}, expect.objectContaining({
onError: expect.any(Function),
onSuccess: expect.any(Function),
}))
})
})

View File

@ -1,5 +1,6 @@
'use client'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from '@langgenius/dify-ui/dialog'
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
@ -9,6 +10,8 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import { consoleQuery } from '@/service/client'
type AgentFormValues = {
@ -17,12 +20,33 @@ type AgentFormValues = {
role?: string
}
const defaultAgentIcon = {
type: 'emoji',
icon: '🧸',
background: '#F5F3FF',
} satisfies AppIconSelection
export function CreateAgentDialog() {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const [open, setOpen] = useState(false)
const [formKey, setFormKey] = useState(0)
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [agentIcon, setAgentIcon] = useState<AppIconSelection>(defaultAgentIcon)
const createAgentMutation = useMutation(consoleQuery.agents.post.mutationOptions())
const resetForm = () => {
setFormKey(key => key + 1)
setAgentIcon(defaultAgentIcon)
setIconPickerOpen(false)
}
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen)
resetForm()
}
const handleSubmit = (formValues: AgentFormValues) => {
const trimmedName = formValues.name?.trim() ?? ''
if (!trimmedName || createAgentMutation.isPending)
@ -33,11 +57,14 @@ export function CreateAgentDialog() {
name: trimmedName,
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,
},
}, {
onSuccess: () => {
toast.success(t('roster.createSuccess'))
setOpen(false)
handleOpenChange(false)
},
onError: () => {
toast.error(t('roster.createFailed'))
@ -46,79 +73,119 @@ export function CreateAgentDialog() {
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={(
<Button
variant="primary"
className="h-8 gap-0.5 px-3"
/>
)}
>
<span aria-hidden className="i-ri-add-line size-4" />
<span className="px-0.5 system-sm-medium">{t('roster.createAgent')}</span>
</DialogTrigger>
<DialogContent>
<DialogCloseButton />
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t('roster.createDialog.title')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('roster.createDialog.description')}
</DialogDescription>
<Form<AgentFormValues>
className="mt-5 space-y-4"
onFormSubmit={handleSubmit}
>
<FieldRoot name="name">
<FieldLabel>
{t('roster.createForm.nameLabel')}
</FieldLabel>
<FieldControl
autoComplete="off"
// eslint-disable-next-line jsx-a11y/no-autofocus -- The create dialog opens from an explicit command, and the next expected action is naming the agent.
autoFocus
maxLength={255}
placeholder={t('roster.createForm.namePlaceholder')}
required
/>
<FieldError match="valueMissing">
{t('roster.createForm.nameRequired')}
</FieldError>
</FieldRoot>
<FieldRoot name="description">
<FieldLabel>
{t('roster.createForm.descriptionLabel')}
</FieldLabel>
<Textarea
autoComplete="off"
placeholder={t('roster.createForm.descriptionPlaceholder')}
/>
</FieldRoot>
<FieldRoot name="role">
<FieldLabel>
{t('roster.createForm.roleLabel')}
</FieldLabel>
<FieldControl
autoComplete="off"
maxLength={255}
placeholder={t('roster.createForm.rolePlaceholder')}
/>
</FieldRoot>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" onClick={() => setOpen(false)} disabled={createAgentMutation.isPending}>
{tCommon('operation.cancel')}
</Button>
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger
render={(
<Button
type="submit"
variant="primary"
loading={createAgentMutation.isPending}
>
{tCommon('operation.create')}
</Button>
className="h-8 gap-0.5 px-3"
/>
)}
>
<span aria-hidden className="i-ri-add-line size-4" />
<span className="px-0.5 system-sm-medium">{t('roster.createAgent')}</span>
</DialogTrigger>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[520px] flex-col overflow-hidden! p-0!">
<DialogCloseButton className="top-5 right-5 size-8 rounded-lg" />
<div className="shrink-0 pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t('roster.createDialog.title')}
</DialogTitle>
<DialogDescription className="sr-only">
{t('roster.createDialog.description')}
</DialogDescription>
</div>
</Form>
</DialogContent>
</Dialog>
<Form<AgentFormValues>
key={formKey}
className="min-h-0 flex-1"
onFormSubmit={handleSubmit}
>
<div className="space-y-5 px-6 py-3">
<div className="flex items-end gap-4">
<button
type="button"
aria-label={t('roster.createForm.changeIcon')}
className="shrink-0 rounded-full outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => setIconPickerOpen(true)}
>
<AppIcon
size="xxl"
rounded
className="size-16 cursor-pointer"
iconType={agentIcon.type}
icon={agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon}
background={agentIcon.type === 'emoji' ? agentIcon.background : undefined}
imageUrl={agentIcon.type === 'image' ? agentIcon.url : undefined}
/>
</button>
<div className="flex min-w-0 flex-1 gap-3 pb-1">
<FieldRoot name="name" className="min-w-0 flex-1">
<FieldLabel>
{t('roster.createForm.nameLabel')}
</FieldLabel>
<FieldControl
autoComplete="off"
// eslint-disable-next-line jsx-a11y/no-autofocus -- The create dialog opens from an explicit command, and the next expected action is naming the agent.
autoFocus
maxLength={255}
placeholder={t('roster.createForm.namePlaceholder')}
required
/>
<FieldError match="valueMissing">
{t('roster.createForm.nameRequired')}
</FieldError>
</FieldRoot>
<FieldRoot name="role" className="min-w-0 flex-1">
<FieldLabel>
{t('roster.createForm.roleLabel')}
</FieldLabel>
<FieldControl
autoComplete="off"
maxLength={255}
placeholder={t('roster.createForm.rolePlaceholder')}
/>
</FieldRoot>
</div>
</div>
<FieldRoot name="description">
<FieldLabel>
{t('roster.createForm.descriptionLabel')}
<span className="ml-1 system-xs-regular text-text-tertiary">
{t('roster.createForm.descriptionOptional')}
</span>
</FieldLabel>
<Textarea
autoComplete="off"
className="h-20 resize-none"
placeholder={t('roster.createForm.descriptionPlaceholder')}
/>
</FieldRoot>
</div>
<div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6">
<Button type="button" className="min-w-18" onClick={() => handleOpenChange(false)} disabled={createAgentMutation.isPending}>
{tCommon('operation.cancel')}
</Button>
<Button
type="submit"
variant="primary"
className="min-w-18"
loading={createAgentMutation.isPending}
>
{tCommon('operation.create')}
</Button>
</div>
</Form>
</DialogContent>
</Dialog>
<AppIconPicker
open={iconPickerOpen}
initialEmoji={agentIcon.type === 'emoji'
? { icon: agentIcon.icon, background: agentIcon.background }
: undefined}
onOpenChange={setIconPickerOpen}
onSelect={setAgentIcon}
/>
</>
)
}

View File

@ -213,13 +213,15 @@
"roster.createDialog.description": "Create a reusable agent in this workspace roster.",
"roster.createDialog.title": "Create agent",
"roster.createFailed": "Failed to create agent.",
"roster.createForm.changeIcon": "Change agent icon",
"roster.createForm.descriptionLabel": "Description",
"roster.createForm.descriptionPlaceholder": "Describe what this agent does…",
"roster.createForm.descriptionOptional": "(optional)",
"roster.createForm.descriptionPlaceholder": "e.g. Gathers sources on a topic and summarizes the key findings",
"roster.createForm.nameLabel": "Name",
"roster.createForm.namePlaceholder": "Enter agent name…",
"roster.createForm.namePlaceholder": "e.g. Max",
"roster.createForm.nameRequired": "Name is required.",
"roster.createForm.roleLabel": "Role",
"roster.createForm.rolePlaceholder": "Enter agent role…",
"roster.createForm.rolePlaceholder": "e.g. Research Assistant",
"roster.createSuccess": "Agent created.",
"roster.dateTimeFormat": "MM/DD/YYYY hh:mm:ss A",
"roster.deleteDialog.description": "This agent will be removed from the active roster list.",

View File

@ -213,13 +213,15 @@
"roster.createDialog.description": "在当前工作区 Roster 中创建一个可复用智能体。",
"roster.createDialog.title": "创建智能体",
"roster.createFailed": "智能体创建失败。",
"roster.createForm.changeIcon": "更换智能体图标",
"roster.createForm.descriptionLabel": "描述",
"roster.createForm.descriptionPlaceholder": "描述这个智能体的用途…",
"roster.createForm.descriptionOptional": "(可选)",
"roster.createForm.descriptionPlaceholder": "例如:收集某个主题的来源资料并总结关键发现",
"roster.createForm.nameLabel": "名称",
"roster.createForm.namePlaceholder": "输入智能体名称…",
"roster.createForm.namePlaceholder": "例如Max",
"roster.createForm.nameRequired": "请输入名称。",
"roster.createForm.roleLabel": "角色",
"roster.createForm.rolePlaceholder": "输入智能体角色…",
"roster.createForm.rolePlaceholder": "例如:研究助理",
"roster.createSuccess": "智能体已创建。",
"roster.dateTimeFormat": "YYYY-MM-DD HH:mm:ss",
"roster.deleteDialog.description": "此智能体将从当前活跃 Roster 列表中移除。",