-
+ {isMobile &&
}
{children}
diff --git a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts
index 45bbfb7447..839e118aa8 100644
--- a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts
+++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-modal.spec.ts
@@ -1,5 +1,5 @@
import { act, renderHook } from '@testing-library/react'
-import { useGotoAnythingModal } from '../use-goto-anything-modal'
+import { GOTO_ANYTHING_OPEN_EVENT, useGotoAnythingModal } from '../use-goto-anything-modal'
type KeyPressEvent = {
preventDefault: () => void
@@ -167,6 +167,20 @@ describe('useGotoAnythingModal', () => {
})
})
+ describe('open event', () => {
+ it('should open the modal when the global open event is dispatched', () => {
+ const { result } = renderHook(() => useGotoAnythingModal())
+
+ expect(result.current.show).toBe(false)
+
+ act(() => {
+ window.dispatchEvent(new Event(GOTO_ANYTHING_OPEN_EVENT))
+ })
+
+ expect(result.current.show).toBe(true)
+ })
+ })
+
describe('setShow', () => {
it('should accept boolean value', () => {
const { result } = renderHook(() => useGotoAnythingModal())
diff --git a/web/app/components/goto-anything/hooks/index.ts b/web/app/components/goto-anything/hooks/index.ts
index c918a0c2bb..5388fd5e03 100644
--- a/web/app/components/goto-anything/hooks/index.ts
+++ b/web/app/components/goto-anything/hooks/index.ts
@@ -1,4 +1,4 @@
-export { useGotoAnythingModal } from './use-goto-anything-modal'
+export { GOTO_ANYTHING_OPEN_EVENT, useGotoAnythingModal } from './use-goto-anything-modal'
export { useGotoAnythingNavigation } from './use-goto-anything-navigation'
diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-modal.ts b/web/app/components/goto-anything/hooks/use-goto-anything-modal.ts
index 3e616cdd95..548d6d111d 100644
--- a/web/app/components/goto-anything/hooks/use-goto-anything-modal.ts
+++ b/web/app/components/goto-anything/hooks/use-goto-anything-modal.ts
@@ -12,6 +12,8 @@ type UseGotoAnythingModalReturn = {
handleClose: () => void
}
+export const GOTO_ANYTHING_OPEN_EVENT = 'dify:goto-anything-open'
+
export const useGotoAnythingModal = (): UseGotoAnythingModalReturn => {
const [show, setShow] = useState
(false)
const inputRef = useRef(null)
@@ -37,6 +39,13 @@ export const useGotoAnythingModal = (): UseGotoAnythingModalReturn => {
}
})
+ useEffect(() => {
+ const handleOpen = () => setShow(true)
+
+ window.addEventListener(GOTO_ANYTHING_OPEN_EVENT, handleOpen)
+ return () => window.removeEventListener(GOTO_ANYTHING_OPEN_EVENT, handleOpen)
+ }, [])
+
const handleClose = useCallback(() => {
setShow(false)
}, [])
diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx
index b62eb42482..ce9a828aba 100644
--- a/web/app/components/header/account-dropdown/index.tsx
+++ b/web/app/components/header/account-dropdown/index.tsx
@@ -1,6 +1,6 @@
'use client'
-import type { MouseEventHandler, ReactNode } from 'react'
+import type { MouseEventHandler, ReactElement, ReactNode } from 'react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
@@ -107,7 +107,16 @@ function AccountMenuSection({ children }: AccountMenuSectionProps) {
return {children}
}
-export default function AppSelector() {
+type AccountDropdownProps = {
+ trigger?: (props: {
+ isOpen: boolean
+ ariaLabel: string
+ }) => ReactElement
+}
+
+export default function AppSelector({
+ trigger,
+}: AccountDropdownProps = {}) {
const router = useRouter()
const [aboutVisible, setAboutVisible] = useState(false)
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
@@ -137,12 +146,23 @@ export default function AppSelector() {
return (
-
-
-
+ {trigger
+ ? (
+
+ )
+ : (
+
+
+
+ )}
({
+ mockToastSuccess: vi.fn(),
+}))
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: vi.fn(),
+}))
+
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: vi.fn(),
+}))
+
+vi.mock('@/context/modal-context', () => ({
+ useModalContext: vi.fn(),
+}))
+
+vi.mock('@/context/workspace-context', () => ({
+ useWorkspacesContext: vi.fn(),
+}))
+
+vi.mock('@/next/navigation', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ usePathname: vi.fn(),
+ useRouter: vi.fn(),
+ }
+})
+
+vi.mock('@/service/common', () => ({
+ switchWorkspace: vi.fn(),
+}))
+
+vi.mock('@/service/use-explore', () => ({
+ useGetInstalledApps: vi.fn(),
+ useUninstallApp: vi.fn(),
+ useUpdateAppPinStatus: vi.fn(),
+}))
+
+vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ toast: {
+ ...actual.toast,
+ success: mockToastSuccess,
+ },
+ }
+})
+
+vi.mock('@/context/i18n', () => ({
+ useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
+}))
+
+const mockPush = vi.fn()
+const mockSetShowPricingModal = vi.fn()
+const mockSetShowAccountSettingModal = vi.fn()
+const mockUninstall = vi.fn()
+const mockUpdatePinStatus = vi.fn()
+let mockPathname = '/apps'
+let mockInstalledApps: InstalledApp[] = []
+
+const createInstalledApp = (overrides: Partial = {}): InstalledApp => ({
+ id: overrides.id ?? 'installed-1',
+ uninstallable: overrides.uninstallable ?? false,
+ is_pinned: overrides.is_pinned ?? false,
+ app: {
+ id: overrides.app?.id ?? 'app-1',
+ mode: overrides.app?.mode ?? AppModeEnum.CHAT,
+ icon_type: overrides.app?.icon_type ?? 'emoji',
+ icon: overrides.app?.icon ?? '🤖',
+ icon_background: overrides.app?.icon_background ?? '#fff',
+ icon_url: overrides.app?.icon_url ?? '',
+ name: overrides.app?.name ?? 'Alpha App',
+ description: overrides.app?.description ?? '',
+ use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
+ },
+})
+
+const appContextValue: AppContextValue = {
+ userProfile: {
+ id: 'user-1',
+ name: 'Evan Z',
+ email: 'evan@example.com',
+ avatar: '',
+ avatar_url: '',
+ is_password_set: true,
+ },
+ mutateUserProfile: vi.fn(),
+ currentWorkspace: {
+ id: 'workspace-1',
+ name: 'Solar Studio',
+ plan: Plan.sandbox,
+ status: 'normal',
+ created_at: 0,
+ role: 'owner',
+ providers: [],
+ trial_credits: 10000,
+ trial_credits_used: 2500,
+ next_credit_reset_date: 0,
+ },
+ isCurrentWorkspaceManager: true,
+ isCurrentWorkspaceOwner: true,
+ isCurrentWorkspaceEditor: true,
+ isCurrentWorkspaceDatasetOperator: false,
+ mutateCurrentWorkspace: vi.fn(),
+ langGeniusVersionInfo: {
+ current_env: 'testing',
+ current_version: '1.0.0',
+ latest_version: '1.0.0',
+ release_date: '',
+ release_notes: '',
+ version: '1.0.0',
+ can_auto_update: false,
+ },
+ useSelector: vi.fn(),
+ isLoadingCurrentWorkspace: false,
+ isValidatingCurrentWorkspace: false,
+}
+
+const renderMainNav = () => renderWithSystemFeatures(, {
+ systemFeatures: { branding: { enabled: false } },
+})
+
+describe('MainNav', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockPathname = '/apps'
+ mockInstalledApps = []
+
+ ;(usePathname as Mock).mockImplementation(() => mockPathname)
+ ;(useRouter as Mock).mockReturnValue({
+ push: mockPush,
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ back: vi.fn(),
+ forward: vi.fn(),
+ refresh: vi.fn(),
+ })
+ ;(useAppContext as Mock).mockReturnValue(appContextValue)
+ ;(useProviderContext as Mock).mockReturnValue({
+ enableBilling: true,
+ isEducationAccount: false,
+ plan: { type: Plan.sandbox },
+ } as ProviderContextState)
+ ;(useModalContext as Mock).mockReturnValue({
+ setShowPricingModal: mockSetShowPricingModal,
+ setShowAccountSettingModal: mockSetShowAccountSettingModal,
+ } as unknown as ModalContextState)
+ ;(useWorkspacesContext as Mock).mockReturnValue({
+ workspaces: [
+ { id: 'workspace-1', name: 'Solar Studio', current: true },
+ { id: 'workspace-2', name: 'Evan Workspace', current: false },
+ ],
+ })
+ ;(useGetInstalledApps as Mock).mockImplementation(() => ({
+ isPending: false,
+ data: { installed_apps: mockInstalledApps },
+ }))
+ ;(useUninstallApp as Mock).mockReturnValue({
+ mutateAsync: mockUninstall,
+ isPending: false,
+ })
+ ;(useUpdateAppPinStatus as Mock).mockReturnValue({
+ mutateAsync: mockUpdatePinStatus,
+ })
+ ;(switchWorkspace as Mock).mockReturnValue(new Promise(() => {}))
+ })
+
+ it('renders primary navigation with the planned routes', () => {
+ renderMainNav()
+
+ expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/explore/apps')
+ expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps')
+ expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute('href', '/datasets')
+ expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute('href', '/tools')
+ expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/plugins')
+ })
+
+ it('marks the matching primary route active', () => {
+ mockPathname = '/datasets'
+
+ renderMainNav()
+
+ expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveClass('bg-components-main-nav-nav-button-bg-active')
+ })
+
+ it('dispatches the goto anything open event from the search button', () => {
+ const handleOpen = vi.fn()
+ window.addEventListener(GOTO_ANYTHING_OPEN_EVENT, handleOpen)
+
+ renderMainNav()
+ fireEvent.click(screen.getByRole('button', { name: 'app.gotoAnything.searchTitle' }))
+
+ expect(handleOpen).toHaveBeenCalledTimes(1)
+ window.removeEventListener(GOTO_ANYTHING_OPEN_EVENT, handleOpen)
+ })
+
+ it('opens workspace settings, members, provider credits, upgrade, and workspace switching actions', async () => {
+ renderMainNav()
+
+ fireEvent.click(screen.getByText(/common\.mainNav\.workspace\.credits|7,500 credits/))
+ expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
+
+ fireEvent.click(screen.getByText('billing.upgradeBtn.encourageShort'))
+ expect(mockSetShowPricingModal).toHaveBeenCalled()
+
+ fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
+ fireEvent.click(await screen.findByText('common.mainNav.workspace.settings'))
+ expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.BILLING })
+
+ fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
+ fireEvent.click(await screen.findByText('common.mainNav.workspace.inviteMembers'))
+ expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.MEMBERS })
+
+ fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
+ fireEvent.click(await screen.findByText('Evan Workspace'))
+ await waitFor(() => {
+ expect(switchWorkspace).toHaveBeenCalledWith({ url: '/workspaces/switch', body: { tenant_id: 'workspace-2' } })
+ })
+ })
+
+ it('filters installed web apps and navigates to an installed app', () => {
+ mockInstalledApps = [
+ createInstalledApp({ id: 'installed-1', app: { ...createInstalledApp().app, name: 'Alpha App' } }),
+ createInstalledApp({ id: 'installed-2', app: { ...createInstalledApp().app, name: 'Beta Tool' } }),
+ ]
+
+ renderMainNav()
+
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.search' }))
+ fireEvent.change(screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder'), {
+ target: { value: 'beta' },
+ })
+
+ expect(screen.queryByText('Alpha App')).not.toBeInTheDocument()
+ fireEvent.click(screen.getByText('Beta Tool'))
+ expect(mockPush).toHaveBeenCalledWith('/explore/installed/installed-2')
+ })
+
+ it('updates pin status and reuses the existing delete confirmation for installed web apps', async () => {
+ mockInstalledApps = [createInstalledApp()]
+ mockUninstall.mockResolvedValue(undefined)
+ mockUpdatePinStatus.mockResolvedValue(undefined)
+
+ renderMainNav()
+
+ fireEvent.mouseEnter(screen.getByTitle('Alpha App'))
+ fireEvent.click(screen.getByTestId('item-operation-trigger'))
+ fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
+
+ await waitFor(() => {
+ expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'installed-1', isPinned: true })
+ })
+
+ fireEvent.mouseEnter(screen.getByTitle('Alpha App'))
+ fireEvent.click(screen.getByTestId('item-operation-trigger'))
+ fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
+ fireEvent.click(await screen.findByText('common.operation.confirm'))
+
+ await waitFor(() => {
+ expect(mockUninstall).toHaveBeenCalledWith('installed-1')
+ expect(mockToastSuccess).toHaveBeenCalledWith('common.api.remove')
+ })
+ })
+})
diff --git a/web/app/components/main-nav/__tests__/layout.spec.tsx b/web/app/components/main-nav/__tests__/layout.spec.tsx
new file mode 100644
index 0000000000..f74d016da9
--- /dev/null
+++ b/web/app/components/main-nav/__tests__/layout.spec.tsx
@@ -0,0 +1,79 @@
+import type { ReactNode } from 'react'
+import { render, screen } from '@testing-library/react'
+import { MediaType } from '@/hooks/use-breakpoints'
+import MainNavLayout from '../layout'
+
+type MediaTypeValue = (typeof MediaType)[keyof typeof MediaType]
+
+let mockMediaType: MediaTypeValue = MediaType.pc
+let mockPathname = '/apps'
+
+vi.mock('@/hooks/use-breakpoints', () => ({
+ default: () => mockMediaType,
+ MediaType: {
+ mobile: 'mobile',
+ tablet: 'tablet',
+ pc: 'pc',
+ },
+}))
+
+vi.mock('@/next/navigation', () => ({
+ usePathname: () => mockPathname,
+}))
+
+vi.mock('@/context/event-emitter', () => ({
+ useEventEmitterContextContext: () => ({
+ eventEmitter: undefined,
+ }),
+}))
+
+vi.mock('@/context/workspace-context-provider', () => ({
+ WorkspaceProvider: ({ children }: { children: ReactNode }) => {children}
,
+}))
+
+vi.mock('@/app/components/header', () => ({
+ default: () => Header
,
+}))
+
+vi.mock('@/app/components/header/header-wrapper', () => ({
+ default: ({ children }: { children: ReactNode }) => {children}
,
+}))
+
+vi.mock('../index', () => ({
+ default: ({ className }: { className?: string }) => ,
+}))
+
+describe('MainNavLayout', () => {
+ beforeEach(() => {
+ mockMediaType = MediaType.pc
+ mockPathname = '/apps'
+ localStorage.clear()
+ })
+
+ it('renders desktop main nav instead of the desktop header', () => {
+ render(content
)
+
+ expect(screen.getByTestId('main-nav')).toBeInTheDocument()
+ expect(screen.queryByTestId('desktop-header')).not.toBeInTheDocument()
+ expect(screen.getByText('content')).toBeInTheDocument()
+ })
+
+ it('keeps the current header on mobile', () => {
+ mockMediaType = MediaType.mobile
+
+ render(content
)
+
+ expect(screen.getByTestId('header-wrapper')).toBeInTheDocument()
+ expect(screen.getByTestId('desktop-header')).toBeInTheDocument()
+ expect(screen.queryByTestId('main-nav')).not.toBeInTheDocument()
+ })
+
+ it('hides the desktop main nav on fullscreen workflow canvases', () => {
+ mockPathname = '/apps/app-1/workflow'
+ localStorage.setItem('workflow-canvas-maximize', 'true')
+
+ render(content
)
+
+ expect(screen.getByTestId('main-nav')).toHaveClass('hidden')
+ })
+})
diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx
new file mode 100644
index 0000000000..c3d07521af
--- /dev/null
+++ b/web/app/components/main-nav/index.tsx
@@ -0,0 +1,634 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import type { InstalledApp } from '@/models/explore'
+import {
+ AlertDialog,
+ AlertDialogActions,
+ AlertDialogCancelButton,
+ AlertDialogConfirmButton,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogTitle,
+} from '@langgenius/dify-ui/alert-dialog'
+import { Avatar } from '@langgenius/dify-ui/avatar'
+import { cn } from '@langgenius/dify-ui/cn'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLinkItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@langgenius/dify-ui/dropdown-menu'
+import { toast } from '@langgenius/dify-ui/toast'
+import { useSuspenseQuery } from '@tanstack/react-query'
+import { useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import AppIcon from '@/app/components/base/app-icon'
+import DifyLogo from '@/app/components/base/logo/dify-logo'
+import { Plan } from '@/app/components/billing/type'
+import ItemOperation from '@/app/components/explore/item-operation'
+import { GOTO_ANYTHING_OPEN_EVENT } from '@/app/components/goto-anything/hooks'
+import AccountAbout from '@/app/components/header/account-about'
+import AccountDropdown from '@/app/components/header/account-dropdown'
+import Compliance from '@/app/components/header/account-dropdown/compliance'
+import Support from '@/app/components/header/account-dropdown/support'
+import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
+import GithubStar from '@/app/components/header/github-star'
+import Indicator from '@/app/components/header/indicator'
+import PlanBadge from '@/app/components/header/plan-badge'
+import { IS_CLOUD_EDITION } from '@/config'
+import { useAppContext } from '@/context/app-context'
+import { useDocLink } from '@/context/i18n'
+import { useModalContext } from '@/context/modal-context'
+import { useProviderContext } from '@/context/provider-context'
+import { useWorkspacesContext } from '@/context/workspace-context'
+import { env } from '@/env'
+import Link from '@/next/link'
+import { usePathname, useRouter } from '@/next/navigation'
+import { switchWorkspace } from '@/service/common'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
+import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
+import { basePath } from '@/utils/var'
+
+type MainNavProps = {
+ className?: string
+}
+
+type MainNavItem = {
+ href: string
+ label: string
+ active: (pathname: string) => boolean
+ icon: string
+ activeIcon: string
+}
+
+const navItemClassName = 'group relative flex h-9 items-center gap-2 rounded-xl p-2 transition-colors'
+
+const activeNavItemClassName = [
+ 'overflow-hidden border border-components-main-nav-glass-edge-reflection-first bg-components-main-nav-nav-button-bg-active',
+ 'bg-[linear-gradient(98deg,var(--color-components-main-nav-glass-surface-first)_0%,var(--color-components-main-nav-glass-surface-middle-1)_18%,var(--color-components-main-nav-glass-surface-middle-2)_59%,var(--color-components-main-nav-glass-surface-end)_100%)]',
+ 'system-md-semibold text-components-main-nav-text-active backdrop-blur-[5px]',
+ 'shadow-[0px_4px_8px_0px_var(--color-components-main-nav-glass-shadow-reflection-glow),0px_12px_16px_-4px_var(--color-shadow-shadow-5),0px_4px_6px_-2px_var(--color-shadow-shadow-1),0px_10px_16px_-4px_var(--color-components-main-nav-glass-shadow-reflection)]',
+ 'before:pointer-events-none before:absolute before:inset-[-1px] before:rounded-xl before:border before:border-components-main-nav-glass-edge-highlight-first before:shadow-[inset_0px_0px_8px_0px_var(--color-components-main-nav-glass-inner-glow)] before:content-[""]',
+].join(' ')
+
+const inactiveNavItemClassName = 'system-md-medium bg-components-main-nav-nav-button-bg text-components-main-nav-text hover:bg-state-base-hover hover:text-components-main-nav-text'
+
+const getWorkspaceInitial = (name?: string) => name?.[0]?.toLocaleUpperCase() || '?'
+
+const getRemainingCredits = (total: number, used: number) => Math.max(total - used, 0)
+
+const formatCredits = (value: number) => new Intl.NumberFormat().format(value)
+
+const WorkspaceIcon = ({
+ name,
+ className,
+}: {
+ name?: string
+ className?: string
+}) => (
+
+ {getWorkspaceInitial(name)}
+
+)
+
+const MenuIcon = ({
+ className,
+}: {
+ className?: string
+}) => (
+
+)
+
+const NavIcon = ({
+ icon,
+ className,
+}: {
+ icon: string
+ className?: string
+}) => (
+
+)
+
+const WorkspaceMenuItemContent = ({
+ icon,
+ label,
+ trailing,
+}: {
+ icon: ReactNode
+ label: ReactNode
+ trailing?: ReactNode
+}) => (
+ <>
+ {icon}
+ {label}
+ {trailing}
+ >
+)
+
+const WorkspaceCard = () => {
+ const { t } = useTranslation()
+ const { currentWorkspace } = useAppContext()
+ const { workspaces } = useWorkspacesContext()
+ const { enableBilling, plan } = useProviderContext()
+ const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
+ const credits = getRemainingCredits(currentWorkspace.trial_credits, currentWorkspace.trial_credits_used)
+ const isFreePlan = plan.type === Plan.sandbox
+
+ const handlePlanClick = () => {
+ if (isFreePlan)
+ setShowPricingModal()
+ else
+ setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
+ }
+
+ const handleSwitchWorkspace = async (tenant_id: string) => {
+ try {
+ if (currentWorkspace.id === tenant_id)
+ return
+
+ await switchWorkspace({ url: '/workspaces/switch', body: { tenant_id } })
+ toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
+ location.assign(`${location.origin}${basePath}`)
+ }
+ catch {
+ toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
+ }
+ }
+
+ return (
+
+
+ )}
+ >
+
+
+
+
+
{currentWorkspace.name}
+
+
+
+
+
+
+
+ {enableBilling && (
+
+ )}
+
+
+
+
+
+
+
+
{currentWorkspace.name}
+
+
+
+ setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })}
+ >
+ } label={t('mainNav.workspace.settings', { ns: 'common' })} />
+
+ setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}
+ >
+ } label={t('mainNav.workspace.inviteMembers', { ns: 'common' })} />
+
+
+ {workspaces.length > 0 && (
+ <>
+
+
+
+ {t('mainNav.workspace.switchWorkspace', { ns: 'common' })}
+
+ {workspaces.map(workspace => (
+ {
+ void handleSwitchWorkspace(workspace.id)
+ }}
+ >
+ }
+ label={workspace.name}
+ trailing={workspace.current ? : undefined}
+ />
+
+ ))}
+
+ >
+ )}
+
+
+ )
+}
+
+const MainNavLink = ({
+ item,
+ pathname,
+}: {
+ item: MainNavItem
+ pathname: string
+}) => {
+ const activated = item.active(pathname)
+ return (
+
+
+ {item.label}
+
+ )
+}
+
+const MainNavSearchButton = () => {
+ const { t } = useTranslation()
+
+ return (
+
+ )
+}
+
+const WebAppItem = ({
+ app,
+ isSelected,
+ onDelete,
+ onTogglePin,
+}: {
+ app: InstalledApp
+ isSelected: boolean
+ onDelete: (id: string) => void
+ onTogglePin: () => void
+}) => {
+ const router = useRouter()
+ const url = `/explore/installed/${app.id}`
+ const [isHovering, setIsHovering] = useState(false)
+
+ return (
+ router.push(url)}
+ onMouseEnter={() => setIsHovering(true)}
+ onMouseLeave={() => setIsHovering(false)}
+ title={app.app.name}
+ >
+
+
e.stopPropagation()}>
+ onDelete(app.id)}
+ />
+
+
+ )
+}
+
+const WebAppsSection = () => {
+ const { t } = useTranslation()
+ const pathname = usePathname()
+ const { data, isPending } = useGetInstalledApps()
+ const installedApps = useMemo(() => data?.installed_apps ?? [], [data?.installed_apps])
+ const { mutateAsync: uninstallApp, isPending: isUninstalling } = useUninstallApp()
+ const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus()
+ const [searchVisible, setSearchVisible] = useState(false)
+ const [searchText, setSearchText] = useState('')
+ const [showConfirm, setShowConfirm] = useState(false)
+ const [currentId, setCurrentId] = useState('')
+
+ const filteredApps = useMemo(() => {
+ const normalizedSearch = searchText.trim().toLowerCase()
+ if (!normalizedSearch)
+ return installedApps
+
+ return installedApps.filter(item => item.app.name.toLowerCase().includes(normalizedSearch))
+ }, [installedApps, searchText])
+
+ const handleDelete = async () => {
+ await uninstallApp(currentId)
+ setShowConfirm(false)
+ toast.success(t('api.remove', { ns: 'common' }))
+ }
+
+ const handleUpdatePinStatus = async (id: string, isPinned: boolean) => {
+ await updatePinStatus({ appId: id, isPinned })
+ toast.success(t('api.success', { ns: 'common' }))
+ }
+
+ return (
+
+
+
+
+
+
+
+ {searchVisible && (
+
+ setSearchText(e.target.value)}
+ placeholder={t('mainNav.webApps.searchPlaceholder', { ns: 'common' })}
+ className="h-8 w-full rounded-lg border border-transparent bg-components-input-bg-normal px-2 system-sm-regular text-text-secondary outline-none placeholder:text-text-quaternary hover:border-components-input-border-hover focus:border-components-input-border-active"
+ />
+
+ )}
+
+ {isPending && (
+
{t('loading', { ns: 'common' })}
+ )}
+ {!isPending && filteredApps.length === 0 && (
+
+ {searchText ? t('mainNav.webApps.noResults', { ns: 'common' }) : t('sidebar.noApps.title', { ns: 'explore' })}
+
+ )}
+ {filteredApps.map(app => (
+
{
+ setCurrentId(id)
+ setShowConfirm(true)
+ }}
+ onTogglePin={() => {
+ void handleUpdatePinStatus(app.id, !app.is_pinned)
+ }}
+ />
+ ))}
+
+
+
+
+
+ {t('sidebar.delete.title', { ns: 'explore' })}
+
+
+ {t('sidebar.delete.content', { ns: 'explore' })}
+
+
+
+
+ {t('operation.cancel', { ns: 'common' })}
+
+
+ {t('operation.confirm', { ns: 'common' })}
+
+
+
+
+
+ )
+}
+
+const HelpMenu = () => {
+ const { t } = useTranslation()
+ const docLink = useDocLink()
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
+ const { langGeniusVersionInfo, isCurrentWorkspaceOwner } = useAppContext()
+ const [aboutVisible, setAboutVisible] = useState(false)
+ const [open, setOpen] = useState(false)
+
+ return (
+ <>
+
+
+
+
+
+ {!systemFeatures.branding.enabled && (
+ <>
+
+
+
+ {t('userProfile.helpCenter', { ns: 'common' })}
+
+ setOpen(false)} />
+ {IS_CLOUD_EDITION && isCurrentWorkspaceOwner && }
+
+
+
+
+
+ {t('userProfile.roadmap', { ns: 'common' })}
+
+
+
+ {t('userProfile.github', { ns: 'common' })}
+
+
+
+
+
+ {env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
+ {
+ setAboutVisible(true)
+ setOpen(false)
+ }}
+ >
+
+ {t('userProfile.about', { ns: 'common' })}
+
+
{langGeniusVersionInfo.current_version}
+
+
+
+ )}
+
+ >
+ )}
+
+
+ {aboutVisible && setAboutVisible(false)} langGeniusVersionInfo={langGeniusVersionInfo} />}
+ >
+ )
+}
+
+const MainNav = ({
+ className,
+}: MainNavProps) => {
+ const { t } = useTranslation()
+ const pathname = usePathname()
+ const { userProfile } = useAppContext()
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
+ const navItems = useMemo(() => [
+ {
+ 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',
+ 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',
+ },
+ {
+ href: '/plugins',
+ label: t('mainNav.marketplace', { ns: 'common' }),
+ active: path => path.startsWith('/plugins'),
+ icon: 'i-custom-vender-main-nav-marketplace',
+ activeIcon: 'i-custom-vender-main-nav-marketplace-active',
+ },
+ ], [t])
+
+ const renderLogo = () => (
+
+
+ {systemFeatures.branding.enabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
+ {systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
+ ? (
+
+ )
+ : }
+
+
+ )
+
+ return (
+
+ )
+}
+
+export default MainNav
diff --git a/web/app/components/main-nav/layout.tsx b/web/app/components/main-nav/layout.tsx
new file mode 100644
index 0000000000..df8e046c97
--- /dev/null
+++ b/web/app/components/main-nav/layout.tsx
@@ -0,0 +1,64 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import type { EventEmitterValue } from '@/context/event-emitter'
+import { cn } from '@langgenius/dify-ui/cn'
+import { useState } from 'react'
+import Header from '@/app/components/header'
+import HeaderWrapper from '@/app/components/header/header-wrapper'
+import { useEventEmitterContextContext } from '@/context/event-emitter'
+import { WorkspaceProvider } from '@/context/workspace-context-provider'
+import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
+import { usePathname } from '@/next/navigation'
+import MainNav from './index'
+
+type MainNavLayoutProps = {
+ children: ReactNode
+}
+
+const MainNavLayout = ({
+ children,
+}: MainNavLayoutProps) => {
+ const media = useBreakpoints()
+ const isMobile = media === MediaType.mobile
+ const pathname = usePathname()
+ const inWorkflowCanvas = pathname.endsWith('/workflow')
+ const isPipelineCanvas = pathname.endsWith('/pipeline')
+ const [hideMainNav, setHideMainNav] = useState(() => (
+ globalThis.localStorage?.getItem('workflow-canvas-maximize') === 'true'
+ ))
+ const { eventEmitter } = useEventEmitterContextContext()
+
+ eventEmitter?.useSubscription((v: EventEmitterValue) => {
+ if (typeof v !== 'string' && v.type === 'workflow-canvas-maximize' && typeof v.payload === 'boolean')
+ setHideMainNav(v.payload)
+ })
+
+ if (isMobile) {
+ return (
+ <>
+
+
+
+ {children}
+ >
+ )
+ }
+
+ return (
+
+ )
+}
+
+export default MainNavLayout
diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json
index d4f72c242d..3fdfc77334 100644
--- a/web/i18n/en-US/common.json
+++ b/web/i18n/en-US/common.json
@@ -214,6 +214,17 @@
"license.expiring_plural": "Expiring in {{count}} days",
"license.unlimited": "Unlimited",
"loading": "Loading",
+ "mainNav.help.openMenu": "Open help menu",
+ "mainNav.home": "Home",
+ "mainNav.integrations": "Integrations",
+ "mainNav.marketplace": "Marketplace",
+ "mainNav.webApps.noResults": "No web apps found",
+ "mainNav.webApps.searchPlaceholder": "Search web apps",
+ "mainNav.workspace.credits": "{{count}} credits",
+ "mainNav.workspace.inviteMembers": "Invite and manage members",
+ "mainNav.workspace.openMenu": "Open workspace menu",
+ "mainNav.workspace.settings": "Settings",
+ "mainNav.workspace.switchWorkspace": "Switch workspace",
"members.admin": "Admin",
"members.adminTip": "Can build apps & manage team settings",
"members.builder": "Builder",