fix: align main nav gating and account popup behavior

This commit is contained in:
Jingyi-Dify 2026-05-08 22:56:56 -07:00
parent baaae00fa8
commit 37ac79cb87
9 changed files with 418 additions and 86 deletions

View File

@ -0,0 +1,138 @@
# Main Nav Gating Follow-ups
Context: the desktop MainNav rewrite moved several workspace, account, tools, and marketplace entry points out of the old header/account-setting layout. These notes track product-contract questions that should be resolved before treating the rewrite as behavior-complete.
## 1. Account-setting modal naming and moved destinations
Current branch behavior:
- `setShowAccountSettingModal(PROVIDER)` routes to `/tools?section=provider`.
- `setShowAccountSettingModal(DATA_SOURCE)` routes to `/tools?section=data-source`.
- `setShowAccountSettingModal(API_BASED_EXTENSION)` routes to `/tools?section=api-based-extension`.
Old behavior: these calls opened the account-setting modal and switched to the matching tab.
Question:
- Since Provider, Data Source, and API-based Extension are no longer inside Account Settings in the new design, should this API still be named `setShowAccountSettingModal` for those destinations?
Follow-up decision needed:
- Either keep the compatibility shim but document that these payloads are now route destinations, or introduce a clearer navigation API for integration destinations and update call sites intentionally.
- Re-check call sites launched from workflows, datasets, and app configuration. Some contexts may expect an in-place modal instead of leaving the current page.
## 2. Integrations sidebar disabled entries
Current branch behavior:
- Integrations includes disabled entries for Trigger, Agent Strategy, and Extension.
- These are visible but not actionable.
Status:
- Currently being handled separately.
Follow-up decision needed:
- Decide whether disabled future entries should remain visible, be hidden until supported, or be gated by feature flags/edition/role.
- If they stay visible, define the tooltip or disabled-state copy so users understand why the option is unavailable.
## 3. Plugin and marketplace status parity
Old header behavior:
- `PluginsNav` showed plugin install progress and error state through the installing icon and red indicator.
- Installing tasks showed the downloading icon.
- Failed or erroring install tasks showed the red status indicator.
Current MainNav behavior:
- MainNav has a Marketplace link, but it does not surface plugin installing/error state.
Follow-up decision needed:
- Decide whether MainNav Marketplace should preserve the old plugin task status indicator.
- If yes, reuse the existing `usePluginTaskStatus` behavior instead of creating a parallel status source.
## 4. Mobile/default account language entry
Current branch behavior:
- Desktop MainNav account menu includes Language and Timezone submenus.
- Mobile still uses the default header/account dropdown path.
- The default account dropdown does not expose the Language settings entry.
Decision:
- Preserve the old language-access contract across breakpoints.
- The desktop MainNav path is acceptable; the missing case is mainly mobile/default account dropdown, including dataset-operator users on mobile.
Follow-up decision needed:
- Add an equivalent language entry to the default/mobile account path, or otherwise ensure mobile users can still reach language settings.
- Keep this as gate-contract parity, not a visual requirement to recreate the old Account Settings sidebar.
## 5. Apps and Datasets quick-switch/create parity
Old header behavior:
- `AppNav` could show the current app, list more apps, load more results, and launch create-app flows from the header nav.
- `DatasetNav` could show the current dataset, list more datasets, load more results, and launch dataset creation from the header nav.
Current MainNav behavior:
- Apps and Datasets are static navigation links.
- App/dataset quick switching and create actions are not present in the desktop MainNav.
Follow-up decision needed:
- Decide whether the new design intentionally removes these quick-switch/create affordances.
- If not, add equivalent behavior in the MainNav flow without copying the old header UI directly.
## 6. Branding-gated Help and Support behavior
Old account dropdown behavior:
- When `systemFeatures.branding.enabled` is `true`, the whole Dify help/community group is hidden.
- That hidden group includes Docs, Support, Compliance, Roadmap, GitHub, and About.
Current MainNav behavior:
- HelpMenu keeps the trigger visible, but its content is gated by `!systemFeatures.branding.enabled`.
- This can produce an empty Help popup when branding is enabled.
Open question:
- The old coarse gate also hides Support, but Support can contain instance-specific channels such as configured Zendesk or support email in addition to Dify forum/Discord links.
- Confirm whether branded deployments should hide Support entirely, or keep configured customer support channels while hiding Dify official/community links.
Follow-up decision needed:
- If strict old parity is required, hide the HelpMenu trigger when branding is enabled.
- If branded deployments should retain support access, split Support into customer-support and Dify-community items with separate gates.
## 7. Paid plan Billing access from workspace plan
Old header behavior:
- The header plan badge was clickable.
- For sandbox/free plans, clicking the badge opened the pricing modal.
- For non-sandbox paid plans, clicking the badge opened Account Settings on the Billing tab.
Current MainNav behavior:
- Sandbox/free plans have an explicit Upgrade action in the WorkspaceCard credit row.
- Non-sandbox paid plans show the workspace plan badge, but the badge is display-only.
- The WorkspaceCard Settings menu item routes to Account Settings on the Billing tab.
- Invite Members remains the Members entry, so Settings and Invite Members do not duplicate the same destination.
Open question:
- Confirm whether product still wants the plan badge itself to be clickable, or whether the Settings-to-Billing menu item is the intended MainNav access path.
- Avoid making the plan badge itself clickable unless the interaction is explicitly approved, because the WorkspaceCard already uses a button to open the workspace menu.
Recommended default:
- Keep sandbox/free behavior as the explicit Upgrade action.
- Keep the plan badge display-only.
- Use the WorkspaceCard Settings item as the Billing entry.

