fix(web): refine agent dialog form sessions (#41492)

This commit is contained in:
yyh 2026-08-31 01:24:03 +00:00 committed by GitHub
parent 6130f081e0
commit fce29e6f6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1028 additions and 590 deletions

View File

@ -179,6 +179,7 @@ function AppIconPickerContent({
return (
<DialogContent
backdropProps={{ forceRender: true }}
className={cn(
'w-full overflow-hidden! border-none text-left align-middle',
s.container,

View File

@ -263,30 +263,11 @@ vi.mock('../components/save-inline-agent-to-roster-dialog', () => ({
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 ? (
<div role="dialog" aria-label="save-inline-agent-to-roster">
<button
type="button"
onClick={() =>
onSaved({
id: 'binding-1',
binding_type: 'roster_agent',
agent_id: 'saved-roster-agent',
current_snapshot_id: 'saved-snapshot',
workflow_id: 'workflow-1',
node_id: 'agent-node',
})
}
>
<button type="button" onClick={() => onSaved('saved-roster-agent')}>
Save inline agent to roster
</button>
</div>

View File

@ -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) => {
<SaveInlineAgentToRosterDialog
flowId="app-1"
flowType={FlowType.appFlow}
formKey={1}
initialAgent={agent}
nodeId="node-1"
open
@ -182,7 +181,6 @@ describe('SaveInlineAgentToRosterDialog', () => {
<SaveInlineAgentToRosterDialog
flowId="snippet-1"
flowType={FlowType.snippet}
formKey={1}
initialAgent={inlineAgent}
nodeId="node-1"
open
@ -295,4 +293,122 @@ describe('SaveInlineAgentToRosterDialog', () => {
}),
)
})
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(
<SaveInlineAgentToRosterDialog
flowId="app-1"
flowType={FlowType.appFlow}
initialAgent={inlineAgent}
nodeId="node-1"
open
onOpenChange={onOpenChange}
onSaved={onSaved}
/>,
)
rerender(
<SaveInlineAgentToRosterDialog
flowId="app-1"
flowType={FlowType.appFlow}
initialAgent={updatedInlineAgent}
nodeId="node-1"
open
onOpenChange={onOpenChange}
onSaved={onSaved}
/>,
)
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(
<SaveInlineAgentToRosterDialog
flowId="app-1"
flowType={FlowType.appFlow}
initialAgent={updatedInlineAgent}
nodeId="node-1"
open={false}
onOpenChange={onOpenChange}
onSaved={onSaved}
/>,
)
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
rerender(
<SaveInlineAgentToRosterDialog
flowId="app-1"
flowType={FlowType.appFlow}
initialAgent={updatedInlineAgent}
nodeId="node-1"
open
onOpenChange={onOpenChange}
onSaved={onSaved}
/>,
)
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()
})
})

View File

@ -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<HTMLInputElement>
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 (
<>
<div className="shrink-0 ps-6 pe-14 pt-6 pb-3">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['roster.saveToRosterDialog.title'])}
</DialogTitle>
<DialogDescription className="sr-only">
{t(($) => $['roster.saveToRosterDialog.description'])}
</DialogDescription>
</div>
<Form<AgentFormValues>
className="flex min-h-0 flex-1 flex-col"
onFormSubmit={(formValues) => onSubmit(formValues, agentIcon)}
>
<AgentFormFields
ref={nameInputRef}
defaultValues={initialValues.fields}
icon={agentIcon}
iconAriaLabel={t(($) => $['roster.saveToRosterForm.changeIcon'])}
onIconClick={() => setIconPickerOpen(true)}
/>
<div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6">
<Button type="button" className="min-w-18" onClick={onCancel} disabled={pending}>
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button type="submit" variant="primary" className="min-w-18" loading={pending}>
{tCommon(($) => $['operation.save'])}
</Button>
</div>
</Form>
<AppIconPicker
open={iconPickerOpen}
initialEmoji={
agentIcon.type === 'emoji'
? { icon: agentIcon.icon, background: agentIcon.background }
: undefined
}
onOpenChange={setIconPickerOpen}
onSelect={setAgentIcon}
/>
</>
)
}
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<AgentIconSelection>(() =>
initialAgent ? createAgentIconSelection(initialAgent) : defaultAgentIcon,
)
const nameInputRef = useRef<HTMLInputElement>(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 (
<>
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!">
<DialogContent
initialFocus={nameInputRef}
className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!"
>
<DialogClose
disabled={isSavingToRoster}
render={
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
size="lg"
className="absolute inset-e-6 top-6"
className="absolute inset-e-5 top-5"
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
<div className="shrink-0 pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['roster.saveToRosterDialog.title'])}
</DialogTitle>
<DialogDescription className="sr-only">
{t(($) => $['roster.saveToRosterDialog.description'])}
</DialogDescription>
</div>
<Form<AgentFormValues>
key={formKey}
className="min-h-0 flex-1"
onFormSubmit={handleSubmit}
>
<AgentFormFields
description={description}
icon={agentIcon}
iconAriaLabel={t(($) => $['roster.saveToRosterForm.changeIcon'])}
name={name}
role={role}
onDescriptionChange={setDescription}
onIconClick={() => setIconPickerOpen(true)}
onNameChange={setName}
onRoleChange={setRole}
/>
<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={isSavingToRoster}
>
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button
type="submit"
variant="primary"
className="min-w-18"
loading={isSavingToRoster}
>
{tCommon(($) => $['operation.save'])}
</Button>
</div>
</Form>
<SaveInlineAgentToRosterFormSession
initialAgent={initialAgent}
nameInputRef={nameInputRef}
pending={isSavingToRoster}
onCancel={() => onOpenChange(false)}
onSubmit={handleSubmit}
/>
</DialogContent>
</Dialog>
<AppIconPicker
open={iconPickerOpen}
initialEmoji={
agentIcon.type === 'emoji'
? { icon: agentIcon.icon, background: agentIcon.background }
: undefined
}
onOpenChange={setIconPickerOpen}
onSelect={(icon) => {
setAgentIcon(icon)
}}
/>
</>
)
}

View File

@ -131,7 +131,6 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
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<AgentV2NodeType>) {
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<AgentV2NodeType>) {
])
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<AgentV2NodeType>) {
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<AgentV2NodeType>) {
onSaveInlineToRoster={canSaveInlineToRoster ? handleSaveInlineToRosterOpen : undefined}
onStartFromScratch={canStartFromScratch ? handleStartFromScratch : undefined}
/>
<SaveInlineAgentToRosterDialog
key={saveToRosterSessionKey}
flowId={configsMap?.flowId}
flowType={configsMap?.flowType}
formKey={saveToRosterSessionKey}
initialAgent={inlineAgent}
nodeId={id}
open={isSaveToRosterDialogOpen}
onOpenChange={setIsSaveToRosterDialogOpen}
onSaved={handleInlineSavedToRoster}
/>
{saveToRosterTarget && inlineAgent && (
<SaveInlineAgentToRosterDialog
flowId={saveToRosterTarget.flowId}
flowType={saveToRosterTarget.flowType}
initialAgent={inlineAgent}
nodeId={id}
open={isSaveToRosterDialogOpen}
onOpenChange={setIsSaveToRosterDialogOpen}
onSaved={handleInlineSavedToRoster}
/>
)}
</div>
<div
aria-disabled={isInlineAgentPending}

View File

@ -1,6 +1,7 @@
'use client'
import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen'
import type { AgentFormSource } from '@/features/agent-v2/roster/components/agent-form'
import {
DropdownMenu,
DropdownMenuContent,
@ -17,50 +18,22 @@ import { DuplicateAgentDialog } from '@/features/agent-v2/roster/components/dupl
import { EditAgentDialog } from '@/features/agent-v2/roster/components/edit-agent-dialog'
import { useRouter } from '@/next/navigation'
type AgentDetailSidebarActionAgent = Pick<
AgentAppPartial,
| 'app_id'
| 'description'
| 'icon'
| 'icon_background'
| 'icon_type'
| 'icon_url'
| 'id'
| 'mode'
| 'name'
| 'role'
>
type AgentDetailSidebarActionAgent = AgentFormSource & Pick<AgentAppPartial, 'app_id'>
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
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<EditAgentDialog
key={editSessionKey}
agent={dialogAgent}
open={isEditOpen}
onOpenChange={setIsEditOpen}
/>
<EditAgentDialog agent={agent} open={isEditOpen} onOpenChange={setIsEditOpen} />
<DuplicateAgentDialog
key={duplicateSessionKey}
agent={dialogAgent}
agent={agent}
open={isDuplicateOpen}
onOpenChange={setIsDuplicateOpen}
/>

View File

@ -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',
})
})
})

