feat(web): enhance app and agent card interactions (#41449)

This commit is contained in:
yyh
2026-08-28 12:24:56 +00:00
committed by GitHub
parent efd90de70a
commit 28e9a52ea5
16 changed files with 1147 additions and 1058 deletions
@@ -692,12 +692,23 @@ describe('AppCard', () => {
expect(screen.getByRole('listitem')).toContainElement(cardLink)
})
it('should expose a visible focus ring on the card link', () => {
it('should keep card navigation and actions as sibling focus targets', async () => {
const user = userEvent.setup()
render(<AppCard app={mockApp} />)
const cardLink = screen.getByRole('link', { name: 'Test App' })
expect(cardLink).toHaveClass('focus-visible:ring-2')
expect(cardLink).toHaveClass('focus-visible:ring-state-accent-solid')
const cardLink = screen.getByRole('link', { name: 'Test App' })
const starToggle = screen.getByRole('button', { name: 'app.studio.starApp: Test App' })
const operationsTrigger = getOperationsTrigger()
expect(cardLink).not.toContainElement(starToggle)
expect(cardLink).not.toContainElement(operationsTrigger)
await user.tab()
expect(cardLink).toHaveFocus()
await user.tab()
expect(starToggle).toHaveFocus()
await user.tab()
expect(operationsTrigger).toHaveFocus()
})
it('should star the app from the card action without navigating', async () => {
@@ -740,20 +751,6 @@ describe('AppCard', () => {
})
describe('Operations Menu', () => {
it('should reveal operations trigger when card receives keyboard focus', () => {
render(<AppCard app={mockApp} />)
const operationsTrigger = getOperationsTrigger()
const operationsTriggerWrapper = operationsTrigger.closest('.absolute')
expect(operationsTriggerWrapper).toHaveClass('top-2')
expect(operationsTriggerWrapper).toHaveClass('right-2')
expect(operationsTriggerWrapper).toHaveClass('group-focus-within:pointer-events-auto')
expect(operationsTriggerWrapper).toHaveClass('group-focus-within:opacity-100')
expect(operationsTriggerWrapper).not.toHaveClass('w-[120px]')
expect(operationsTrigger).toHaveClass('focus-visible:ring-2')
expect(operationsTrigger).toHaveClass('focus-visible:ring-state-accent-solid')
})
it('should show edit option when dropdown menu is opened', async () => {
const user = userEvent.setup()
render(<AppCard app={mockApp} />)
@@ -766,6 +763,32 @@ describe('AppCard', () => {
expect(mockPush).not.toHaveBeenCalled()
})
it('should expose the same operations from the card context menu', async () => {
const user = userEvent.setup()
render(<AppCard app={mockApp} />)
await user.pointer({
target: screen.getByRole('link', { name: 'Test App' }),
keys: '[MouseRight]',
})
expect(await screen.findByRole('menuitem', { name: 'app.editApp' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'app.duplicate' })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'app.export' })).toBeInTheDocument()
})
it('should keep card actions outside the card context menu trigger', async () => {
const user = userEvent.setup()
render(<AppCard app={mockApp} />)
await user.pointer({
target: screen.getByRole('button', { name: 'app.studio.starApp: Test App' }),
keys: '[MouseRight]',
})
expect(screen.queryByRole('menuitem', { name: 'app.editApp' })).not.toBeInTheDocument()
})
it('should show duplicate option when dropdown menu is opened', async () => {
render(<AppCard app={mockApp} />)
@@ -1203,6 +1226,7 @@ describe('AppCard', () => {
render(<AppCard app={mockApp} />)
const trigger = screen.getByRole('button', { name: 'common.operation.exporting' })
expect(trigger).toBeDisabled()
})
})
+38 -17
View File
@@ -3,7 +3,7 @@ import type { StepByStepTourSessionState } from '@/app/components/step-by-step-t
import type { App } from '@/models/explore'
import type { TryAppSelection } from '@/types/try-app'
import { keepPreviousData } from '@tanstack/react-query'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createStore, Provider as JotaiProvider } from 'jotai'
import * as React from 'react'
@@ -398,12 +398,11 @@ vi.mock('../app-card', () => ({
stepByStepTourCardHighlightPart?: string
}) => {
return React.createElement(
'div',
'li',
{
'data-testid': `app-card-${app.id}`,
'data-step-by-step-tour-target': stepByStepTourCardTarget,
'data-step-by-step-tour-highlight-part': stepByStepTourCardHighlightPart,
role: 'article',
},
app.name,
React.createElement('button', {
@@ -415,20 +414,27 @@ vi.mock('../app-card', () => ({
)
},
default: ({ app }: { app: { id: string; name: string } }) => {
return React.createElement(
'div',
{ 'data-testid': `app-card-${app.id}`, role: 'article' },
app.name,
)
return React.createElement('li', { 'data-testid': `app-card-${app.id}` }, app.name)
},
}))
vi.mock('../app-card/action-bar', () => ({
AppCardActionBar: ({ app }: { app: { id: string; name: string } }) => {
return React.createElement('button', {
'aria-label': `Actions for ${app.name}`,
type: 'button',
})
vi.mock('../app-card/interactions', () => ({
AppCardInteractions: ({
app,
children,
}: {
app: { id: string; name: string }
children: React.ReactElement
}) => {
return React.createElement(
React.Fragment,
null,
children,
React.createElement('button', {
'aria-label': `Actions for ${app.name}`,
type: 'button',
}),
)
},
}))
@@ -690,14 +696,20 @@ describe('List', () => {
renderList()
const starredLabel = screen.getByRole('heading', { level: 2, name: 'Starred' })
const starredList = screen.getByRole('list', { name: 'Starred' })
const starredCard = screen.getByRole('link', { name: 'Starred App' })
const allAppsLabel = screen.getByRole('heading', { level: 2, name: 'All Apps' })
const allAppsList = screen.getByRole('list', { name: 'All Apps' })
const firstAppCard = screen.getByTestId('app-card-app-1')
const actionBar = screen.getByRole('button', { name: 'Actions for Starred App' })
expect(starredCard).toBeInTheDocument()
expect(actionBar).toBeInTheDocument()
expect(screen.getAllByRole('list')).toHaveLength(2)
expect(starredList).toContainElement(starredCard)
expect(within(starredList).getAllByRole('listitem')).toHaveLength(1)
expect(firstAppCard.parentElement).toBe(allAppsList)
expect(within(allAppsList).getAllByRole('listitem')).toHaveLength(2)
expect(
starredLabel.compareDocumentPosition(starredCard) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
@@ -939,7 +951,10 @@ describe('List', () => {
expect(screen.getByTestId('empty-state'))!.toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('app.filterEmpty.noApps')
expect(screen.getByRole('status')).toHaveClass('sr-only')
expect(screen.getByTestId('empty-state').parentElement).toHaveAttribute('aria-busy', 'false')
expect(screen.getByTestId('empty-state').closest('[aria-busy]')).toHaveAttribute(
'aria-busy',
'false',
)
expect(screen.getByRole('button', { name: 'Types' }))!.toBeInTheDocument()
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument()
@@ -953,7 +968,10 @@ describe('List', () => {
renderList('?keywords=missing+app')
expect(screen.getByRole('status')).toBeEmptyDOMElement()
expect(screen.getByTestId('empty-state').parentElement).toHaveAttribute('aria-busy', 'true')
expect(screen.getByTestId('empty-state').closest('[aria-busy]')).toHaveAttribute(
'aria-busy',
'true',
)
})
it('should keep the settled empty status during a background refetch', () => {
@@ -963,7 +981,10 @@ describe('List', () => {
renderList('?keywords=missing+app')
expect(screen.getByRole('status')).toHaveTextContent('app.filterEmpty.noApps')
expect(screen.getByTestId('empty-state').parentElement).toHaveAttribute('aria-busy', 'true')
expect(screen.getByTestId('empty-state').closest('[aria-busy]')).toHaveAttribute(
'aria-busy',
'true',
)
})
it('should leave the first empty state as soon as a filter changes', () => {
@@ -17,7 +17,8 @@ export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps)
return (
<>
{skeletonKeys.map((key) => (
<div
<li
aria-hidden
key={key}
className="h-40 overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg p-4 shadow-xs"
>
@@ -34,7 +35,7 @@ export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps)
<SkeletonRectangle className="h-3 w-4/5 animate-pulse" />
</div>
</SkeletonContainer>
</div>
</li>
))}
</>
)
@@ -1,567 +0,0 @@
'use client'
import type {
AppPartial,
EnvironmentVariableItemResponse,
} from '@dify/contracts/api/console/apps/types.gen'
import type { FormEventHandler } from 'react'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import { zIconType } from '@dify/contracts/api/console/apps/zod.gen'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
import { toast } from '@langgenius/dify-ui/toast'
import { Toggle } from '@langgenius/dify-ui/toggle'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { memo, useCallback, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useExportAppDsl, useExportWorkflowAppDsl } from '@/app/components/app/use-export-app-dsl'
import StarIcon from '@/app/components/base/icons/src/vender/Star'
import {
getStepByStepTourDropdownMenuContentProps,
useStepByStepTourControlledDropdown,
} from '@/app/components/step-by-step-tour/dropdown-menu'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import dynamic from '@/next/dynamic'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import {
getAppACLCapabilities,
hasOnlyAppPreviewPermission,
hasPermission,
} from '@/utils/permission'
import { AppCardOperationsMenuContent } from '../operations-menu'
const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), {
ssr: false,
})
const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), {
ssr: false,
})
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
ssr: false,
})
const DSLExportConfirmModal = dynamic(
() => import('@/app/components/workflow/dsl-export-confirm-modal'),
{
ssr: false,
},
)
const OPERATIONS_MENU_POPUP_CLASS_NAME = 'min-w-[216px]'
type AppCardActionBarProps = {
app: AppPartial
stepByStepTourActionMenuOpen?: boolean
stepByStepTourActionMenuHighlightPart?: string
}
export const AppCardActionBar = memo(
({
app,
stepByStepTourActionMenuOpen = false,
stepByStepTourActionMenuHighlightPart,
}: AppCardActionBarProps) => {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isRbacEnabled = systemFeatures.rbac_enabled
const { onPlanInfoChanged } = useProviderContext()
const { push } = useRouter()
const { mutate: copyApp } = useMutation(consoleQuery.apps.byAppId.copy.post.mutationOptions())
const { mutateAsync: updateApp } = useMutation(consoleQuery.apps.byAppId.put.mutationOptions())
const { mutate: deleteApp, isPending: isDeleting } = useMutation(
consoleQuery.apps.byAppId.delete.mutationOptions(),
)
const { mutate: starApp, isPending: isStarring } = useMutation(
consoleQuery.apps.byAppId.star.post.mutationOptions(),
)
const { mutate: unstarApp, isPending: isUnstarring } = useMutation(
consoleQuery.apps.byAppId.star.delete.mutationOptions(),
)
const [showEditModal, setShowEditModal] = useState(false)
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
const operationsMenu = useStepByStepTourControlledDropdown({
allowTriggerCloseWhileControlled: false,
controlledOpen: stepByStepTourActionMenuOpen,
})
const isOperationsMenuOpen = operationsMenu.open
const setIsOperationsMenuOpen = operationsMenu.onOpenChange
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariableItemResponse[]>([])
const { exportAppDsl, isExporting: isAppDslExporting } = useExportAppDsl()
const { exportWorkflowAppDsl, isExporting: isWorkflowAppDslExporting } =
useExportWorkflowAppDsl()
const isExporting = isAppDslExporting || isWorkflowAppDslExporting
const isTogglingStar = isStarring || isUnstarring
const appIconType = zIconType.safeParse(app.icon_type).data ?? null
const resourceMaintainer = app.maintainer ?? undefined
const maintainerPermissionOptions = useMemo(
() => ({
currentUserId,
resourceMaintainer,
workspacePermissionKeys,
isRbacEnabled,
}),
[currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys],
)
const appACLCapabilities = useMemo(
() => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions),
[app.permission_keys, maintainerPermissionOptions],
)
const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys)
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
const onConfirmDelete = useCallback(() => {
try {
deleteApp(
{ params: { app_id: app.id } },
{
onSuccess: () => {
toast.success(t(($) => $.appDeleted, { ns: 'app' }))
onPlanInfoChanged()
setShowConfirmDelete(false)
setConfirmDeleteInput('')
},
onError: (error) => {
const message = error instanceof Error ? error.message : ''
toast.error(
`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`,
)
},
},
)
} catch (error) {
const message = error instanceof Error ? error.message : ''
toast.error(`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`)
}
}, [app.id, deleteApp, onPlanInfoChanged, t])
const onDeleteDialogOpenChange = useCallback(
(open: boolean) => {
if (isDeleting) return
setShowConfirmDelete(open)
if (!open) setConfirmDeleteInput('')
},
[isDeleting],
)
const isDeleteConfirmDisabled = isDeleting || confirmDeleteInput !== app.name
const onDeleteDialogSubmit: FormEventHandler<HTMLFormElement> = useCallback(
(e) => {
e.preventDefault()
if (isDeleteConfirmDisabled) return
void onConfirmDelete()
},
[isDeleteConfirmDisabled, onConfirmDelete],
)
const handleShowEditModal = useCallback(() => {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowEditModal(true)
})
}, [setIsOperationsMenuOpen])
const handleShowDuplicateModal = useCallback(() => {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowDuplicateModal(true)
})
}, [setIsOperationsMenuOpen])
const handleShowSwitchModal = useCallback(() => {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowSwitchModal(true)
})
}, [setIsOperationsMenuOpen])
const handleShowDeleteConfirm = useCallback(() => {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowConfirmDelete(true)
})
}, [setIsOperationsMenuOpen])
const handleOpenAccessConfig = useCallback(() => {
setIsOperationsMenuOpen(false)
push(`/app/${app.id}/access-config`)
}, [app.id, push, setIsOperationsMenuOpen])
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(
async ({
name,
icon_type,
icon,
icon_background,
description,
use_icon_as_answer_icon,
max_active_requests,
}) => {
try {
await updateApp({
params: { app_id: app.id },
body: {
name,
icon_type,
icon,
icon_background,
description,
use_icon_as_answer_icon,
max_active_requests,
},
})
setShowEditModal(false)
toast.success(t(($) => $.editDone, { ns: 'app' }))
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.editFailed, { ns: 'app' }))
}
},
[app.id, t, updateApp],
)
const onCopy: DuplicateAppModalProps['onConfirm'] = ({
name,
icon_type,
icon,
icon_background,
}) => {
try {
copyApp(
{
params: { app_id: app.id },
body: {
name,
icon_type,
icon,
icon_background,
},
},
{
onSuccess: (newApp) => {
if (!('mode' in newApp)) {
toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
return
}
setShowDuplicateModal(false)
toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' }))
onPlanInfoChanged()
getRedirection(newApp, push, {
currentUserId,
resourceMaintainer: newApp.maintainer ?? undefined,
workspacePermissionKeys,
isRbacEnabled,
})
},
onError: () => toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })),
},
)
} catch {
toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
}
return Promise.resolve()
}
const onExport = async (include = false) => {
await exportAppDsl({ appId: app.id, appName: app.name, includeSecret: include })
}
const exportCheck = async () => {
if (isExporting) return
setIsOperationsMenuOpen(false)
const isWorkflowApp =
app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT
const result = isWorkflowApp
? await exportWorkflowAppDsl({ appId: app.id, appName: app.name })
: await exportAppDsl({ appId: app.id, appName: app.name })
if (result?.status === 'confirmation-required') setSecretEnvList(result.secretEnvList)
}
const handleToggleStar = useCallback(
(pressed: boolean) => {
if (isTogglingStar) return
const mutateStar = pressed ? starApp : unstarApp
try {
mutateStar(
{ params: { app_id: app.id } },
{
onError: (error) =>
toast.error(
error instanceof Error
? error.message
: t(($) => $['studio.starFailed'], { ns: 'app' }),
),
},
)
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t(($) => $['studio.starFailed'], { ns: 'app' }),
)
}
},
[app.id, isTogglingStar, starApp, t, unstarApp],
)
const shouldShowEditOption = appACLCapabilities.canEdit
const shouldShowDuplicateOption = canCreateApp
const shouldShowExportOption = appACLCapabilities.canImportExportDSL
const shouldShowSwitchOption =
appACLCapabilities.canEdit &&
(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT)
const shouldShowAccessConfigOption = appACLCapabilities.canAccessConfig
const shouldShowDeleteOption = appACLCapabilities.canDelete
const shouldShowOperationsMenu =
shouldShowEditOption ||
shouldShowDuplicateOption ||
shouldShowExportOption ||
shouldShowSwitchOption ||
shouldShowAccessConfigOption ||
shouldShowDeleteOption
const starToggleLabel = t(($) => $['studio.starApp'], { ns: 'app' })
const starToggleAccessibleLabel = `${starToggleLabel}: ${app.name}`
return (
<>
{!isPreviewOnly && (
<div
className={cn(
'absolute top-2 right-2 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-xs transition-opacity',
isOperationsMenuOpen || isExporting
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<Tooltip>
<TooltipTrigger
render={
<Toggle
pressed={app.is_starred}
disabled={isTogglingStar}
onPressedChange={handleToggleStar}
render={
<IconButton
size="lg"
aria-label={starToggleAccessibleLabel}
className="group disabled:opacity-70"
>
<StarIcon
aria-hidden
className="size-4.5 text-text-tertiary group-data-pressed:text-text-warning-secondary"
/>
</IconButton>
}
/>
}
/>
<TooltipContent>{starToggleLabel}</TooltipContent>
</Tooltip>
{shouldShowOperationsMenu && (
<DropdownMenu
modal={false}
open={isOperationsMenuOpen}
onOpenChange={setIsOperationsMenuOpen}
>
<DropdownMenuTrigger
render={
<IconButton
size="lg"
aria-label={
isExporting
? t(($) => $['operation.exporting'], { ns: 'common' })
: t(($) => $['operation.moreActionsFor'], {
ns: 'common',
name: app.name,
})
}
disabled={isExporting}
className="data-popup-open:bg-state-base-hover"
>
<span
aria-hidden
className={cn(
'size-4.5 text-text-tertiary',
isExporting
? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none'
: 'i-ri-more-fill',
)}
/>
</IconButton>
}
/>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
{...getStepByStepTourDropdownMenuContentProps({
highlightPart: stepByStepTourActionMenuHighlightPart,
interactionMode: operationsMenu.controlled ? 'presentation' : 'interactive',
className: OPERATIONS_MENU_POPUP_CLASS_NAME,
})}
>
<AppCardOperationsMenuContent
app={app}
shouldShowEditOption={shouldShowEditOption}
shouldShowDuplicateOption={shouldShowDuplicateOption}
shouldShowExportOption={shouldShowExportOption}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowAccessConfigOption={shouldShowAccessConfigOption}
shouldShowDeleteOption={shouldShowDeleteOption}
isExporting={isExporting}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessConfig={handleOpenAccessConfig}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
{showEditModal && (
<EditAppModal
isEditModal
appName={app.name}
appIconType={appIconType}
appIcon={app.icon ?? ''}
appIconBackground={app.icon_background}
appIconUrl={app.icon_url}
appDescription={app.description ?? ''}
appMode={app.mode}
appUseIconAsAnswerIcon={app.use_icon_as_answer_icon ?? false}
max_active_requests={app.max_active_requests ?? null}
show={showEditModal}
onConfirm={onEdit}
onHide={() => setShowEditModal(false)}
/>
)}
{showDuplicateModal && (
<DuplicateAppModal
appName={app.name}
icon_type={appIconType}
icon={app.icon ?? ''}
icon_background={app.icon_background}
icon_url={app.icon_url}
show={showDuplicateModal}
onConfirm={onCopy}
onHide={() => setShowDuplicateModal(false)}
/>
)}
{showSwitchModal && (
<SwitchAppModal
show={showSwitchModal}
appDetail={app}
onClose={() => setShowSwitchModal(false)}
/>
)}
<AlertDialog open={showConfirmDelete} onOpenChange={onDeleteDialogOpenChange}>
<AlertDialogContent>
<form className="flex flex-col" onSubmit={onDeleteDialogSubmit}>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $.deleteAppConfirmTitle, { ns: 'app' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t(($) => $.deleteAppConfirmContent, { ns: 'app' })}
</AlertDialogDescription>
<Field name="confirm-app-name" className="mt-2">
<FieldLabel className="mb-1 block py-0 system-sm-regular text-text-secondary">
<Trans
i18nKey={($) => $.deleteAppConfirmInputLabel}
ns="app"
values={{ appName: app.name }}
components={{
appName: (
<span className="system-sm-semibold text-text-primary" translate="no" />
),
}}
/>
</FieldLabel>
<InputGroup className="border-components-input-border-hover">
<InputGroupInput
type="text"
autoComplete="off"
spellCheck={false}
placeholder={t(($) => $.deleteAppConfirmInputPlaceholder, { ns: 'app' })}
value={confirmDeleteInput}
onValueChange={setConfirmDeleteInput}
/>
<InputGroupAddon align="inline-end" className="min-w-20 justify-end pe-1.75">
<Button
variant="tertiary"
size="small"
onClick={() => setConfirmDeleteInput(app.name)}
className="rounded-full px-2.5"
>
{t(($) => $['operation.fill'], { ns: 'common' })}
</Button>
</InputGroupAddon>
</InputGroup>
</Field>
</div>
<AlertDialogActions>
<AlertDialogCancelButton type="button" disabled={isDeleting}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
type="submit"
loading={isDeleting}
disabled={isDeleteConfirmDisabled}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</form>
</AlertDialogContent>
</AlertDialog>
{secretEnvList.length > 0 && (
<DSLExportConfirmModal
envList={secretEnvList}
onConfirm={onExport}
onClose={() => setSecretEnvList([])}
/>
)}
</>
)
},
)
+25 -23
View File
@@ -25,7 +25,7 @@ import {
hasPermission,
} from '@/utils/permission'
import { formatTime } from '@/utils/time'
import { AppCardActionBar } from './action-bar'
import { AppCardInteractions } from './interactions'
const EMPTY_ONLINE_USERS: WorkflowOnlineUser[] = []
@@ -120,10 +120,8 @@ export const AppCard = memo(
const appIconType = zIconType.safeParse(app.icon_type).data ?? null
const appHref = getRedirectionPath(app, maintainerPermissionOptions)
const appCardClassName = cn(
'inline-flex h-full w-full touch-manipulation flex-col overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs outline-hidden transition-shadow duration-200 ease-in-out',
isPreviewOnly
? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid'
: 'cursor-pointer hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'inline-flex h-full w-full touch-manipulation flex-col rounded-xl outline-hidden',
isPreviewOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
)
const showPreviewOnlyAccessWarning = useCallback(() => {
toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' }))
@@ -172,8 +170,7 @@ export const AppCard = memo(
{app.description}
</div>
</div>
<div className="flex h-6.5 shrink-0 items-start px-3" />
<div className="flex min-w-0 shrink-0 items-center overflow-hidden pt-2 pr-4 pb-3 pl-4 system-xs-regular text-text-tertiary">
<div className="mt-6.5 flex min-w-0 shrink-0 items-center overflow-hidden pt-2 pr-4 pb-3 pl-4 system-xs-regular text-text-tertiary">
<div className="flex min-w-0 flex-1 items-center gap-1 whitespace-nowrap">
{app.author_name && (
<>
@@ -188,7 +185,13 @@ export const AppCard = memo(
)
return (
<div role="listitem" className="group relative col-span-1 h-41.5">
<li
className={cn(
"group relative isolate col-span-1 h-41.5 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-shadow duration-200 ease-in-out after:pointer-events-none after:absolute after:inset-0 after:z-1 after:rounded-xl after:content-[''] focus-within:bg-components-card-bg-alt has-[>a:focus-visible]:after:inset-ring-2 has-[>a:focus-visible]:after:inset-ring-state-accent-solid has-[>button:focus-visible]:after:inset-ring-2 has-[>button:focus-visible]:after:inset-ring-state-accent-solid motion-reduce:transition-none",
!isPreviewOnly &&
'hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5 has-data-popup-open:bg-components-card-bg-alt has-data-popup-open:shadow-md has-data-popup-open:shadow-shadow-shadow-5 [@media(hover:none)]:bg-components-card-bg-alt',
)}
>
{isPreviewOnly ? (
<button
type="button"
@@ -202,23 +205,22 @@ export const AppCard = memo(
{appCardContent}
</button>
) : (
<Link
href={appHref}
aria-labelledby={appNameId}
aria-describedby={app.description ? appDescriptionId : undefined}
data-step-by-step-tour-target={stepByStepTourCardTarget}
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
className={appCardClassName}
>
{appCardContent}
</Link>
)}
{!isPreviewOnly && (
<AppCardActionBar
<AppCardInteractions
app={app}
stepByStepTourActionMenuOpen={stepByStepTourActionMenuOpen}
stepByStepTourActionMenuHighlightPart={stepByStepTourActionMenuHighlightPart}
/>
>
<Link
href={appHref}
aria-labelledby={appNameId}
aria-describedby={app.description ? appDescriptionId : undefined}
data-step-by-step-tour-target={stepByStepTourCardTarget}
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
className={appCardClassName}
>
{appCardContent}
</Link>
</AppCardInteractions>
)}
<div className="absolute top-26 right-3 left-3 flex h-6.5 min-w-0 items-start">
<AppCardTags
@@ -229,7 +231,7 @@ export const AppCard = memo(
onOpenTagManagement={onOpenTagManagement}
/>
</div>
</div>
</li>
)
},
)
@@ -0,0 +1,755 @@
'use client'
import type {
AppPartial,
EnvironmentVariableItemResponse,
} from '@dify/contracts/api/console/apps/types.gen'
import type { FormEventHandler, MouseEvent, ReactElement } from 'react'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import { zIconType } from '@dify/contracts/api/console/apps/zod.gen'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@langgenius/dify-ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
import { toast } from '@langgenius/dify-ui/toast'
import { Toggle } from '@langgenius/dify-ui/toggle'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useCallback, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useExportAppDsl, useExportWorkflowAppDsl } from '@/app/components/app/use-export-app-dsl'
import StarIcon from '@/app/components/base/icons/src/vender/Star'
import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes'
import {
getStepByStepTourDropdownMenuContentProps,
useStepByStepTourControlledDropdown,
} from '@/app/components/step-by-step-tour/dropdown-menu'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import dynamic from '@/next/dynamic'
import { useRouter } from '@/next/navigation'
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import { consoleQuery } from '@/service/client'
import { fetchInstalledAppList } from '@/service/explore'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { getAppACLCapabilities, hasPermission } from '@/utils/permission'
import { basePath } from '@/utils/var'
const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), {
ssr: false,
})
const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), {
ssr: false,
})
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
ssr: false,
})
const DSLExportConfirmModal = dynamic(
() => import('@/app/components/workflow/dsl-export-confirm-modal'),
{
ssr: false,
},
)
const OPERATIONS_MENU_POPUP_CLASS_NAME = 'min-w-[216px]'
const APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE = new Set<AppPartial['mode']>([
AppModeEnum.ADVANCED_CHAT,
AppModeEnum.WORKFLOW,
])
function requiresPublishedWorkflowInExplore(app: AppPartial) {
return APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE.has(app.mode)
}
type AppCardOperationsMenuItemsProps = {
app: AppPartial
kind: 'context' | 'dropdown'
shouldShowEditOption: boolean
shouldShowDuplicateOption: boolean
shouldShowExportOption: boolean
shouldShowSwitchOption: boolean
shouldShowAccessConfigOption: boolean
shouldShowDeleteOption: boolean
isExporting: boolean
onEdit: () => void
onDuplicate: () => void
onExport: () => void
onSwitch: () => void
onDelete: () => void
onAccessConfig: () => void
}
function AppCardOperationsMenuItems({
app,
kind,
shouldShowEditOption,
shouldShowDuplicateOption,
shouldShowExportOption,
shouldShowSwitchOption,
shouldShowAccessConfigOption,
shouldShowDeleteOption,
isExporting,
onEdit,
onDuplicate,
onExport,
onSwitch,
onDelete,
onAccessConfig,
}: AppCardOperationsMenuItemsProps) {
const { t } = useTranslation()
const openAsyncWindow = useAsyncWindowOpen()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({
appId: app.id,
enabled: systemFeatures.webapp_auth.enabled,
})
const needsPublishBeforeExplore = requiresPublishedWorkflowInExplore(app) && !app.workflow?.id
const shouldShowOpenInExploreOption =
!app.has_draft_trigger &&
(needsPublishBeforeExplore ||
!systemFeatures.webapp_auth.enabled ||
(!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result)))
const hasEditGroup = shouldShowEditOption
const hasCreateExportGroup = shouldShowDuplicateOption || shouldShowExportOption
const hasSwitchOrExploreGroup = shouldShowSwitchOption || shouldShowOpenInExploreOption
const hasAccessDeleteGroup = shouldShowAccessConfigOption || shouldShowDeleteOption
const MenuItem = kind === 'context' ? ContextMenuItem : DropdownMenuItem
const MenuSeparator = kind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator
function handleMenuAction(event: MouseEvent<HTMLElement>, action: () => void) {
event.stopPropagation()
event.preventDefault()
action()
}
async function handleOpenInstalledApp(event: MouseEvent<HTMLElement>) {
event.stopPropagation()
event.preventDefault()
if (requiresPublishedWorkflowInExplore(app) && !app.workflow?.id) {
toast.error(t(($) => $.notPublishedYet, { ns: 'app' }))
return
}
try {
await openAsyncWindow(
async () => {
const { installed_apps } = await fetchInstalledAppList(app.id)
if (installed_apps?.length > 0)
return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}`
throw new Error(t(($) => $.notPublishedYet, { ns: 'app' }))
},
{
onError: (error) => {
toast.error(`${error.message || error}`)
},
},
)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : `${error}`
toast.error(message)
}
}
return (
<>
{shouldShowEditOption && (
<MenuItem className="gap-2 px-3" onClick={(event) => handleMenuAction(event, onEdit)}>
<span className="system-sm-regular text-text-secondary">
{t(($) => $.editApp, { ns: 'app' })}
</span>
</MenuItem>
)}
{hasEditGroup &&
(hasCreateExportGroup || hasSwitchOrExploreGroup || hasAccessDeleteGroup) && (
<MenuSeparator />
)}
{shouldShowDuplicateOption && (
<MenuItem className="gap-2 px-3" onClick={(event) => handleMenuAction(event, onDuplicate)}>
<span className="system-sm-regular text-text-secondary">
{t(($) => $.duplicate, { ns: 'app' })}
</span>
</MenuItem>
)}
{shouldShowExportOption && (
<MenuItem
className="gap-2 px-3"
disabled={isExporting}
onClick={(event) => handleMenuAction(event, onExport)}
>
<span className="system-sm-regular text-text-secondary">
{t(($) => $.export, { ns: 'app' })}
</span>
</MenuItem>
)}
{hasCreateExportGroup && (hasSwitchOrExploreGroup || hasAccessDeleteGroup) && (
<MenuSeparator />
)}
{shouldShowSwitchOption && (
<MenuItem className="gap-2 px-3" onClick={(event) => handleMenuAction(event, onSwitch)}>
<span className="text-sm/5 text-text-secondary">{t(($) => $.switch, { ns: 'app' })}</span>
</MenuItem>
)}
{shouldShowOpenInExploreOption && (
<MenuItem className="gap-2 px-3" onClick={handleOpenInstalledApp}>
<span className="system-sm-regular text-text-secondary">
{t(($) => $.openInExplore, { ns: 'app' })}
</span>
</MenuItem>
)}
{hasSwitchOrExploreGroup && hasAccessDeleteGroup && <MenuSeparator />}
{shouldShowAccessConfigOption && (
<MenuItem
className="gap-2 px-3"
onClick={(event) => handleMenuAction(event, onAccessConfig)}
>
<span className="text-sm/5 text-text-secondary">
{t(($) => $['settings.resourceAccess'], { ns: 'common' })}
</span>
</MenuItem>
)}
{shouldShowAccessConfigOption && shouldShowDeleteOption && <MenuSeparator />}
{shouldShowDeleteOption && (
<MenuItem
variant="destructive"
className="gap-2 px-3"
onClick={(event) => handleMenuAction(event, onDelete)}
>
<span className="system-sm-regular">
{t(($) => $['operation.delete'], { ns: 'common' })}
</span>
</MenuItem>
)}
</>
)
}
type AppCardInteractionsProps = {
app: AppPartial
children: ReactElement
stepByStepTourActionMenuOpen?: boolean
stepByStepTourActionMenuHighlightPart?: string
}
export function AppCardInteractions({
app,
children,
stepByStepTourActionMenuOpen = false,
stepByStepTourActionMenuHighlightPart,
}: AppCardInteractionsProps) {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isRbacEnabled = systemFeatures.rbac_enabled
const { onPlanInfoChanged } = useProviderContext()
const { push } = useRouter()
const { mutate: copyApp } = useMutation(consoleQuery.apps.byAppId.copy.post.mutationOptions())
const { mutateAsync: updateApp } = useMutation(consoleQuery.apps.byAppId.put.mutationOptions())
const { mutate: deleteApp, isPending: isDeleting } = useMutation(
consoleQuery.apps.byAppId.delete.mutationOptions(),
)
const { mutate: starApp, isPending: isStarring } = useMutation(
consoleQuery.apps.byAppId.star.post.mutationOptions(),
)
const { mutate: unstarApp, isPending: isUnstarring } = useMutation(
consoleQuery.apps.byAppId.star.delete.mutationOptions(),
)
const [activeDialog, setActiveDialog] = useState<
'delete' | 'duplicate' | 'edit' | 'switch' | null
>(null)
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
const operationsMenu = useStepByStepTourControlledDropdown({
allowTriggerCloseWhileControlled: false,
controlledOpen: stepByStepTourActionMenuOpen,
})
const isOperationsMenuOpen = operationsMenu.open
const setIsOperationsMenuOpen = operationsMenu.onOpenChange
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariableItemResponse[]>([])
const { exportAppDsl, isExporting: isAppDslExporting } = useExportAppDsl()
const { exportWorkflowAppDsl, isExporting: isWorkflowAppDslExporting } = useExportWorkflowAppDsl()
const isExporting = isAppDslExporting || isWorkflowAppDslExporting
const isTogglingStar = isStarring || isUnstarring
const appIconType = zIconType.safeParse(app.icon_type).data ?? null
const resourceMaintainer = app.maintainer ?? undefined
const maintainerPermissionOptions = useMemo(
() => ({
currentUserId,
resourceMaintainer,
workspacePermissionKeys,
isRbacEnabled,
}),
[currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys],
)
const appACLCapabilities = useMemo(
() => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions),
[app.permission_keys, maintainerPermissionOptions],
)
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
const onConfirmDelete = useCallback(() => {
try {
deleteApp(
{ params: { app_id: app.id } },
{
onSuccess: () => {
toast.success(t(($) => $.appDeleted, { ns: 'app' }))
onPlanInfoChanged()
setActiveDialog(null)
setConfirmDeleteInput('')
},
onError: (error) => {
const message = error instanceof Error ? error.message : ''
toast.error(
`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`,
)
},
},
)
} catch (error) {
const message = error instanceof Error ? error.message : ''
toast.error(`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`)
}
}, [app.id, deleteApp, onPlanInfoChanged, t])
const onDeleteDialogOpenChange = useCallback(
(open: boolean) => {
if (isDeleting) return
setActiveDialog(open ? 'delete' : null)
if (!open) setConfirmDeleteInput('')
},
[isDeleting],
)
const isDeleteConfirmDisabled = isDeleting || confirmDeleteInput !== app.name
const onDeleteDialogSubmit: FormEventHandler<HTMLFormElement> = useCallback(
(e) => {
e.preventDefault()
if (isDeleteConfirmDisabled) return
void onConfirmDelete()
},
[isDeleteConfirmDisabled, onConfirmDelete],
)
const handleShowEditModal = useCallback(() => {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setActiveDialog('edit')
})
}, [setIsOperationsMenuOpen])
const handleShowDuplicateModal = useCallback(() => {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setActiveDialog('duplicate')
})
}, [setIsOperationsMenuOpen])
const handleShowSwitchModal = useCallback(() => {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setActiveDialog('switch')
})
}, [setIsOperationsMenuOpen])
const handleShowDeleteConfirm = useCallback(() => {
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setActiveDialog('delete')
})
}, [setIsOperationsMenuOpen])
const handleOpenAccessConfig = useCallback(() => {
setIsOperationsMenuOpen(false)
push(`/app/${app.id}/access-config`)
}, [app.id, push, setIsOperationsMenuOpen])
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(
async ({
name,
icon_type,
icon,
icon_background,
description,
use_icon_as_answer_icon,
max_active_requests,
}) => {
try {
await updateApp({
params: { app_id: app.id },
body: {
name,
icon_type,
icon,
icon_background,
description,
use_icon_as_answer_icon,
max_active_requests,
},
})
setActiveDialog(null)
toast.success(t(($) => $.editDone, { ns: 'app' }))
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.editFailed, { ns: 'app' }))
}
},
[app.id, t, updateApp],
)
const onCopy: DuplicateAppModalProps['onConfirm'] = ({
name,
icon_type,
icon,
icon_background,
}) => {
try {
copyApp(
{
params: { app_id: app.id },
body: {
name,
icon_type,
icon,
icon_background,
},
},
{
onSuccess: (newApp) => {
if (!('mode' in newApp)) {
toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
return
}
setActiveDialog(null)
toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' }))
onPlanInfoChanged()
getRedirection(newApp, push, {
currentUserId,
resourceMaintainer: newApp.maintainer ?? undefined,
workspacePermissionKeys,
isRbacEnabled,
})
},
onError: () => toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })),
},
)
} catch {
toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
}
return Promise.resolve()
}
const onExport = async (include = false) => {
await exportAppDsl({ appId: app.id, appName: app.name, includeSecret: include })
}
const exportCheck = async () => {
if (isExporting) return
setIsOperationsMenuOpen(false)
const isWorkflowApp =
app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT
const result = isWorkflowApp
? await exportWorkflowAppDsl({ appId: app.id, appName: app.name })
: await exportAppDsl({ appId: app.id, appName: app.name })
if (result?.status === 'confirmation-required') setSecretEnvList(result.secretEnvList)
}
const handleToggleStar = useCallback(
(pressed: boolean) => {
if (isTogglingStar) return
const mutateStar = pressed ? starApp : unstarApp
try {
mutateStar(
{ params: { app_id: app.id } },
{
onError: (error) =>
toast.error(
error instanceof Error
? error.message
: t(($) => $['studio.starFailed'], { ns: 'app' }),
),
},
)
} catch (error) {
toast.error(
error instanceof Error ? error.message : t(($) => $['studio.starFailed'], { ns: 'app' }),
)
}
},
[app.id, isTogglingStar, starApp, t, unstarApp],
)
const shouldShowEditOption = appACLCapabilities.canEdit
const shouldShowDuplicateOption = canCreateApp
const shouldShowExportOption = appACLCapabilities.canImportExportDSL
const shouldShowSwitchOption =
appACLCapabilities.canEdit &&
(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT)
const shouldShowAccessConfigOption = appACLCapabilities.canAccessConfig
const shouldShowDeleteOption = appACLCapabilities.canDelete
const shouldShowOperationsMenu =
shouldShowEditOption ||
shouldShowDuplicateOption ||
shouldShowExportOption ||
shouldShowSwitchOption ||
shouldShowAccessConfigOption ||
shouldShowDeleteOption
const starToggleLabel = t(($) => $['studio.starApp'], { ns: 'app' })
const starToggleAccessibleLabel = `${starToggleLabel}: ${app.name}`
const operationsMenuItemsProps = {
app,
shouldShowEditOption,
shouldShowDuplicateOption,
shouldShowExportOption,
shouldShowSwitchOption,
shouldShowAccessConfigOption,
shouldShowDeleteOption,
isExporting,
onEdit: handleShowEditModal,
onDuplicate: handleShowDuplicateModal,
onExport: exportCheck,
onSwitch: handleShowSwitchModal,
onDelete: handleShowDeleteConfirm,
onAccessConfig: handleOpenAccessConfig,
}
return (
<>
{shouldShowOperationsMenu ? (
<ContextMenu>
<ContextMenuTrigger render={children} />
<ContextMenuContent className={OPERATIONS_MENU_POPUP_CLASS_NAME}>
<AppCardOperationsMenuItems kind="context" {...operationsMenuItemsProps} />
</ContextMenuContent>
</ContextMenu>
) : (
children
)}
<div
className={cn(
'pointer-events-none absolute top-[-0.5px] right-[-0.5px] flex h-16 w-30 items-start justify-end bg-[linear-gradient(67deg,var(--color-components-card-bg-alt-transparent)_0%,var(--color-components-card-bg-alt)_75%)] p-2 opacity-0',
isOperationsMenuOpen || isExporting
? 'opacity-100'
: 'group-focus-within:opacity-100 group-hover:opacity-100 has-data-popup-open:opacity-100 [@media(hover:none)]:opacity-100',
)}
>
<div
className={cn(
'flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-xs',
isOperationsMenuOpen || isExporting
? 'pointer-events-auto'
: 'pointer-events-none group-focus-within:pointer-events-auto group-hover:pointer-events-auto has-data-popup-open:pointer-events-auto [@media(hover:none)]:pointer-events-auto',
)}
>
<Tooltip>
<TooltipTrigger
render={
<Toggle
pressed={app.is_starred}
disabled={isTogglingStar}
onPressedChange={handleToggleStar}
render={
<IconButton
size="lg"
aria-label={starToggleAccessibleLabel}
className="group disabled:opacity-70"
>
<StarIcon
aria-hidden
className="size-4.5 text-text-tertiary group-data-pressed:text-text-warning-secondary"
/>
</IconButton>
}
/>
}
/>
<TooltipContent>{starToggleLabel}</TooltipContent>
</Tooltip>
{shouldShowOperationsMenu && (
<DropdownMenu
modal={false}
open={isOperationsMenuOpen}
onOpenChange={setIsOperationsMenuOpen}
>
<DropdownMenuTrigger
render={
<IconButton
size="lg"
aria-label={
isExporting
? t(($) => $['operation.exporting'], { ns: 'common' })
: t(($) => $['operation.moreActionsFor'], {
ns: 'common',
name: app.name,
})
}
disabled={isExporting}
className="data-popup-open:bg-state-base-hover"
>
<span
aria-hidden
className={cn(
'size-4.5 text-text-tertiary',
isExporting
? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none'
: 'i-ri-more-fill',
)}
/>
</IconButton>
}
/>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
{...getStepByStepTourDropdownMenuContentProps({
highlightPart: stepByStepTourActionMenuHighlightPart,
interactionMode: operationsMenu.controlled ? 'presentation' : 'interactive',
className: OPERATIONS_MENU_POPUP_CLASS_NAME,
})}
>
<AppCardOperationsMenuItems kind="dropdown" {...operationsMenuItemsProps} />
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{activeDialog === 'edit' && (
<EditAppModal
isEditModal
appName={app.name}
appIconType={appIconType}
appIcon={app.icon ?? ''}
appIconBackground={app.icon_background}
appIconUrl={app.icon_url}
appDescription={app.description ?? ''}
appMode={app.mode}
appUseIconAsAnswerIcon={app.use_icon_as_answer_icon ?? false}
max_active_requests={app.max_active_requests ?? null}
show
onConfirm={onEdit}
onHide={() => setActiveDialog(null)}
/>
)}
{activeDialog === 'duplicate' && (
<DuplicateAppModal
appName={app.name}
icon_type={appIconType}
icon={app.icon ?? ''}
icon_background={app.icon_background}
icon_url={app.icon_url}
show
onConfirm={onCopy}
onHide={() => setActiveDialog(null)}
/>
)}
{activeDialog === 'switch' && (
<SwitchAppModal show appDetail={app} onClose={() => setActiveDialog(null)} />
)}
<AlertDialog open={activeDialog === 'delete'} onOpenChange={onDeleteDialogOpenChange}>
<AlertDialogContent>
<form className="flex flex-col" onSubmit={onDeleteDialogSubmit}>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $.deleteAppConfirmTitle, { ns: 'app' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t(($) => $.deleteAppConfirmContent, { ns: 'app' })}
</AlertDialogDescription>
<Field name="confirm-app-name" className="mt-2">
<FieldLabel className="mb-1 block py-0 system-sm-regular text-text-secondary">
<Trans
i18nKey={($) => $.deleteAppConfirmInputLabel}
ns="app"
values={{ appName: app.name }}
components={{
appName: (
<span className="system-sm-semibold text-text-primary" translate="no" />
),
}}
/>
</FieldLabel>
<InputGroup className="border-components-input-border-hover">
<InputGroupInput
type="text"
autoComplete="off"
spellCheck={false}
placeholder={t(($) => $.deleteAppConfirmInputPlaceholder, { ns: 'app' })}
value={confirmDeleteInput}
onValueChange={setConfirmDeleteInput}
/>
<InputGroupAddon align="inline-end" className="min-w-20 justify-end pe-1.75">
<Button
variant="tertiary"
size="small"
onClick={() => setConfirmDeleteInput(app.name)}
className="rounded-full px-2.5"
>
{t(($) => $['operation.fill'], { ns: 'common' })}
</Button>
</InputGroupAddon>
</InputGroup>
</Field>
</div>
<AlertDialogActions>
<AlertDialogCancelButton type="button" disabled={isDeleting}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
type="submit"
loading={isDeleting}
disabled={isDeleteConfirmDisabled}
>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</form>
</AlertDialogContent>
</AlertDialog>
{secretEnvList.length > 0 && (
<DSLExportConfirmModal
envList={secretEnvList}
onConfirm={onExport}
onClose={() => setSecretEnvList([])}
/>
)}
</>
)
}
@@ -1,201 +0,0 @@
'use client'
import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
import type { MouseEvent } from 'react'
import { DropdownMenuItem, DropdownMenuSeparator } from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
import { fetchInstalledAppList } from '@/service/explore'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
const APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE = new Set<AppPartial['mode']>([
AppModeEnum.ADVANCED_CHAT,
AppModeEnum.WORKFLOW,
])
function requiresPublishedWorkflowInExplore(app: AppPartial) {
return APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE.has(app.mode)
}
type AppCardOperationsMenuProps = {
app: AppPartial
shouldShowEditOption: boolean
shouldShowDuplicateOption: boolean
shouldShowExportOption: boolean
shouldShowSwitchOption: boolean
shouldShowOpenInExploreOption: boolean
shouldShowAccessConfigOption: boolean
shouldShowDeleteOption: boolean
isExporting: boolean
onEdit: () => void
onDuplicate: () => void
onExport: () => void
onSwitch: () => void
onDelete: () => void
onAccessConfig: () => void
}
function AppCardOperationsMenu({
app,
shouldShowEditOption,
shouldShowDuplicateOption,
shouldShowExportOption,
shouldShowSwitchOption,
shouldShowOpenInExploreOption,
shouldShowAccessConfigOption,
shouldShowDeleteOption,
isExporting,
onEdit,
onDuplicate,
onExport,
onSwitch,
onDelete,
onAccessConfig,
}: AppCardOperationsMenuProps) {
const { t } = useTranslation()
const openAsyncWindow = useAsyncWindowOpen()
const hasEditGroup = shouldShowEditOption
const hasCreateExportGroup = shouldShowDuplicateOption || shouldShowExportOption
const hasSwitchOrExploreGroup = shouldShowSwitchOption || shouldShowOpenInExploreOption
const hasAccessDeleteGroup = shouldShowAccessConfigOption || shouldShowDeleteOption
function handleMenuAction(e: MouseEvent<HTMLElement>, action: () => void) {
e.stopPropagation()
e.preventDefault()
action()
}
async function handleOpenInstalledApp(e: MouseEvent<HTMLElement>) {
e.stopPropagation()
e.preventDefault()
if (requiresPublishedWorkflowInExplore(app) && !app.workflow?.id) {
toast.error(t(($) => $.notPublishedYet, { ns: 'app' }))
return
}
try {
await openAsyncWindow(
async () => {
const { installed_apps } = await fetchInstalledAppList(app.id)
if (installed_apps?.length > 0)
return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}`
throw new Error(t(($) => $.notPublishedYet, { ns: 'app' }))
},
{
onError: (err) => {
toast.error(`${err.message || err}`)
},
},
)
} catch (e: unknown) {
const message = e instanceof Error ? e.message : `${e}`
toast.error(message)
}
}
return (
<>
{shouldShowEditOption && (
<DropdownMenuItem className="gap-2 px-3" onClick={(e) => handleMenuAction(e, onEdit)}>
<span className="system-sm-regular text-text-secondary">
{t(($) => $.editApp, { ns: 'app' })}
</span>
</DropdownMenuItem>
)}
{hasEditGroup &&
(hasCreateExportGroup || hasSwitchOrExploreGroup || hasAccessDeleteGroup) && (
<DropdownMenuSeparator />
)}
{shouldShowDuplicateOption && (
<DropdownMenuItem className="gap-2 px-3" onClick={(e) => handleMenuAction(e, onDuplicate)}>
<span className="system-sm-regular text-text-secondary">
{t(($) => $.duplicate, { ns: 'app' })}
</span>
</DropdownMenuItem>
)}
{shouldShowExportOption && (
<DropdownMenuItem
className="gap-2 px-3"
disabled={isExporting}
onClick={(e) => handleMenuAction(e, onExport)}
>
<span className="system-sm-regular text-text-secondary">
{t(($) => $.export, { ns: 'app' })}
</span>
</DropdownMenuItem>
)}
{hasCreateExportGroup && (hasSwitchOrExploreGroup || hasAccessDeleteGroup) && (
<DropdownMenuSeparator />
)}
{shouldShowSwitchOption && (
<DropdownMenuItem className="gap-2 px-3" onClick={(e) => handleMenuAction(e, onSwitch)}>
<span className="text-sm/5 text-text-secondary">{t(($) => $.switch, { ns: 'app' })}</span>
</DropdownMenuItem>
)}
{shouldShowOpenInExploreOption && (
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenInstalledApp}>
<span className="system-sm-regular text-text-secondary">
{t(($) => $.openInExplore, { ns: 'app' })}
</span>
</DropdownMenuItem>
)}
{hasSwitchOrExploreGroup && hasAccessDeleteGroup && <DropdownMenuSeparator />}
{shouldShowAccessConfigOption && (
<DropdownMenuItem
className="gap-2 px-3"
onClick={(e) => handleMenuAction(e, onAccessConfig)}
>
<span className="text-sm/5 text-text-secondary">
{t(($) => $['settings.resourceAccess'], { ns: 'common' })}
</span>
</DropdownMenuItem>
)}
{shouldShowAccessConfigOption && shouldShowDeleteOption && <DropdownMenuSeparator />}
{shouldShowDeleteOption && (
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"
onClick={(e) => handleMenuAction(e, onDelete)}
>
<span className="system-sm-regular">
{t(($) => $['operation.delete'], { ns: 'common' })}
</span>
</DropdownMenuItem>
)}
</>
)
}
type AppCardOperationsMenuContentProps = Omit<
AppCardOperationsMenuProps,
'shouldShowOpenInExploreOption'
>
export function AppCardOperationsMenuContent(props: AppCardOperationsMenuContentProps) {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({
appId: props.app.id,
enabled: systemFeatures.webapp_auth.enabled,
})
const needsPublishBeforeExplore =
requiresPublishedWorkflowInExplore(props.app) && !props.app.workflow?.id
const shouldShowOpenInExploreOption =
!props.app.has_draft_trigger &&
(needsPublishBeforeExplore ||
!systemFeatures.webapp_auth.enabled ||
(!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result)))
return (
<AppCardOperationsMenu
{...props}
shouldShowOpenInExploreOption={shouldShowOpenInExploreOption}
/>
)
}
+57 -47
View File
@@ -69,12 +69,10 @@ function CatalogSkeleton() {
const { t } = useTranslation()
return (
<div
className={`relative grow content-start ${APP_LIST_GRID_CLASS_NAME}`}
role="status"
aria-label={t(($) => $.loading, { ns: 'common' })}
>
<AppCardSkeleton count={6} />
<div className="relative grow" role="status" aria-label={t(($) => $.loading, { ns: 'common' })}>
<ul aria-hidden className={APP_LIST_GRID_CLASS_NAME}>
<AppCardSkeleton count={6} />
</ul>
</div>
)
}
@@ -178,56 +176,68 @@ function AppListCatalogContent({
/>
)}
<div
role={hasAnyApp ? 'list' : undefined}
aria-busy={isFetching}
aria-labelledby={hasAnyApp && starredApps.length > 0 ? ALL_APPS_HEADING_ID : undefined}
className={cn(
`relative grow content-start ${APP_LIST_GRID_CLASS_NAME}`,
!hasAnyApp && 'overflow-hidden',
)}
className={cn('relative grow', !hasAnyApp && 'overflow-hidden')}
>
{hasAnyApp ? (
apps.map((app, index) => (
<AppCard
key={app.id}
app={app}
onlineUsers={workflowOnlineUsersMap[app.id]}
onOpenTagManagement={onOpenTagManagement}
stepByStepTourActionMenuOpen={
index === 0 ? shouldOpenFirstAppActionMenu : undefined
}
stepByStepTourCardTarget={
index === 0
? shouldHighlightAllAppsRow
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard
: canCreateApp
? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard
: undefined
: undefined
}
stepByStepTourCardHighlightPart={
index < STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT && shouldHighlightAllAppsRow
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard
: undefined
}
stepByStepTourActionMenuHighlightPart={
index === 0 && shouldOpenFirstAppActionMenu
? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu
<ul
// Safari list semantics: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/list-style#accessibility
// oxlint-disable-next-line jsx-a11y/no-redundant-roles -- Dify's preflight removes list markers.
role="list"
aria-label={
starredApps.length === 0
? t(($) => $['studio.allApps'], { ns: 'app' })
: undefined
}
aria-labelledby={starredApps.length > 0 ? ALL_APPS_HEADING_ID : undefined}
className={APP_LIST_GRID_CLASS_NAME}
>
{apps.map((app, index) => (
<AppCard
key={app.id}
app={app}
onlineUsers={workflowOnlineUsersMap[app.id]}
onOpenTagManagement={onOpenTagManagement}
stepByStepTourActionMenuOpen={
index === 0 ? shouldOpenFirstAppActionMenu : undefined
}
stepByStepTourCardTarget={
index === 0
? shouldHighlightAllAppsRow
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard
: canCreateApp
? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard
: undefined
: undefined
}
stepByStepTourCardHighlightPart={
index < STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT && shouldHighlightAllAppsRow
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard
: undefined
}
stepByStepTourActionMenuHighlightPart={
index === 0 && shouldOpenFirstAppActionMenu
? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu
: undefined
}
/>
))}
{hasNextPage && <AppCardSkeleton count={3} />}
</ul>
) : (
<div className={`content-start ${APP_LIST_GRID_CLASS_NAME}`}>
<Empty
message={emptyMessage}
stepByStepTourTarget={
showNoCreateEmptyState
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateEmpty
: undefined
}
/>
))
) : (
<Empty
message={emptyMessage}
stepByStepTourTarget={
showNoCreateEmptyState ? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateEmpty : undefined
}
/>
</div>
)}
{hasNextPage && (
<>
<AppCardSkeleton count={3} />
{isFetchNextPageError && (
<div
className="absolute inset-x-0 bottom-0 flex h-40 items-center justify-center gap-2 bg-background-body system-xs-regular text-text-tertiary"
+22 -17
View File
@@ -17,7 +17,7 @@ import Link from '@/next/link'
import { getRedirectionPath } from '@/utils/app-redirection'
import { hasOnlyAppPreviewPermission } from '@/utils/permission'
import { formatTime } from '@/utils/time'
import { AppCardActionBar } from './app-card/action-bar'
import { AppCardInteractions } from './app-card/interactions'
type StarredAppCardProps = {
app: AppPartial
@@ -55,10 +55,8 @@ export const StarredAppCard = memo(
isRbacEnabled,
})
const cardClassName = cn(
'flex h-18 min-w-0 items-center gap-3 overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg px-4 py-3 shadow-xs outline-hidden transition-shadow duration-200',
isPreviewOnly
? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid'
: 'hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'flex h-18 min-w-0 items-center gap-3 rounded-xl px-4 py-3 outline-hidden',
isPreviewOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
)
const showPreviewOnlyAccessWarning = useCallback(() => {
toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' }))
@@ -95,7 +93,13 @@ export const StarredAppCard = memo(
)
return (
<div role="listitem" className="group relative">
<li
className={cn(
"group relative isolate overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-shadow duration-200 after:pointer-events-none after:absolute after:inset-0 after:z-1 after:rounded-xl after:content-[''] focus-within:bg-components-card-bg-alt has-[>a:focus-visible]:after:inset-ring-2 has-[>a:focus-visible]:after:inset-ring-state-accent-solid has-[>button:focus-visible]:after:inset-ring-2 has-[>button:focus-visible]:after:inset-ring-state-accent-solid motion-reduce:transition-none",
!isPreviewOnly &&
'hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5 has-data-popup-open:bg-components-card-bg-alt has-data-popup-open:shadow-md has-data-popup-open:shadow-shadow-shadow-5 [@media(hover:none)]:bg-components-card-bg-alt',
)}
>
{isPreviewOnly ? (
<button
type="button"
@@ -108,18 +112,19 @@ export const StarredAppCard = memo(
{cardContent}
</button>
) : (
<Link
href={href}
aria-labelledby={appNameId}
data-step-by-step-tour-target={stepByStepTourCardTarget}
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
className={cardClassName}
>
{cardContent}
</Link>
<AppCardInteractions app={app}>
<Link
href={href}
aria-labelledby={appNameId}
data-step-by-step-tour-target={stepByStepTourCardTarget}
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
className={cardClassName}
>
{cardContent}
</Link>
</AppCardInteractions>
)}
{!isPreviewOnly && <AppCardActionBar app={app} />}
</div>
</li>
)
},
)
+4 -2
View File
@@ -43,7 +43,9 @@ export function StarredAppList({
id={STARRED_APPS_HEADING_ID}
label={t(($) => $['studio.starred'], { ns: 'app' })}
/>
<div
<ul
// Safari list semantics: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/list-style#accessibility
// oxlint-disable-next-line jsx-a11y/no-redundant-roles -- Dify's preflight removes list markers.
role="list"
aria-labelledby={STARRED_APPS_HEADING_ID}
className={APP_LIST_GRID_CLASS_NAME}
@@ -60,7 +62,7 @@ export function StarredAppList({
}
/>
))}
</div>
</ul>
</>
)
}
@@ -113,14 +113,14 @@ export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebar
</DropdownMenuContent>
</DropdownMenu>
<EditAgentDialog
key={editSessionKey}
agent={dialogAgent}
formKey={editSessionKey}
open={isEditOpen}
onOpenChange={setIsEditOpen}
/>
<DuplicateAgentDialog
key={duplicateSessionKey}
agent={dialogAgent}
formKey={duplicateSessionKey}
open={isDuplicateOpen}
onOpenChange={setIsDuplicateOpen}
/>
@@ -130,9 +130,11 @@ describe('AgentRosterList', () => {
it('exposes each agent card with the agent name', () => {
renderList([createAgent()])
const card = screen.getByRole('article', { name: 'Research Agent' })
const list = screen.getByRole('list')
const card = within(list).getByRole('listitem', { name: 'Research Agent' })
const cardLink = within(card).getByRole('link', { name: 'Research Agent' })
expect(card.parentElement).toBe(list)
expect(cardLink).toHaveAttribute('href', '/agents/agent-1/configure')
expect(cardLink).toHaveAccessibleDescription('Find and summarize market materials.')
})
@@ -222,7 +224,7 @@ describe('AgentRosterList', () => {
footer: { status: 'error', onRetry: onLoadMore },
})
expect(screen.getByRole('article', { name: 'Research Agent' })).toBeInTheDocument()
expect(screen.getByRole('listitem', { name: 'Research Agent' })).toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent('agentV2.roster.loadingError')
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
@@ -234,7 +236,7 @@ describe('AgentRosterList', () => {
const onRetry = vi.fn()
renderList([createAgent()], { footer: { status: 'error', onRetry } })
expect(screen.getByRole('article', { name: 'Research Agent' })).toBeInTheDocument()
expect(screen.getByRole('listitem', { name: 'Research Agent' })).toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent('agentV2.roster.loadingError')
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
@@ -272,7 +274,7 @@ describe('AgentRosterList', () => {
it('announces zero workflow references without exposing an inactive button', () => {
renderList([createAgent()])
const card = screen.getByRole('article', { name: 'Research Agent' })
const card = screen.getByRole('listitem', { name: 'Research Agent' })
expect(within(card).getByText(/^agentV2\.roster\.references\.trigger/)).toHaveClass('sr-only')
expect(
within(card).queryByRole('button', { name: /agentV2\.roster\.references\.trigger/ }),
@@ -296,7 +298,7 @@ describe('AgentRosterList', () => {
}),
])
const card = screen.getByRole('article', { name: 'Research Agent' })
const card = screen.getByRole('listitem', { name: 'Research Agent' })
const cardLink = within(card).getByRole('link', { name: 'Research Agent' })
const references = within(card).getByRole('button', {
name: /agentV2\.roster\.references\.trigger.*1/,
@@ -344,6 +346,33 @@ describe('AgentRosterList', () => {
expect(duplicateAgentMutationFn).not.toHaveBeenCalled()
})
it('opens the same duplicate action from the card context menu', async () => {
const user = userEvent.setup()
renderList([createAgent()])
const cardLink = screen.getByRole('link', { name: 'Research Agent' })
await user.pointer({ target: cardLink, keys: '[MouseRight]' })
await user.click(await screen.findByRole('menuitem', { name: /common\.operation\.duplicate/ }))
expect(
await screen.findByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' }),
).toBeInTheDocument()
})
it('keeps the more button outside the card context menu trigger', async () => {
const user = userEvent.setup()
renderList([createAgent()])
await user.pointer({
target: screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ }),
keys: '[MouseRight]',
})
expect(
screen.queryByRole('menuitem', { name: /common\.operation\.duplicate/ }),
).not.toBeInTheDocument()
})
it('exports the Agent App DSL with the backing App id', async () => {
const user = userEvent.setup()
renderList([createAgent()])
@@ -544,6 +573,7 @@ describe('AgentRosterList', () => {
screen.queryByRole('dialog', { name: 'agentV2.roster.editDialog.title' }),
).not.toBeInTheDocument()
})
expect(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ })).toHaveFocus()
await user.click(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ }))
await user.click(screen.getByRole('menuitem', { name: /agentV2\.roster\.editInfo/ }))
@@ -71,7 +71,7 @@ const createAgent = (overrides: Partial<AgentAppPartial> = {}): AgentAppPartial
const renderDialog = (agent = createAgent()) => {
const onOpenChange = vi.fn()
render(<EditAgentDialog agent={agent} formKey={0} open onOpenChange={onOpenChange} />)
render(<EditAgentDialog agent={agent} open onOpenChange={onOpenChange} />)
return { onOpenChange }
}
@@ -4,6 +4,13 @@ import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.ge
import { zAgentIconType } from '@dify/contracts/api/console/agent/zod.gen'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@langgenius/dify-ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
@@ -105,9 +112,9 @@ function AgentRosterPlaceholderState({
const { t } = useTranslation('common')
return (
<section
<div
aria-labelledby="agent-roster-placeholder-title"
className="relative col-span-full min-h-[calc(100vh-142px)] overflow-hidden"
className="relative min-h-[calc(100vh-142px)] overflow-hidden"
role={role}
>
<div
@@ -141,22 +148,65 @@ function AgentRosterPlaceholderState({
)}
</div>
</div>
</section>
</div>
)
}
type AgentCardActionMenuItemsProps = {
kind: 'context' | 'dropdown'
isExporting: boolean
onDelete: () => void
onDuplicate: () => void
onEdit: () => void
onExport: () => void
}
function AgentCardActionMenuItems({
kind,
isExporting,
onDelete,
onDuplicate,
onEdit,
onExport,
}: AgentCardActionMenuItemsProps) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const { t: tApp } = useTranslation('app')
const MenuItem = kind === 'context' ? ContextMenuItem : DropdownMenuItem
const MenuSeparator = kind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator
return (
<>
<MenuItem className="gap-2" onClick={onEdit}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span>{t(($) => $['roster.editInfo'])}</span>
</MenuItem>
<MenuItem className="gap-2" onClick={onDuplicate}>
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
<span>{tCommon(($) => $['operation.duplicate'])}</span>
</MenuItem>
<MenuItem className="gap-2" disabled={isExporting} onClick={onExport}>
<span aria-hidden className="i-ri-download-line size-4 shrink-0 text-text-tertiary" />
<span>{tApp(($) => $.export)}</span>
</MenuItem>
<MenuSeparator />
<MenuItem variant="destructive" className="gap-2" onClick={onDelete}>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span>{tCommon(($) => $['operation.delete'])}</span>
</MenuItem>
</>
)
}
function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const { t: tApp } = useTranslation('app')
const { formatTime } = useTimestamp()
const nameId = useId()
const descriptionId = useId()
const [isEditOpen, setIsEditOpen] = useState(false)
const [activeDialog, setActiveDialog] = useState<'delete' | 'duplicate' | 'edit' | null>(null)
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 updatedAt =
agent.updated_at != null
@@ -175,12 +225,20 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
const handleEditOpen = () => {
setEditSessionKey((key) => key + 1)
setIsEditOpen(true)
setActiveDialog('edit')
}
const handleDuplicateOpen = () => {
setDuplicateSessionKey((key) => key + 1)
setIsDuplicateOpen(true)
setActiveDialog('duplicate')
}
const handleDeleteOpen = () => {
setActiveDialog('delete')
}
const handleDialogOpenChange = (open: boolean) => {
if (!open) setActiveDialog(null)
}
const handleExport = () => {
@@ -196,49 +254,64 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
}
return (
<article
<li
aria-labelledby={nameId}
className="group relative isolate col-span-1 h-36.5 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-shadow duration-200 ease-in-out after:pointer-events-none after:absolute after:inset-0 after:z-1 after:rounded-xl after:content-[''] focus-within:bg-components-card-bg-alt hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5 has-data-popup-open:bg-components-card-bg-alt has-[>a:focus-visible]:after:inset-ring-2 has-[>a:focus-visible]:after:inset-ring-state-accent-solid motion-reduce:transition-none [@media(hover:none)]:bg-components-card-bg-alt"
className="group relative isolate col-span-1 h-36.5 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-shadow duration-200 ease-in-out after:pointer-events-none after:absolute after:inset-0 after:z-1 after:rounded-xl after:content-[''] focus-within:bg-components-card-bg-alt hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5 has-data-popup-open:bg-components-card-bg-alt has-data-popup-open:shadow-md has-data-popup-open:shadow-shadow-shadow-5 has-[>a:focus-visible]:after:inset-ring-2 has-[>a:focus-visible]:after:inset-ring-state-accent-solid motion-reduce:transition-none [@media(hover:none)]:bg-components-card-bg-alt"
>
<Link
href={`/agents/${agent.id}/configure`}
aria-labelledby={nameId}
aria-describedby={agent.description ? descriptionId : undefined}
className="flex h-full min-w-0 cursor-pointer touch-manipulation flex-col rounded-xl outline-hidden"
>
<div className="flex items-center gap-3 pt-3.5 pr-4 pb-2 pl-3.5">
<span aria-hidden className="shrink-0">
<AppIcon
size="xl"
rounded
iconType={iconType}
icon={agent.icon ?? undefined}
background={agent.icon_background}
imageUrl={imageUrl}
/>
</span>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 py-px">
<h2 id={nameId} className="truncate system-md-semibold text-text-secondary">
{agent.name}
</h2>
<p className="truncate system-xs-regular text-text-tertiary">{agent.role}</p>
</div>
</div>
<div className="px-4 py-1 system-xs-regular text-text-tertiary">
<div id={descriptionId} className="line-clamp-2 min-h-8">
{agent.description}
</div>
</div>
<div aria-hidden className="h-9 shrink-0" />
{isDraft && (
<div className="pointer-events-none absolute top-[-0.5px] right-0 flex h-5 items-start overflow-hidden">
<div className="h-5 w-3 bg-background-section-burn [clip-path:polygon(0_0,100%_0,100%_100%)]" />
<div className="flex h-5 items-center bg-background-section-burn pr-2 pl-0.5 system-2xs-medium-uppercase text-text-tertiary">
{t(($) => $['roster.usageStatus.draft'])}
</div>
</div>
)}
</Link>
<ContextMenu>
<ContextMenuTrigger
render={
<Link
href={`/agents/${agent.id}/configure`}
aria-labelledby={nameId}
aria-describedby={agent.description ? descriptionId : undefined}
className="flex h-full min-w-0 cursor-pointer touch-manipulation flex-col rounded-xl pb-9 outline-hidden"
>
<div className="flex items-center gap-3 pt-3.5 pr-4 pb-2 pl-3.5">
<span aria-hidden className="shrink-0">
<AppIcon
size="xl"
rounded
iconType={iconType}
icon={agent.icon ?? undefined}
background={agent.icon_background}
imageUrl={imageUrl}
/>
</span>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 py-px">
<h2 id={nameId} className="truncate system-md-semibold text-text-secondary">
{agent.name}
</h2>
<p className="truncate system-xs-regular text-text-tertiary">{agent.role}</p>
</div>
</div>
<div className="px-4 py-1 system-xs-regular text-text-tertiary">
<div id={descriptionId} className="line-clamp-2 min-h-8">
{agent.description}
</div>
</div>
{isDraft && (
<div className="pointer-events-none absolute top-[-0.5px] right-0 flex h-5 items-start overflow-hidden">
<div className="h-5 w-3 bg-background-section-burn [clip-path:polygon(0_0,100%_0,100%_100%)]" />
<div className="flex h-5 items-center bg-background-section-burn pr-2 pl-0.5 system-2xs-medium-uppercase text-text-tertiary">
{t(($) => $['roster.usageStatus.draft'])}
</div>
</div>
)}
</Link>
}
/>
<ContextMenuContent className="w-40">
<AgentCardActionMenuItems
kind="context"
isExporting={isExporting}
onEdit={handleEditOpen}
onDuplicate={handleDuplicateOpen}
onExport={handleExport}
onDelete={handleDeleteOpen}
/>
</ContextMenuContent>
</ContextMenu>
<div className="pointer-events-none absolute top-[-0.5px] right-[-0.5px] flex h-16 w-30 items-start justify-end bg-[linear-gradient(67deg,var(--color-components-card-bg-alt-transparent)_0%,var(--color-components-card-bg-alt)_75%)] p-2 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 has-data-popup-open:opacity-100 [@media(hover:none)]:opacity-100">
<div className="pointer-events-none flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-xs group-focus-within:pointer-events-auto group-hover:pointer-events-auto has-data-popup-open:pointer-events-auto [@media(hover:none)]:pointer-events-auto">
<DropdownMenu modal={false}>
@@ -254,33 +327,14 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
}
/>
<DropdownMenuContent placement="bottom-end" sideOffset={4} className="w-40">
<DropdownMenuItem className="gap-2" onClick={handleEditOpen}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span>{t(($) => $['roster.editInfo'])}</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2" onClick={handleDuplicateOpen}>
<span
aria-hidden
className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary"
/>
<span>{tCommon(($) => $['operation.duplicate'])}</span>
</DropdownMenuItem>
<DropdownMenuItem className="gap-2" disabled={isExporting} onClick={handleExport}>
<span
aria-hidden
className="i-ri-download-line size-4 shrink-0 text-text-tertiary"
/>
<span>{tApp(($) => $.export)}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="gap-2"
onClick={() => setIsDeleteOpen(true)}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span>{tCommon(($) => $['operation.delete'])}</span>
</DropdownMenuItem>
<AgentCardActionMenuItems
kind="dropdown"
isExporting={isExporting}
onEdit={handleEditOpen}
onDuplicate={handleDuplicateOpen}
onExport={handleExport}
onDelete={handleDeleteOpen}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -316,24 +370,24 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
</div>
</div>
<EditAgentDialog
key={editSessionKey}
agent={agent}
formKey={editSessionKey}
open={isEditOpen}
onOpenChange={setIsEditOpen}
open={activeDialog === 'edit'}
onOpenChange={handleDialogOpenChange}
/>
<DuplicateAgentDialog
key={duplicateSessionKey}
agent={agent}
formKey={duplicateSessionKey}
open={isDuplicateOpen}
onOpenChange={setIsDuplicateOpen}
open={activeDialog === 'duplicate'}
onOpenChange={handleDialogOpenChange}
/>
<DeleteAgentDialog
agentId={agent.id}
agentName={agent.name}
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
open={activeDialog === 'delete'}
onOpenChange={handleDialogOpenChange}
/>
</article>
</li>
)
}
@@ -343,8 +397,12 @@ export function AgentRosterList({ label, state }: AgentRosterListProps) {
const isBusy = state.status === 'pending' || (state.status === 'ready' && state.isFetching)
return (
<section aria-label={label} className={AGENT_ROSTER_GRID_CLASS_NAME} aria-busy={isBusy}>
{state.status === 'pending' && <AgentRosterSkeleton />}
<section aria-label={label} aria-busy={isBusy}>
{state.status === 'pending' && (
<div className={AGENT_ROSTER_GRID_CLASS_NAME}>
<AgentRosterSkeleton />
</div>
)}
{state.status === 'error' && (
<AgentRosterPlaceholderState
onRetry={state.onRetry}
@@ -363,10 +421,18 @@ export function AgentRosterList({ label, state }: AgentRosterListProps) {
/>
)}
{state.status === 'ready' &&
state.agents.map((agent) => <AgentRosterItem key={agent.id} agent={agent} />)}
state.agents.length > 0 && (
// Safari list semantics: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/list-style#accessibility
// oxlint-disable-next-line jsx-a11y/no-redundant-roles -- Dify's preflight removes list markers.
<ul role="list" className={AGENT_ROSTER_GRID_CLASS_NAME}>
{state.agents.map((agent) => (
<AgentRosterItem key={agent.id} agent={agent} />
))}
</ul>
)}
{state.status === 'ready' && state.footer.status === 'error' && (
<div
className="col-span-full flex items-center justify-center gap-3 pt-1 system-xs-regular text-text-destructive"
className="flex items-center justify-center gap-3 pt-1 system-xs-regular text-text-destructive"
role="alert"
>
<span>{t(($) => $['roster.loadingError'])}</span>
@@ -376,7 +442,7 @@ export function AgentRosterList({ label, state }: AgentRosterListProps) {
</div>
)}
{state.status === 'ready' && state.footer.status === 'load-more' && (
<div className="col-span-full flex justify-center pt-1">
<div className="flex justify-center pt-1">
<Button loading={state.footer.isLoading} onClick={state.footer.onLoadMore}>
{t(($) => $['roster.loadMore'])}
</Button>
@@ -28,7 +28,6 @@ import { createAgentIconSelection } from './agent-form'
type DuplicateAgentDialogProps = {
agent: AgentAppPartial
formKey: number
open: boolean
onOpenChange: (open: boolean) => void
}
@@ -38,12 +37,7 @@ const getDefaultCopyName = (name: string) => {
return `${name.slice(0, 255 - suffix.length)}${suffix}`
}
export function DuplicateAgentDialog({
agent,
formKey,
open,
onOpenChange,
}: DuplicateAgentDialogProps) {
export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAgentDialogProps) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const queryClient = useQueryClient()
@@ -57,7 +51,6 @@ export function DuplicateAgentDialog({
},
}),
) ?? agent
const [renderedFormKey, setRenderedFormKey] = useState(formKey)
const [name, setName] = useState(() => getDefaultCopyName(latestAgent.name))
const [description, setDescription] = useState(latestAgent.description ?? '')
const [role, setRole] = useState(latestAgent.role ?? '')
@@ -68,34 +61,8 @@ export function DuplicateAgentDialog({
const duplicateAgentMutation = useMutation(
consoleQuery.agent.byAgentId.copy.post.mutationOptions(),
)
if (formKey !== renderedFormKey) {
setRenderedFormKey(formKey)
setName(getDefaultCopyName(latestAgent.name))
setDescription(latestAgent.description ?? '')
setRole(latestAgent.role ?? '')
setIconPickerOpen(false)
setAgentIcon(createAgentIconSelection(latestAgent))
}
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
const currentAgent =
queryClient.getQueryData<AgentAppPartial>(
consoleQuery.agent.byAgentId.get.queryKey({
input: {
params: {
agent_id: agent.id,
},
},
}),
) ?? agent
setName(getDefaultCopyName(currentAgent.name))
setDescription(currentAgent.description ?? '')
setRole(currentAgent.role ?? '')
setAgentIcon(createAgentIconSelection(currentAgent))
} else {
setIconPickerOpen(false)
}
if (!nextOpen) setIconPickerOpen(false)
onOpenChange(nextOpen)
}
@@ -152,11 +119,7 @@ export function DuplicateAgentDialog({
{t(($) => $['roster.duplicateDialog.description'], { name: latestAgent.name })}
</DialogDescription>
</div>
<Form<AgentFormValues>
key={formKey}
className="min-h-0 flex-1"
onFormSubmit={handleSubmit}
>
<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
@@ -25,7 +25,6 @@ import { AgentFormFields } from './agent-form-fields'
type EditAgentDialogProps = {
agent: AgentAppPartial
formKey: number
open: boolean
onOpenChange: (open: boolean) => void
}
@@ -43,10 +42,9 @@ const applyIconPayload = (body: AgentAppUpdatePayload, icon: AgentIconSelection)
body.icon_background = undefined
}
export function EditAgentDialog({ agent, formKey, open, onOpenChange }: EditAgentDialogProps) {
export function EditAgentDialog({ agent, open, onOpenChange }: EditAgentDialogProps) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const [renderedFormKey, setRenderedFormKey] = useState(formKey)
const [name, setName] = useState(agent.name)
const [description, setDescription] = useState(agent.description ?? '')
const [role, setRole] = useState(agent.role ?? '')
@@ -56,24 +54,8 @@ export function EditAgentDialog({ agent, formKey, open, onOpenChange }: EditAgen
)
const updateAgentMutation = useMutation(consoleQuery.agent.byAgentId.put.mutationOptions())
if (formKey !== renderedFormKey) {
setRenderedFormKey(formKey)
setName(agent.name)
setDescription(agent.description ?? '')
setRole(agent.role ?? '')
setIconPickerOpen(false)
setAgentIcon(createAgentIconSelection(agent))
}
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
setName(agent.name)
setDescription(agent.description ?? '')
setRole(agent.role ?? '')
setAgentIcon(createAgentIconSelection(agent))
} else {
setIconPickerOpen(false)
}
if (!nextOpen) setIconPickerOpen(false)
onOpenChange(nextOpen)
}
@@ -153,11 +135,7 @@ export function EditAgentDialog({ agent, formKey, open, onOpenChange }: EditAgen
{t(($) => $['roster.editDialog.description'])}
</DialogDescription>
</div>
<Form<AgentFormValues>
key={formKey}
className="min-h-0 flex-1"
onFormSubmit={handleSubmit}
>
<Form<AgentFormValues> className="min-h-0 flex-1" onFormSubmit={handleSubmit}>
<AgentFormFields
description={description}
icon={agentIcon}