View File

@ -1,6 +1,6 @@
'use client'
import type { ReactElement, ReactNode } from 'react'
import type { ReactElement } from 'react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import {
@ -23,14 +23,12 @@ type AccountDropdownProps = {
isOpen: boolean
ariaLabel: string
}) => ReactElement
mainNavBadge?: ReactNode
variant?: 'default' | 'mainNav'
}
const mainNavMenuPopupClassName = 'w-60 max-w-80 overflow-hidden bg-components-panel-bg-blur! p-0! backdrop-blur-[5px]'
export default function AppSelector({
mainNavBadge,
trigger,
variant = 'default',
}: AccountDropdownProps = {}) {
@ -82,7 +80,7 @@ export default function AppSelector({
popupClassName={variant === 'mainNav' ? mainNavMenuPopupClassName : 'w-60 max-w-80 bg-components-panel-bg-blur! py-0! backdrop-blur-xs'}
>
{variant === 'mainNav'
? <MainNavMenuContent mainNavBadge={mainNavBadge} onLogout={handleLogout} />
? <MainNavMenuContent onLogout={handleLogout} />
: (
<DefaultMenuContent
closeAccountDropdown={() => setIsAccountMenuOpen(false)}

View File

@ -21,8 +21,10 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useTheme } from 'next-themes'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import PremiumBadge from '@/app/components/base/premium-badge'
import { useAppContext } from '@/context/app-context'
import { useLocale } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import { setLocaleOnClient } from '@/i18n-config'
import { languages } from '@/i18n-config/language'
import Link from '@/next/link'
@ -190,29 +192,33 @@ function LanguageSubmenu() {
}
type MainNavMenuContentProps = {
mainNavBadge?: ReactNode
onLogout: () => Promise<void>
}
export function MainNavMenuContent({
mainNavBadge,
onLogout,
}: MainNavMenuContentProps) {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const { isEducationAccount } = useProviderContext()
return (
<>
<DropdownMenuGroup className={mainNavMenuGroupClassName}>
<div className="flex items-center gap-1 rounded-xl bg-gradient-to-b from-background-section-burn to-background-section p-3">
<div className="flex items-center gap-3 rounded-xl bg-gradient-to-b from-background-section-burn to-background-section p-3">
<div className="flex min-w-0 grow flex-col gap-1">
<div className="flex min-w-0 items-center gap-1">
<div className="max-w-[80px] min-w-0 truncate body-md-medium text-text-primary" title={userProfile.name}>{userProfile.name}</div>
{mainNavBadge}
<div className="min-w-0 flex-1 truncate body-md-medium text-text-primary" title={userProfile.name}>{userProfile.name}</div>
{isEducationAccount && (
<PremiumBadge size="s" color="blue" className="shrink-0 px-2!">
<span aria-hidden className="mr-1 i-ri-graduation-cap-fill h-3 w-3" />
<span className="system-2xs-medium">EDU</span>
</PremiumBadge>
)}
</div>
<div className="truncate system-xs-regular text-text-tertiary" title={userProfile.email}>{userProfile.email}</div>
</div>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" />
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="lg" className="shrink-0" />
</div>
</DropdownMenuGroup>
<DropdownMenuGroup className={mainNavMenuGroupClassName}>

View File

@ -71,6 +71,7 @@ vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
})
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
@ -171,6 +172,7 @@ describe('MainNav', () => {
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,
isEducationAccount: false,
isEducationWorkspace: false,
isFetchedPlan: true,
plan: { type: Plan.sandbox },
} as ProviderContextState)
@ -210,6 +212,91 @@ describe('MainNav', () => {
expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/plugins')
})
it('renders the desktop environment tag from the old header contract', () => {
;(useAppContext as Mock).mockReturnValue({
...appContextValue,
langGeniusVersionInfo: {
...appContextValue.langGeniusVersionInfo,
current_env: 'TESTING',
},
})
renderMainNav()
expect(screen.getByText('common.environment.testing')).toBeInTheDocument()
})
it('does not reserve environment tag space when the environment is not shown', () => {
const { container } = renderMainNav()
expect(screen.queryByText('common.environment.testing')).not.toBeInTheDocument()
expect(screen.queryByText('common.environment.development')).not.toBeInTheDocument()
expect(container.querySelector('.relative.z-30')).not.toBeInTheDocument()
})
it('shows the user education badge in the account popup without adding the workspace plan there', async () => {
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,
isEducationAccount: true,
isEducationWorkspace: false,
isFetchedPlan: true,
plan: { type: Plan.sandbox },
} as ProviderContextState)
renderMainNav()
fireEvent.click(screen.getByRole('button', { name: 'common.account.account' }))
expect(await screen.findByText('EDU')).toBeInTheDocument()
expect(screen.getByText('evan@example.com')).toBeInTheDocument()
expect(screen.getAllByText(Plan.team)).toHaveLength(1)
})
it('hides app and tools entries for dataset operators', () => {
;(useAppContext as Mock).mockReturnValue({
...appContextValue,
currentWorkspace: {
...appContextValue.currentWorkspace,
role: 'dataset_operator',
},
isCurrentWorkspaceDatasetOperator: true,
isCurrentWorkspaceEditor: false,
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
})
renderMainNav()
expect(screen.queryByRole('link', { name: /common.mainNav.home/ })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.menus.apps/ })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute('href', '/datasets')
expect(screen.queryByRole('link', { name: /common.mainNav.integrations/ })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/plugins')
expect(screen.queryByRole('button', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument()
})
it('hides datasets for members without editor or dataset-operator access', () => {
;(useAppContext as Mock).mockReturnValue({
...appContextValue,
currentWorkspace: {
...appContextValue.currentWorkspace,
role: 'normal',
},
isCurrentWorkspaceDatasetOperator: false,
isCurrentWorkspaceEditor: false,
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
})
renderMainNav()
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.menus.datasets/ })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toBeInTheDocument()
})
it('marks the matching primary route active', () => {
mockPathname = '/datasets'
@ -272,6 +359,60 @@ describe('MainNav', () => {
})
})
it('hides the upgrade shortcut for paid plans', () => {
;(useProviderContext as Mock).mockReturnValue({
enableBilling: true,
isEducationAccount: false,
isEducationWorkspace: false,
isFetchedPlan: true,
plan: { type: Plan.team },
} as ProviderContextState)
renderMainNav()
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
})
it('limits workspace settings and invite actions by role', async () => {
;(useAppContext as Mock).mockReturnValue({
...appContextValue,
currentWorkspace: {
...appContextValue.currentWorkspace,
role: 'normal',
},
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
})
renderMainNav()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
expect(await screen.findByText('common.mainNav.workspace.settings')).toBeInTheDocument()
expect(screen.queryByText('common.mainNav.workspace.inviteMembers')).not.toBeInTheDocument()
})
it('hides workspace settings actions for dataset operators', () => {
;(useAppContext as Mock).mockReturnValue({
...appContextValue,
currentWorkspace: {
...appContextValue.currentWorkspace,
role: 'dataset_operator',
},
isCurrentWorkspaceDatasetOperator: true,
isCurrentWorkspaceEditor: false,
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
})
renderMainNav()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
expect(screen.queryByText('common.mainNav.workspace.settings')).not.toBeInTheDocument()
expect(screen.queryByText('common.mainNav.workspace.inviteMembers')).not.toBeInTheDocument()
})
it('filters installed web apps and navigates to an installed app', () => {
mockInstalledApps = [
createInstalledApp({ id: 'installed-1', app: { ...createInstalledApp().app, name: 'Alpha App' } }),

View File

@ -8,6 +8,7 @@ import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useWorkspacesContext } from '@/context/workspace-context'
import { LicenseStatus } from '@/types/feature'
import WorkspaceCard from '../workspace-card'
vi.mock('@/config', async (importOriginal) => {
@ -86,6 +87,7 @@ describe('WorkspaceCard', () => {
vi.mocked(useProviderContext).mockReturnValue({
enableBilling: true,
isEducationAccount: false,
isEducationWorkspace: false,
isFetchedPlan: true,
plan: { type: Plan.sandbox },
} as ProviderContextState)
@ -107,4 +109,26 @@ describe('WorkspaceCard', () => {
expect(screen.queryByRole('button', { name: /common\.mainNav\.workspace\.credits/ })).not.toBeInTheDocument()
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
})
it('shows the license status instead of a billing plan when billing is disabled', () => {
vi.mocked(useProviderContext).mockReturnValue({
enableBilling: false,
isEducationAccount: false,
isEducationWorkspace: false,
isFetchedPlan: false,
plan: { type: Plan.sandbox },
} as ProviderContextState)
renderWithSystemFeatures(<WorkspaceCard />, {
systemFeatures: {
license: {
status: LicenseStatus.ACTIVE,
expired_at: null,
},
},
})
expect(screen.getByText('Enterprise')).toBeInTheDocument()
expect(screen.queryByText(Plan.sandbox)).not.toBeInTheDocument()
})
})

View File

@ -1,24 +1,15 @@
'use client'
import type { Plan } from '@/app/components/billing/type'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import AccountDropdown from '@/app/components/header/account-dropdown'
import { useAppContext } from '@/context/app-context'
import WorkspacePlanBadge from './workspace-plan-badge'
type AccountSectionProps = {
workspacePlan: Plan
}
const AccountSection = ({
workspacePlan,
}: AccountSectionProps) => {
const AccountSection = () => {
const { userProfile } = useAppContext()
return (
<AccountDropdown
mainNavBadge={<WorkspacePlanBadge plan={workspacePlan} />}
variant="mainNav"
trigger={({ isOpen, ariaLabel }) => (
<button

View File

@ -7,6 +7,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import LicenseNav from '@/app/components/header/license-env'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
@ -51,7 +52,7 @@ const WorkspaceMenuItemContent = ({
const WorkspaceCard = () => {
const { t } = useTranslation()
const { currentWorkspace } = useAppContext()
const { currentWorkspace, isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceManager } = useAppContext()
const { workspaces } = useWorkspacesContext()
const { enableBilling, plan } = useProviderContext()
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
@ -60,6 +61,11 @@ const WorkspaceCard = () => {
const formattedCredits = formatCredits(credits)
const workspacePlan = (workspaces.find(workspace => workspace.current)?.plan || currentWorkspace.plan || plan.type) as Plan
const isFreePlan = plan.type === Plan.sandbox
const showCloudBilling = IS_CLOUD_EDITION && enableBilling
const showUpgradeAction = showCloudBilling && isFreePlan
const showWorkspaceSettings = !isCurrentWorkspaceDatasetOperator
const showInviteMembers = showWorkspaceSettings && isCurrentWorkspaceManager
const renderWorkspaceStatus = () => enableBilling ? <WorkspacePlanBadge plan={workspacePlan} /> : <LicenseNav />
const handlePlanClick = () => {
if (isFreePlan)
@ -109,12 +115,12 @@ const WorkspaceCard = () => {
<div className="min-w-0 grow">
<div className="flex min-w-0 items-center gap-1.5">
<span className="max-w-[120px] truncate system-sm-medium text-text-primary" title={currentWorkspace.name}>{currentWorkspace.name}</span>
<WorkspacePlanBadge plan={workspacePlan} />
{renderWorkspaceStatus()}
</div>
</div>
<span aria-hidden className="i-ri-expand-up-down-line h-4 w-4 shrink-0 text-text-tertiary" />
</button>
{IS_CLOUD_EDITION && (
{showCloudBilling && (
<div className="flex items-center justify-center gap-1.5 border-t border-divider-subtle py-2 pr-2.5 pl-2">
<button
type="button"
@ -129,7 +135,7 @@ const WorkspaceCard = () => {
<span className="truncate system-xs-medium" title={formattedCredits}>{formattedCredits}</span>
<span className="shrink-0 system-xs-regular">{t('mainNav.workspace.creditsUnit', { ns: 'common' })}</span>
</button>
{enableBilling && (
{showUpgradeAction && (
<button
type="button"
className="max-w-[120px] shrink-0 truncate px-1 system-xs-semibold-uppercase text-saas-dify-blue-accessible transition-colors hover:text-saas-dify-blue-static-hover"
@ -150,37 +156,41 @@ const WorkspaceCard = () => {
<div className="rounded-xl bg-gradient-to-b from-background-section-burn to-background-section pb-2">
<button
type="button"
className="flex w-full items-center gap-2 rounded-xl p-3 text-left transition-colors hover:bg-state-base-hover"
className="flex w-full items-start gap-2 rounded-xl p-3 text-left transition-colors hover:bg-state-base-hover"
aria-expanded={open}
aria-label={t('mainNav.workspace.openMenu', { ns: 'common' })}
onClick={() => setOpen(false)}
>
<div className="flex min-w-0 grow flex-col items-start justify-center gap-1">
<div className="max-w-[120px] shrink-0 truncate system-xl-medium leading-5 text-text-primary" title={currentWorkspace.name}>{currentWorkspace.name}</div>
<WorkspacePlanBadge plan={workspacePlan} />
{renderWorkspaceStatus()}
</div>
<WorkspaceIcon name={currentWorkspace.name} className="h-9 w-9" />
</button>
<button
type="button"
className="flex h-8 w-full items-center gap-1 rounded-lg px-2 py-1 text-left transition-colors hover:bg-state-base-hover"
onClick={() => {
setOpen(false)
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}}
>
<WorkspaceMenuItemContent icon={<span aria-hidden className="i-custom-vender-main-nav-workspace-settings h-4 w-4" />} label={t('mainNav.workspace.settings', { ns: 'common' })} />
</button>
<button
type="button"
className="flex h-8 w-full items-center gap-1 rounded-lg px-2 py-1 text-left transition-colors hover:bg-state-base-hover"
onClick={() => {
setOpen(false)
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })
}}
>
<WorkspaceMenuItemContent icon={<span aria-hidden className="i-ri-user-add-line h-4 w-4" />} label={t('mainNav.workspace.inviteMembers', { ns: 'common' })} />
</button>
{showWorkspaceSettings && (
<button
type="button"
className="flex h-8 w-full items-center gap-1 rounded-lg px-2 py-1 text-left transition-colors hover:bg-state-base-hover"
onClick={() => {
setOpen(false)
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}}
>
<WorkspaceMenuItemContent icon={<span aria-hidden className="i-custom-vender-main-nav-workspace-settings h-4 w-4" />} label={t('mainNav.workspace.settings', { ns: 'common' })} />
</button>
)}
{showInviteMembers && (
<button
type="button"
className="flex h-8 w-full items-center gap-1 rounded-lg px-2 py-1 text-left transition-colors hover:bg-state-base-hover"
onClick={() => {
setOpen(false)
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })
}}
>
<WorkspaceMenuItemContent icon={<span aria-hidden className="i-ri-user-add-line h-4 w-4" />} label={t('mainNav.workspace.inviteMembers', { ns: 'common' })} />
</button>
)}
</div>
{workspaces.length > 0 && (
<div className="mt-1 flex flex-col">

View File

@ -1,5 +1,6 @@
import Badge from '@/app/components/base/badge'
import { Plan } from '@/app/components/billing/type'
import { useProviderContext } from '@/context/provider-context'
type WorkspacePlanBadgeProps = {
plan: Plan
@ -8,9 +9,17 @@ type WorkspacePlanBadgeProps = {
const WorkspacePlanBadge = ({
plan,
}: WorkspacePlanBadgeProps) => {
const { isEducationWorkspace, isFetchedPlan } = useProviderContext()
if (!isFetchedPlan)
return null
return (
<Badge size="xs" variant="dimm" className="shrink-0">
{plan === Plan.professional ? 'pro' : plan}
<span className="inline-flex items-center gap-1">
{plan === Plan.professional && isEducationWorkspace && <span aria-hidden className="i-ri-graduation-cap-fill h-3 w-3" />}
{plan === Plan.professional ? 'pro' : plan}
</span>
</Badge>
)
}

View File

@ -1,15 +1,13 @@
'use client'
import type { MainNavItem, MainNavProps } from './types'
import type { Plan } from '@/app/components/billing/type'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import EnvNav from '@/app/components/header/env-nav'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useWorkspacesContext } from '@/context/workspace-context'
import Link from '@/next/link'
import { usePathname } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
@ -25,40 +23,50 @@ const MainNav = ({
}: MainNavProps) => {
const { t } = useTranslation()
const pathname = usePathname()
const { currentWorkspace } = useAppContext()
const { plan } = useProviderContext()
const { workspaces } = useWorkspacesContext()
const { langGeniusVersionInfo, isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceEditor } = useAppContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const workspacePlan = (workspaces.find(workspace => workspace.current)?.plan || currentWorkspace.plan || plan.type) as Plan
const showEnvTag = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT'
const navItems = useMemo<MainNavItem[]>(() => [
{
href: '/explore/apps',
label: t('mainNav.home', { ns: 'common' }),
active: path => path.startsWith('/explore'),
icon: 'i-custom-vender-main-nav-home',
activeIcon: 'i-custom-vender-main-nav-home-active',
},
{
href: '/apps',
label: t('menus.apps', { ns: 'common' }),
active: path => path.startsWith('/apps') || path.startsWith('/app/'),
icon: 'i-custom-vender-main-nav-studio',
activeIcon: 'i-custom-vender-main-nav-studio-active',
},
{
href: '/datasets',
label: t('menus.datasets', { ns: 'common' }),
active: path => path.startsWith('/datasets'),
icon: 'i-custom-vender-main-nav-knowledge',
activeIcon: 'i-custom-vender-main-nav-knowledge-active',
},
{
href: '/tools?section=provider',
label: t('mainNav.integrations', { ns: 'common' }),
active: path => path.startsWith('/tools'),
icon: 'i-custom-vender-main-nav-integrations',
activeIcon: 'i-custom-vender-main-nav-integrations-active',
},
...(!isCurrentWorkspaceDatasetOperator
? [
{
href: '/explore/apps',
label: t('mainNav.home', { ns: 'common' }),
active: (path: string) => path.startsWith('/explore'),
icon: 'i-custom-vender-main-nav-home',
activeIcon: 'i-custom-vender-main-nav-home-active',
},
{
href: '/apps',
label: t('menus.apps', { ns: 'common' }),
active: (path: string) => path.startsWith('/apps') || path.startsWith('/app/'),
icon: 'i-custom-vender-main-nav-studio',
activeIcon: 'i-custom-vender-main-nav-studio-active',
},
]
: []),
...((isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator)
? [
{
href: '/datasets',
label: t('menus.datasets', { ns: 'common' }),
active: (path: string) => path.startsWith('/datasets'),
icon: 'i-custom-vender-main-nav-knowledge',
activeIcon: 'i-custom-vender-main-nav-knowledge-active',
},
]
: []),
...(!isCurrentWorkspaceDatasetOperator
? [
{
href: '/tools?section=provider',
label: t('mainNav.integrations', { ns: 'common' }),
active: (path: string) => path.startsWith('/tools'),
icon: 'i-custom-vender-main-nav-integrations',
activeIcon: 'i-custom-vender-main-nav-integrations-active',
},
]
: []),
{
href: '/plugins',
label: t('mainNav.marketplace', { ns: 'common' }),
@ -66,11 +74,11 @@ const MainNav = ({
icon: 'i-custom-vender-main-nav-marketplace',
activeIcon: 'i-custom-vender-main-nav-marketplace-active',
},
], [t])
], [isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceEditor, t])
const renderLogo = () => (
<h1 className="min-w-0">
<Link href="/apps" className="flex h-8 shrink-0 items-center overflow-hidden px-2 indent-[-9999px] whitespace-nowrap">
<Link href={isCurrentWorkspaceDatasetOperator ? '/datasets' : '/apps'} className="flex h-8 shrink-0 items-center overflow-hidden px-2 indent-[-9999px] whitespace-nowrap">
{systemFeatures.branding.enabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? (
@ -100,10 +108,17 @@ const MainNav = ({
<MainNavLink key={item.href} item={item} pathname={pathname} />
))}
</nav>
<WebAppsSection />
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
{showEnvTag && (
<div className="relative z-30 px-3 pb-2">
<EnvNav />
</div>
)}
</div>
<div className="flex w-[240px] items-center justify-between bg-gradient-to-b from-background-body-transparent to-background-body to-50% py-3 pr-1 pl-3 backdrop-blur-[2px]">
<AccountSection workspacePlan={workspacePlan} />
<div className="flex min-w-0 items-center gap-1">
<AccountSection />
</div>
<HelpMenu />
</div>
</aside>