View File

@ -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(<CreateAgentDialog />)
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(<CreateAgentDialog />)

View File

@ -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 ? <span>{`${initialEmoji?.icon}:${initialEmoji?.background}`}</span> : null),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
agent: {
byAgentId: {
copy: {
post: {
mutationOptions: vi.fn(() => ({})),
},
},
get: {
queryKey: vi.fn(() => ['agent']),
},
},
},
},
}))
const createAgent = (overrides: Partial<AgentAppPartial> = {}): 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(
<DuplicateAgentDialog agent={createAgent()} open onOpenChange={onOpenChange} />,
)
rerender(<DuplicateAgentDialog agent={updatedAgent} open onOpenChange={onOpenChange} />)
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(<DuplicateAgentDialog agent={updatedAgent} open={false} onOpenChange={onOpenChange} />)
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
rerender(<DuplicateAgentDialog agent={updatedAgent} open onOpenChange={onOpenChange} />)
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(
<DuplicateAgentDialog agent={createAgent()} open onOpenChange={onOpenChange} />,
)
rerender(<DuplicateAgentDialog agent={secondAgent} open onOpenChange={onOpenChange} />)
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),
}),
)
})
})

View File

@ -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 ? (
<button
type="button"
onClick={() => onSelect({ type: 'emoji', icon: '🧠', background: '#E0F2FE' })}
>
Select brain icon
</button>
<div>
<span>{`${initialEmoji?.icon}:${initialEmoji?.background}`}</span>
<button
type="button"
onClick={() => onSelect({ type: 'emoji', icon: '🧠', background: '#E0F2FE' })}
>
Select brain icon
</button>
</div>
) : null,
}))
@ -71,9 +76,9 @@ const createAgent = (overrides: Partial<AgentAppPartial> = {}): AgentAppPartial
const renderDialog = (agent = createAgent()) => {
const onOpenChange = vi.fn()
render(<EditAgentDialog agent={agent} open onOpenChange={onOpenChange} />)
const renderResult = render(<EditAgentDialog agent={agent} open onOpenChange={onOpenChange} />)
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(<EditAgentDialog agent={agent} open onOpenChange={onOpenChange} />)
let dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' })
expect(within(dialog).getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
rerender(
<EditAgentDialog
agent={createAgent({ icon: '🦊', icon_background: '#FFEDD5' })}
open
onOpenChange={onOpenChange}
/>,
)
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(
<EditAgentDialog agent={createAgent()} open onOpenChange={onOpenChange} />,
)
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(
<EditAgentDialog
agent={createAgent({ icon: '🦊', icon_background: '#FFEDD5' })}
open
onOpenChange={onOpenChange}
/>,
)
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(<EditAgentDialog agent={agent} open onOpenChange={onOpenChange} />)
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(<EditAgentDialog agent={agent} open={false} onOpenChange={onOpenChange} />)
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
rerender(
<EditAgentDialog
agent={createAgent({ icon: '🦊', icon_background: '#FFEDD5' })}
open
onOpenChange={onOpenChange}
/>,
)
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(
<EditAgentDialog agent={createAgent()} open onOpenChange={onOpenChange} />,
)
rerender(
<EditAgentDialog
agent={createAgent({
description: 'Second description',
icon: '🦊',
icon_background: '#FFEDD5',
id: 'agent-2',
name: 'Second Agent',
role: 'Second Role',
})}
open
onOpenChange={onOpenChange}
/>,
)
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()

View File

@ -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<HTMLInputElement>
}
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 (
<div className="space-y-5 px-6 py-3">
<div className="flex items-end gap-4 pb-2">
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto overscroll-contain px-6 py-3">
<div className="flex items-start gap-4">
<button
type="button"
aria-label={iconAriaLabel}
@ -50,10 +43,10 @@ export function AgentFormFields({
imageUrl={icon.type === 'emoji' ? undefined : icon.url}
/>
</button>
<div className="flex min-w-0 flex-1 gap-3 pb-1">
<div className="flex min-w-0 flex-1 flex-col items-start gap-3 pb-1 sm:flex-row">
<Field
name="name"
className="relative min-w-0 flex-1"
className="min-w-0 flex-1"
validate={(value) => {
if (typeof value === 'string' && value.length > 0 && !value.trim())
return t(($) => $['roster.createForm.nameRequired'])
@ -63,23 +56,19 @@ export function AgentFormFields({
>
<FieldLabel>{t(($) => $['roster.createForm.nameLabel'])}</FieldLabel>
<Input
ref={ref}
autoComplete="off"
// oxlint-disable-next-line jsx-a11y/no-autofocus -- Agent roster dialogs open from explicit commands, and the name field is the primary editable control.
autoFocus
defaultValue={defaultValues.name}
maxLength={255}
onValueChange={onNameChange}
placeholder={t(($) => $['roster.createForm.namePlaceholder'])}
required
value={name}
/>
<div className="absolute top-full left-0 mt-1">
<FieldError match="valueMissing">
{t(($) => $['roster.createForm.nameRequired'])}
</FieldError>
<FieldError match="customError" />
</div>
<FieldError match="valueMissing">
{t(($) => $['roster.createForm.nameRequired'])}
</FieldError>
<FieldError match="customError" />
</Field>
<Field name="role" className="relative min-w-0 flex-1">
<Field name="role" className="min-w-0 flex-1">
<FieldLabel>
{t(($) => $['roster.createForm.roleLabel'])}
<span className="ml-1 system-xs-regular text-text-tertiary">
@ -88,10 +77,9 @@ export function AgentFormFields({
</FieldLabel>
<Input
autoComplete="off"
defaultValue={defaultValues.role}
maxLength={255}
onValueChange={onRoleChange}
placeholder={t(($) => $['roster.createForm.rolePlaceholder'])}
value={role}
/>
</Field>
</div>
@ -106,9 +94,9 @@ export function AgentFormFields({
<Textarea
autoComplete="off"
className="h-20 resize-none"
onValueChange={onDescriptionChange}
defaultValue={defaultValues.description}
maxLength={400}
placeholder={t(($) => $['roster.createForm.descriptionPlaceholder'])}
value={description}
/>
</Field>
</div>

View File

@ -1,11 +1,20 @@
import type {
AgentAppCreatePayload,
AgentAppPartial,
} from '@dify/contracts/api/console/agent/types.gen'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
type AgentFormField = 'description' | 'name' | 'role'
export type AgentFormValues = {
description?: string
name?: string
role?: string
[Field in AgentFormField]-?: NonNullable<AgentAppCreatePayload[Field]>
}
export type AgentFormSource = Pick<
AgentAppPartial,
'description' | 'icon' | 'icon_background' | 'icon_type' | 'icon_url' | 'id' | 'name' | 'role'
>
export type AgentIconSelection =
| AppIconSelection
| {
@ -24,6 +33,7 @@ type AgentIconSource = {
icon?: string | null
icon_background?: string | null
icon_type?: string | null
icon_url?: string | null
}
export const createAgentIconSelection = (agent: AgentIconSource): AgentIconSelection => {
@ -31,7 +41,7 @@ export const createAgentIconSelection = (agent: AgentIconSource): AgentIconSelec
return {
type: 'image',
fileId: agent.icon,
url: agent.icon,
url: agent.icon_url ?? agent.icon,
}
}

View File

@ -205,8 +205,6 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
const nameId = useId()
const descriptionId = useId()
const [activeDialog, setActiveDialog] = useState<'delete' | 'duplicate' | 'edit' | null>(null)
const [editSessionKey, setEditSessionKey] = useState(0)
const [duplicateSessionKey, setDuplicateSessionKey] = useState(0)
const { exportAppDsl, isExporting } = useExportAppDsl()
const updatedAt =
agent.updated_at != null
@ -220,16 +218,19 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
const hasPublishedReferences = publishedReferences.length > 0
const isDraft = agent.active_config_is_published !== true
const parsedIconType = zAgentIconType.safeParse(agent.icon_type).data
const imageUrl = parsedIconType === 'image' || parsedIconType === 'link' ? agent.icon : undefined
const imageUrl =
parsedIconType === 'image'
? (agent.icon_url ?? agent.icon)
: parsedIconType === 'link'
? agent.icon
: undefined
const iconType = parsedIconType === 'link' ? 'image' : parsedIconType
const handleEditOpen = () => {
setEditSessionKey((key) => key + 1)
setActiveDialog('edit')
}
const handleDuplicateOpen = () => {
setDuplicateSessionKey((key) => key + 1)
setActiveDialog('duplicate')
}
@ -370,13 +371,11 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
</div>
</div>
<EditAgentDialog
key={editSessionKey}
agent={agent}
open={activeDialog === 'edit'}
onOpenChange={handleDialogOpenChange}
/>
<DuplicateAgentDialog
key={duplicateSessionKey}
agent={agent}
open={activeDialog === 'duplicate'}
onOpenChange={handleDialogOpenChange}

View File

@ -1,5 +1,6 @@
'use client'
import type { AgentAppCreatePayload } from '@dify/contracts/api/console/agent/types.gen'
import type { Ref } from 'react'
import type { AgentFormValues, AgentIconSelection } from './agent-form'
import { Button } from '@langgenius/dify-ui/button'
import {
@ -12,9 +13,8 @@ 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 { AgentScope } from '@/features/agent-v2/analytics'
@ -30,43 +30,98 @@ type CreateAgentDialogProps = {
onOpenChange?: (open: boolean) => void
}
export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps = {}) {
type CreateAgentFormSessionProps = {
nameInputRef: Ref<HTMLInputElement>
pending: boolean
onCancel: () => void
onSubmit: (formValues: AgentFormValues, agentIcon: AgentIconSelection) => void
}
const createAgentDefaultValues = {
description: '',
name: '',
role: '',
} satisfies AgentFormValues
function CreateAgentFormSession({
nameInputRef,
pending,
onCancel,
onSubmit,
}: CreateAgentFormSessionProps) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(defaultAgentIcon)
const [iconPickerOpen, setIconPickerOpen] = useState(false)
return (
<>
<div className="shrink-0 ps-6 pe-14 pt-6 pb-3">
<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<AgentFormValues>
className="flex min-h-0 flex-1 flex-col"
onFormSubmit={(formValues) => onSubmit(formValues, agentIcon)}
>
<AgentFormFields
ref={nameInputRef}
defaultValues={createAgentDefaultValues}
icon={agentIcon}
iconAriaLabel={t(($) => $['roster.createForm.changeIcon'])}
onIconClick={() => setIconPickerOpen(true)}
/>
<div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6">
<Button type="button" className="min-w-18" onClick={onCancel} disabled={pending}>
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button type="submit" variant="primary" className="min-w-18" loading={pending}>
{tCommon(($) => $['operation.create'])}
</Button>
</div>
</Form>
<AppIconPicker
open={iconPickerOpen}
initialEmoji={
agentIcon.type === 'emoji'
? { icon: agentIcon.icon, background: agentIcon.background }
: undefined
}
onOpenChange={setIconPickerOpen}
onSelect={setAgentIcon}
/>
</>
)
}
export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps = {}) {
const { t } = useTranslation('agentV2')
const router = useRouter()
const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
const [formKey, setFormKey] = useState(0)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [role, setRole] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(defaultAgentIcon)
const nameInputRef = useRef<HTMLInputElement>(null)
const createAgentMutation = useMutation(consoleQuery.agent.post.mutationOptions())
const resetForm = () => {
setFormKey((key) => key + 1)
setName('')
setDescription('')
setRole('')
setAgentIcon(defaultAgentIcon)
setIconPickerOpen(false)
const setDialogOpen = (nextOpen: boolean) => {
if (open === undefined) setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
}
const handleOpenChange = (nextOpen: boolean) => {
if (open === undefined) setUncontrolledOpen(nextOpen)
onOpenChange?.(nextOpen)
if (!nextOpen) resetForm()
if (!nextOpen && createAgentMutation.isPending) return
setDialogOpen(nextOpen)
}
const handleSubmit = (formValues: AgentFormValues) => {
const trimmedName = formValues.name?.trim() ?? ''
const trimmedRole = formValues.role?.trim() ?? ''
const handleSubmit = (formValues: AgentFormValues, agentIcon: AgentIconSelection) => {
if (createAgentMutation.isPending) return
const body = {
name: trimmedName,
description: formValues.description?.trim() ?? '',
role: trimmedRole,
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,
@ -83,8 +138,7 @@ export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps
appMode: 'agent-v2',
agentScope: AgentScope.Global,
})
toast.success(t(($) => $['roster.createSuccess']))
handleOpenChange(false)
setDialogOpen(false)
router.push(getAgentDetailPath(createdAgent.id, 'configure'))
},
},
@ -104,73 +158,30 @@ export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps
<span className="system-sm-medium">{t(($) => $['roster.createAgent'])}</span>
</DialogTrigger>
)}
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!">
<DialogContent
initialFocus={nameInputRef}
className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!"
>
<DialogClose
disabled={createAgentMutation.isPending}
render={
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
size="lg"
className="absolute inset-e-6 top-6"
className="absolute inset-e-5 top-5"
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
<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<AgentFormValues>
key={formKey}
className="min-h-0 flex-1"
onFormSubmit={handleSubmit}
>
<AgentFormFields
description={description}
icon={agentIcon}
iconAriaLabel={t(($) => $['roster.createForm.changeIcon'])}
name={name}
role={role}
onDescriptionChange={setDescription}
onIconClick={() => setIconPickerOpen(true)}
onNameChange={setName}
onRoleChange={setRole}
/>
<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>
<CreateAgentFormSession
nameInputRef={nameInputRef}
pending={createAgentMutation.isPending}
onCancel={() => setDialogOpen(false)}
onSubmit={handleSubmit}
/>
</DialogContent>
</Dialog>
<AppIconPicker
open={iconPickerOpen}
initialEmoji={
agentIcon.type === 'emoji'
? { icon: agentIcon.icon, background: agentIcon.background }
: undefined
}
onOpenChange={setIconPickerOpen}
onSelect={setAgentIcon}
/>
</>
)
}

View File

@ -1,9 +1,7 @@
'use client'
import type {
AgentAppCopyPayload,
AgentAppPartial,
} from '@dify/contracts/api/console/agent/types.gen'
import type { AgentFormValues, AgentIconSelection } from './agent-form'
import type { AgentAppCopyPayload } from '@dify/contracts/api/console/agent/types.gen'
import type { Ref } from 'react'
import type { AgentFormSource, AgentFormValues, AgentIconSelection } from './agent-form'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
@ -12,37 +10,110 @@ import {
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { Field, FieldError, FieldLabel } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Input } from '@langgenius/dify-ui/input'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useRef, 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'
import { createAgentIconSelection } from './agent-form'
import { AgentFormFields } from './agent-form-fields'
type DuplicateAgentDialogProps = {
agent: AgentAppPartial
agent: AgentFormSource
open: boolean
onOpenChange: (open: boolean) => void
}
type DuplicateAgentFormSessionProps = {
agent: AgentFormSource
nameInputRef: Ref<HTMLInputElement>
pending: boolean
onCancel: () => void
onSubmit: (formValues: AgentFormValues, agentIcon: AgentIconSelection) => void
}
const getDefaultCopyName = (name: string) => {
const suffix = ' copy'
return `${name.slice(0, 255 - suffix.length)}${suffix}`
}
export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAgentDialogProps) {
function DuplicateAgentFormSession({
agent,
nameInputRef,
pending,
onCancel,
onSubmit,
}: DuplicateAgentFormSessionProps) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const [initialValues] = useState(() => ({
fields: {
description: agent.description ?? '',
name: getDefaultCopyName(agent.name),
role: agent.role ?? '',
} satisfies AgentFormValues,
icon: createAgentIconSelection(agent),
sourceName: agent.name,
}))
const [agentIcon, setAgentIcon] = useState(initialValues.icon)
const [iconPickerOpen, setIconPickerOpen] = useState(false)
return (
<>
<div className="shrink-0 ps-6 pe-14 pt-6 pb-3">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['roster.duplicateDialog.title'])}
</DialogTitle>
<DialogDescription className="sr-only">
{t(($) => $['roster.duplicateDialog.description'], {
name: initialValues.sourceName,
})}
</DialogDescription>
</div>
<Form<AgentFormValues>
className="flex min-h-0 flex-1 flex-col"
onFormSubmit={(formValues) => onSubmit(formValues, agentIcon)}
>
<AgentFormFields
ref={nameInputRef}
defaultValues={initialValues.fields}
icon={agentIcon}
iconAriaLabel={t(($) => $['roster.duplicateForm.changeIcon'], {
name: initialValues.sourceName,
})}
onIconClick={() => setIconPickerOpen(true)}
/>
<div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6">
<Button type="button" className="min-w-18" onClick={onCancel} disabled={pending}>
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button type="submit" variant="primary" className="min-w-18" loading={pending}>
{tCommon(($) => $['operation.duplicate'])}
</Button>
</div>
</Form>
<AppIconPicker
open={iconPickerOpen}
initialEmoji={
agentIcon.type === 'emoji'
? { icon: agentIcon.icon, background: agentIcon.background }
: undefined
}
onOpenChange={setIconPickerOpen}
onSelect={setAgentIcon}
/>
</>
)
}
export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAgentDialogProps) {
const { t } = useTranslation('agentV2')
const queryClient = useQueryClient()
const latestAgent =
queryClient.getQueryData<AgentAppPartial>(
queryClient.getQueryData<AgentFormSource>(
consoleQuery.agent.byAgentId.get.queryKey({
input: {
params: {
@ -51,30 +122,22 @@ export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAge
},
}),
) ?? agent
const [name, setName] = useState(() => getDefaultCopyName(latestAgent.name))
const [description, setDescription] = useState(latestAgent.description ?? '')
const [role, setRole] = useState(latestAgent.role ?? '')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(() =>
createAgentIconSelection(latestAgent),
)
const nameInputRef = useRef<HTMLInputElement>(null)
const duplicateAgentMutation = useMutation(
consoleQuery.agent.byAgentId.copy.post.mutationOptions(),
)
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) setIconPickerOpen(false)
if (!nextOpen && duplicateAgentMutation.isPending) return
onOpenChange(nextOpen)
}
const handleSubmit = (formValues: AgentFormValues) => {
const handleSubmit = (formValues: AgentFormValues, agentIcon: AgentIconSelection) => {
if (duplicateAgentMutation.isPending) return
const trimmedName = formValues.name?.trim() ?? ''
const trimmedRole = formValues.role?.trim() ?? ''
const body: AgentAppCopyPayload = {
name: trimmedName,
description: formValues.description?.trim() ?? '',
role: trimmedRole,
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,
@ -90,7 +153,7 @@ export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAge
{
onSuccess: () => {
toast.success(t(($) => $['roster.duplicateSuccess']))
handleOpenChange(false)
onOpenChange(false)
},
},
)
@ -99,140 +162,32 @@ export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAge
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!">
<DialogContent
initialFocus={nameInputRef}
className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!"
>
<DialogClose
disabled={duplicateAgentMutation.isPending}
render={
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
size="lg"
className="absolute inset-e-6 top-6"
className="absolute inset-e-5 top-5"
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
<div className="shrink-0 pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['roster.duplicateDialog.title'])}
</DialogTitle>
<DialogDescription className="sr-only">
{t(($) => $['roster.duplicateDialog.description'], { name: latestAgent.name })}
</DialogDescription>
</div>
<Form<AgentFormValues> className="min-h-0 flex-1" onFormSubmit={handleSubmit}>
<div className="space-y-5 px-6 py-3">
<div className="flex items-end gap-4 pb-2">
<button
type="button"
aria-label={t(($) => $['roster.duplicateForm.changeIcon'], {
name: latestAgent.name,
})}
className="shrink-0 rounded-full focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={() => setIconPickerOpen(true)}
>
<AppIcon
size="xxl"
rounded
className="size-16 cursor-pointer"
iconType={agentIcon.type === 'link' ? 'image' : agentIcon.type}
icon={agentIcon.type === 'emoji' ? agentIcon.icon : undefined}
background={agentIcon.type === 'emoji' ? agentIcon.background : undefined}
imageUrl={agentIcon.type === 'emoji' ? undefined : agentIcon.url}
/>
</button>
<div className="flex min-w-0 flex-1 gap-3 pb-1">
<Field
name="name"
className="relative min-w-0 flex-1"
validate={(value) => {
if (typeof value === 'string' && value.length > 0 && !value.trim())
return t(($) => $['roster.createForm.nameRequired'])
return null
}}
>
<FieldLabel>{t(($) => $['roster.createForm.nameLabel'])}</FieldLabel>
<Input
autoComplete="off"
// oxlint-disable-next-line jsx-a11y/no-autofocus -- The duplicate dialog opens from an explicit command, and naming the copy is the primary editable action.
autoFocus
maxLength={255}
onValueChange={setName}
placeholder={t(($) => $['roster.createForm.namePlaceholder'])}
required
value={name}
/>
<div className="absolute top-full left-0 mt-1">
<FieldError match="valueMissing">
{t(($) => $['roster.createForm.nameRequired'])}
</FieldError>
<FieldError match="customError" />
</div>
</Field>
<Field name="role" className="relative min-w-0 flex-1">
<FieldLabel>
{t(($) => $['roster.createForm.roleLabel'])}
<span className="ml-1 system-xs-regular text-text-tertiary">
{tCommon(($) => $['label.optional'])}
</span>
</FieldLabel>
<Input
autoComplete="off"
maxLength={255}
onValueChange={setRole}
placeholder={t(($) => $['roster.createForm.rolePlaceholder'])}
value={role}
/>
</Field>
</div>
</div>
<Field name="description">
<FieldLabel>
{t(($) => $['roster.createForm.descriptionLabel'])}
<span className="ml-1 system-xs-regular text-text-tertiary">
{tCommon(($) => $['label.optional'])}
</span>
</FieldLabel>
<Textarea
autoComplete="off"
className="h-20 resize-none"
onValueChange={setDescription}
placeholder={t(($) => $['roster.createForm.descriptionPlaceholder'])}
value={description}
/>
</Field>
</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={duplicateAgentMutation.isPending}
>
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button
type="submit"
variant="primary"
className="min-w-18"
loading={duplicateAgentMutation.isPending}
>
{tCommon(($) => $['operation.duplicate'])}
</Button>
</div>
</Form>
<DuplicateAgentFormSession
key={latestAgent.id}
agent={latestAgent}
nameInputRef={nameInputRef}
pending={duplicateAgentMutation.isPending}
onCancel={() => onOpenChange(false)}
onSubmit={handleSubmit}
/>
</DialogContent>
</Dialog>
<AppIconPicker
open={iconPickerOpen}
initialEmoji={
agentIcon.type === 'emoji'
? { icon: agentIcon.icon, background: agentIcon.background }
: undefined
}
onOpenChange={setIconPickerOpen}
onSelect={setAgentIcon}
/>
</>
)
}

View File

@ -1,9 +1,7 @@
'use client'
import type {
AgentAppPartial,
AgentAppUpdatePayload,
} from '@dify/contracts/api/console/agent/types.gen'
import type { AgentFormValues, AgentIconSelection } from './agent-form'
import type { AgentAppUpdatePayload } from '@dify/contracts/api/console/agent/types.gen'
import type { ChangeEventHandler, Ref } from 'react'
import type { AgentFormSource, AgentFormValues, AgentIconSelection } from './agent-form'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
@ -14,9 +12,8 @@ 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 { consoleQuery } from '@/service/client'
@ -24,11 +21,19 @@ import { createAgentIconSelection, getAgentIconKey } from './agent-form'
import { AgentFormFields } from './agent-form-fields'
type EditAgentDialogProps = {
agent: AgentAppPartial
agent: AgentFormSource
open: boolean
onOpenChange: (open: boolean) => void
}
type EditAgentFormSessionProps = {
agent: AgentFormSource
nameInputRef: Ref<HTMLInputElement>
pending: boolean
onCancel: () => void
onSubmit: (formValues: AgentFormValues, agentIcon: AgentIconSelection) => void
}
const applyIconPayload = (body: AgentAppUpdatePayload, icon: AgentIconSelection) => {
if (icon.type === 'emoji') {
body.icon_type = icon.type
@ -42,133 +47,78 @@ const applyIconPayload = (body: AgentAppUpdatePayload, icon: AgentIconSelection)
body.icon_background = undefined
}
export function EditAgentDialog({ agent, open, onOpenChange }: EditAgentDialogProps) {
function EditAgentFormSession({
agent,
nameInputRef,
pending,
onCancel,
onSubmit,
}: EditAgentFormSessionProps) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const [name, setName] = useState(agent.name)
const [description, setDescription] = useState(agent.description ?? '')
const [role, setRole] = useState(agent.role ?? '')
const [initialValues] = useState(() => ({
fields: {
description: agent.description ?? '',
name: agent.name,
role: agent.role ?? '',
} satisfies AgentFormValues,
icon: createAgentIconSelection(agent),
}))
const [agentIcon, setAgentIcon] = useState(initialValues.icon)
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(() =>
createAgentIconSelection(agent),
)
const updateAgentMutation = useMutation(consoleQuery.agent.byAgentId.put.mutationOptions())
const [hasTextChanges, setHasTextChanges] = useState(false)
const hasIconChanges = getAgentIconKey(agentIcon) !== getAgentIconKey(initialValues.icon)
const hasChanges = hasTextChanges || hasIconChanges
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) setIconPickerOpen(false)
onOpenChange(nextOpen)
}
const handleSubmit = (formValues: AgentFormValues) => {
const trimmedName = formValues.name?.trim() ?? ''
const trimmedDescription = formValues.description?.trim() ?? ''
const trimmedRole = formValues.role?.trim() ?? ''
const hasIconChanges =
getAgentIconKey(agentIcon) !== getAgentIconKey(createAgentIconSelection(agent))
const hasFormChanges =
trimmedName !== agent.name.trim() ||
trimmedDescription !== (agent.description?.trim() ?? '') ||
trimmedRole !== (agent.role?.trim() ?? '') ||
hasIconChanges
if (updateAgentMutation.isPending) return
if (!hasFormChanges) return
const body: AgentAppUpdatePayload = {
name: trimmedName,
description: trimmedDescription,
// Keep sending the trimmed role even when empty: omitting the field
// preserves the current backing-agent role, while "" intentionally clears it.
role: trimmedRole,
}
applyIconPayload(body, agentIcon)
updateAgentMutation.mutate(
{
params: {
agent_id: agent.id,
},
body,
},
{
onSuccess: () => {
toast.success(t(($) => $['roster.updateSuccess']))
handleOpenChange(false)
},
},
const handleFormChange: ChangeEventHandler<HTMLFormElement> = (event) => {
const formValues = new FormData(event.currentTarget)
setHasTextChanges(
String(formValues.get('name') ?? '').trim() !== initialValues.fields.name.trim() ||
String(formValues.get('description') ?? '').trim() !==
initialValues.fields.description.trim() ||
String(formValues.get('role') ?? '').trim() !== initialValues.fields.role.trim(),
)
}
const trimmedName = name.trim()
const trimmedDescription = description.trim()
const trimmedRole = role.trim()
const hasIconChanges =
getAgentIconKey(agentIcon) !== getAgentIconKey(createAgentIconSelection(agent))
const hasChanges =
trimmedName !== agent.name.trim() ||
trimmedDescription !== (agent.description?.trim() ?? '') ||
trimmedRole !== (agent.role?.trim() ?? '') ||
hasIconChanges
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!">
<DialogClose
render={
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
size="lg"
className="absolute inset-e-6 top-6"
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
<div className="shrink-0 pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['roster.editDialog.title'])}
</DialogTitle>
<DialogDescription className="sr-only">
{t(($) => $['roster.editDialog.description'])}
</DialogDescription>
</div>
<Form<AgentFormValues> className="min-h-0 flex-1" onFormSubmit={handleSubmit}>
<AgentFormFields
description={description}
icon={agentIcon}
iconAriaLabel={t(($) => $['roster.editAgent'], { name: agent.name })}
name={name}
role={role}
onDescriptionChange={setDescription}
onIconClick={() => setIconPickerOpen(true)}
onNameChange={setName}
onRoleChange={setRole}
/>
<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={updateAgentMutation.isPending}
>
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button
type="submit"
variant="primary"
className="min-w-18"
disabled={!hasChanges}
loading={updateAgentMutation.isPending}
>
{tCommon(($) => $['operation.save'])}
</Button>
</div>
</Form>
</DialogContent>
</Dialog>
<div className="shrink-0 ps-6 pe-14 pt-6 pb-3">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['roster.editDialog.title'])}
</DialogTitle>
<DialogDescription className="sr-only">
{t(($) => $['roster.editDialog.description'])}
</DialogDescription>
</div>
<Form<AgentFormValues>
className="flex min-h-0 flex-1 flex-col"
onChange={handleFormChange}
onFormSubmit={(formValues) => {
if (hasChanges) onSubmit(formValues, agentIcon)
}}
>
<AgentFormFields
ref={nameInputRef}
defaultValues={initialValues.fields}
icon={agentIcon}
iconAriaLabel={t(($) => $['roster.createForm.changeIcon'])}
onIconClick={() => setIconPickerOpen(true)}
/>
<div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6">
<Button type="button" className="min-w-18" onClick={onCancel} disabled={pending}>
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button
type="submit"
variant="primary"
className="min-w-18"
disabled={!hasChanges}
loading={pending}
>
{tCommon(($) => $['operation.save'])}
</Button>
</div>
</Form>
<AppIconPicker
open={iconPickerOpen}
initialEmoji={
@ -182,3 +132,74 @@ export function EditAgentDialog({ agent, open, onOpenChange }: EditAgentDialogPr
</>
)
}
export function EditAgentDialog({ agent, open, onOpenChange }: EditAgentDialogProps) {
const { t } = useTranslation('agentV2')
const nameInputRef = useRef<HTMLInputElement>(null)
const updateAgentMutation = useMutation(consoleQuery.agent.byAgentId.put.mutationOptions())
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen && updateAgentMutation.isPending) return
onOpenChange(nextOpen)
}
const handleSubmit = (formValues: AgentFormValues, agentIcon: AgentIconSelection) => {
if (updateAgentMutation.isPending) return
const body: AgentAppUpdatePayload = {
name: formValues.name.trim(),
description: formValues.description.trim(),
// Keep sending the trimmed role even when empty: omitting the field
// preserves the current backing-agent role, while "" intentionally clears it.
role: formValues.role.trim(),
}
applyIconPayload(body, agentIcon)
updateAgentMutation.mutate(
{
params: {
agent_id: agent.id,
},
body,
},
{
onSuccess: () => {
onOpenChange(false)
},
},
)
}
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
<DialogContent
initialFocus={nameInputRef}
className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!"
>
<DialogClose
disabled={updateAgentMutation.isPending}
render={
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
size="lg"
className="absolute inset-e-5 top-5"
>
<span aria-hidden className="i-ri-close-line size-4" />
</IconButton>
}
/>
<EditAgentFormSession
key={agent.id}
agent={agent}
nameInputRef={nameInputRef}
pending={updateAgentMutation.isPending}
onCancel={() => onOpenChange(false)}
onSubmit={handleSubmit}
/>
</DialogContent>
</Dialog>
</>
)
}