diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx
index 84a880c6804..46a56fb684e 100644
--- a/web/app/components/apps/__tests__/app-card.spec.tsx
+++ b/web/app/components/apps/__tests__/app-card.spec.tsx
@@ -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()
- 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()
- 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()
@@ -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()
+
+ 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()
+
+ 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()
@@ -1203,6 +1226,7 @@ describe('AppCard', () => {
render()
const trigger = screen.getByRole('button', { name: 'common.operation.exporting' })
+
expect(trigger).toBeDisabled()
})
})
diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx
index 9ef5a5cdd8b..f1cf813ea31 100644
--- a/web/app/components/apps/__tests__/list.spec.tsx
+++ b/web/app/components/apps/__tests__/list.spec.tsx
@@ -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', () => {
diff --git a/web/app/components/apps/app-card-skeleton.tsx b/web/app/components/apps/app-card-skeleton.tsx
index 977ff7a3407..c60429d914c 100644
--- a/web/app/components/apps/app-card-skeleton.tsx
+++ b/web/app/components/apps/app-card-skeleton.tsx
@@ -17,7 +17,8 @@ export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps)
return (
<>
{skeletonKeys.map((key) => (
-
@@ -34,7 +35,7 @@ export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps)
-
+
))}
>
)
diff --git a/web/app/components/apps/app-card/action-bar/index.tsx b/web/app/components/apps/app-card/action-bar/index.tsx
deleted file mode 100644
index 5d46c6b36b1..00000000000
--- a/web/app/components/apps/app-card/action-bar/index.tsx
+++ /dev/null
@@ -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(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([])
- 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 = 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 && (
-
-
-
-
-
- }
- />
- }
- />
- {starToggleLabel}
-
- {shouldShowOperationsMenu && (
-
- $['operation.exporting'], { ns: 'common' })
- : t(($) => $['operation.moreActionsFor'], {
- ns: 'common',
- name: app.name,
- })
- }
- disabled={isExporting}
- className="data-popup-open:bg-state-base-hover"
- >
-
-
- }
- />
-
-
-
-
- )}
-
- )}
- {showEditModal && (
- setShowEditModal(false)}
- />
- )}
- {showDuplicateModal && (
- setShowDuplicateModal(false)}
- />
- )}
- {showSwitchModal && (
- setShowSwitchModal(false)}
- />
- )}
-
-
-
-
-
- {secretEnvList.length > 0 && (
- setSecretEnvList([])}
- />
- )}
- >
- )
- },
-)
diff --git a/web/app/components/apps/app-card/index.tsx b/web/app/components/apps/app-card/index.tsx
index d31bf1df410..0d790f2dd9d 100644
--- a/web/app/components/apps/app-card/index.tsx
+++ b/web/app/components/apps/app-card/index.tsx
@@ -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}
-
-
+
{app.author_name && (
<>
@@ -188,7 +185,13 @@ export const AppCard = memo(
)
return (
-
+
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 ? (
+
)
},
)
diff --git a/web/app/components/apps/app-card/interactions.tsx b/web/app/components/apps/app-card/interactions.tsx
new file mode 100644
index 00000000000..137e04c0bdb
--- /dev/null
+++ b/web/app/components/apps/app-card/interactions.tsx
@@ -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
([
+ 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, action: () => void) {
+ event.stopPropagation()
+ event.preventDefault()
+ action()
+ }
+
+ async function handleOpenInstalledApp(event: MouseEvent) {
+ 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 && (
+
+ )}
+ {hasEditGroup &&
+ (hasCreateExportGroup || hasSwitchOrExploreGroup || hasAccessDeleteGroup) && (
+
+ )}
+ {shouldShowDuplicateOption && (
+
+ )}
+ {shouldShowExportOption && (
+
+ )}
+ {hasCreateExportGroup && (hasSwitchOrExploreGroup || hasAccessDeleteGroup) && (
+
+ )}
+ {shouldShowSwitchOption && (
+
+ )}
+ {shouldShowOpenInExploreOption && (
+
+ )}
+ {hasSwitchOrExploreGroup && hasAccessDeleteGroup && }
+ {shouldShowAccessConfigOption && (
+
+ )}
+ {shouldShowAccessConfigOption && shouldShowDeleteOption && }
+ {shouldShowDeleteOption && (
+
+ )}
+ >
+ )
+}
+
+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([])
+ 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 = 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 ? (
+
+
+
+
+
+
+ ) : (
+ children
+ )}
+
+
+
+
+
+
+ }
+ />
+ }
+ />
+ {starToggleLabel}
+
+ {shouldShowOperationsMenu && (
+
+ $['operation.exporting'], { ns: 'common' })
+ : t(($) => $['operation.moreActionsFor'], {
+ ns: 'common',
+ name: app.name,
+ })
+ }
+ disabled={isExporting}
+ className="data-popup-open:bg-state-base-hover"
+ >
+
+
+ }
+ />
+
+
+
+
+ )}
+
+
+ {activeDialog === 'edit' && (
+ setActiveDialog(null)}
+ />
+ )}
+ {activeDialog === 'duplicate' && (
+ setActiveDialog(null)}
+ />
+ )}
+ {activeDialog === 'switch' && (
+ setActiveDialog(null)} />
+ )}
+
+
+
+
+
+ {secretEnvList.length > 0 && (
+ setSecretEnvList([])}
+ />
+ )}
+ >
+ )
+}
diff --git a/web/app/components/apps/app-card/operations-menu.tsx b/web/app/components/apps/app-card/operations-menu.tsx
deleted file mode 100644
index 714d9dce82e..00000000000
--- a/web/app/components/apps/app-card/operations-menu.tsx
+++ /dev/null
@@ -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([
- 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, action: () => void) {
- e.stopPropagation()
- e.preventDefault()
- action()
- }
-
- async function handleOpenInstalledApp(e: MouseEvent) {
- 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 && (
- handleMenuAction(e, onEdit)}>
-
- {t(($) => $.editApp, { ns: 'app' })}
-
-
- )}
- {hasEditGroup &&
- (hasCreateExportGroup || hasSwitchOrExploreGroup || hasAccessDeleteGroup) && (
-
- )}
- {shouldShowDuplicateOption && (
- handleMenuAction(e, onDuplicate)}>
-
- {t(($) => $.duplicate, { ns: 'app' })}
-
-
- )}
- {shouldShowExportOption && (
- handleMenuAction(e, onExport)}
- >
-
- {t(($) => $.export, { ns: 'app' })}
-
-
- )}
- {hasCreateExportGroup && (hasSwitchOrExploreGroup || hasAccessDeleteGroup) && (
-
- )}
- {shouldShowSwitchOption && (
- handleMenuAction(e, onSwitch)}>
- {t(($) => $.switch, { ns: 'app' })}
-
- )}
- {shouldShowOpenInExploreOption && (
-
-
- {t(($) => $.openInExplore, { ns: 'app' })}
-
-
- )}
- {hasSwitchOrExploreGroup && hasAccessDeleteGroup && }
- {shouldShowAccessConfigOption && (
- handleMenuAction(e, onAccessConfig)}
- >
-
- {t(($) => $['settings.resourceAccess'], { ns: 'common' })}
-
-
- )}
- {shouldShowAccessConfigOption && shouldShowDeleteOption && }
- {shouldShowDeleteOption && (
- handleMenuAction(e, onDelete)}
- >
-
- {t(($) => $['operation.delete'], { ns: 'common' })}
-
-
- )}
- >
- )
-}
-
-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 (
-
- )
-}
diff --git a/web/app/components/apps/app-list-catalog.tsx b/web/app/components/apps/app-list-catalog.tsx
index 307d317819b..1330fe8d3b1 100644
--- a/web/app/components/apps/app-list-catalog.tsx
+++ b/web/app/components/apps/app-list-catalog.tsx
@@ -69,12 +69,10 @@ function CatalogSkeleton() {
const { t } = useTranslation()
return (
- $.loading, { ns: 'common' })}
- >
-
+
$.loading, { ns: 'common' })}>
+
)
}
@@ -178,56 +176,68 @@ function AppListCatalogContent({
/>
)}
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) => (
-
$['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) => (
+
+ ))}
+ {hasNextPage && }
+
+ ) : (
+
+
- ))
- ) : (
-
+
)}
{hasNextPage && (
<>
-
{isFetchNextPageError && (
{
toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' }))
@@ -95,7 +93,13 @@ export const StarredAppCard = memo(
)
return (
-
+
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 ? (
+
)
},
)
diff --git a/web/app/components/apps/starred-app-list.tsx b/web/app/components/apps/starred-app-list.tsx
index 1486fd12eab..9d1a1e41695 100644
--- a/web/app/components/apps/starred-app-list.tsx
+++ b/web/app/components/apps/starred-app-list.tsx
@@ -43,7 +43,9 @@ export function StarredAppList({
id={STARRED_APPS_HEADING_ID}
label={t(($) => $['studio.starred'], { ns: 'app' })}
/>
-
))}
-
+
>
)
}
diff --git a/web/features/agent-v2/agent-detail/sidebar-actions.tsx b/web/features/agent-v2/agent-detail/sidebar-actions.tsx
index fd60792dc1c..03385bfda6e 100644
--- a/web/features/agent-v2/agent-detail/sidebar-actions.tsx
+++ b/web/features/agent-v2/agent-detail/sidebar-actions.tsx
@@ -113,14 +113,14 @@ export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebar
diff --git a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx
index 538e8008949..ad0bdf8d1cb 100644
--- a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx
+++ b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx
@@ -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/ }))
diff --git a/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx b/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx
index 9087b190def..8cee962675c 100644
--- a/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx
+++ b/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx
@@ -71,7 +71,7 @@ const createAgent = (overrides: Partial = {}): AgentAppPartial
const renderDialog = (agent = createAgent()) => {
const onOpenChange = vi.fn()
- render()
+ render()
return { onOpenChange }
}
diff --git a/web/features/agent-v2/roster/components/agent-roster-list.tsx b/web/features/agent-v2/roster/components/agent-roster-list.tsx
index 49b73cd8c2a..64184d7b599 100644
--- a/web/features/agent-v2/roster/components/agent-roster-list.tsx
+++ b/web/features/agent-v2/roster/components/agent-roster-list.tsx
@@ -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 (
-
+
+ )
+}
+
+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 (
+ <>
+
+
+
+
+
+ >
)
}
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 (
-
-
-
-
-
-
-
-
- {agent.name}
-
-
{agent.role}
-
-
-
-
- {agent.description}
-
-
-
- {isDraft && (
-
-
-
- {t(($) => $['roster.usageStatus.draft'])}
-
-
- )}
-
+
+
+
+
+
+
+
+
+ {agent.name}
+
+
{agent.role}
+
+
+
+
+ {agent.description}
+
+
+ {isDraft && (
+
+
+
+ {t(($) => $['roster.usageStatus.draft'])}
+
+
+ )}
+
+ }
+ />
+
+
+
+
@@ -254,33 +327,14 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
}
/>
-
-
- {t(($) => $['roster.editInfo'])}
-
-
-
- {tCommon(($) => $['operation.duplicate'])}
-
-
-
- {tApp(($) => $.export)}
-
-
- setIsDeleteOpen(true)}
- >
-
- {tCommon(($) => $['operation.delete'])}
-
+
@@ -316,24 +370,24 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
-
+
)
}
@@ -343,8 +397,12 @@ export function AgentRosterList({ label, state }: AgentRosterListProps) {
const isBusy = state.status === 'pending' || (state.status === 'ready' && state.isFetching)
return (
-
- {state.status === 'pending' && }
+
+ {state.status === 'pending' && (
+
+ )}
{state.status === 'error' && (
)}
{state.status === 'ready' &&
- state.agents.map((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.
+
+ {state.agents.map((agent) => (
+
+ ))}
+
+ )}
{state.status === 'ready' && state.footer.status === 'error' && (
{t(($) => $['roster.loadingError'])}
@@ -376,7 +442,7 @@ export function AgentRosterList({ label, state }: AgentRosterListProps) {
)}
{state.status === 'ready' && state.footer.status === 'load-more' && (
-
+
diff --git a/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx b/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx
index dc4e8b84b2e..779adcd1fd1 100644
--- a/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx
+++ b/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx
@@ -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
(
- 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 })}
-