-
+
{t(($) => $['card.notDeployed'])}
-
+
{isLoading ? (
) : isError ? (
@@ -68,17 +68,21 @@ export function EnvironmentDeployMenu({
{t(($) => $['common.loadFailed'])}
- void refetchEnvironments()}
>
{tCommon(($) => $['operation.retry'])}
-
+ {isRetrying && (
+
+ )}
+
) : undeployedEnvironments.length === 0 ? (
@@ -90,7 +94,7 @@ export function EnvironmentDeployMenu({
undeployedEnvironments.map((environment) => (
onSelectEnvironment(environment)}
>
({
- useTheme: () => ({ theme: 'system', setTheme }),
-}))
-
-describe('ThemeSwitcher', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- it.each([
- ['System theme', 'system'],
- ['Light theme', 'light'],
- ['Dark theme', 'dark'],
- ])('selects %s', async (name, theme) => {
- const user = userEvent.setup()
- render( )
-
- await user.click(screen.getByRole('button', { name }))
-
- expect(setTheme).toHaveBeenCalledWith(theme)
- })
-})
diff --git a/web/app/components/base/inline-delete-confirm/__tests__/index.spec.tsx b/web/app/components/base/inline-delete-confirm/__tests__/index.spec.tsx
deleted file mode 100644
index 691529c6912..00000000000
--- a/web/app/components/base/inline-delete-confirm/__tests__/index.spec.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { cleanup, fireEvent, render } from '@testing-library/react'
-import * as React from 'react'
-import { createReactI18nextMock } from '@/test/i18n-mock'
-import InlineDeleteConfirm from '../index'
-
-// Mock react-i18next with custom translations for test assertions
-vi.mock('react-i18next', () =>
- createReactI18nextMock({
- 'operation.deleteConfirmTitle': 'Delete?',
- 'operation.yes': 'Yes',
- 'operation.no': 'No',
- 'operation.confirmAction': 'Please confirm your action.',
- }),
-)
-
-afterEach(cleanup)
-
-describe('InlineDeleteConfirm', () => {
- describe('Rendering', () => {
- it('should render with default text', () => {
- const onConfirm = vi.fn()
- const onCancel = vi.fn()
- const { getByText } = render(
- ,
- )
-
- expect(getByText('Delete?')).toBeInTheDocument()
- expect(getByText('No')).toBeInTheDocument()
- expect(getByText('Yes')).toBeInTheDocument()
- })
-
- it('should render with custom text', () => {
- const onConfirm = vi.fn()
- const onCancel = vi.fn()
- const { getByText } = render(
- ,
- )
-
- expect(getByText('Remove?')).toBeInTheDocument()
- expect(getByText('Cancel')).toBeInTheDocument()
- expect(getByText('Confirm')).toBeInTheDocument()
- })
-
- it('should expose the prompt title and description on a semantic group', () => {
- const onConfirm = vi.fn()
- const onCancel = vi.fn()
- const { getByRole } = render(
- ,
- )
-
- const prompt = getByRole('group', { name: 'Delete?' })
- expect(prompt).toHaveAccessibleDescription('Please confirm your action.')
- })
- })
-
- describe('Button interactions', () => {
- it('should call onCancel when cancel button is clicked', () => {
- const onConfirm = vi.fn()
- const onCancel = vi.fn()
- const { getByText } = render(
- ,
- )
-
- fireEvent.click(getByText('No'))
- expect(onCancel).toHaveBeenCalledTimes(1)
- expect(onConfirm).not.toHaveBeenCalled()
- })
-
- it('should call onConfirm when confirm button is clicked', () => {
- const onConfirm = vi.fn()
- const onCancel = vi.fn()
- const { getByText } = render(
- ,
- )
-
- fireEvent.click(getByText('Yes'))
- expect(onConfirm).toHaveBeenCalledTimes(1)
- expect(onCancel).not.toHaveBeenCalled()
- })
- })
-})
diff --git a/web/app/components/base/inline-delete-confirm/index.stories.tsx b/web/app/components/base/inline-delete-confirm/index.stories.tsx
deleted file mode 100644
index 66d8cbde70e..00000000000
--- a/web/app/components/base/inline-delete-confirm/index.stories.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { useState } from 'react'
-import { fn } from 'storybook/test'
-import InlineDeleteConfirm from '.'
-
-const meta = {
- title: 'Base/Feedback/InlineDeleteConfirm',
- component: InlineDeleteConfirm,
- parameters: {
- layout: 'centered',
- docs: {
- description: {
- component:
- 'Compact confirmation prompt that appears inline, commonly used near delete buttons or destructive controls.',
- },
- },
- },
- argTypes: {
- variant: {
- control: 'select',
- options: ['delete', 'warning', 'info'],
- },
- },
- args: {
- title: 'Delete this item?',
- confirmText: 'Delete',
- cancelText: 'Cancel',
- onConfirm: fn(),
- onCancel: fn(),
- },
- tags: ['autodocs'],
-} satisfies Meta
-
-export default meta
-type Story = StoryObj
-
-const InlineDeleteConfirmDemo = (args: Story['args']) => {
- const [visible, setVisible] = useState(true)
-
- return (
-
- setVisible(true)}
- >
- Trigger inline confirm
-
- {visible && (
- {
- console.log('✅ Confirm clicked')
- setVisible(false)
- }}
- onCancel={() => {
- console.log('❎ Cancel clicked')
- setVisible(false)
- }}
- />
- )}
-
- )
-}
-
-export const Playground: Story = {
- render: (args) => ,
-}
-
-export const WarningVariant: Story = {
- render: (args) => ,
- args: {
- variant: 'warning',
- title: 'Archive conversation?',
- confirmText: 'Archive',
- cancelText: 'Keep',
- },
-}
-
-export const InfoVariant: Story = {
- render: (args) => ,
- args: {
- variant: 'info',
- title: 'Remove collaborator?',
- confirmText: 'Remove',
- cancelText: 'Keep',
- },
-}
diff --git a/web/app/components/base/inline-delete-confirm/index.tsx b/web/app/components/base/inline-delete-confirm/index.tsx
deleted file mode 100644
index efde27c8f80..00000000000
--- a/web/app/components/base/inline-delete-confirm/index.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-'use client'
-import type { FC } from 'react'
-import { Button } from '@langgenius/dify-ui/button'
-import { cn } from '@langgenius/dify-ui/cn'
-import { useId } from 'react'
-import { useTranslation } from 'react-i18next'
-
-type InlineDeleteConfirmProps = {
- title?: string
- confirmText?: string
- cancelText?: string
- onConfirm: () => void
- onCancel: () => void
- className?: string
- variant?: 'delete' | 'warning' | 'info'
-}
-
-const InlineDeleteConfirm: FC = ({
- title,
- confirmText,
- cancelText,
- onConfirm,
- onCancel,
- className,
- variant = 'delete',
-}) => {
- const { t } = useTranslation(['common'])
- const titleId = useId()
- const descriptionId = useId()
-
- const titleText =
- title || t(($) => $['operation.deleteConfirmTitle'], { ns: 'common', defaultValue: 'Delete?' })
- const confirmTxt =
- confirmText || t(($) => $['operation.yes'], { ns: 'common', defaultValue: 'Yes' })
- const cancelTxt = cancelText || t(($) => $['operation.no'], { ns: 'common', defaultValue: 'No' })
-
- return (
-
-
- {titleText}
-
-
-
-
- {cancelTxt}
-
-
- {confirmTxt}
-
-
-
-
- {t(($) => $['operation.confirmAction'], {
- ns: 'common',
- defaultValue: 'Please confirm your action.',
- })}
-
-
- )
-}
-
-InlineDeleteConfirm.displayName = 'InlineDeleteConfirm'
-
-export default InlineDeleteConfirm
diff --git a/web/app/components/base/sort/index.tsx b/web/app/components/base/sort/index.tsx
index 31a2284a253..404f1a9fbce 100644
--- a/web/app/components/base/sort/index.tsx
+++ b/web/app/components/base/sort/index.tsx
@@ -4,6 +4,7 @@ import {
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
+ DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useMemo } from 'react'
@@ -29,7 +30,7 @@ function Sort({ order, value, items, onSelect }: Props) {
}, [items, value])
return (
-
+
@@ -54,7 +55,7 @@ function Sort({ order, value, items, onSelect }: Props) {
{item.name}
- {value === item.value && (
-
- )}
+
))}
@@ -89,7 +85,7 @@ function Sort({ order, value, items, onSelect }: Props) {
$[`filter.${order ? 'ascending' : 'descending'}`], { ns: 'appLog' })}
- className="ml-px shrink-0 cursor-pointer rounded-r-lg border-none bg-components-button-tertiary-bg p-2 outline-hidden hover:bg-components-button-tertiary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
+ className="shrink-0 cursor-pointer rounded-r-lg border-none bg-components-button-tertiary-bg p-2 outline-hidden hover:bg-components-button-tertiary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => onSelect(`${order ? '' : '-'}${value}`)}
>
{!order && (
diff --git a/web/app/components/base/theme-switcher.tsx b/web/app/components/base/theme-switcher.tsx
deleted file mode 100644
index 06ad259dd1e..00000000000
--- a/web/app/components/base/theme-switcher.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-'use client'
-import { cn } from '@langgenius/dify-ui/cn'
-import { useTheme } from 'next-themes'
-
-type Theme = 'light' | 'dark' | 'system'
-
-export default function ThemeSwitcher() {
- const { theme, setTheme } = useTheme()
-
- const handleThemeChange = (newTheme: Theme) => {
- setTheme(newTheme)
- }
-
- return (
-
-
handleThemeChange('system')}
- aria-label="System theme"
- >
-
-
-
-
-
-
handleThemeChange('light')}
- aria-label="Light theme"
- >
-
-
-
-
-
-
handleThemeChange('dark')}
- aria-label="Dark theme"
- >
-
-
-
-
-
- )
-}
diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx
index 11bd8068214..18899094264 100644
--- a/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx
+++ b/web/app/components/datasets/create-from-pipeline/list/template-card/actions.tsx
@@ -3,14 +3,13 @@ import { cn } from '@langgenius/dify-ui/cn'
import { DialogTrigger } from '@langgenius/dify-ui/dialog'
import {
DropdownMenu,
- DropdownMenuPopup,
- DropdownMenuPortal,
- DropdownMenuPositioner,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
-import Operations from './operations'
type ActionsProps = {
onApplyTemplate: () => void
@@ -28,16 +27,9 @@ const Actions = ({
handleDelete,
}: ActionsProps) => {
const { t } = useTranslation(['common', 'datasetPipeline'])
- const [isMoreOperationsOpen, setIsMoreOperationsOpen] = React.useState(false)
return (
-
+
{t(($) => $['operations.choose'], { ns: 'datasetPipeline' })}
@@ -51,29 +43,42 @@ const Actions = ({
}
/>
{showMoreOperations && (
-
+
$['operation.more'], { ns: 'common' })}
className={cn(
'flex size-8 cursor-pointer items-center justify-center rounded-lg p-0 shadow-xs shadow-shadow-shadow-3',
- 'data-popup-open:bg-state-base-hover',
+ 'outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover',
)}
onClick={(e) => e.stopPropagation()}
>
-
-
-
- setIsMoreOperationsOpen(false)}
- />
-
-
-
+
+ {
+ openEditModal()
+ }}
+ >
+ {t(($) => $['operations.editInfo'], { ns: 'datasetPipeline' })}
+
+ {
+ handleExportDSL()
+ }}
+ >
+ {t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })}
+
+
+ {
+ handleDelete()
+ }}
+ >
+ {t(($) => $['operation.delete'], { ns: 'common' })}
+
+
)}
diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/operations.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/operations.tsx
deleted file mode 100644
index f2c8c0ebf37..00000000000
--- a/web/app/components/datasets/create-from-pipeline/list/template-card/operations.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { Separator } from '@langgenius/dify-ui/separator'
-import * as React from 'react'
-import { useTranslation } from 'react-i18next'
-
-type OperationsProps = {
- openEditModal: () => void
- onDelete: () => void
- onExport: () => void
- onClose?: () => void
-}
-
-const Operations = ({ openEditModal, onDelete, onExport, onClose }: OperationsProps) => {
- const { t } = useTranslation(['common', 'datasetPipeline'])
-
- const onClickEdit = (e: React.MouseEvent
) => {
- e.stopPropagation()
- e.preventDefault()
- onClose?.()
- openEditModal()
- }
-
- const onClickExport = (e: React.MouseEvent) => {
- e.stopPropagation()
- e.preventDefault()
- onClose?.()
- onExport()
- }
-
- const onClickDelete = (e: React.MouseEvent) => {
- e.stopPropagation()
- e.preventDefault()
- onClose?.()
- onDelete()
- }
-
- return (
-
-
-
-
- {t(($) => $['operations.editInfo'], { ns: 'datasetPipeline' })}
-
-
-
-
- {t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })}
-
-
-
-
-
-
-
- {t(($) => $['operation.delete'], { ns: 'common' })}
-
-
-
-
- )
-}
-
-export default React.memo(Operations)
diff --git a/web/app/components/datasets/documents/components/operations.tsx b/web/app/components/datasets/documents/components/operations.tsx
index 94c09896ed3..3dbcc1c9f51 100644
--- a/web/app/components/datasets/documents/components/operations.tsx
+++ b/web/app/components/datasets/documents/components/operations.tsx
@@ -14,6 +14,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
@@ -296,8 +297,8 @@ const Operations = ({
},
[canDownload, closeOperationsMenu, handleDownload],
)
- const menuActionClassName = cn(s.actionItem, 'border-none bg-transparent')
- const menuDeleteActionClassName = cn(menuActionClassName, s.deleteActionItem, 'group')
+ const menuActionClassName = 'h-9 w-[calc(100%-8px)] gap-2 px-3 py-2 text-left'
+ const menuDeleteActionClassName = cn(menuActionClassName, 'group')
const handleSettingsClick = useCallback(
(evt: React.MouseEvent) => {
evt.preventDefault()
@@ -311,16 +312,12 @@ const Operations = ({
[canViewSettings, closeOperationsMenu, datasetId, detail.id, router],
)
const settingsMenuItem = (
-
+
-
+
{t(($) => $['list.action.settings'], { ns: 'datasetDocuments' })}
-
+
)
const enabledLabel = `${t(($) => $['list.status.enabled'], { ns: 'datasetDocuments' })}: ${name}`
const renderListSwitch = () => {
@@ -417,141 +414,119 @@ const Operations = ({
-
- {canShowPrimarySection && (
- <>
- {canShowRenameAction && (
-
-
-
- {t(($) => $['list.table.rename'], { ns: 'datasetDocuments' })}
-
-
- )}
- {canShowSummaryAction && (
- handleMenuOperation('summary')}
- >
-
-
- {t(($) => $['list.action.summary'], { ns: 'datasetDocuments' })}
-
-
- )}
- {canShowSettingsAction && settingsMenuItem}
- {canShowDownloadAction && (
-
-
-
- {t(($) => $['list.action.download'], { ns: 'datasetDocuments' })}
-
-
- )}
- {canShowSyncAction && (
- handleMenuOperation('sync')}
- >
-
-
- {t(($) => $['list.action.sync'], { ns: 'datasetDocuments' })}
-
-
- )}
- {(canShowStatusSection || canShowDeleteAction) && (
-
- )}
- >
- )}
- {canShowPauseAction && (
- handleMenuOperation('pause')}
- >
-
-
- {t(($) => $['list.action.pause'], { ns: 'datasetDocuments' })}
-
-
- )}
- {canShowResumeAction && (
- handleMenuOperation('resume')}
- >
-
-
- {t(($) => $['list.action.resume'], { ns: 'datasetDocuments' })}
-
-
- )}
- {canShowArchiveAction && (
- handleMenuOperation('archive')}
- >
-
-
- {t(($) => $['list.action.archive'], { ns: 'datasetDocuments' })}
-
-
- )}
- {canShowUnarchiveAction && (
- handleMenuOperation('un_archive')}
- >
-
-
- {t(($) => $['list.action.unarchive'], { ns: 'datasetDocuments' })}
-
-
- )}
- {canShowDeleteAction && (
-
-
-
- {t(($) => $['list.action.delete'], { ns: 'datasetDocuments' })}
-
-
- )}
-
+ {canShowPrimarySection && (
+ <>
+ {canShowRenameAction && (
+
+
+
+ {t(($) => $['list.table.rename'], { ns: 'datasetDocuments' })}
+
+
+ )}
+ {canShowSummaryAction && (
+ handleMenuOperation('summary')}
+ >
+
+
+ {t(($) => $['list.action.summary'], { ns: 'datasetDocuments' })}
+
+
+ )}
+ {canShowSettingsAction && settingsMenuItem}
+ {canShowDownloadAction && (
+
+
+
+ {t(($) => $['list.action.download'], { ns: 'datasetDocuments' })}
+
+
+ )}
+ {canShowSyncAction && (
+ handleMenuOperation('sync')}
+ >
+
+
+ {t(($) => $['list.action.sync'], { ns: 'datasetDocuments' })}
+
+
+ )}
+ {(canShowStatusSection || canShowDeleteAction) && (
+
+ )}
+ >
+ )}
+ {canShowPauseAction && (
+ handleMenuOperation('pause')}
+ >
+
+
+ {t(($) => $['list.action.pause'], { ns: 'datasetDocuments' })}
+
+
+ )}
+ {canShowResumeAction && (
+ handleMenuOperation('resume')}
+ >
+
+
+ {t(($) => $['list.action.resume'], { ns: 'datasetDocuments' })}
+
+
+ )}
+ {canShowArchiveAction && (
+ handleMenuOperation('archive')}
+ >
+
+
+ {t(($) => $['list.action.archive'], { ns: 'datasetDocuments' })}
+
+
+ )}
+ {canShowUnarchiveAction && (
+ handleMenuOperation('un_archive')}
+ >
+
+
+ {t(($) => $['list.action.unarchive'], { ns: 'datasetDocuments' })}
+
+
+ )}
+ {canShowDeleteAction && (
+
+
+
+ {t(($) => $['list.action.delete'], { ns: 'datasetDocuments' })}
+
+
+ )}
>
diff --git a/web/app/components/datasets/documents/style.module.css b/web/app/components/datasets/documents/style.module.css
index b5f7fd19e8b..535b380102c 100644
--- a/web/app/components/datasets/documents/style.module.css
+++ b/web/app/components/datasets/documents/style.module.css
@@ -16,15 +16,6 @@
.actionIconWrapperDetail {
@apply !border-[0.5px] !border-components-button-secondary-border !bg-components-button-secondary-bg !p-2 shadow-xs shadow-shadow-shadow-3 hover:!border-components-button-secondary-border-hover hover:!bg-components-button-secondary-bg-hover;
}
-.actionItem {
- @apply mx-1 flex h-9 w-[calc(100%-8px)] cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent px-3 py-2 text-left hover:bg-state-base-hover;
-}
-.deleteActionItem {
- @apply hover:!bg-state-destructive-hover;
-}
-.actionName {
- @apply text-sm text-text-secondary;
-}
.addFileBtn {
@apply mt-4 w-fit border-[0.5px] border-components-button-secondary-border !text-[13px] font-medium;
}
diff --git a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx
index c7f1bed0133..11f83943744 100644
--- a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx
+++ b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx
@@ -3,11 +3,14 @@ import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import * as React from 'react'
+import { useTranslation } from 'react-i18next'
import {
getStepByStepTourDropdownMenuContentProps,
useStepByStepTourControlledDropdown,
@@ -16,7 +19,6 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { getDatasetACLCapabilities } from '@/utils/permission'
-import Operations from '../operations'
type OperationsDropdownProps = {
dataset: DataSet
@@ -43,6 +45,7 @@ const OperationsDropdown = ({
})
const open = operationsMenu.open
const setOpen = operationsMenu.onOpenChange
+ const { t } = useTranslation(['common', 'datasetPipeline', 'navigation'])
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
@@ -106,18 +109,33 @@ const OperationsDropdown = ({
className: 'min-w-[186px]',
})}
>
-
+ {datasetACLCapabilities.canEdit && (
+
+
+ {t(($) => $['operation.edit'], { ns: 'common' })}
+
+ )}
+ {dataset.runtime_mode === 'rag_pipeline' && datasetACLCapabilities.canImportExportDSL && (
+ handleExportPipeline()}>
+
+ {t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })}
+
+ )}
+ {datasetACLCapabilities.canAccessConfig && (
+
+
+ {t(($) => $['settings.resourceAccess'], { ns: 'navigation' })}
+
+ )}
+ {datasetACLCapabilities.canDelete && (
+ <>
+
+
+
+ {t(($) => $['operation.delete'], { ns: 'common' })}
+
+ >
+ )}
diff --git a/web/app/components/datasets/list/dataset-card/operations.tsx b/web/app/components/datasets/list/dataset-card/operations.tsx
deleted file mode 100644
index 02e7e94afda..00000000000
--- a/web/app/components/datasets/list/dataset-card/operations.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import { DropdownMenuItem, DropdownMenuSeparator } from '@langgenius/dify-ui/dropdown-menu'
-import * as React from 'react'
-import { useTranslation } from 'react-i18next'
-
-type OperationsProps = {
- showEdit?: boolean
- showDelete: boolean
- showExportPipeline: boolean
- showAccessConfig?: boolean
- openRenameModal: () => void
- handleExportPipeline: () => void
- detectIsUsedByApp: () => void
- openAccessConfig: () => void
- onClose?: () => void
-}
-
-const Operations = ({
- showEdit = true,
- showDelete,
- showExportPipeline,
- showAccessConfig = false,
- openRenameModal,
- handleExportPipeline,
- detectIsUsedByApp,
- openAccessConfig,
- onClose,
-}: OperationsProps) => {
- const { t } = useTranslation(['common', 'datasetPipeline', 'navigation'])
-
- const handleRename = () => {
- onClose?.()
- openRenameModal()
- }
-
- const handleExport = () => {
- onClose?.()
- handleExportPipeline()
- }
-
- const handleDelete = () => {
- onClose?.()
- detectIsUsedByApp()
- }
-
- const handleAccessConfig = () => {
- onClose?.()
- openAccessConfig()
- }
-
- return (
- <>
- {showEdit && (
-
-
- {t(($) => $['operation.edit'], { ns: 'common' })}
-
- )}
- {showExportPipeline && (
-
-
- {t(($) => $['operations.exportPipeline'], { ns: 'datasetPipeline' })}
-
- )}
- {showAccessConfig && (
-
-
- {t(($) => $['settings.resourceAccess'], { ns: 'navigation' })}
-
- )}
- {showDelete && (
- <>
-
-
-
- {t(($) => $['operation.delete'], { ns: 'common' })}
-
- >
- )}
- >
- )
-}
-
-export default React.memo(Operations)
diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx
index 81411c34163..a9ecc994bfa 100644
--- a/web/app/components/header/account-dropdown/index.tsx
+++ b/web/app/components/header/account-dropdown/index.tsx
@@ -1,27 +1,218 @@
'use client'
-import type { ReactElement } from 'react'
+import type { ReactElement, ReactNode } from 'react'
+import type { Theme } from '@/app/components/base/theme-selector'
+import { Avatar } from '@langgenius/dify-ui/avatar'
+import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLinkItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuRadioItemIndicator,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
+import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
+import { useTheme } from 'next-themes'
+import { useQueryState } from 'nuqs'
import { useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
+import PremiumBadge from '@/app/components/base/premium-badge'
+import {
+ settingsQueryParamName,
+ settingsQueryParser,
+} from '@/app/components/header/account-setting/query-params'
+import { userProfileQueryOptions } from '@/features/account-profile/client'
+import Link from '@/next/link'
+import { consoleQuery } from '@/service/console'
import { useLogout } from '@/service/use-common'
-import { MainNavMenuContent } from './main-nav-menu-content'
+import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
type AccountDropdownProps = {
trigger: (props: { ariaLabel: string }) => ReactElement
}
-const mainNavMenuPopupClassName =
- 'w-60 max-w-80 overflow-hidden bg-components-panel-bg-blur! p-0! backdrop-blur-[5px]'
+const mainNavMenuPopupClassName = 'w-60 max-w-80 overflow-hidden p-0'
const subscribeHydrationState = () => () => {}
const getHydrationSnapshot = () => false
const getServerHydrationSnapshot = () => true
+type MainNavRadioItemContentProps = {
+ iconClassName?: string
+ label: ReactNode
+}
+
+function MainNavRadioItemContent({ iconClassName, label }: MainNavRadioItemContentProps) {
+ const labelTitle = typeof label === 'string' ? label : undefined
+
+ return (
+ <>
+ {iconClassName && (
+
+ )}
+
+ {label}
+
+
+ >
+ )
+}
+
+function AppearanceSubmenu() {
+ const { t } = useTranslation(['accountSettings'])
+ const { theme, setTheme } = useTheme()
+ const currentTheme: Theme =
+ theme === 'light' || theme === 'dark' || theme === 'system' ? theme : 'system'
+
+ return (
+
+
+ $['account.appearanceLabel'], { ns: 'accountSettings' })}
+ />
+
+
+
+ value={currentTheme}
+ onValueChange={(nextTheme) => setTheme(nextTheme)}
+ >
+ value="light" className="mx-0 py-1">
+ $['account.appearanceLight'], { ns: 'accountSettings' })}
+ />
+
+ value="dark" className="mx-0 py-1">
+ $['account.appearanceDark'], { ns: 'accountSettings' })}
+ />
+
+ value="system" className="mx-0 py-1">
+ $['account.appearanceSystem'], { ns: 'accountSettings' })}
+ />
+
+
+
+
+ )
+}
+
+type MainNavMenuContentProps = {
+ onLogout: () => void
+}
+
+function MainNavMenuContent({ onLogout }: MainNavMenuContentProps) {
+ const { t } = useTranslation(['common', 'accountSettings', 'navigation'])
+ const { data: userProfile } = useSuspenseQuery({
+ ...userProfileQueryOptions(),
+ select: (data) => data.profile,
+ })
+ const { data: enableEducationPlan } = useQuery(
+ consoleQuery.features.get.queryOptions({
+ select: (features) => features.education.enabled,
+ }),
+ )
+ const { data: isEducationAccount = false } = useQuery(
+ consoleQuery.account.education.get.queryOptions({
+ enabled: enableEducationPlan === true,
+ select: ({ is_student }) => is_student ?? false,
+ }),
+ )
+ const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
+
+ return (
+ <>
+
+
+
+
+
+ {userProfile.name}
+
+ {isEducationAccount && (
+
+
+ EDU
+
+ )}
+
+
+ {userProfile.email}
+
+
+
+
+
+
+ }
+ >
+ $['account.account'], { ns: 'accountSettings' })}
+ trailing={ }
+ />
+
+ setSettingsDestination('preferences')}
+ >
+ $['settings.preferences'], { ns: 'navigation' })}
+ />
+
+
+
+
+
+ {
+ onLogout()
+ }}
+ >
+ $['userProfile.logout'], { ns: 'common' })}
+ />
+
+
+ >
+ )
+}
+
export default function AccountDropdown({ trigger }: AccountDropdownProps) {
const isHydrating = useSyncExternalStore(
subscribeHydrationState,
diff --git a/web/app/components/header/account-dropdown/main-nav-menu-content.tsx b/web/app/components/header/account-dropdown/main-nav-menu-content.tsx
deleted file mode 100644
index 974215d6a99..00000000000
--- a/web/app/components/header/account-dropdown/main-nav-menu-content.tsx
+++ /dev/null
@@ -1,211 +0,0 @@
-'use client'
-
-import type { ReactNode } from 'react'
-import type { Theme } from '@/app/components/base/theme-selector'
-import { Avatar } from '@langgenius/dify-ui/avatar'
-import { cn } from '@langgenius/dify-ui/cn'
-import {
- DropdownMenuGroup,
- DropdownMenuItem,
- DropdownMenuLinkItem,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuRadioItemIndicator,
- DropdownMenuSeparator,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
-} from '@langgenius/dify-ui/dropdown-menu'
-import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
-import { useTheme } from 'next-themes'
-import { useQueryState } from 'nuqs'
-import { useTranslation } from 'react-i18next'
-import PremiumBadge from '@/app/components/base/premium-badge'
-import {
- settingsQueryParamName,
- settingsQueryParser,
-} from '@/app/components/header/account-setting/query-params'
-import { userProfileQueryOptions } from '@/features/account-profile/client'
-import Link from '@/next/link'
-import { consoleQuery } from '@/service/console'
-import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
-
-type MainNavRadioItemContentProps = {
- iconClassName?: string
- label: ReactNode
-}
-
-function MainNavRadioItemContent({ iconClassName, label }: MainNavRadioItemContentProps) {
- const labelTitle = typeof label === 'string' ? label : undefined
-
- return (
- <>
- {iconClassName && (
-
- )}
-
- {label}
-
-
- >
- )
-}
-
-function AppearanceSubmenu() {
- const { t } = useTranslation(['accountSettings'])
- const { theme, setTheme } = useTheme()
- const currentTheme: Theme =
- theme === 'light' || theme === 'dark' || theme === 'system' ? theme : 'system'
-
- return (
-
-
- $['account.appearanceLabel'], { ns: 'accountSettings' })}
- />
-
-
-
- value={currentTheme}
- onValueChange={(nextTheme) => setTheme(nextTheme)}
- >
-
- value="light"
- closeOnClick
- className="mx-0 h-8 gap-1 px-2 py-1"
- >
- $['account.appearanceLight'], { ns: 'accountSettings' })}
- />
-
-
- value="dark"
- closeOnClick
- className="mx-0 h-8 gap-1 px-2 py-1"
- >
- $['account.appearanceDark'], { ns: 'accountSettings' })}
- />
-
-
- value="system"
- closeOnClick
- className="mx-0 h-8 gap-1 px-2 py-1"
- >
- $['account.appearanceSystem'], { ns: 'accountSettings' })}
- />
-
-
-
-
- )
-}
-
-type MainNavMenuContentProps = {
- onLogout: () => void
-}
-
-export function MainNavMenuContent({ onLogout }: MainNavMenuContentProps) {
- const { t } = useTranslation(['common', 'accountSettings', 'navigation'])
- const { data: userProfile } = useSuspenseQuery({
- ...userProfileQueryOptions(),
- select: (data) => data.profile,
- })
- const { data: enableEducationPlan } = useQuery(
- consoleQuery.features.get.queryOptions({
- select: (features) => features.education.enabled,
- }),
- )
- const { data: isEducationAccount = false } = useQuery(
- consoleQuery.account.education.get.queryOptions({
- enabled: enableEducationPlan === true,
- select: ({ is_student }) => is_student ?? false,
- }),
- )
- const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
-
- return (
- <>
-
-
-
-
-
- {userProfile.name}
-
- {isEducationAccount && (
-
-
- EDU
-
- )}
-
-
- {userProfile.email}
-
-
-
-
-
-
- }
- >
- $['account.account'], { ns: 'accountSettings' })}
- trailing={ }
- />
-
- setSettingsDestination('preferences')}
- >
- $['settings.preferences'], { ns: 'navigation' })}
- />
-
-
-
-
-
- {
- onLogout()
- }}
- >
- $['userProfile.logout'], { ns: 'common' })}
- />
-
-
- >
- )
-}
diff --git a/web/app/components/integrations/sidebar-actions.tsx b/web/app/components/integrations/sidebar-actions.tsx
index 7e18118963a..a29fbd85af8 100644
--- a/web/app/components/integrations/sidebar-actions.tsx
+++ b/web/app/components/integrations/sidebar-actions.tsx
@@ -105,20 +105,15 @@ function IntegrationSidebarInstallActions({
disabled={!canManagement}
rootClassName="w-full"
triggerVariant="primary"
- triggerClassName="h-8 min-w-0 justify-start gap-2 px-2.5 py-2 system-sm-medium transition-[width]"
+ triggerClassName="h-8 min-w-0 justify-start gap-2 px-2.5 py-2 system-sm-medium transition-[width] data-popup-open:bg-components-button-primary-bg-hover"
triggerLabel={installLabel}
- triggerOpenClassName="bg-components-button-primary-bg-hover"
popupClassName="w-[200px]"
installContextCategory={installContextCategory}
showTriggerArrow={false}
onSwitchToMarketplaceTab={onSwitchToMarketplace}
/>
- actionRowRef.current}
- dropdownPlacement="bottom-start"
- />
+ actionRowRef.current} placement="bottom-start" />
)
}
diff --git a/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx
index 64308a69659..19d02145244 100644
--- a/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx
+++ b/web/app/components/plugins/marketplace/sort-dropdown/__tests__/index.spec.tsx
@@ -67,13 +67,20 @@ describe('SortDropdown', () => {
expect(within(content).getByText('First Released')).toBeInTheDocument()
})
- it('shows a check icon for the currently selected option', async () => {
+ it('exposes the currently selected sort option', async () => {
const user = userEvent.setup()
render( )
await user.click(screen.getByRole('button', { name: 'Sort by Most Popular' }))
- expect(document.querySelector('.i-ri-check-line')).toBeInTheDocument()
+ expect(screen.getByRole('menuitemradio', { name: 'Most Popular' })).toHaveAttribute(
+ 'aria-checked',
+ 'true',
+ )
+ expect(screen.getByRole('menuitemradio', { name: 'Recently Updated' })).toHaveAttribute(
+ 'aria-checked',
+ 'false',
+ )
})
it('updates the sort and closes the menu when an option is selected', async () => {
@@ -81,7 +88,7 @@ describe('SortDropdown', () => {
render( )
await user.click(screen.getByRole('button', { name: 'Sort by Most Popular' }))
- await user.click(await screen.findByRole('menuitem', { name: 'Recently Updated' }))
+ await user.click(await screen.findByRole('menuitemradio', { name: 'Recently Updated' }))
expect(mockHandleSortChange).toHaveBeenCalledWith({
sortBy: 'version_updated_at',
diff --git a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx
index e44e5b013e5..dfe428beecf 100644
--- a/web/app/components/plugins/marketplace/sort-dropdown/index.tsx
+++ b/web/app/components/plugins/marketplace/sort-dropdown/index.tsx
@@ -2,10 +2,11 @@
import {
DropdownMenu,
DropdownMenuContent,
- DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
-import { useState } from 'react'
import { useTranslation } from '#i18n'
import { useMarketplaceSort } from '../atoms'
@@ -34,36 +35,39 @@ const SortDropdown = () => {
},
]
const [sort, handleSortChange] = useMarketplaceSort()
- const [open, setOpen] = useState(false)
const selectedOption =
options.find((option) => option.value === sort.sortBy && option.order === sort.sortOrder) ??
options[0]!
return (
-
-
-
+
+
+
{t(($) => $['marketplace.sortBy'], { ns: 'plugin' })}
- {selectedOption.text}
+ {selectedOption.text}
-
- {options.map((option) => (
- {
- handleSortChange({ sortBy: option.value, sortOrder: option.order })
- setOpen(false)
- }}
- >
- {option.text}
- {sort.sortBy === option.value && sort.sortOrder === option.order && (
-
- )}
-
- ))}
+
+ {
+ const option = options.find((option) => `${option.value}-${option.order}` === nextValue)
+ if (option) handleSortChange({ sortBy: option.value, sortOrder: option.order })
+ }}
+ >
+ {options.map((option) => (
+
+ {option.text}
+
+
+ ))}
+
)
diff --git a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx
index f8f9a2b6d28..6747dfd2f23 100644
--- a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx
+++ b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx
@@ -81,7 +81,6 @@ describe('InstallPluginDropdown', () => {
rootClassName="custom-root"
triggerClassName="custom-trigger"
triggerLabel="Install"
- triggerOpenClassName="custom-open"
triggerVariant="primary"
popupClassName="custom-popup"
/>,
@@ -98,7 +97,6 @@ describe('InstallPluginDropdown', () => {
fireEvent.click(trigger)
- expect(trigger).toHaveClass('custom-open')
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(screen.getByRole('menu')).toHaveClass('custom-popup')
})
diff --git a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx
index 0fbafb3bb65..874c6a18899 100644
--- a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx
+++ b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx
@@ -7,6 +7,8 @@ import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuGroupLabel,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
@@ -32,7 +34,6 @@ type Props = Readonly<{
rootClassName?: string
triggerClassName?: string
triggerLabel?: string
- triggerOpenClassName?: string
triggerVariant?: ButtonProps['variant']
installContextCategory?: PluginCategoryEnum
showTriggerArrow?: boolean
@@ -51,7 +52,6 @@ const InstallPluginDropdown = ({
rootClassName,
triggerClassName,
triggerLabel,
- triggerOpenClassName = 'bg-state-base-hover',
triggerVariant,
installContextCategory,
showTriggerArrow = true,
@@ -160,18 +160,12 @@ const InstallPluginDropdown = ({
accept={SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS}
/>
(
+ render={
@@ -179,28 +173,26 @@ const InstallPluginDropdown = ({
{showTriggerArrow && }
- )}
+ }
/>
-
- {t(($) => $.installFrom, { ns: 'plugin' })}
-
- {installMethods.map(({ icon: Icon, text, action }) => (
- handleInstallMethodSelect(action)}
- >
-
-
- {text}
-
-
- ))}
+
+
+ {t(($) => $.installFrom, { ns: 'plugin' })}
+
+ {installMethods.map(({ icon: Icon, text, action }) => (
+ handleInstallMethodSelect(action)}>
+
+
+ {text}
+
+
+ ))}
+
{selectedAction === 'github' && (
diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx
index 507d957798d..62299d05c68 100644
--- a/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx
+++ b/web/app/components/plugins/plugin-page/plugin-tasks/__tests__/index.spec.tsx
@@ -590,7 +590,6 @@ describe('PluginTasks Component', () => {
// Click to open
fireEvent.click(getTaskMenuTrigger())
- // The popover content should be visible (PluginTaskList)
// The popover content should be visible (PluginTaskList)
expect(screen.getByTestId('plugin-task-list'))!.toBeInTheDocument()
})
@@ -602,12 +601,12 @@ describe('PluginTasks Component', () => {
render(
)
const taskMenuTrigger = getTaskMenuTrigger()
- expect(taskMenuTrigger).not.toHaveAttribute('data-menu-open')
+ expect(taskMenuTrigger).not.toHaveAttribute('data-popup-open')
await user.click(taskMenuTrigger)
expect(taskMenuTrigger).toHaveAttribute('aria-expanded', 'true')
- expect(taskMenuTrigger).toHaveAttribute('data-menu-open', '')
+ expect(taskMenuTrigger).toHaveAttribute('data-popup-open', '')
})
it('should keep the task menu trigger enabled when it can open', () => {
@@ -618,10 +617,10 @@ describe('PluginTasks Component', () => {
expect(getTaskMenuTrigger()).toHaveAttribute('aria-disabled', 'false')
})
- it('should apply custom dropdown positioning props', () => {
+ it('should apply custom popover positioning props', () => {
setupMocks([createMockPlugin({ status: TaskStatus.failed })])
- render(
document.body} />)
+ render( document.body} />)
fireEvent.click(getTaskMenuTrigger())
diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx
index 86a4decbd55..8b18e0311cf 100644
--- a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx
+++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-item.tsx
@@ -1,4 +1,4 @@
-import type { FC, ReactNode } from 'react'
+import type { ReactNode } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import type { PluginLanguage } from '@/i18n/metadata'
import CardIcon from '@/app/components/plugins/card/base/card-icon'
@@ -14,7 +14,7 @@ type PluginItemProps = {
onClear?: () => void
}
-const PluginItem: FC = ({
+function PluginItem({
plugin,
getIconUrl,
language,
@@ -23,7 +23,7 @@ const PluginItem: FC = ({
statusClassName,
action,
onClear,
-}) => {
+}: PluginItemProps) {
const hasPluginIcon = !!plugin.icon
const pluginName = plugin.labels[language] || plugin.plugin_unique_identifier
@@ -55,7 +55,7 @@ const PluginItem: FC = ({
diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.module.css b/web/app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.module.css
index 2ca8503e4c5..75438b94d1b 100644
--- a/web/app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.module.css
+++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.module.css
@@ -7,7 +7,7 @@
@apply cursor-default bg-components-panel-bg text-components-button-secondary-text shadow-none inset-ring-components-panel-border-subtle backdrop-blur-[5px];
}
- &[data-menu-open] {
+ &[data-popup-open] {
@apply bg-components-button-secondary-bg-hover shadow-xs shadow-shadow-shadow-3 inset-ring-components-button-secondary-border-hover backdrop-blur-[5px];
}
@@ -15,7 +15,7 @@
@apply cursor-pointer bg-state-destructive-hover text-components-button-destructive-secondary-text shadow-xs shadow-shadow-shadow-3 inset-ring-components-button-destructive-secondary-border-hover backdrop-blur-[5px];
&:hover,
- &[data-menu-open] {
+ &[data-popup-open] {
@apply bg-state-destructive-hover-alt;
}
}
diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx
index 232e59b67e6..2b6550365b7 100644
--- a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx
+++ b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx
@@ -1,12 +1,12 @@
-import type { DropdownMenuPositionerProps } from '@langgenius/dify-ui/dropdown-menu'
+import type { PopoverPositionerProps } from '@langgenius/dify-ui/popover'
import { cn } from '@langgenius/dify-ui/cn'
import {
- DropdownMenu,
- DropdownMenuPopup,
- DropdownMenuPortal,
- DropdownMenuPositioner,
- DropdownMenuTrigger,
-} from '@langgenius/dify-ui/dropdown-menu'
+ Popover,
+ PopoverPopup,
+ PopoverPortal,
+ PopoverPositioner,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
@@ -16,15 +16,11 @@ import { usePluginTaskStatus } from './hooks'
type PluginTasksProps = {
animatedSlot?: boolean
- dropdownAnchor?: () => Element | null
- dropdownPlacement?: DropdownMenuPositionerProps['placement']
+ anchor?: () => Element | null
+ placement?: PopoverPositionerProps['placement']
}
-const PluginTasks = ({
- animatedSlot = false,
- dropdownAnchor,
- dropdownPlacement = 'bottom',
-}: PluginTasksProps) => {
+const PluginTasks = ({ animatedSlot = false, anchor, placement = 'bottom' }: PluginTasksProps) => {
const { t } = useTranslation(['plugin'])
const [open, setOpen] = useState(false)
const {
@@ -44,7 +40,7 @@ const PluginTasks = ({
} = usePluginTaskStatus()
const { getIconUrl } = useGetIcon()
const hasPluginTasks = totalPluginsLength > 0
- const canOpenMenu =
+ const canOpenPopover =
isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess
// Generate tooltip text based on status
@@ -124,8 +120,8 @@ const PluginTasks = ({
return (
-
-
+
}
- disabled={!canOpenMenu}
+ disabled={!canOpenPopover}
/>
-
-
-
+
+
+
-
-
-
-
+
+
+
+
)
}
diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/installation-task-clearing.browser.spec.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/installation-task-clearing.browser.spec.tsx
new file mode 100644
index 00000000000..44c16b72074
--- /dev/null
+++ b/web/app/components/plugins/plugin-page/plugin-tasks/installation-task-clearing.browser.spec.tsx
@@ -0,0 +1,61 @@
+import type { PluginStatus } from '@/app/components/plugins/types'
+import { userEvent } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import { PluginSource, TaskStatus } from '@/app/components/plugins/types'
+import PluginTasks from './index'
+
+const service = vi.hoisted(() => ({ clear: vi.fn().mockResolvedValue({}), refetch: vi.fn() }))
+vi.mock('@/service/use-plugins', () => ({
+ usePluginTaskList: () => ({
+ pluginTasks: [
+ {
+ id: 'task-1',
+ plugins: [
+ {
+ plugin_unique_identifier: 'plugin-1',
+ plugin_id: 'plugin-1',
+ source: PluginSource.marketplace,
+ status: TaskStatus.success,
+ message: '',
+ icon: '',
+ labels: { en_US: 'Installed Plugin' } as Record,
+ } satisfies Omit,
+ ],
+ },
+ ],
+ handleRefetch: service.refetch,
+ }),
+ useMutationClearTaskPlugin: () => ({ mutateAsync: service.clear }),
+}))
+vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({
+ default: () => null,
+}))
+vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
+ default: () => ({ getIconUrl: () => '' }),
+}))
+vi.mock('@/context/i18n', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useGetLanguage: () => 'en_US',
+}))
+
+it('clears a completed plugin installation task using the keyboard and returns focus to installation status', async () => {
+ // Native Tab must include the button that CSS used to hide from keyboard users.
+ const screen = await render( )
+ const trigger = screen.getByRole('button', { name: /plugin.task.installSuccess/ })
+ await trigger.click()
+ const taskDetails = screen.getByRole('dialog')
+ await expect.element(taskDetails).toBeVisible()
+ await expect.element(screen.getByRole('menu')).not.toBeInTheDocument()
+ const clearAll = screen.getByRole('button', {
+ name: 'plugin.task.successPlugins plugin.task.clearAll',
+ })
+ clearAll.element().focus()
+ await userEvent.tab()
+ const clear = screen.getByRole('button', { name: 'Clear Installed Plugin' })
+ await expect.element(clear).toHaveFocus()
+ expect(clear.element().checkVisibility({ checkOpacity: true })).toBe(true)
+ await userEvent.keyboard('{Enter}')
+ expect(service.clear).toHaveBeenCalledExactlyOnceWith({ taskId: 'task-1', pluginId: 'plugin-1' })
+ await expect.element(taskDetails).not.toBeInTheDocument()
+ await expect.element(trigger).toHaveFocus()
+})
diff --git a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx
index d5b9dd3f112..e93f3e0980b 100644
--- a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx
+++ b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx
@@ -1,6 +1,7 @@
import type { SiteInfo } from '@/models/share'
import type { WebAppAddress } from '@/service/webapp-address'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import MenuDropdown from '../menu-dropdown'
@@ -26,6 +27,9 @@ vi.mock('../info-modal', () => ({
},
}))
+const mockSetTheme = vi.fn()
+vi.mock('next-themes', () => ({ useTheme: () => ({ theme: 'system', setTheme: mockSetTheme }) }))
+
const mockReplace = vi.fn()
const mockPathname = '/test-path'
vi.mock('@/next/navigation', () => ({
@@ -108,6 +112,23 @@ describe('MenuDropdown', () => {
})
})
+ it('exposes checked theme items and keeps the menu open after changing the theme', async () => {
+ const user = userEvent.setup()
+ render( )
+ const trigger = screen.getByRole('button')
+ await user.click(trigger)
+ expect(screen.getByRole('menuitemradio', { name: 'common.theme.auto' })).toHaveAttribute(
+ 'aria-checked',
+ 'true',
+ )
+ const light = screen.getByRole('menuitemradio', { name: 'common.theme.light' })
+ await user.click(light)
+ expect(mockSetTheme).toHaveBeenCalledWith('light', expect.anything())
+ expect(screen.getByRole('menu')).toBeInTheDocument()
+ await user.keyboard('{Escape}')
+ await waitFor(() => expect(trigger).toHaveFocus())
+ })
+
describe('privacy policy link', () => {
it('should show privacy policy link when provided', async () => {
const siteInfoWithPrivacy: SiteInfo = {
diff --git a/web/app/components/share/text-generation/menu-dropdown.tsx b/web/app/components/share/text-generation/menu-dropdown.tsx
index ec031d44dc9..5bf086ff163 100644
--- a/web/app/components/share/text-generation/menu-dropdown.tsx
+++ b/web/app/components/share/text-generation/menu-dropdown.tsx
@@ -1,20 +1,22 @@
'use client'
import type { DropdownMenuContentProps } from '@langgenius/dify-ui/dropdown-menu'
-import type { FC } from 'react'
import type { SiteInfo } from '@/models/share'
import {
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuGroupLabel,
DropdownMenuItem,
DropdownMenuLinkItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { IconButton } from '@langgenius/dify-ui/icon-button'
+import { useTheme } from 'next-themes'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
-import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control'
import { usePathname, useRouter } from '@/next/navigation'
@@ -29,7 +31,7 @@ type Props = Readonly<
}
>
-const MenuDropdown: FC = ({ data, placement, hideLogout }) => {
+function MenuDropdown({ data, placement, hideLogout }: Props) {
const webAppAccessMode = useWebAppStore((s) => s.webAppAccessMode)
const router = useRouter()
const pathname = usePathname()
@@ -40,6 +42,7 @@ const MenuDropdown: FC = ({ data, placement, hideLogout }) => {
router.replace(`/webapp-signin?redirect_url=${pathname}`)
}
+ const { theme, setTheme } = useTheme()
const [show, setShow] = useState(false)
const handleOpenInfoModal = () => {
queueMicrotask(() => {
@@ -62,16 +65,63 @@ const MenuDropdown: FC = ({ data, placement, hideLogout }) => {
}
/>
-
-
-
{t(($) => $['theme.theme'], { ns: 'common' })}
-
+
+
+ {t(($) => $['theme.theme'], { ns: 'common' })}
+
+
+ {(['system', 'light', 'dark'] as const).map((value, index) => (
+
+ {index > 0 && (
+
+ )}
+
+ $[
+ value === 'system'
+ ? 'theme.auto'
+ : value === 'light'
+ ? 'theme.light'
+ : 'theme.dark'
+ ],
+ { ns: 'common' },
+ )}
+ className="mx-0 h-auto py-1 text-text-tertiary data-checked:bg-components-segmented-control-item-active-bg data-checked:text-text-accent-light-mode-only data-checked:shadow-sm data-highlighted:text-text-secondary data-checked:data-highlighted:bg-components-segmented-control-item-active-bg data-checked:data-highlighted:text-text-accent-light-mode-only"
+ >
+
+
+
+
+
+ ))}
-
-
+
+
{data?.privacy_policy && (
= ({ data, placement, hideLogout }) => {
)}
-
+
{t(($) => $['userProfile.about'], { ns: 'common' })}
{!(
@@ -89,7 +139,7 @@ const MenuDropdown: FC = ({ data, placement, hideLogout }) => {
webAppAccessMode === AccessMode.EXTERNAL_MEMBERS ||
webAppAccessMode === AccessMode.PUBLIC
) && (
-
+
{t(($) => $['userProfile.logout'], { ns: 'common' })}
)}
diff --git a/web/app/components/snippet-list/components/snippet-card.tsx b/web/app/components/snippet-list/components/snippet-card.tsx
index 6ed874d41dc..dd7fee4aacb 100644
--- a/web/app/components/snippet-list/components/snippet-card.tsx
+++ b/web/app/components/snippet-list/components/snippet-card.tsx
@@ -10,7 +10,6 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
-import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@@ -188,14 +187,7 @@ const SnippetCard = ({
{canShowOperations && (
-
+
{
expect(onPositionUpdate).toHaveBeenCalledTimes(2)
})
+ it('confirms reply deletion outside the menu and restores focus when cancelled', async () => {
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
+ const onDelete = vi.fn()
+ const onClose = vi.fn()
+ const comment = createComment()
+ comment.replies = [
+ {
+ id: 'reply-1',
+ content: 'A reply',
+ created_at: 2,
+ created_by: 'user-1',
+ created_by_account: comment.created_by_account,
+ },
+ ]
+ renderWithAccountProfile(
+ {}}
+ onReplyEdit={() => {}}
+ onReplyDeleteDirect={onDelete}
+ />,
+ )
+ await act(() => vi.advanceTimersByTimeAsync(100))
+ const trigger = screen.getByRole('button', {
+ name: 'workflowComments.comments.aria.replyActions',
+ })
+ await user.click(trigger)
+ const deleteItem = screen.getByRole('menuitem', {
+ name: 'workflowComments.comments.actions.deleteReply',
+ })
+ await user.click(deleteItem)
+ const confirm = await screen.findByRole('alertdialog', {
+ name: 'workflowComments.comments.actions.deleteReply',
+ })
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument()
+ expect(onDelete).not.toHaveBeenCalled()
+ await user.click(within(confirm).getByRole('button', { name: 'common.operation.cancel' }))
+ await waitFor(() => expect(trigger).toHaveFocus())
+ expect(onClose).not.toHaveBeenCalled()
+ await user.click(trigger)
+ await user.click(
+ await screen.findByRole('menuitem', {
+ name: 'workflowComments.comments.actions.deleteReply',
+ }),
+ )
+ const reopened = await screen.findByRole('alertdialog')
+ await user.click(within(reopened).getByRole('button', { name: 'common.operation.delete' }))
+ expect(onDelete).toHaveBeenCalledExactlyOnceWith('reply-1')
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
+ expect(onClose).not.toHaveBeenCalled()
+ })
+
it('focuses the reply input when navigating to another comment', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const onReply = vi.fn()
diff --git a/web/app/components/workflow/comment/reply-deletion.browser.spec.tsx b/web/app/components/workflow/comment/reply-deletion.browser.spec.tsx
new file mode 100644
index 00000000000..37aff4a9036
--- /dev/null
+++ b/web/app/components/workflow/comment/reply-deletion.browser.spec.tsx
@@ -0,0 +1,172 @@
+import type { WorkflowCommentDetail } from './types'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { Suspense, useState } from 'react'
+import { page, userEvent } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import { CommentThread } from './thread'
+
+vi.mock('@/next/navigation', () => ({ useParams: () => ({ appId: 'app-1' }) }))
+vi.mock('reactflow', () => ({
+ useReactFlow: () => ({ flowToScreenPosition: (position: { x: number; y: number }) => position }),
+ useViewport: () => ({ x: 0, y: 0, zoom: 1 }),
+}))
+const store = vi.hoisted(() => ({
+ mentionableUsersCache: { 'app-1': [] },
+ setCommentPreviewHovering: () => {},
+}))
+vi.mock('../store', () => ({
+ useStore: (selector: (state: typeof store) => unknown) => selector(store),
+ useWorkflowStore: () => ({ getState: () => store }),
+}))
+vi.mock('@/features/account-profile/client', () => ({
+ userProfileQueryOptions: () => ({
+ queryKey: ['profile'],
+ queryFn: async () => ({ profile: { id: 'author', name: 'Alice', avatar_url: null } }),
+ }),
+}))
+
+const author = { id: 'author', name: 'Alice', email: 'alice@example.com', avatar_url: null }
+const comment: WorkflowCommentDetail = {
+ id: 'comment',
+ content: 'Review this node',
+ created_by: 'author',
+ created_by_account: author,
+ position_x: 100,
+ position_y: 100,
+ created_at: 1,
+ updated_at: 1,
+ resolved: false,
+ mentions: [],
+ replies: [
+ {
+ id: 'reply',
+ content: 'A reply',
+ created_by: 'author',
+ created_by_account: author,
+ created_at: 2,
+ },
+ ],
+}
+
+function Fixture({
+ onDelete,
+ detail = comment,
+ loading = false,
+}: {
+ onDelete: (id: string) => Promise | void
+ detail?: WorkflowCommentDetail
+ loading?: boolean
+}) {
+ const [queryClient] = useState(
+ () => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
+ )
+ return (
+
+ Loading profile}>
+ Outside thread
+
+ {}}
+ onReply={() => {}}
+ onReplyEdit={() => {}}
+ onReplyDeleteDirect={onDelete}
+ />
+
+
+
+ )
+}
+
+it('lets keyboard users request reply deletion and returns focus to reply actions when cancelled', async () => {
+ // Native Tab and CSS visibility expose regressions hidden by DOM-only tests.
+ await page.viewport(1200, 800)
+ const onDelete = vi.fn()
+ const screen = await render( )
+ const replyInput = screen.getByRole('textbox')
+ await expect.element(replyInput).toHaveFocus()
+ await userEvent.tab({ shift: true })
+ const trigger = screen.getByRole('button', {
+ name: 'workflowComments.comments.aria.replyActions',
+ })
+ await expect.element(trigger).toHaveFocus()
+ expect(trigger.element().checkVisibility({ checkOpacity: true })).toBe(true)
+ await userEvent.keyboard('{ArrowDown}')
+ const edit = screen.getByRole('menuitem', { name: 'workflowComments.comments.actions.editReply' })
+ await expect.element(edit).toHaveFocus()
+ await userEvent.keyboard('{End}')
+ const deleteItem = screen.getByRole('menuitem', {
+ name: 'workflowComments.comments.actions.deleteReply',
+ })
+ await expect.element(deleteItem).toHaveFocus()
+ await userEvent.keyboard('{Enter}')
+ const confirmation = screen.getByRole('alertdialog', {
+ name: 'workflowComments.comments.actions.deleteReply',
+ })
+ await expect.element(confirmation).toBeVisible()
+ await expect.element(screen.getByRole('menu')).not.toBeInTheDocument()
+ expect(onDelete).not.toHaveBeenCalled()
+ await confirmation.getByRole('button', { name: 'common.operation.cancel' }).click()
+ await expect.element(confirmation).not.toBeInTheDocument()
+ await expect.element(trigger).toHaveFocus()
+ expect(trigger.element().checkVisibility({ checkOpacity: true })).toBe(true)
+})
+
+function createPendingRequest() {
+ let resolve!: () => void
+ const promise = new Promise((complete) => {
+ resolve = complete
+ })
+ return { promise, resolve }
+}
+
+it('keeps reply deletion pending through refetch and restores focus to the input after the reply disappears', async () => {
+ const deletion = createPendingRequest()
+ const refetch = createPendingRequest()
+ const onDelete = vi.fn()
+
+ function DeletionFixture() {
+ const [detail, setDetail] = useState(comment)
+ const [loading, setLoading] = useState(false)
+ return (
+ {
+ setLoading(true)
+ onDelete(id)
+ await deletion.promise
+ await refetch.promise
+ setDetail({ ...comment, replies: [] })
+ setLoading(false)
+ }}
+ />
+ )
+ }
+
+ const screen = await render( )
+ const replyInput = screen.getByRole('textbox')
+ await expect.element(replyInput).toHaveFocus()
+ await userEvent.tab({ shift: true })
+ await userEvent.keyboard('{ArrowDown}{End}{Enter}')
+ const confirmation = screen.getByRole('alertdialog')
+ const confirm = confirmation.getByRole('button', { name: 'common.operation.delete' })
+ const cancel = confirmation.getByRole('button', { name: 'common.operation.cancel' })
+ await confirm.click()
+ await expect.element(confirm).toBeDisabled()
+ await expect.element(cancel).toBeDisabled()
+ await userEvent.keyboard('{Enter}{Escape}')
+ await expect.element(confirmation).toBeVisible()
+ expect(onDelete).toHaveBeenCalledExactlyOnceWith('reply')
+
+ deletion.resolve()
+ await expect.element(confirmation).toBeVisible()
+ await expect.element(confirm).toBeDisabled()
+ refetch.resolve()
+ await expect.element(confirmation).not.toBeInTheDocument()
+ await expect
+ .element(screen.getByRole('button', { name: 'workflowComments.comments.aria.replyActions' }))
+ .not.toBeInTheDocument()
+ await expect.element(replyInput).toHaveFocus()
+})
diff --git a/web/app/components/workflow/comment/thread.spec.tsx b/web/app/components/workflow/comment/thread.spec.tsx
index 478e11e51c9..c955415b3f4 100644
--- a/web/app/components/workflow/comment/thread.spec.tsx
+++ b/web/app/components/workflow/comment/thread.spec.tsx
@@ -57,14 +57,6 @@ vi.mock('@/app/components/workflow/collaboration/utils/user-color', () => ({
getUserColor: () => '#22c55e',
}))
-vi.mock('@/app/components/base/inline-delete-confirm', () => ({
- default: ({ onConfirm }: { onConfirm: () => void }) => (
-
- confirm delete
-
- ),
-}))
-
vi.mock('./mention-input', () => ({
MentionInput: ({
placeholder,
@@ -254,7 +246,7 @@ describe('CommentThread', () => {
})
await user.click(screen.getByLabelText('workflowComments.comments.aria.replyActions'))
await user.click(await screen.findByText('workflowComments.comments.actions.deleteReply'))
- await user.click(screen.getByTestId('confirm-delete-reply'))
+ await user.click(await screen.findByRole('button', { name: 'common.operation.delete' }))
expect(onReplyDeleteDirect).toHaveBeenCalledWith('reply-1')
})
diff --git a/web/app/components/workflow/comment/thread.tsx b/web/app/components/workflow/comment/thread.tsx
index 53ec2235a89..bae41bb218e 100644
--- a/web/app/components/workflow/comment/thread.tsx
+++ b/web/app/components/workflow/comment/thread.tsx
@@ -1,15 +1,25 @@
'use client'
-import type { FC, ReactNode } from 'react'
+import type { ReactNode } from 'react'
import type {
WorkflowCommentDetail,
WorkflowCommentDetailReply,
} from '@/app/components/workflow/comment/types'
+import {
+ AlertDialog,
+ AlertDialogActions,
+ AlertDialogCancelButton,
+ AlertDialogConfirmButton,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogTitle,
+} from '@langgenius/dify-ui/alert-dialog'
import { Avatar, AvatarFallback, AvatarImage, AvatarRoot } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Separator } from '@langgenius/dify-ui/separator'
@@ -27,7 +37,6 @@ import { useSuspenseQuery } from '@tanstack/react-query'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useReactFlow, useViewport } from 'reactflow'
-import InlineDeleteConfirm from '@/app/components/base/inline-delete-confirm'
import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
@@ -58,7 +67,7 @@ type CommentThreadProps = {
onReplyDeleteDirect?: (replyId: string) => Promise | void
}
-const ThreadMessage: FC<{
+type ThreadMessageProps = {
authorId: string
authorName: string
avatarUrl?: string | null
@@ -66,7 +75,17 @@ const ThreadMessage: FC<{
content: string
mentionableNames: string[]
className?: string
-}> = ({ authorId, authorName, avatarUrl, createdAt, content, mentionableNames, className }) => {
+}
+
+function ThreadMessage({
+ authorId,
+ authorName,
+ avatarUrl,
+ createdAt,
+ content,
+ mentionableNames,
+ className,
+}: ThreadMessageProps) {
const { formatTimeFromNow } = useFormatTimeFromNow()
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
@@ -159,664 +178,670 @@ const ThreadMessage: FC<{
)
}
-export const CommentThread: FC = memo(
- ({
- comment,
- loading = false,
- replySubmitting = false,
- replyUpdating = false,
- onClose,
- onDelete,
- onResolve,
- onPrev,
- onNext,
- canGoPrev,
- canGoNext,
- onCommentEdit,
- onReply,
- onReplyEdit,
- onReplyDelete,
- onReplyDeleteDirect,
- }) => {
- const params = useParams()
- const appId = params.appId as string
- const { flowToScreenPosition } = useReactFlow()
- const viewport = useViewport()
- const { data: userProfile } = useSuspenseQuery({
- ...userProfileQueryOptions(),
- select: (data) => data.profile,
- })
- const currentUserId = userProfile.id
- const { t } = useTranslation(['common', 'workflowComments'])
- const [replyContent, setReplyContent] = useState('')
- const [editingCommentContent, setEditingCommentContent] = useState('')
- const [activeReplyMenuId, setActiveReplyMenuId] = useState(null)
- const [editingReply, setEditingReply] = useState<{ id: string; content: string }>({
- id: '',
- content: '',
- })
- const [deletingReplyId, setDeletingReplyId] = useState(null)
- const [isCommentEditing, setIsCommentEditing] = useState(false)
- const [isSubmittingEdit, setIsSubmittingEdit] = useState(false)
+function CommentThreadComponent({
+ comment,
+ loading = false,
+ replySubmitting = false,
+ replyUpdating = false,
+ onClose,
+ onDelete,
+ onResolve,
+ onPrev,
+ onNext,
+ canGoPrev,
+ canGoNext,
+ onCommentEdit,
+ onReply,
+ onReplyEdit,
+ onReplyDelete,
+ onReplyDeleteDirect,
+}: CommentThreadProps) {
+ const params = useParams()
+ const appId = params.appId as string
+ const { flowToScreenPosition } = useReactFlow()
+ const viewport = useViewport()
+ const { data: userProfile } = useSuspenseQuery({
+ ...userProfileQueryOptions(),
+ select: (data) => data.profile,
+ })
+ const currentUserId = userProfile.id
+ const { t } = useTranslation(['common', 'workflowComments'])
+ const [replyContent, setReplyContent] = useState('')
+ const [editingCommentContent, setEditingCommentContent] = useState('')
+ const [activeReplyMenuId, setActiveReplyMenuId] = useState(null)
+ const [editingReply, setEditingReply] = useState<{ id: string; content: string }>({
+ id: '',
+ content: '',
+ })
+ const [deletingReplyId, setDeletingReplyId] = useState(null)
+ const [isCommentEditing, setIsCommentEditing] = useState(false)
+ const [isSubmittingEdit, setIsSubmittingEdit] = useState(false)
- // Focus management refs
- const replyInputRef = useRef(null)
+ // Focus management refs
+ const replyInputRef = useRef(null)
+ const replyMenuTriggersRef = useRef(new Map())
+ const deleteReplyTriggerRef = useRef(null)
- // Get mentionable users from store
- const mentionUsersFromStore = useStore((state) =>
- appId ? state.mentionableUsersCache[appId] : undefined,
- )
- const mentionUsers = mentionUsersFromStore ?? []
- const setCommentPreviewHovering = useStore((state) => state.setCommentPreviewHovering)
+ // Get mentionable users from store
+ const mentionUsersFromStore = useStore((state) =>
+ appId ? state.mentionableUsersCache[appId] : undefined,
+ )
+ const mentionUsers = mentionUsersFromStore ?? []
+ const setCommentPreviewHovering = useStore((state) => state.setCommentPreviewHovering)
- // Extract all mentionable names for highlighting
- const mentionableNames = useMemo(() => {
- const names = mentionUsers
- .map((user) => user.name?.trim())
- .filter((name): name is string => Boolean(name))
- return Array.from(new Set(names))
- }, [mentionUsers])
+ // Extract all mentionable names for highlighting
+ const mentionableNames = useMemo(() => {
+ const names = mentionUsers
+ .map((user) => user.name?.trim())
+ .filter((name): name is string => Boolean(name))
+ return Array.from(new Set(names))
+ }, [mentionUsers])
- useEffect(() => {
- Promise.resolve().then(() => {
- setReplyContent('')
- setEditingCommentContent('')
- setIsCommentEditing(false)
- setEditingReply({ id: '', content: '' })
- setActiveReplyMenuId(null)
- setDeletingReplyId(null)
- })
- }, [comment.id])
-
- useEffect(
- () => () => {
- setCommentPreviewHovering(false)
- },
- [setCommentPreviewHovering],
- )
-
- const canReply = Boolean(onReply)
-
- // Focus on thread transitions, not callback changes after position updates.
- useEffect(() => {
- const timer = setTimeout(() => {
- if (replyInputRef.current && !editingReply.id && !isCommentEditing && canReply)
- replyInputRef.current.focus()
- }, 100)
-
- return () => clearTimeout(timer)
- }, [comment.id, editingReply.id, isCommentEditing, canReply])
-
- const handleReplySubmit = useCallback(
- async (content: string, mentionedUserIds: string[]) => {
- if (!onReply || replySubmitting) return
-
- setReplyContent('')
-
- try {
- await onReply(content, mentionedUserIds)
-
- // P0: Restore focus to reply input after successful submission
- setTimeout(() => {
- replyInputRef.current?.focus()
- }, 0)
- } catch (error) {
- console.error('Failed to send reply', error)
- setReplyContent(content)
- }
- },
- [onReply, replySubmitting],
- )
-
- const screenPosition = useMemo(() => {
- return flowToScreenPosition({
- x: comment.position_x,
- y: comment.position_y,
- })
- }, [
- comment.position_x,
- comment.position_y,
- viewport.x,
- viewport.y,
- viewport.zoom,
- flowToScreenPosition,
- ])
- const workflowContainerRect =
- typeof document !== 'undefined'
- ? document.getElementById('workflow-container')?.getBoundingClientRect()
- : null
- const containerLeft = workflowContainerRect?.left ?? 0
- const containerTop = workflowContainerRect?.top ?? 0
- const canvasPosition = useMemo(
- () => ({
- x: screenPosition.x - containerLeft,
- y: screenPosition.y - containerTop,
- }),
- [screenPosition.x, screenPosition.y, containerLeft, containerTop],
- )
-
- const handleStartEdit = useCallback((reply: WorkflowCommentDetailReply) => {
- setEditingReply({ id: reply.id, content: reply.content })
- setIsCommentEditing(false)
- setActiveReplyMenuId(null)
- }, [])
-
- const handleStartCommentEdit = useCallback(() => {
- setEditingCommentContent(comment.content)
- setEditingReply({ id: '', content: '' })
- setIsCommentEditing(true)
- setActiveReplyMenuId(null)
- }, [comment.content])
-
- const handleCancelEdit = useCallback(() => {
- setEditingReply({ id: '', content: '' })
-
- // P1: Restore focus to reply input after canceling edit
- setTimeout(() => {
- replyInputRef.current?.focus()
- }, 0)
- }, [])
-
- const handleCancelCommentEdit = useCallback(() => {
+ useEffect(() => {
+ Promise.resolve().then(() => {
+ setReplyContent('')
setEditingCommentContent('')
setIsCommentEditing(false)
+ setEditingReply({ id: '', content: '' })
+ setActiveReplyMenuId(null)
+ setDeletingReplyId(null)
+ })
+ }, [comment.id])
- setTimeout(() => {
- replyInputRef.current?.focus()
- }, 0)
- }, [])
+ useEffect(
+ () => () => {
+ setCommentPreviewHovering(false)
+ },
+ [setCommentPreviewHovering],
+ )
- const handleCommentEditSubmit = useCallback(
- async (content: string, mentionedUserIds: string[]) => {
- if (!onCommentEdit) return
- const trimmed = content.trim()
- if (!trimmed) return
+ const canReply = Boolean(onReply)
- setIsSubmittingEdit(true)
- try {
- await onCommentEdit(trimmed, mentionedUserIds)
- setEditingCommentContent('')
- setIsCommentEditing(false)
+ // Focus on thread transitions, not callback changes after position updates.
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ if (replyInputRef.current && !editingReply.id && !isCommentEditing && canReply)
+ replyInputRef.current.focus()
+ }, 100)
- setTimeout(() => {
- replyInputRef.current?.focus()
- }, 0)
- } catch (error) {
- console.error('Failed to edit comment', error)
- } finally {
- setIsSubmittingEdit(false)
- }
- },
- [onCommentEdit],
- )
+ return () => clearTimeout(timer)
+ }, [comment.id, editingReply.id, isCommentEditing, canReply])
- const handleEditSubmit = useCallback(
- async (content: string, mentionedUserIds: string[]) => {
- if (!onReplyEdit || !editingReply) return
- const trimmed = content.trim()
- if (!trimmed) return
+ const handleReplySubmit = useCallback(
+ async (content: string, mentionedUserIds: string[]) => {
+ if (!onReply || replySubmitting) return
- setIsSubmittingEdit(true)
- try {
- await onReplyEdit(editingReply.id, trimmed, mentionedUserIds)
- setEditingReply({ id: '', content: '' })
+ setReplyContent('')
- // P1: Restore focus to reply input after saving edit
- setTimeout(() => {
- replyInputRef.current?.focus()
- }, 0)
- } catch (error) {
- console.error('Failed to edit reply', error)
- } finally {
- setIsSubmittingEdit(false)
- }
- },
- [editingReply, onReplyEdit],
- )
+ try {
+ await onReply(content, mentionedUserIds)
- const replies = comment.replies || []
- const isOwnComment = comment.created_by_account?.id === currentUserId
- const messageListRef = useRef(null)
- const previousReplyCountRef = useRef(undefined)
- const previousCommentIdRef = useRef(undefined)
-
- // Close dropdown when scrolling
- useEffect(() => {
- const container = messageListRef.current
- if (!container || !activeReplyMenuId) return
-
- const handleScroll = () => {
- setActiveReplyMenuId(null)
+ // P0: Restore focus to reply input after successful submission
+ setTimeout(() => {
+ replyInputRef.current?.focus()
+ }, 0)
+ } catch (error) {
+ console.error('Failed to send reply', error)
+ setReplyContent(content)
}
+ },
+ [onReply, replySubmitting],
+ )
- container.addEventListener('scroll', handleScroll)
- return () => container.removeEventListener('scroll', handleScroll)
- }, [activeReplyMenuId])
+ const screenPosition = useMemo(() => {
+ return flowToScreenPosition({
+ x: comment.position_x,
+ y: comment.position_y,
+ })
+ }, [
+ comment.position_x,
+ comment.position_y,
+ viewport.x,
+ viewport.y,
+ viewport.zoom,
+ flowToScreenPosition,
+ ])
+ const workflowContainerRect =
+ typeof document !== 'undefined'
+ ? document.getElementById('workflow-container')?.getBoundingClientRect()
+ : null
+ const containerLeft = workflowContainerRect?.left ?? 0
+ const containerTop = workflowContainerRect?.top ?? 0
+ const canvasPosition = useMemo(
+ () => ({
+ x: screenPosition.x - containerLeft,
+ y: screenPosition.y - containerTop,
+ }),
+ [screenPosition.x, screenPosition.y, containerLeft, containerTop],
+ )
- // Auto-scroll to bottom on new messages
- useEffect(() => {
- const container = messageListRef.current
- if (!container) return
+ const handleStartEdit = useCallback((reply: WorkflowCommentDetailReply) => {
+ setEditingReply({ id: reply.id, content: reply.content })
+ setIsCommentEditing(false)
+ setActiveReplyMenuId(null)
+ }, [])
- const isFirstRender = previousCommentIdRef.current === undefined
- const isNewComment = comment.id !== previousCommentIdRef.current
- const hasNewReply =
- previousReplyCountRef.current !== undefined &&
- replies.length > previousReplyCountRef.current
+ const handleStartCommentEdit = useCallback(() => {
+ setEditingCommentContent(comment.content)
+ setEditingReply({ id: '', content: '' })
+ setIsCommentEditing(true)
+ setActiveReplyMenuId(null)
+ }, [comment.content])
- // Scroll on first render, new comment, or new reply
- if (isFirstRender || isNewComment || hasNewReply) {
- container.scrollTo({
- top: container.scrollHeight,
- behavior: 'smooth',
- })
+ const handleCancelEdit = useCallback(() => {
+ setEditingReply({ id: '', content: '' })
+
+ // P1: Restore focus to reply input after canceling edit
+ setTimeout(() => {
+ replyInputRef.current?.focus()
+ }, 0)
+ }, [])
+
+ const handleCancelCommentEdit = useCallback(() => {
+ setEditingCommentContent('')
+ setIsCommentEditing(false)
+
+ setTimeout(() => {
+ replyInputRef.current?.focus()
+ }, 0)
+ }, [])
+
+ const handleCommentEditSubmit = useCallback(
+ async (content: string, mentionedUserIds: string[]) => {
+ if (!onCommentEdit) return
+ const trimmed = content.trim()
+ if (!trimmed) return
+
+ setIsSubmittingEdit(true)
+ try {
+ await onCommentEdit(trimmed, mentionedUserIds)
+ setEditingCommentContent('')
+ setIsCommentEditing(false)
+
+ setTimeout(() => {
+ replyInputRef.current?.focus()
+ }, 0)
+ } catch (error) {
+ console.error('Failed to edit comment', error)
+ } finally {
+ setIsSubmittingEdit(false)
}
+ },
+ [onCommentEdit],
+ )
- previousCommentIdRef.current = comment.id
- previousReplyCountRef.current = replies.length
- }, [comment.id, replies.length])
+ const handleEditSubmit = useCallback(
+ async (content: string, mentionedUserIds: string[]) => {
+ if (!onReplyEdit || !editingReply) return
+ const trimmed = content.trim()
+ if (!trimmed) return
- return (
+ setIsSubmittingEdit(true)
+ try {
+ await onReplyEdit(editingReply.id, trimmed, mentionedUserIds)
+ setEditingReply({ id: '', content: '' })
+
+ // P1: Restore focus to reply input after saving edit
+ setTimeout(() => {
+ replyInputRef.current?.focus()
+ }, 0)
+ } catch (error) {
+ console.error('Failed to edit reply', error)
+ } finally {
+ setIsSubmittingEdit(false)
+ }
+ },
+ [editingReply, onReplyEdit],
+ )
+
+ const replies = comment.replies || []
+ const isOwnComment = comment.created_by_account?.id === currentUserId
+ const messageListRef = useRef(null)
+ const previousReplyCountRef = useRef(undefined)
+ const previousCommentIdRef = useRef(undefined)
+
+ // Close dropdown when scrolling
+ useEffect(() => {
+ const container = messageListRef.current
+ if (!container || !activeReplyMenuId) return
+
+ const handleScroll = () => {
+ setActiveReplyMenuId(null)
+ }
+
+ container.addEventListener('scroll', handleScroll)
+ return () => container.removeEventListener('scroll', handleScroll)
+ }, [activeReplyMenuId])
+
+ // Auto-scroll to bottom on new messages
+ useEffect(() => {
+ const container = messageListRef.current
+ if (!container) return
+
+ const isFirstRender = previousCommentIdRef.current === undefined
+ const isNewComment = comment.id !== previousCommentIdRef.current
+ const hasNewReply =
+ previousReplyCountRef.current !== undefined && replies.length > previousReplyCountRef.current
+
+ // Scroll on first render, new comment, or new reply
+ if (isFirstRender || isNewComment || hasNewReply) {
+ container.scrollTo({
+ top: container.scrollHeight,
+ behavior: 'smooth',
+ })
+ }
+
+ previousCommentIdRef.current = comment.id
+ previousReplyCountRef.current = replies.length
+ }, [comment.id, replies.length])
+
+ return (
+ setCommentPreviewHovering(true)}
+ onMouseLeave={() => setCommentPreviewHovering(false)}
+ >
+ {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- The thread handles bubbling Escape after its child editor and menus have handled it. */}
{
+ if (event.defaultPrevented || event.nativeEvent.isComposing || event.key !== 'Escape')
+ return
+ if (editingReply.id || isCommentEditing) return
+ event.preventDefault()
+ event.stopPropagation()
+ onClose()
}}
- onMouseEnter={() => setCommentPreviewHovering(true)}
- onMouseLeave={() => setCommentPreviewHovering(false)}
+ className="relative flex h-90 flex-col overflow-hidden rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl"
+ role="dialog"
+ aria-labelledby="comment-thread-title"
>
- {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- The thread handles bubbling Escape after its child editor and menus have handled it. */}
-
{
- if (event.defaultPrevented || event.nativeEvent.isComposing || event.key !== 'Escape')
- return
- if (editingReply.id || isCommentEditing) return
- event.preventDefault()
- event.stopPropagation()
- onClose()
- }}
- className="relative flex h-90 flex-col overflow-hidden rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl"
- role="dialog"
- aria-labelledby="comment-thread-title"
- >
-
-
-
-
- $['comments.aria.deleteComment'], {
- ns: 'workflowComments',
- })}
- >
-
-
- }
- />
-
- {t(($) => $['comments.aria.deleteComment'], { ns: 'workflowComments' })}
-
-
-
- $['comments.aria.resolveComment'], {
- ns: 'workflowComments',
- })}
- >
- {comment.resolved ? (
-
- ) : (
-
- )}
-
- }
- />
-
- {t(($) => $['comments.aria.resolveComment'], { ns: 'workflowComments' })}
-
-
-
-
- $['comments.aria.previousComment'], {
- ns: 'workflowComments',
- })}
- >
-
-
- }
- />
-
- {t(($) => $['comments.aria.previousComment'], { ns: 'workflowComments' })}
-
-
-
- $['comments.aria.nextComment'], {
- ns: 'workflowComments',
- })}
- >
-
-
- }
- />
-
- {t(($) => $['comments.aria.nextComment'], { ns: 'workflowComments' })}
-
-
- $['comments.aria.closeComment'], { ns: 'workflowComments' })}
- >
-
-
-
+
+
-
-
- {isOwnComment && !isCommentEditing && (
-
- setActiveReplyMenuId(open ? comment.id : null)}
- >
- $['comments.aria.commentActions'], {
- ns: 'workflowComments',
- })}
- >
-
-
-
- {
- e.stopPropagation()
- handleStartCommentEdit()
- }}
- >
- {t(($) => $['comments.actions.editComment'], { ns: 'workflowComments' })}
-
-
-
-
- )}
- {isCommentEditing ? (
-
-
-
$['comments.fallback.user'], { ns: 'workflowComments' })
- }
- avatar={comment.created_by_account?.avatar_url || null}
- size="sm"
- className="size-8 rounded-full"
- />
-
-
-
- $['comments.placeholder.editComment'], {
- ns: 'workflowComments',
- })}
- disabled={loading}
- loading={isSubmittingEdit}
- isEditing={true}
- className="system-sm-regular"
- autoFocus
- />
-
-
-
- ) : (
-
$['comments.fallback.user'], { ns: 'workflowComments' })
- }
- avatarUrl={comment.created_by_account?.avatar_url || null}
- createdAt={comment.created_at ?? comment.updated_at ?? 0}
- content={comment.content}
- mentionableNames={mentionableNames}
- />
- )}
-
- {replies.length > 0 && (
-
- {replies.map((reply) => {
- const isReplyEditing = editingReply?.id === reply.id
- const isOwnReply = reply.created_by_account?.id === currentUserId
- return (
-
- {isOwnReply && !isReplyEditing && (
-
-
{
- if (!open) setDeletingReplyId(null)
- setActiveReplyMenuId(open ? reply.id : null)
- }}
- >
- $['comments.aria.replyActions'], {
- ns: 'workflowComments',
- })}
- >
-
-
-
-
- {
- e.stopPropagation()
- handleStartEdit(reply)
- }}
- >
- {t(($) => $['comments.actions.editReply'], {
- ns: 'workflowComments',
- })}
-
- {
- e.stopPropagation()
- e.preventDefault()
- if (onReplyDeleteDirect) {
- setDeletingReplyId(reply.id)
- } else {
- setActiveReplyMenuId(null)
- onReplyDelete?.(reply.id)
- }
- }}
- >
- {t(($) => $['comments.actions.deleteReply'], {
- ns: 'workflowComments',
- })}
-
-
-
-
- $['comments.actions.deleteReply'], {
- ns: 'workflowComments',
- })}
- onConfirm={() => {
- setDeletingReplyId(null)
- setActiveReplyMenuId(null)
- onReplyDeleteDirect?.(reply.id)
- }}
- onCancel={() => {
- setDeletingReplyId(null)
- }}
- className="m-0 w-full border-0 shadow-none"
- />
-
-
-
-
- )}
- {isReplyEditing ? (
-
-
-
$['comments.fallback.user'], { ns: 'workflowComments' })
- }
- avatar={reply.created_by_account?.avatar_url || null}
- size="sm"
- className="size-8 rounded-full"
- />
-
-
-
-
- setEditingReply((prev) =>
- prev ? { ...prev, content: newContent } : prev,
- )
- }
- onSubmit={handleEditSubmit}
- onCancel={handleCancelEdit}
- placeholder={t(($) => $['comments.placeholder.editReply'], {
- ns: 'workflowComments',
- })}
- disabled={loading}
- loading={replyUpdating || isSubmittingEdit}
- isEditing={true}
- className="system-sm-regular"
- autoFocus
- />
-
-
-
- ) : (
-
$['comments.fallback.user'], { ns: 'workflowComments' })
- }
- avatarUrl={reply.created_by_account?.avatar_url || null}
- createdAt={reply.created_at ?? 0}
- content={reply.content}
- mentionableNames={mentionableNames}
- />
- )}
-
- )
- })}
-
- )}
-
- {loading && (
-
- {t(($) => $['comments.loading'], { ns: 'workflowComments' })}
-
- )}
- {onReply && (
-
-
-
$.you, { ns: 'common' })}
- size="sm"
- className="size-8"
- />
-
-
$['comments.placeholder.reply'], {
+
+
+ $['comments.aria.deleteComment'], {
ns: 'workflowComments',
})}
- disabled={loading}
- loading={replySubmitting}
+ >
+
+
+ }
+ />
+
+ {t(($) => $['comments.aria.deleteComment'], { ns: 'workflowComments' })}
+
+
+
+ $['comments.aria.resolveComment'], {
+ ns: 'workflowComments',
+ })}
+ >
+ {comment.resolved ? (
+
+ ) : (
+
+ )}
+
+ }
+ />
+
+ {t(($) => $['comments.aria.resolveComment'], { ns: 'workflowComments' })}
+
+
+
+
+ $['comments.aria.previousComment'], {
+ ns: 'workflowComments',
+ })}
+ >
+
+
+ }
+ />
+
+ {t(($) => $['comments.aria.previousComment'], { ns: 'workflowComments' })}
+
+
+
+ $['comments.aria.nextComment'], {
+ ns: 'workflowComments',
+ })}
+ >
+
+
+ }
+ />
+
+ {t(($) => $['comments.aria.nextComment'], { ns: 'workflowComments' })}
+
+
+ $['comments.aria.closeComment'], { ns: 'workflowComments' })}
+ >
+
+
+
+
+
+
+ {isOwnComment && !isCommentEditing && (
+
setActiveReplyMenuId(open ? comment.id : null)}
+ >
+ $['comments.aria.commentActions'], {
+ ns: 'workflowComments',
+ })}
+ >
+
+
+
+ {
+ e.stopPropagation()
+ handleStartCommentEdit()
+ }}
+ >
+ {t(($) => $['comments.actions.editComment'], { ns: 'workflowComments' })}
+
+
+
+ )}
+ {isCommentEditing ? (
+
+
+
$['comments.fallback.user'], { ns: 'workflowComments' })
+ }
+ avatar={comment.created_by_account?.avatar_url || null}
+ size="sm"
+ className="size-8 rounded-full"
/>
+
+
+ $['comments.placeholder.editComment'], {
+ ns: 'workflowComments',
+ })}
+ disabled={loading}
+ loading={isSubmittingEdit}
+ isEditing={true}
+ className="system-sm-regular"
+ autoFocus
+ />
+
+
+ ) : (
+
$['comments.fallback.user'], { ns: 'workflowComments' })
+ }
+ avatarUrl={comment.created_by_account?.avatar_url || null}
+ createdAt={comment.created_at ?? comment.updated_at ?? 0}
+ content={comment.content}
+ mentionableNames={mentionableNames}
+ />
+ )}
+
+ {replies.length > 0 && (
+
+ {replies.map((reply) => {
+ const isReplyEditing = editingReply?.id === reply.id
+ const isOwnReply = reply.created_by_account?.id === currentUserId
+ return (
+
+ {isOwnReply && !isReplyEditing && (
+
{
+ setActiveReplyMenuId(open ? reply.id : null)
+ }}
+ >
+ {
+ if (element) replyMenuTriggersRef.current.set(reply.id, element)
+ else replyMenuTriggersRef.current.delete(reply.id)
+ }}
+ className="absolute top-1 right-1 flex size-6 items-center justify-center rounded-md text-text-tertiary opacity-0 outline-hidden group-hover:opacity-100 hover:bg-state-base-hover hover:text-text-secondary focus:opacity-100 focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:opacity-100 [@media(hover:none)]:opacity-100"
+ data-reply-menu
+ aria-label={t(($) => $['comments.aria.replyActions'], {
+ ns: 'workflowComments',
+ })}
+ >
+
+
+
+ {
+ event.stopPropagation()
+ handleStartEdit(reply)
+ }}
+ >
+ {t(($) => $['comments.actions.editReply'], {
+ ns: 'workflowComments',
+ })}
+
+ {
+ event.stopPropagation()
+ setActiveReplyMenuId(null)
+ if (onReplyDeleteDirect) {
+ deleteReplyTriggerRef.current =
+ replyMenuTriggersRef.current.get(reply.id) ?? null
+ queueMicrotask(() => setDeletingReplyId(reply.id))
+ } else onReplyDelete?.(reply.id)
+ }}
+ >
+ {t(($) => $['comments.actions.deleteReply'], {
+ ns: 'workflowComments',
+ })}
+
+
+
+ )}
+ {isReplyEditing ? (
+
+
+
$['comments.fallback.user'], { ns: 'workflowComments' })
+ }
+ avatar={reply.created_by_account?.avatar_url || null}
+ size="sm"
+ className="size-8 rounded-full"
+ />
+
+
+
+
+ setEditingReply((prev) =>
+ prev ? { ...prev, content: newContent } : prev,
+ )
+ }
+ onSubmit={handleEditSubmit}
+ onCancel={handleCancelEdit}
+ placeholder={t(($) => $['comments.placeholder.editReply'], {
+ ns: 'workflowComments',
+ })}
+ disabled={loading}
+ loading={replyUpdating || isSubmittingEdit}
+ isEditing={true}
+ className="system-sm-regular"
+ autoFocus
+ />
+
+
+
+ ) : (
+
$['comments.fallback.user'], { ns: 'workflowComments' })
+ }
+ avatarUrl={reply.created_by_account?.avatar_url || null}
+ createdAt={reply.created_at ?? 0}
+ content={reply.content}
+ mentionableNames={mentionableNames}
+ />
+ )}
+
+ )
+ })}
)}
+ {loading && (
+
+ {t(($) => $['comments.loading'], { ns: 'workflowComments' })}
+
+ )}
+ {onReply && (
+
+
+
$.you, { ns: 'common' })}
+ size="sm"
+ className="size-8"
+ />
+
+ $['comments.placeholder.reply'], {
+ ns: 'workflowComments',
+ })}
+ disabled={loading}
+ loading={replySubmitting}
+ />
+
+
+
+ )}
- )
- },
-)
+
{
+ if (!open && loading) {
+ eventDetails.cancel()
+ return
+ }
+ if (!open) setDeletingReplyId(null)
+ }}
+ >
+
+ deleteReplyTriggerRef.current?.isConnected
+ ? deleteReplyTriggerRef.current
+ : (replyInputRef.current ?? true)
+ }
+ >
+
+
+ {t(($) => $['comments.actions.deleteReply'], { ns: 'workflowComments' })}
+
+
+ {t(($) => $['operation.confirmAction'], { ns: 'common' })}
+
+
+
+
+ {t(($) => $['operation.cancel'], { ns: 'common' })}
+
+ {
+ if (deletingReplyId) await onReplyDeleteDirect?.(deletingReplyId)
+ setDeletingReplyId(null)
+ }}
+ >
+ {t(($) => $['operation.delete'], { ns: 'common' })}
+
+
+
+
+
+ )
+}
+
+export const CommentThread = memo(CommentThreadComponent)
CommentThread.displayName = 'CommentThread'
diff --git a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx
index c5cf8963cc9..adc94c6031e 100644
--- a/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx
+++ b/web/app/components/workflow/header/__tests__/test-run-menu-helpers.spec.tsx
@@ -1,45 +1,15 @@
-import type { TriggerOption } from '../test-run-menu'
-import { DropdownMenu, DropdownMenuContent } from '@langgenius/dify-ui/dropdown-menu'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { TriggerType } from '../test-run-menu'
-import { getNormalizedShortcutKey, OptionRow, SingleOptionTrigger } from '../test-run-menu-helpers'
-
-const createOption = (overrides: Partial
= {}): TriggerOption => ({
- id: 'user-input',
- type: TriggerType.UserInput,
- name: 'User Input',
- icon: icon ,
- enabled: true,
- ...overrides,
-})
+import { getNormalizedShortcutKey, SingleOptionTrigger } from '../test-run-menu-helpers'
describe('test-run-menu helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
- it('should normalize shortcut keys and render option rows with clickable shortcuts', async () => {
- const user = userEvent.setup()
- const onSelect = vi.fn()
- const option = createOption()
-
+ it('should normalize shortcut keys', () => {
expect(getNormalizedShortcutKey(new KeyboardEvent('keydown', { key: '`' }))).toBe('~')
expect(getNormalizedShortcutKey(new KeyboardEvent('keydown', { key: '1' }))).toBe('1')
-
- render(
-
-
-
-
- ,
- )
-
- expect(screen.getByText('1')).toBeInTheDocument()
-
- await user.click(screen.getByText('User Input'))
-
- expect(onSelect).toHaveBeenCalledWith(option)
})
it('should run single options for element and non-element children unless the click is prevented', async () => {
diff --git a/web/app/components/workflow/header/test-run-menu-helpers.tsx b/web/app/components/workflow/header/test-run-menu-helpers.tsx
index 66b691b3c94..99944ff3f56 100644
--- a/web/app/components/workflow/header/test-run-menu-helpers.tsx
+++ b/web/app/components/workflow/header/test-run-menu-helpers.tsx
@@ -1,8 +1,6 @@
import type { KeyboardEvent, MouseEvent, MouseEventHandler, ReactElement } from 'react'
import type { TriggerOption } from './test-run-menu'
-import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu'
import { cloneElement, isValidElement } from 'react'
-import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
export type ShortcutMapping = {
option: TriggerOption
@@ -13,31 +11,6 @@ export const getNormalizedShortcutKey = (event: Pick) => {
return event.key === '`' ? '~' : event.key
}
-export const OptionRow = ({
- option,
- shortcutKey,
- onSelect,
-}: {
- option: TriggerOption
- shortcutKey?: string
- onSelect: (option: TriggerOption) => void
-}) => {
- return (
- onSelect(option)}
- >
-
-
{option.icon}
-
{option.name}
-
- {shortcutKey && (
-
- )}
-
- )
-}
-
export function handleShortcutMenuKeyDown(
event: KeyboardEvent,
shortcutMappings: ShortcutMapping[],
diff --git a/web/app/components/workflow/header/test-run-menu.tsx b/web/app/components/workflow/header/test-run-menu.tsx
index 34c570936c6..ed3c9773c92 100644
--- a/web/app/components/workflow/header/test-run-menu.tsx
+++ b/web/app/components/workflow/header/test-run-menu.tsx
@@ -3,7 +3,8 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
- DropdownMenuLabel,
+ DropdownMenuGroupLabel,
+ DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
@@ -16,7 +17,8 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
-import { handleShortcutMenuKeyDown, OptionRow, SingleOptionTrigger } from './test-run-menu-helpers'
+import { ShortcutKbd } from '../shortcuts/shortcut-kbd'
+import { handleShortcutMenuKeyDown, SingleOptionTrigger } from './test-run-menu-helpers'
export const TriggerType = {
UserInput: 'user_input',
@@ -54,6 +56,29 @@ export type TestRunMenuRef = {
toggle: () => void
}
+const OptionRow = ({
+ option,
+ shortcutKey,
+ onSelect,
+}: {
+ option: TriggerOption
+ shortcutKey?: string
+ onSelect: (option: TriggerOption) => void
+}) => {
+ return (
+ onSelect(option)}
+ >
+
+
{option.icon}
+
{option.name}
+
+ {shortcutKey && }
+
+ )
+}
+
const getEnabledOptions = (options: TestRunOptions) => {
const flattened: TriggerOption[] = []
@@ -169,12 +194,12 @@ const TestRunMenu = forwardRef(
placement="bottom-start"
sideOffset={8}
alignOffset={-4}
- className="w-71 p-1"
+ className="w-71 px-1"
>
-
-
+
+
{t(($) => $['common.chooseStartNodeToRun'], { ns: 'workflow' })}
-
+
{hasUserInput && renderOption(options.userInput!)}
diff --git a/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx b/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx
index 8b6fc711995..62334178808 100644
--- a/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx
+++ b/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx
@@ -1,8 +1,3 @@
-import {
- DropdownMenu,
- DropdownMenuPortal,
- DropdownMenuTrigger,
-} from '@langgenius/dify-ui/dropdown-menu'
import { detectPlatform } from '@tanstack/react-hotkeys'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -17,7 +12,6 @@ import { useAvailableBlocks } from '../../hooks/use-available-blocks'
import { useNodesInteractions } from '../../hooks/use-nodes-interactions'
import { useNodeMetaData } from '../../hooks/use-nodes-meta-data'
import { useIsChatMode, useNodesReadOnly } from '../../hooks/use-workflow'
-import { NodeActionsDropdownContent } from '../dropdown-content'
import { NodeActionsDropdown } from '../index'
vi.mock('../../hooks/use-available-blocks', async (importOriginal) => {
@@ -94,7 +88,7 @@ const mockUseHooksStore = vi.mocked(useHooksStore)
const mockUseNodes = vi.mocked(useNodes)
const mockUseAllWorkflowTools = vi.mocked(useAllWorkflowTools)
-function renderDropdownContent({
+async function renderDropdownContent({
showHelpLink = true,
onClose = vi.fn(),
data = {},
@@ -103,23 +97,20 @@ function renderDropdownContent({
onClose?: () => void
data?: Record
} = {}) {
- return renderWorkflowFlowComponent(
-
- open} />
-
-
-
- ,
+ const result = renderWorkflowFlowComponent(
+ !open && onClose()}
+ showHelpLink={showHelpLink}
+ />,
{
nodes: [],
edges: [{ id: 'edge-1', source: 'node-0', target: 'node-1', sourceHandle: 'branch-a' }],
},
)
+ await userEvent.setup().click(screen.getByRole('button', { name: 'common.operation.more' }))
+ return result
}
describe('node actions menu details', () => {
@@ -238,16 +229,17 @@ describe('node actions menu details', () => {
it('should run, copy, duplicate, delete, and expose the help link', async () => {
const user = userEvent.setup()
- const { store } = renderDropdownContent()
+ const { store } = await renderDropdownContent()
const deleteMenuItem = screen.getByText('common.operation.delete').closest('[role="menuitem"]')
- expect(deleteMenuItem).toHaveAttribute('data-variant', 'default')
- expect(deleteMenuItem).toHaveClass('text-text-secondary')
- expect(deleteMenuItem).toHaveClass('data-highlighted:text-text-destructive')
+ expect(deleteMenuItem).toHaveAttribute('data-variant', 'destructive')
await user.click(screen.getByText('workflow.panel.runThisStep'))
+ await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
await user.click(screen.getByText('workflow.common.copy'))
+ await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
await user.click(screen.getByText('workflow.common.duplicate'))
+ await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
await user.click(screen.getByText('common.operation.delete'))
expect(handleNodeSelect).toHaveBeenCalledWith('node-1')
@@ -256,6 +248,7 @@ describe('node actions menu details', () => {
expect(handleNodesCopy).toHaveBeenCalledWith('node-1')
expect(handleNodesDuplicate).toHaveBeenCalledWith('node-1')
expect(handleNodeDelete).toHaveBeenCalledWith('node-1')
+ await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
expect(screen.getByRole('menuitem', { name: 'workflow.panel.helpLink' })).toHaveAttribute(
'href',
'https://docs.example.com/node',
@@ -264,7 +257,7 @@ describe('node actions menu details', () => {
it('should stop the current single run from the run action when the node is running', async () => {
const user = userEvent.setup()
- const { store } = renderDropdownContent({
+ const { store } = await renderDropdownContent({
data: {
_singleRunningStatus: NodeRunningStatus.Running,
},
@@ -277,7 +270,7 @@ describe('node actions menu details', () => {
expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'node-1', action: 'stop' })
})
- it('should hide change action when node is undeletable', () => {
+ it('should hide change action when node is undeletable', async () => {
mockUseNodeMetaData.mockReturnValueOnce({
isTypeFixed: false,
isSingleton: true,
@@ -286,44 +279,39 @@ describe('node actions menu details', () => {
author: 'Dify',
} as ReturnType)
- renderDropdownContent({ showHelpLink: false })
+ await renderDropdownContent({ showHelpLink: false })
expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument()
expect(screen.queryByText('workflow.panel.change')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
- it('should render workflow-tool and readonly variants', () => {
+ it('should render workflow-tool and readonly variants', async () => {
mockUseAllWorkflowTools.mockReturnValueOnce({
data: [{ id: 'workflow-tool', workflow_app_id: 'app-123' }],
} as any)
const { rerender } = renderWorkflowFlowComponent(
-
- open} />
-
-
-
- ,
+ ,
{
nodes: [],
edges: [],
},
)
+ await userEvent.setup().click(screen.getByRole('button', { name: 'common.operation.more' }))
expect(screen.getByRole('menuitem', { name: 'workflow.panel.openWorkflow' })).toHaveAttribute(
'href',
'/app/app-123/workflow',
@@ -341,17 +329,11 @@ describe('node actions menu details', () => {
} as ReturnType)
rerender(
-
- open} />
-
-
-
- ,
+ ,
)
expect(screen.queryByText('workflow.panel.runThisStep')).not.toBeInTheDocument()
diff --git a/web/app/components/workflow/node-actions-menu/context-menu-content.tsx b/web/app/components/workflow/node-actions-menu/context-menu-content.tsx
index def6bed31ef..7a9e622076e 100644
--- a/web/app/components/workflow/node-actions-menu/context-menu-content.tsx
+++ b/web/app/components/workflow/node-actions-menu/context-menu-content.tsx
@@ -19,7 +19,6 @@ import { useStoreApi } from 'reactflow'
import { handleWorkflowMenuKeyDown } from '../shortcuts/handle-workflow-menu-key-down'
import { ChangeBlockPopup } from './change-block-popup'
import {
- NODE_ACTIONS_MENU_DELETE_ITEM_CLASS_NAME,
NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME,
NODE_ACTIONS_MENU_WIDTH_CLASS_NAME,
NodeActionsMenuAbout,
@@ -116,7 +115,8 @@ export function NodeActionsContextMenuContent(props: NodeActionsMenuProps) {
{hasDeleteGroup && (
diff --git a/web/app/components/workflow/node-actions-menu/dropdown-content.tsx b/web/app/components/workflow/node-actions-menu/dropdown-content.tsx
deleted file mode 100644
index efac120b86b..00000000000
--- a/web/app/components/workflow/node-actions-menu/dropdown-content.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-import type { NodeActionsMenuProps } from './types'
-import {
- DropdownMenuGroup,
- DropdownMenuItem,
- DropdownMenuLinkItem,
- DropdownMenuPopup,
- DropdownMenuPositioner,
- DropdownMenuSeparator,
-} from '@langgenius/dify-ui/dropdown-menu'
-import {
- Popover,
- PopoverBackdrop,
- PopoverPortal,
- PopoverPositioner,
- PopoverTrigger,
-} from '@langgenius/dify-ui/popover'
-import { useRef } from 'react'
-import { useTranslation } from 'react-i18next'
-import { useStoreApi } from 'reactflow'
-import { handleWorkflowMenuKeyDown } from '../shortcuts/handle-workflow-menu-key-down'
-import { ChangeBlockPopup } from './change-block-popup'
-import {
- NODE_ACTIONS_MENU_DELETE_ITEM_CLASS_NAME,
- NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME,
- NODE_ACTIONS_MENU_WIDTH_CLASS_NAME,
- NodeActionsMenuAbout,
- NodeActionsMenuItemContent,
-} from './shared'
-import { useNodeActionsMenuModel } from './use-node-actions-menu-model'
-
-export function NodeActionsDropdownContent(props: NodeActionsMenuProps) {
- const { t } = useTranslation(['common', 'workflow', 'workflowDebug'])
- const model = useNodeActionsMenuModel(props)
- const flowStore = useStoreApi()
- const deletingRef = useRef(false)
- const hasRunGroup = model.canRun || model.canChangeBlock
- const hasEditGroup = !model.nodesReadOnly && !model.isSingleton
- const hasDeleteGroup = !model.nodesReadOnly && !model.isUndeletable
- const singleRunActionLabel = model.isSingleRunning
- ? t(($) => $['debug.variableInspect.trigger.stop'], { ns: 'workflowDebug' })
- : t(($) => $['panel.runThisStep'], { ns: 'workflow' })
-
- function handleDelete() {
- deletingRef.current = true
- model.handleDelete()
- }
-
- return (
-
- (deletingRef.current ? (flowStore.getState().domNode ?? true) : true)}
- className={`${NODE_ACTIONS_MENU_WIDTH_CLASS_NAME} border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 shadow-lg backdrop-blur-[5px]`}
- onKeyDown={(event) =>
- handleWorkflowMenuKeyDown(event, [
- ['workflow.copy', hasEditGroup ? model.handleCopy : undefined],
- ['workflow.duplicate', hasEditGroup ? model.handleDuplicate : undefined],
- ['workflow.delete', hasDeleteGroup ? handleDelete : undefined],
- ])
- }
- >
- {hasRunGroup && (
-
- {model.canRun && (
- {singleRunActionLabel}
- )}
- {model.canChangeBlock && (
-
- } />}
- className="data-popup-open:bg-state-base-hover"
- >
- {t(($) => $['panel.changeBlock'], { ns: 'workflow' })}
-
-
-
-
-
-
-
-
- )}
-
- )}
- {hasRunGroup &&
- (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && (
-
- )}
- {hasEditGroup && (
-
-
-
- {t(($) => $['common.copy'], { ns: 'workflow' })}
-
-
-
-
- {t(($) => $['common.duplicate'], { ns: 'workflow' })}
-
-
-
- )}
- {hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && (
-
- )}
- {hasDeleteGroup && (
-
-
-
- {t(($) => $['operation.delete'], { ns: 'common' })}
-
-
-
- )}
- {hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && (
-
- )}
- {model.workflowAppHref && (
-
-
- {t(($) => $['panel.openWorkflow'], { ns: 'workflow' })}
-
-
- )}
- {model.workflowAppHref && model.helpLinkUri && }
- {model.helpLinkUri && (
-
-
- {t(($) => $['panel.helpLink'], { ns: 'workflow' })}
-
-
- )}
-
- $['panel.about'], { ns: 'workflow' })}
- description={model.about.description}
- author={`${t(($) => $['panel.createdBy'], { ns: 'workflow' })} ${model.about.author}`}
- />
-
-
- )
-}
diff --git a/web/app/components/workflow/node-actions-menu/index.tsx b/web/app/components/workflow/node-actions-menu/index.tsx
index 40d8df801ec..7ab114f8b2e 100644
--- a/web/app/components/workflow/node-actions-menu/index.tsx
+++ b/web/app/components/workflow/node-actions-menu/index.tsx
@@ -1,14 +1,37 @@
+import type { NodeActionsMenuProps } from './types'
import type { Node } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLinkItem,
+ DropdownMenuPopup,
DropdownMenuPortal,
+ DropdownMenuPositioner,
+ DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { IconButton } from '@langgenius/dify-ui/icon-button'
-import { useCallback, useState } from 'react'
+import {
+ Popover,
+ PopoverBackdrop,
+ PopoverPortal,
+ PopoverPositioner,
+ PopoverTrigger,
+} from '@langgenius/dify-ui/popover'
+import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { NodeActionsDropdownContent } from './dropdown-content'
+import { useStoreApi } from 'reactflow'
+import { handleWorkflowMenuKeyDown } from '../shortcuts/handle-workflow-menu-key-down'
+import { ChangeBlockPopup } from './change-block-popup'
+import {
+ NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME,
+ NODE_ACTIONS_MENU_WIDTH_CLASS_NAME,
+ NodeActionsMenuAbout,
+ NodeActionsMenuItemContent,
+} from './shared'
+import { useNodeActionsMenuModel } from './use-node-actions-menu-model'
type NodeActionsDropdownProps = {
id: string
@@ -65,3 +88,139 @@ export function NodeActionsDropdown({
)
}
+
+function NodeActionsDropdownContent(props: NodeActionsMenuProps) {
+ const { t } = useTranslation(['common', 'workflow', 'workflowDebug'])
+ const model = useNodeActionsMenuModel(props)
+ const flowStore = useStoreApi()
+ const deletingRef = useRef(false)
+ const hasRunGroup = model.canRun || model.canChangeBlock
+ const hasEditGroup = !model.nodesReadOnly && !model.isSingleton
+ const hasDeleteGroup = !model.nodesReadOnly && !model.isUndeletable
+ const singleRunActionLabel = model.isSingleRunning
+ ? t(($) => $['debug.variableInspect.trigger.stop'], { ns: 'workflowDebug' })
+ : t(($) => $['panel.runThisStep'], { ns: 'workflow' })
+
+ function handleDelete() {
+ deletingRef.current = true
+ model.handleDelete()
+ }
+
+ return (
+
+ (deletingRef.current ? (flowStore.getState().domNode ?? true) : true)}
+ className={`${NODE_ACTIONS_MENU_WIDTH_CLASS_NAME} border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 shadow-lg backdrop-blur-[5px]`}
+ onKeyDown={(event) =>
+ handleWorkflowMenuKeyDown(event, [
+ ['workflow.copy', hasEditGroup ? model.handleCopy : undefined],
+ ['workflow.duplicate', hasEditGroup ? model.handleDuplicate : undefined],
+ ['workflow.delete', hasDeleteGroup ? handleDelete : undefined],
+ ])
+ }
+ >
+ {hasRunGroup && (
+
+ {model.canRun && (
+ {singleRunActionLabel}
+ )}
+ {model.canChangeBlock && (
+
+ } />}
+ className="data-popup-open:bg-state-base-hover"
+ >
+ {t(($) => $['panel.changeBlock'], { ns: 'workflow' })}
+
+
+
+
+
+
+
+
+ )}
+
+ )}
+ {hasRunGroup &&
+ (hasEditGroup || hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && (
+
+ )}
+ {hasEditGroup && (
+
+
+
+ {t(($) => $['common.copy'], { ns: 'workflow' })}
+
+
+
+
+ {t(($) => $['common.duplicate'], { ns: 'workflow' })}
+
+
+
+ )}
+ {hasEditGroup && (hasDeleteGroup || model.workflowAppHref || model.helpLinkUri) && (
+
+ )}
+ {hasDeleteGroup && (
+
+
+
+ {t(($) => $['operation.delete'], { ns: 'common' })}
+
+
+
+ )}
+ {hasDeleteGroup && (model.workflowAppHref || model.helpLinkUri) && (
+
+ )}
+ {model.workflowAppHref && (
+
+
+ {t(($) => $['panel.openWorkflow'], { ns: 'workflow' })}
+
+
+ )}
+ {model.workflowAppHref && model.helpLinkUri && }
+ {model.helpLinkUri && (
+
+
+ {t(($) => $['panel.helpLink'], { ns: 'workflow' })}
+
+
+ )}
+
+ $['panel.about'], { ns: 'workflow' })}
+ description={model.about.description}
+ author={`${t(($) => $['panel.createdBy'], { ns: 'workflow' })} ${model.about.author}`}
+ />
+
+
+ )
+}
diff --git a/web/app/components/workflow/node-actions-menu/shared.tsx b/web/app/components/workflow/node-actions-menu/shared.tsx
index b057b0abdfb..6951943cafa 100644
--- a/web/app/components/workflow/node-actions-menu/shared.tsx
+++ b/web/app/components/workflow/node-actions-menu/shared.tsx
@@ -4,7 +4,6 @@ import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd'
export const NODE_ACTIONS_MENU_WIDTH_CLASS_NAME = 'w-[240px] rounded-lg'
export const NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME = 'w-auto justify-between gap-4'
-export const NODE_ACTIONS_MENU_DELETE_ITEM_CLASS_NAME = `${NODE_ACTIONS_MENU_ITEM_WITH_SHORTCUT_CLASS_NAME} text-text-secondary data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive`
export function NodeActionsMenuItemContent({
children,
diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx
index 5222a94f2c8..015d5eb8e83 100644
--- a/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx
+++ b/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx
@@ -4,9 +4,10 @@ import {
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
+ DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
-import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
+import { RiArrowDownSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ErrorHandleTypeEnum } from './types'
@@ -58,24 +59,20 @@ const ErrorHandleTypeSelector = ({
{selectedOption?.label}
-
+
{availableOptions.map((option) => (
{
e.stopPropagation()
}}
>
- {value === option.value && }
+
{option.label}
diff --git a/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx b/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx
index d6a4e5a8660..ec0b87be3f1 100644
--- a/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx
+++ b/web/app/components/workflow/nodes/assigner/components/__tests__/operation-selector.spec.tsx
@@ -27,7 +27,14 @@ describe('assigner/operation-selector', () => {
expect(screen.getByText('workflowLogic.nodes.assigner.operations.set')).toBeInTheDocument()
expect(screen.getByText('workflowLogic.nodes.assigner.operations.+=')).toBeInTheDocument()
- await user.click(screen.getAllByText('workflowLogic.nodes.assigner.operations.+=').at(-1)!)
+ expect(
+ screen.getByRole('menuitemradio', {
+ name: 'workflowLogic.nodes.assigner.operations.over-write',
+ }),
+ ).toHaveAttribute('aria-checked', 'true')
+ await user.click(
+ screen.getByRole('menuitemradio', { name: 'workflowLogic.nodes.assigner.operations.+=' }),
+ )
expect(onSelect).toHaveBeenCalledWith({ value: WriteMode.increment, name: WriteMode.increment })
})
diff --git a/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx
index a853755c86f..996542403a6 100644
--- a/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx
+++ b/web/app/components/workflow/nodes/assigner/components/operation-selector.tsx
@@ -1,4 +1,3 @@
-import type { FC } from 'react'
import type { WriteMode } from '../types'
import type { Item } from '../utils'
import type { VarType } from '@/app/components/workflow/types'
@@ -7,8 +6,10 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
- DropdownMenuItem,
- DropdownMenuLabel,
+ DropdownMenuGroupLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuRadioItemIndicator,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
@@ -28,7 +29,7 @@ type OperationSelectorProps = {
writeModeTypesNum?: WriteMode[]
}
-const OperationSelector: FC
= ({
+function OperationSelector({
value,
onSelect,
disabled = false,
@@ -38,7 +39,7 @@ const OperationSelector: FC = ({
writeModeTypes,
writeModeTypesArr,
writeModeTypesNum,
-}) => {
+}: OperationSelectorProps) {
const { t } = useTranslation(['workflowLogic'])
const items = getOperationItems(
@@ -86,31 +87,38 @@ const OperationSelector: FC = ({
className={cn('w-35', popupClassName)}
>
-
+
{t(($) => $['nodes.assigner.operations.title'], { ns: 'workflowLogic' })}
-
- {items.map((item) =>
- !isOperationItem(item) ? (
-
- ) : (
- onSelect(item)}
- >
-
-
- {t(($) => $[`nodes.assigner.operations.${item.name}`], { ns: 'workflowLogic' })}
-
-
- {item.value === value && (
-
-
+
+
{
+ const item = items.find((item) => isOperationItem(item) && item.value === nextValue)
+ if (item && isOperationItem(item)) onSelect(item)
+ }}
+ >
+ {items.map((item) =>
+ !isOperationItem(item) ? (
+
+ ) : (
+
+
+
+ {t(($) => $[`nodes.assigner.operations.${item.name}`], {
+ ns: 'workflowLogic',
+ })}
+
- )}
-
- ),
- )}
+
+
+ ),
+ )}
+
diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx
index c578a931fed..fc517124426 100644
--- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx
+++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx
@@ -4,9 +4,10 @@ import {
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
+ DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
-import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
+import { RiArrowDownSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
@@ -59,21 +60,17 @@ const MetadataFilterSelector = ({
{selectedOption.value}
-
+
{options.map((option) => (
- {option.key === value && }
+
{option.value}
diff --git a/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx b/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx
index 173ab9734e4..7babfd7f96b 100644
--- a/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx
+++ b/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx
@@ -158,15 +158,11 @@ describe('VersionHistoryItem', () => {
expect(screen.getByText('version-1')).toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
- const restoreItem = screen.getByText('workflow.common.restore').closest('.cursor-pointer')
- if (!restoreItem) throw new Error('Expected restore menu item')
-
- fireEvent.click(restoreItem)
+ await user.click(screen.getByRole('menuitem', { name: 'workflow.common.restore' }))
expect(handleClickActionMenuItem).toHaveBeenCalledTimes(1)
expect(handleClickActionMenuItem).toHaveBeenCalledWith(
VersionHistoryContextMenuOptions.restore,
- VersionHistoryContextMenuOptions.restore,
)
})
diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/action-menu-item.spec.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/action-menu-item.spec.tsx
deleted file mode 100644
index 8001f748409..00000000000
--- a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/action-menu-item.spec.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { DropdownMenu, DropdownMenuContent } from '@langgenius/dify-ui/dropdown-menu'
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { VersionHistoryContextMenuOptions } from '../../../../types'
-import ActionMenuItem from '../action-menu-item'
-
-describe('ActionMenuItem', () => {
- it('forwards the selected operation and supports destructive styling', async () => {
- const user = userEvent.setup()
- const onClick = vi.fn()
-
- render(
-
-
-
-
- ,
- )
-
- await user.click(screen.getByText('Delete'))
-
- expect(onClick).toHaveBeenCalledWith(VersionHistoryContextMenuOptions.delete)
- })
-})
diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx
index 5bb89b3efd0..b5b79b87366 100644
--- a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx
+++ b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx
@@ -14,11 +14,14 @@ import ActionMenu from '../index'
let mockPlanType: CloudPlan = 'professional'
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'CLOUD'
-const renderActionMenu = (ui: React.ReactElement) => {
+const renderActionMenu = (ui: React.ReactElement, pipelineId?: string) => {
const queryClient = createConsoleQueryClient()
seedFeatures(queryClient, { billing: { subscription: { plan: mockPlanType } } })
seedSystemFeatures(queryClient, { deployment_edition: deploymentEdition })
- return renderWorkflowComponent(
{ui} , { queryClient })
+ return renderWorkflowComponent(
{ui} , {
+ queryClient,
+ initialStoreState: { pipelineId },
+ })
}
vi.mock('@/config', async (importOriginal) => {
@@ -54,25 +57,47 @@ describe('ActionMenu', () => {
const trigger = screen.getByRole('button', { name: 'common.operation.more' })
expect(trigger).not.toHaveAttribute('role')
+ handleClickActionMenuItem.mockImplementation(() => expect(trigger).toHaveFocus())
await user.click(trigger)
await user.click(screen.getByText('workflow.common.restore'))
await user.click(screen.getByText('common.operation.delete'))
expect(setOpen).toHaveBeenCalled()
- expect(handleClickActionMenuItem).toHaveBeenCalledWith(
- VersionHistoryContextMenuOptions.restore,
- VersionHistoryContextMenuOptions.restore,
- )
- expect(handleClickActionMenuItem).toHaveBeenCalledWith(
- VersionHistoryContextMenuOptions.delete,
- VersionHistoryContextMenuOptions.delete,
- )
+ expect(handleClickActionMenuItem).toHaveBeenCalledWith(VersionHistoryContextMenuOptions.restore)
+ expect(handleClickActionMenuItem).toHaveBeenCalledWith(VersionHistoryContextMenuOptions.delete)
})
- it('shows upgrade buttons beside restore and export for sandbox users', async () => {
+ it.each([
+ { pipelineId: 'pipeline-1', canImportExportDSL: true },
+ { pipelineId: undefined, canImportExportDSL: false },
+ ])('omits export outside permitted app workflows: %j', ({ pipelineId, canImportExportDSL }) => {
+ renderActionMenu(
+
,
+ pipelineId,
+ )
+ expect(screen.queryByRole('menuitem', { name: 'app.exportApp' })).not.toBeInTheDocument()
+ expect(
+ screen.getByRole('menuitem', { name: 'workflowHistory.versionHistory.nameThisVersion' }),
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByRole('menuitem', { name: 'common.operation.delete' }),
+ ).not.toBeInTheDocument()
+ })
+
+ it('keeps upgrade badges within one menu action for sandbox users', async () => {
const user = userEvent.setup()
const handleClickActionMenuItem = vi.fn()
+ const trackUpgrade = vi.fn()
+ vi.stubGlobal('gtag', trackUpgrade)
mockPlanType = 'sandbox'
renderActionMenu(
@@ -87,13 +112,20 @@ describe('ActionMenu', () => {
/>,
)
- const upgradeButtons = screen.getAllByRole('button', {
- name: 'billing.upgradeBtn.encourageShort',
+ expect(
+ screen.queryByRole('button', { name: 'billing.upgradeBtn.encourageShort' }),
+ ).not.toBeInTheDocument()
+ const restore = screen.getByRole('menuitem', { name: /workflow.common.restore/ })
+ expect(restore).toHaveTextContent('billing.upgradeBtn.encourageShort')
+ await user.click(
+ screen.getAllByText('billing.upgradeBtn.encourageShort', { selector: 'span' })[0]!,
+ )
+ expect(handleClickActionMenuItem).toHaveBeenCalledExactlyOnceWith(
+ VersionHistoryContextMenuOptions.restore,
+ )
+ expect(trackUpgrade).toHaveBeenCalledExactlyOnceWith('event', 'click_upgrade_btn', {
+ loc: 'workflow-version-history-menu',
})
- expect(upgradeButtons).toHaveLength(2)
-
- await user.click(upgradeButtons[0]!)
-
- expect(handleClickActionMenuItem).not.toHaveBeenCalled()
+ vi.unstubAllGlobals()
})
})
diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/use-action-menu.spec.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/use-action-menu.spec.tsx
deleted file mode 100644
index b27a953dca5..00000000000
--- a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/use-action-menu.spec.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { renderWorkflowHook } from '../../../../__tests__/workflow-test-env'
-import { VersionHistoryContextMenuOptions } from '../../../../types'
-import useActionMenu from '../use-action-menu'
-
-describe('useActionMenu', () => {
- it('returns restore, edit, export, copy and delete operations for app workflows', () => {
- const { result } = renderWorkflowHook(() =>
- useActionMenu({
- workflowId: 'version-1',
- isNamedVersion: true,
- canImportExportDSL: true,
- isShowDelete: false,
- open: false,
- setOpen: vi.fn(),
- handleClickActionMenuItem: vi.fn(),
- }),
- )
-
- expect(result.current.deleteOperation).toEqual({
- key: VersionHistoryContextMenuOptions.delete,
- name: 'common.operation.delete',
- })
- expect(result.current.options.map((item) => item.key)).toEqual([
- VersionHistoryContextMenuOptions.restore,
- VersionHistoryContextMenuOptions.edit,
- VersionHistoryContextMenuOptions.exportDSL,
- VersionHistoryContextMenuOptions.copyId,
- ])
- })
-
- it('omits export for pipelines and renames the edit action for unnamed versions', () => {
- const { result } = renderWorkflowHook(
- () =>
- useActionMenu({
- workflowId: 'version-1',
- isNamedVersion: false,
- canImportExportDSL: true,
- isShowDelete: true,
- open: false,
- setOpen: vi.fn(),
- handleClickActionMenuItem: vi.fn(),
- }),
- {
- initialStoreState: {
- pipelineId: 'pipeline-1',
- },
- },
- )
-
- expect(result.current.options).toEqual([
- {
- key: VersionHistoryContextMenuOptions.restore,
- name: 'workflow.common.restore',
- disabled: false,
- },
- {
- key: VersionHistoryContextMenuOptions.edit,
- name: 'workflowHistory.versionHistory.nameThisVersion',
- },
- {
- key: VersionHistoryContextMenuOptions.copyId,
- name: 'workflowHistory.versionHistory.copyId',
- description: 'version-1',
- },
- ])
- })
-
- it('omits export when import/export DSL permission is missing', () => {
- const { result } = renderWorkflowHook(() =>
- useActionMenu({
- workflowId: 'version-1',
- isNamedVersion: true,
- canImportExportDSL: false,
- isShowDelete: false,
- open: false,
- setOpen: vi.fn(),
- handleClickActionMenuItem: vi.fn(),
- }),
- )
-
- expect(result.current.options.map((item) => item.key)).toEqual([
- VersionHistoryContextMenuOptions.restore,
- VersionHistoryContextMenuOptions.edit,
- VersionHistoryContextMenuOptions.copyId,
- ])
- })
-})
diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx
deleted file mode 100644
index 5db4aedac44..00000000000
--- a/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import type { FC } from 'react'
-import type { VersionHistoryContextMenuOptions } from '../../../types'
-import { cn } from '@langgenius/dify-ui/cn'
-import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu'
-import * as React from 'react'
-import UpgradeBtn from '@/app/components/billing/upgrade-btn'
-
-type ActionMenuItemProps = {
- item: {
- key: VersionHistoryContextMenuOptions
- name: string
- description?: string
- disabled?: boolean
- showUpgrade?: boolean
- }
- onClick: (operation: VersionHistoryContextMenuOptions) => void
- isDestructive?: boolean
-}
-
-const ActionMenuItem: FC
= ({ item, onClick, isDestructive = false }) => {
- return (
- {
- event.stopPropagation()
- const target = event.target
- if (target instanceof Element && target.closest('[data-upgrade-action]')) return
-
- onClick(item.key)
- }}
- >
-
-
{item.name}
- {item.description && (
-
- {item.description}
-
- )}
-
- {item.showUpgrade && (
-
-
-
- )}
-
- )
-}
-
-export default React.memo(ActionMenuItem)
diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx
index bdce50c0214..299da2b8888 100644
--- a/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx
+++ b/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx
@@ -1,18 +1,24 @@
-import type { FC } from 'react'
+import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { IconButton } from '@langgenius/dify-ui/icon-button'
+import { useQuery } from '@tanstack/react-query'
+import { useAtomValue } from 'jotai'
import * as React from 'react'
+import { useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
+import PremiumBadge from '@/app/components/base/premium-badge'
+import { useStore } from '@/app/components/workflow/store'
+import { deploymentEditionAtom } from '@/features/system-features/state'
+import { consoleQuery } from '@/service/console'
import { VersionHistoryContextMenuOptions } from '../../../types'
-import ActionMenuItem from './action-menu-item'
-import useActionMenu from './use-action-menu'
-export type ActionMenuProps = {
+type ActionMenuProps = {
workflowId: string
isShowDelete: boolean
isNamedVersion: boolean
@@ -22,14 +28,169 @@ export type ActionMenuProps = {
handleClickActionMenuItem: (operation: VersionHistoryContextMenuOptions) => void
}
-const ActionMenu: FC = (props: ActionMenuProps) => {
+const useActionMenu = (props: ActionMenuProps) => {
+ const { workflowId, isNamedVersion, canImportExportDSL } = props
+ const { t } = useTranslation(['app', 'common', 'workflow', 'workflowHistory'])
+ const pipelineId = useStore((s) => s.pipelineId)
+ const deploymentEdition = useAtomValue(deploymentEditionAtom)
+ const { data: plan } = useQuery(
+ consoleQuery.features.get.queryOptions({
+ enabled: deploymentEdition === 'CLOUD',
+ select: (data) => data.billing.subscription.plan,
+ }),
+ )
+ const shouldShowUpgrade = deploymentEdition === 'CLOUD' && plan === 'sandbox'
+
+ const deleteOperation = {
+ key: VersionHistoryContextMenuOptions.delete,
+ name: t(($) => $['operation.delete'], { ns: 'common' }),
+ }
+
+ const options = useMemo(() => {
+ return [
+ {
+ key: VersionHistoryContextMenuOptions.restore,
+ name: t(($) => $['common.restore'], { ns: 'workflow' }),
+ disabled: deploymentEdition === 'CLOUD' && plan === undefined,
+ ...(shouldShowUpgrade ? { showUpgrade: true } : {}),
+ },
+ isNamedVersion
+ ? {
+ key: VersionHistoryContextMenuOptions.edit,
+ name: t(($) => $['versionHistory.editVersionInfo'], { ns: 'workflowHistory' }),
+ }
+ : {
+ key: VersionHistoryContextMenuOptions.edit,
+ name: t(($) => $['versionHistory.nameThisVersion'], { ns: 'workflowHistory' }),
+ },
+ // todo: pipeline support export specific version DSL
+ ...(canImportExportDSL && !pipelineId
+ ? [
+ {
+ key: VersionHistoryContextMenuOptions.exportDSL,
+ name: t(($) => $.exportApp, { ns: 'app' }),
+ disabled: deploymentEdition === 'CLOUD' && plan === undefined,
+ ...(shouldShowUpgrade ? { showUpgrade: true } : {}),
+ },
+ ]
+ : []),
+ {
+ key: VersionHistoryContextMenuOptions.copyId,
+ name: t(($) => $['versionHistory.copyId'], { ns: 'workflowHistory' }),
+ description: workflowId,
+ },
+ ]
+ }, [
+ deploymentEdition,
+ plan,
+ canImportExportDSL,
+ isNamedVersion,
+ pipelineId,
+ shouldShowUpgrade,
+ t,
+ workflowId,
+ ])
+
+ return {
+ deleteOperation,
+ options,
+ }
+}
+
+type ActionMenuItemProps = {
+ item: {
+ key: VersionHistoryContextMenuOptions
+ name: string
+ description?: string
+ disabled?: boolean
+ showUpgrade?: boolean
+ }
+ onClick: (operation: VersionHistoryContextMenuOptions) => void
+ isDestructive?: boolean
+}
+
+function ActionMenuItem({ item, onClick, isDestructive = false }: ActionMenuItemProps) {
+ const { t } = useTranslation(['billing'])
+ return (
+ {
+ event.stopPropagation()
+ onClick(item.key)
+ if (item.showUpgrade) {
+ const gtag = (
+ window as Window & {
+ gtag?: (
+ command: 'event',
+ action: 'click_upgrade_btn',
+ payload: { loc: string },
+ ) => void
+ }
+ ).gtag
+ gtag?.('event', 'click_upgrade_btn', { loc: 'workflow-version-history-menu' })
+ }
+ }}
+ >
+
+
{item.name}
+ {item.description && (
+
+ {item.description}
+
+ )}
+
+ {item.showUpgrade && (
+
+
+
+ {t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })}
+
+
+ )}
+
+ )
+}
+
+function ActionMenu(props: ActionMenuProps) {
const { isShowDelete, handleClickActionMenuItem, open, setOpen } = props
const { deleteOperation, options } = useActionMenu(props)
const { t } = useTranslation(['common'])
+ const triggerRef = useRef(null)
+
+ function handleAction(operation: VersionHistoryContextMenuOptions) {
+ queueMicrotask(() => {
+ triggerRef.current?.focus()
+ handleClickActionMenuItem(operation)
+ })
+ }
return (
= (props: ActionMenuProps) => {
className="w-max max-w-[calc(100vw-24px)] min-w-46 shadow-shadow-shadow-5"
>
{options.map((option) => (
-
+
))}
{isShowDelete && (
<>
-
-
+
+
>
)}
diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts b/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts
deleted file mode 100644
index 606053eddcc..00000000000
--- a/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import type { ActionMenuProps } from './index'
-import { useQuery } from '@tanstack/react-query'
-import { useAtomValue } from 'jotai'
-import { useMemo } from 'react'
-import { useTranslation } from 'react-i18next'
-import { useStore } from '@/app/components/workflow/store'
-import { deploymentEditionAtom } from '@/features/system-features/state'
-import { consoleQuery } from '@/service/console'
-import { VersionHistoryContextMenuOptions } from '../../../types'
-
-const useActionMenu = (props: ActionMenuProps) => {
- const { workflowId, isNamedVersion, canImportExportDSL } = props
- const { t } = useTranslation(['app', 'common', 'workflow', 'workflowHistory'])
- const pipelineId = useStore((s) => s.pipelineId)
- const deploymentEdition = useAtomValue(deploymentEditionAtom)
- const { data: plan } = useQuery(
- consoleQuery.features.get.queryOptions({
- enabled: deploymentEdition === 'CLOUD',
- select: (data) => data.billing.subscription.plan,
- }),
- )
- const shouldShowUpgrade = deploymentEdition === 'CLOUD' && plan === 'sandbox'
-
- const deleteOperation = {
- key: VersionHistoryContextMenuOptions.delete,
- name: t(($) => $['operation.delete'], { ns: 'common' }),
- }
-
- const options = useMemo(() => {
- return [
- {
- key: VersionHistoryContextMenuOptions.restore,
- name: t(($) => $['common.restore'], { ns: 'workflow' }),
- disabled: deploymentEdition === 'CLOUD' && plan === undefined,
- ...(shouldShowUpgrade ? { showUpgrade: true } : {}),
- },
- isNamedVersion
- ? {
- key: VersionHistoryContextMenuOptions.edit,
- name: t(($) => $['versionHistory.editVersionInfo'], { ns: 'workflowHistory' }),
- }
- : {
- key: VersionHistoryContextMenuOptions.edit,
- name: t(($) => $['versionHistory.nameThisVersion'], { ns: 'workflowHistory' }),
- },
- // todo: pipeline support export specific version DSL
- ...(canImportExportDSL && !pipelineId
- ? [
- {
- key: VersionHistoryContextMenuOptions.exportDSL,
- name: t(($) => $.exportApp, { ns: 'app' }),
- disabled: deploymentEdition === 'CLOUD' && plan === undefined,
- ...(shouldShowUpgrade ? { showUpgrade: true } : {}),
- },
- ]
- : []),
- {
- key: VersionHistoryContextMenuOptions.copyId,
- name: t(($) => $['versionHistory.copyId'], { ns: 'workflowHistory' }),
- description: workflowId,
- },
- ]
- }, [
- deploymentEdition,
- plan,
- canImportExportDSL,
- isNamedVersion,
- pipelineId,
- shouldShowUpgrade,
- t,
- workflowId,
- ])
-
- return {
- deleteOperation,
- options,
- }
-}
-
-export default useActionMenu
diff --git a/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx b/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx
index 1ec4aeb8ab3..265336aac8d 100644
--- a/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx
+++ b/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx
@@ -36,7 +36,7 @@ const formatVersion = (versionHistory: VersionHistory, latestVersionId: string):
}
}
-const VersionHistoryItem: React.FC = ({
+function VersionHistoryItem({
item,
currentVersion,
latestVersionId,
@@ -45,7 +45,7 @@ const VersionHistoryItem: React.FC = ({
canImportExportDSL,
isLast,
hideActionMenu,
-}) => {
+}: VersionHistoryItemProps) {
const { t } = useTranslation(['workflowHistory'])
const [open, setOpen] = useState(false)
@@ -76,7 +76,6 @@ const VersionHistoryItem: React.FC = ({
'group relative flex gap-x-1 rounded-lg p-2',
isSelected ? 'bg-state-accent-active' : 'hover:bg-state-base-hover',
)}
- onMouseLeave={() => setOpen(false)}
onContextMenu={(e) => {
if (hideActionMenu) return
@@ -157,22 +156,15 @@ const VersionHistoryItem: React.FC = ({
{/* Action Menu */}
{!hideActionMenu && !isDraft && (
-
+
)}
)
diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx
index 9f05067ddef..806c9703b10 100644
--- a/web/app/components/workflow/selection-contextmenu.tsx
+++ b/web/app/components/workflow/selection-contextmenu.tsx
@@ -3,8 +3,8 @@ import type { I18nKeysWithPrefix } from '@/types/i18n'
import {
ContextMenuContent,
ContextMenuGroup,
+ ContextMenuGroupLabel,
ContextMenuItem,
- ContextMenuLabel,
ContextMenuSeparator,
} from '@langgenius/dify-ui/context-menu'
import { produce } from 'immer'
@@ -463,7 +463,8 @@ export function SelectionContextmenu({ onClose }: { onClose: () => void }) {
@@ -476,9 +477,9 @@ export function SelectionContextmenu({ onClose }: { onClose: () => void }) {
{menuSections.map((section, sectionIndex) => (
{sectionIndex > 0 && }
-
+
{t(($) => $[section.titleKey], { defaultValue: section.titleKey, ns: 'workflow' })}
-
+
{section.items.map((item) => {
return (
{
name: 'agentV2.agentDetail.configure.skills.moreActions:{"name":"Tender Analyzer"}',
}),
)
- expect(embeddedBadge).toHaveClass('opacity-0')
const deleteAction = screen.getByText('common.operation.delete')
fireEvent.mouseEnter(deleteAction.closest('[data-agent-skill-remove-button]')!)
@@ -1307,7 +1306,6 @@ describe('AgentSkills', () => {
name: 'agentV2.agentDetail.configure.skills.moreActions:{"name":"Refund approval"}',
}),
)
- expect(screen.getByText('refund-approval')).toHaveClass('opacity-0')
const removeAction = await screen.findByText(
'agentV2.agentDetail.configure.skills.removeAction',
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx
index 8ebe57f6129..617201e3320 100644
--- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx
@@ -356,7 +356,6 @@ function WorkspaceAgentSkillItem({
}) {
const { t } = useTranslation(['agentV2'])
const readOnly = useAgentOrchestrateReadOnly()
- const [isActionsOpen, setIsActionsOpen] = useState(false)
const [isRemoveHighlighted, setIsRemoveHighlighted] = useState(false)
const displayName = skill.display_name || skill.name
const handleOpenInLibrary = useCallback(() => {
@@ -395,7 +394,7 @@ function WorkspaceAgentSkillItem({
className={cn(
'shrink-0 system-xs-regular text-text-tertiary',
!readOnly && 'group-focus-within:opacity-0 group-hover:opacity-0',
- isActionsOpen && 'opacity-0',
+ 'group-has-data-popup-open:opacity-0',
)}
>
{skill.name}
@@ -404,7 +403,6 @@ function WorkspaceAgentSkillItem({
{
- setIsActionsOpen(open)
if (!open) setIsRemoveHighlighted(false)
}}
>
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/item.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/item.tsx
index f07baf4883f..35b2ea3985a 100644
--- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/item.tsx
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/item.tsx
@@ -38,7 +38,6 @@ export function AgentSkillItem({
const { t: tCommon } = useTranslation(['common'])
const queryClient = useQueryClient()
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
- const [isActionsOpen, setIsActionsOpen] = useState(false)
const [isRemoveHighlighted, setIsRemoveHighlighted] = useState(false)
const handleRemove = useCallback(() => {
onRemove(skill.id)
@@ -147,7 +146,7 @@ export function AgentSkillItem({
className={cn(
'shrink-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary',
hasActions && 'group-focus-within:opacity-0 group-hover:opacity-0',
- isActionsOpen && 'opacity-0',
+ 'group-has-data-popup-open:opacity-0',
)}
>
{t(($) => $['agentDetail.configure.skills.addMenu.upload.badge'])}
@@ -164,7 +163,6 @@ export function AgentSkillItem({
{
- setIsActionsOpen(open)
if (!open) setIsRemoveHighlighted(false)
}}
>
diff --git a/web/features/agent-v2/roster/components/agent-workflow-references-dropdown.tsx b/web/features/agent-v2/roster/components/agent-workflow-references-dropdown.tsx
index bf9ef727abe..40d6e7bbbed 100644
--- a/web/features/agent-v2/roster/components/agent-workflow-references-dropdown.tsx
+++ b/web/features/agent-v2/roster/components/agent-workflow-references-dropdown.tsx
@@ -6,7 +6,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
- DropdownMenuLabel,
+ DropdownMenuGroupLabel,
DropdownMenuLinkItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
@@ -52,9 +52,9 @@ export function AgentWorkflowReferencesDropdown({
-
+
{t(($) => $['roster.references.label'], { ns: 'agentRoster', name: agentName })}
-
+
{publishedReferences.map((reference) => {
const { iconType, imageUrl } = getWorkflowReferenceIcon(reference)
@@ -68,7 +68,7 @@ export function AgentWorkflowReferencesDropdown({
rel="noopener noreferrer"
/>
}
- className="group mx-0 h-8 gap-2 px-2 py-1 pr-2.5 system-md-regular text-text-secondary"
+ className="group mx-0 gap-2 py-1 pr-2.5 system-md-regular text-text-secondary"
>