mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 08:48:10 +08:00
fix(web): refine agent dialog form sessions (#41492)
This commit is contained in:
parent
6130f081e0
commit
fce29e6f6a
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 />)
|
||||
|
||||
@ -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),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user