refactor(web): decouple detail sidebars from main nav (#38241)

This commit is contained in:
Stephen Zhou 2026-07-01 14:07:07 +08:00 committed by GitHub
parent 93981cf75f
commit edf21269b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 1169 additions and 760 deletions

View File

@ -0,0 +1,5 @@
import { AppDetailSidebarSlot } from '../sidebar-page'
export default function AppAccessConfigDetailSidebarSlot() {
return <AppDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { AppDetailSidebarSlot } from '../sidebar-page'
export default function AppAnnotationsDetailSidebarSlot() {
return <AppDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { AppDetailSidebarSlot } from '../sidebar-page'
export default function AppConfigurationDetailSidebarSlot() {
return <AppDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { AppDetailSidebarSlot } from '../sidebar-page'
export default function AppDevelopDetailSidebarSlot() {
return <AppDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { AppDetailSidebarSlot } from '../sidebar-page'
export default function AppLogsDetailSidebarSlot() {
return <AppDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { AppDetailSidebarSlot } from '../sidebar-page'
export default function AppOverviewDetailSidebarSlot() {
return <AppDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { AppDetailSidebar } from '@/app/components/app-sidebar/app-detail-sidebar'
export function AppDetailSidebarSlot() {
return <AppDetailSidebar />
}

View File

@ -0,0 +1,5 @@
import { AppDetailSidebarSlot } from '../sidebar-page'
export default function AppWorkflowDetailSidebarSlot() {
return <AppDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { DatasetDetailSidebarSlot } from '../sidebar-page'
export default function DatasetAccessConfigDetailSidebarSlot() {
return <DatasetDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { DatasetDetailSidebarSlot } from '../sidebar-page'
export default function DatasetApiDetailSidebarSlot() {
return <DatasetDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { DatasetDetailSidebarSlot } from '../../sidebar-page'
export default function DatasetDocumentDetailSidebarSlot() {
return <DatasetDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { DatasetDetailSidebarSlot } from '../../../sidebar-page'
export default function DatasetDocumentSettingsDetailSidebarSlot() {
return <DatasetDetailSidebarSlot />
}

View File

@ -0,0 +1,3 @@
export default function DatasetDocumentCreateFromPipelineDetailSidebarSlot() {
return null
}

View File

@ -0,0 +1,3 @@
export default function DatasetDocumentCreateDetailSidebarSlot() {
return null
}

View File

@ -0,0 +1,5 @@
import { DatasetDetailSidebarSlot } from '../sidebar-page'
export default function DatasetDocumentsDetailSidebarSlot() {
return <DatasetDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { DatasetDetailSidebarSlot } from '../sidebar-page'
export default function DatasetHitTestingDetailSidebarSlot() {
return <DatasetDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { DatasetDetailSidebarSlot } from '../sidebar-page'
export default function DatasetPipelineDetailSidebarSlot() {
return <DatasetDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { DatasetDetailSidebarSlot } from '../sidebar-page'
export default function DatasetSettingsDetailSidebarSlot() {
return <DatasetDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { DatasetDetailSidebar } from '@/app/components/app-sidebar/dataset-detail-sidebar'
export function DatasetDetailSidebarSlot() {
return <DatasetDetailSidebar />
}

View File

@ -0,0 +1,3 @@
export default function DatasetConnectDetailSidebarSlot() {
return null
}

View File

@ -0,0 +1,3 @@
export default function DatasetCreateFromPipelineDetailSidebarSlot() {
return null
}

View File

@ -0,0 +1,3 @@
export default function DatasetCreateDetailSidebarSlot() {
return null
}

View File

@ -0,0 +1,3 @@
export default function DefaultDetailSidebarSlot() {
return null
}

View File

@ -0,0 +1,5 @@
import { DeploymentDetailSidebarSlot } from '../sidebar-page'
export default function DeploymentAccessDetailSidebarSlot() {
return <DeploymentDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { DeploymentDetailSidebarSlot } from '../sidebar-page'
export default function DeploymentApiTokensDetailSidebarSlot() {
return <DeploymentDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { DeploymentDetailSidebarSlot } from '../sidebar-page'
export default function DeploymentInstancesDetailSidebarSlot() {
return <DeploymentDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { DeploymentDetailSidebarSlot } from '../sidebar-page'
export default function DeploymentOverviewDetailSidebarSlot() {
return <DeploymentDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { DeploymentDetailSidebarSlot } from './sidebar-page'
export default function DeploymentDetailPageSidebarSlot() {
return <DeploymentDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { DeploymentDetailSidebarSlot } from '../sidebar-page'
export default function DeploymentReleasesDetailSidebarSlot() {
return <DeploymentDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { DeploymentDetailSidebar } from '@/features/deployments/detail/detail-sidebar'
export function DeploymentDetailSidebarSlot() {
return <DeploymentDetailSidebar />
}

View File

@ -0,0 +1,3 @@
export default function DeploymentCreateDetailSidebarSlot() {
return null
}

View File

@ -0,0 +1,5 @@
import { AgentDetailSidebarSlot } from '../sidebar-page'
export default function AgentAccessDetailSidebarSlot() {
return <AgentDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { AgentDetailSidebarSlot } from '../sidebar-page'
export default function AgentConfigureDetailSidebarSlot() {
return <AgentDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { AgentDetailSidebarSlot } from '../sidebar-page'
export default function AgentLogsDetailSidebarSlot() {
return <AgentDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { AgentDetailSidebarSlot } from '../sidebar-page'
export default function AgentMonitoringDetailSidebarSlot() {
return <AgentDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { AgentDetailSidebarSlot } from './sidebar-page'
export default function AgentDetailPageSidebarSlot() {
return <AgentDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { AgentDetailSidebar } from '@/features/agent-v2/agent-detail/detail-sidebar'
export function AgentDetailSidebarSlot() {
return <AgentDetailSidebar />
}

View File

@ -0,0 +1,5 @@
import { SnippetDetailSidebarSlot } from '../sidebar-page'
export default function SnippetOrchestrateDetailSidebarSlot() {
return <SnippetDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { SnippetDetailSidebarSlot } from './sidebar-page'
export default function SnippetDetailPageSidebarSlot() {
return <SnippetDetailSidebarSlot />
}

View File

@ -0,0 +1,5 @@
import { SnippetDetailSidebar } from '@/app/components/snippets/components/snippet-detail-sidebar'
export function SnippetDetailSidebarSlot() {
return <SnippetDetailSidebar />
}

View File

@ -31,6 +31,7 @@ vi.mock('@/service/apps', () => ({
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: { id: 'workspace-1' },
isCurrentWorkspaceDatasetOperator: false,
isLoadingCurrentWorkspace: false,
isLoadingWorkspacePermissionKeys: mockIsLoadingWorkspacePermissionKeys,
userProfile: { id: 'user-1' },
@ -111,6 +112,34 @@ describe('AppDetailLayout', () => {
expect(useStore.getState().appDetail?.id).toBe('app-1')
})
it('should render app detail content without owning the main skip target', async () => {
render(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
)
await waitForAppContent()
expect(screen.queryByRole('main')).not.toBeInTheDocument()
})
it('should preserve the column flex context for full-height workflow content', async () => {
render(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
)
await waitForAppContent()
const contentSurface = screen.getByText('App page content').parentElement
const appDetailContent = contentSurface?.parentElement
const appDetailRoot = appDetailContent?.parentElement
expect(appDetailRoot).toHaveClass('flex-col')
})
it('should redirect restricted app pages before exposing app detail content', async () => {
mockPathname = '/app/app-1/logs'
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }))

View File

@ -133,29 +133,32 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
setAppDetail({ ...appDetailRes, enable_sso: false })
}, [appDetail?.id, appDetailRes, appId, currentWorkspace.id, isLoadingAppDetail, isLoadingCurrentWorkspace, isLoadingWorkspacePermissionKeys, isRbacEnabled, pathname, routeAppDetail, router, setAppDetail, userProfile?.id, workspacePermissionKeys])
if (!appDetail) {
return (
<div className="flex h-full items-center justify-center bg-background-body">
<Loading />
</div>
)
}
const isWorkflowPage = pathname.endsWith('/workflow')
const content = !appDetail
? (
<div className="flex min-w-0 grow items-center justify-center bg-background-body">
<Loading />
</div>
)
: (
<div className={cn(
'relative flex h-0 min-h-0 min-w-0 grow overflow-hidden',
!isWorkflowPage && 'pt-1 pr-1 pb-1',
)}
>
<div className={cn(
'min-w-0 grow overflow-hidden bg-components-panel-bg',
!isWorkflowPage && 'rounded-lg shadow-xs shadow-shadow-shadow-3',
)}
>
{children}
</div>
</div>
)
return (
<div className={cn(
'relative flex h-0 grow overflow-hidden',
!isWorkflowPage && 'pt-1 pr-1 pb-1',
)}
>
<div className={cn(
'grow overflow-hidden bg-components-panel-bg',
!isWorkflowPage && 'rounded-lg shadow-xs shadow-shadow-shadow-3',
)}
>
{children}
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background-body">
{content}
</div>
)
}

View File

@ -186,6 +186,36 @@ describe('DatasetDetailLayout', () => {
expect(screen.getByText('Pipeline content').parentElement).not.toHaveClass('rounded-lg')
})
it('should preserve the column flex context for full-height pipeline content', () => {
// Arrange
mockUsePathname.mockReturnValue('/datasets/dataset-1/pipeline')
mockUseDatasetDetail.mockReturnValue({
data: {
id: 'dataset-1',
name: 'Dataset 1',
provider: 'vendor',
runtime_mode: 'rag_pipeline',
is_published: false,
},
error: null,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useDatasetDetail>)
// Act
render(
<DatasetDetailLayout datasetId="dataset-1">
<div>Pipeline content</div>
</DatasetDetailLayout>,
)
// Assert
const contentSurface = screen.getByText('Pipeline content').parentElement
const datasetDetailContent = contentSurface?.parentElement
const datasetDetailRoot = datasetDetailContent?.parentElement
expect(datasetDetailRoot).toHaveClass('flex-col')
})
it('should keep create-from-pipeline pages unframed', () => {
// Arrange
mockUsePathname.mockReturnValue('/datasets/dataset-1/documents/create-from-pipeline')
@ -211,6 +241,33 @@ describe('DatasetDetailLayout', () => {
// Assert
expect(screen.getByText('Create from pipeline content').parentElement).not.toHaveClass('rounded-lg')
})
it('should render document creation route content without owning the main skip target', () => {
// Arrange
mockUsePathname.mockReturnValue('/datasets/dataset-1/documents/create')
mockUseDatasetDetail.mockReturnValue({
data: {
id: 'dataset-1',
name: 'Dataset 1',
provider: 'vendor',
runtime_mode: 'rag_pipeline',
is_published: true,
},
error: null,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useDatasetDetail>)
// Act
render(
<DatasetDetailLayout datasetId="dataset-1">
<div>Create document content</div>
</DatasetDetailLayout>,
)
// Assert
expect(screen.getByText('Create document content')).toBeInTheDocument()
expect(screen.queryByRole('main')).not.toBeInTheDocument()
})
})
describe('Permission Route Guards', () => {

View File

@ -102,38 +102,37 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
router.replace(getDatasetRedirectionPath(datasetRes, datasetACLCapabilities))
}, [datasetACLCapabilities, datasetRes, router, shouldRedirectUnauthorizedRoute])
if (!datasetRes && !error)
return <Loading type="app" />
if (shouldRedirect)
return <Loading type="app" />
if (isCheckingRouteAccess || shouldRedirectUnauthorizedRoute)
return <Loading type="app" />
const isPipelinePage = pathname.endsWith('/pipeline') || pathname.includes('/create-from-pipeline')
const shouldShowLoading = (!datasetRes && !error) || shouldRedirect || isCheckingRouteAccess || shouldRedirectUnauthorizedRoute
const content = shouldShowLoading
? <Loading type="app" />
: (
<div
className={cn(
'relative flex h-0 min-h-0 min-w-0 grow overflow-hidden',
!isPipelinePage && 'pt-1 pr-1 pb-1',
)}
>
<DatasetDetailContext.Provider value={{
indexingTechnique: datasetRes?.indexing_technique,
dataset: datasetRes,
mutateDatasetRes,
}}
>
<div className={cn(
'min-w-0 grow overflow-hidden bg-components-panel-bg',
!isPipelinePage && 'rounded-lg shadow-xs shadow-shadow-shadow-3',
)}
>
{children}
</div>
</DatasetDetailContext.Provider>
</div>
)
return (
<div
className={cn(
'relative flex h-0 grow overflow-hidden',
!isPipelinePage && 'pt-1 pr-1 pb-1',
)}
>
<DatasetDetailContext.Provider value={{
indexingTechnique: datasetRes?.indexing_technique,
dataset: datasetRes,
mutateDatasetRes,
}}
>
<div className={cn(
'grow overflow-hidden bg-components-panel-bg',
!isPipelinePage && 'rounded-lg shadow-xs shadow-shadow-shadow-3',
)}
>
{children}
</div>
</DatasetDetailContext.Provider>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background-body">
{content}
</div>
)
}

View File

@ -13,7 +13,13 @@ import { ProviderContextProvider } from '@/context/provider-context-provider'
import { CommonLayoutGlobalMounts } from './global-mounts'
import { CommonLayoutHydrationBoundary } from './hydration-boundary'
export default async function Layout({ children }: { children: ReactNode }) {
export default async function Layout({
children,
detailSidebar,
}: {
children: ReactNode
detailSidebar: ReactNode
}) {
return (
<>
<GoogleAnalyticsScripts />
@ -26,7 +32,7 @@ export default async function Layout({ children }: { children: ReactNode }) {
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<MainNavLayout>
<MainNavLayout detailSidebar={detailSidebar}>
{children}
</MainNavLayout>
<CommonLayoutGlobalMounts />

View File

@ -0,0 +1,19 @@
'use client'
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
import AppDetailSection from './app-detail-section'
import AppDetailTop from './app-detail-top'
export function AppDetailSidebar() {
return (
<DetailSidebarFrame
renderTop={({ expand, onToggle }) => (
<AppDetailTop
expand={expand}
onToggle={onToggle}
/>
)}
renderSection={({ expand }) => <AppDetailSection expand={expand} />}
/>
)
}

View File

@ -0,0 +1,19 @@
'use client'
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
import DatasetDetailSection from './dataset-detail-section'
import DatasetDetailTop from './dataset-detail-top'
export function DatasetDetailSidebar() {
return (
<DetailSidebarFrame
renderTop={({ expand, onToggle }) => (
<DatasetDetailTop
expand={expand}
onToggle={onToggle}
/>
)}
renderSection={({ expand }) => <DatasetDetailSection expand={expand} />}
/>
)
}

View File

@ -90,7 +90,7 @@ vi.mock('@/app/components/app/store', () => ({
}),
}))
vi.mock('@/app/components/main-nav/storage', () => ({
vi.mock('@/app/components/detail-sidebar/storage', () => ({
useSetDetailSidebarMode: () => mockSetDetailSidebarMode,
}))

View File

@ -34,6 +34,7 @@ import {
} from '@/app/components/app/configuration/debug/hooks'
import useAdvancedPromptConfig from '@/app/components/app/configuration/hooks/use-advanced-prompt-config'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useSetDetailSidebarMode } from '@/app/components/detail-sidebar/storage'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
@ -41,7 +42,6 @@ import {
useTextGenerationCurrentProviderAndModelAndModelList,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import { useSetDetailSidebarMode } from '@/app/components/main-nav/storage'
import { ANNOTATION_DEFAULT, DATASET_DEFAULT, DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'

View File

@ -0,0 +1,125 @@
import type { Mock } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { useAppContext } from '@/context/app-context'
import { DetailSidebarFrame } from '..'
import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
const { hotkeyRegistrations } = vi.hoisted(() => ({
hotkeyRegistrations: new Map<string, {
handler: (event: { preventDefault: () => void }) => void
options?: { ignoreInputs?: boolean }
}>(),
}))
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
return {
...actual,
useHotkey: (
hotkey: string,
handler: (event: { preventDefault: () => void }) => void,
options?: { ignoreInputs?: boolean },
) => {
hotkeyRegistrations.set(hotkey, { handler, options })
},
}
})
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/app/components/main-nav/components/account-section', () => ({
default: ({ compact }: { compact?: boolean }) => (
<button type="button" aria-label="account">{compact ? 'Compact account' : 'Expanded account'}</button>
),
}))
vi.mock('@/app/components/main-nav/components/help-menu', () => ({
default: ({ triggerClassName }: { triggerClassName?: string }) => (
<button type="button" aria-label="help" className={triggerClassName}>Help</button>
),
}))
vi.mock('@/app/components/header/env-nav', () => ({
default: () => <div>Environment tag</div>,
}))
function renderDetailSidebarFrame() {
return render(
<DetailSidebarFrame
renderTop={({ expand, onToggle }) => (
<div data-testid="detail-top" data-expand={expand}>
<button type="button" data-testid="detail-toggle" onClick={onToggle}>Toggle</button>
</div>
)}
renderSection={({ expand }) => (
<div data-testid="detail-section" data-expand={expand}>Section</div>
)}
/>,
)
}
describe('DetailSidebarFrame', () => {
beforeEach(() => {
localStorage.clear()
hotkeyRegistrations.clear()
;(useAppContext as Mock).mockReturnValue({
langGeniusVersionInfo: {
current_env: '',
},
})
})
it('renders expanded detail content by default and registers the shortcut for focused inputs', () => {
renderDetailSidebarFrame()
expect(screen.getByRole('complementary')).toHaveClass('w-62')
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true')
expect(hotkeyRegistrations.get('Mod+B')?.options).toEqual(
expect.objectContaining({ ignoreInputs: false }),
)
})
it('collapses detail content from the top toggle and hides environment metadata', () => {
;(useAppContext as Mock).mockReturnValue({
langGeniusVersionInfo: {
current_env: 'TESTING',
},
})
renderDetailSidebarFrame()
fireEvent.click(screen.getByTestId('detail-toggle'))
expect(screen.getByRole('complementary')).toHaveClass('w-16')
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'false')
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'false')
expect(screen.queryByText('Environment tag')).not.toBeInTheDocument()
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
})
it('shows a floating preview on collapsed hover without changing persisted state', () => {
renderDetailSidebarFrame()
fireEvent.click(screen.getByTestId('detail-toggle'))
fireEvent.mouseEnter(screen.getByTestId('detail-top').parentElement!)
expect(screen.getByRole('complementary')).toHaveClass('w-16', 'overflow-visible')
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
})
it('persists expansion without width animation when the hovered preview toggle is clicked', () => {
renderDetailSidebarFrame()
fireEvent.click(screen.getByTestId('detail-toggle'))
fireEvent.mouseEnter(screen.getByTestId('detail-top').parentElement!)
fireEvent.click(screen.getByTestId('detail-toggle'))
expect(screen.getByRole('complementary')).toHaveClass('w-62', 'transition-none')
expect(screen.getByRole('complementary')).not.toHaveClass('overflow-visible')
expect(screen.getByTestId('detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('detail-section')).toHaveAttribute('data-expand', 'true')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('expand')
})
})

View File

@ -0,0 +1,173 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useHotkey } from '@tanstack/react-hotkeys'
import { useCallback, useEffect, useRef, useState } from 'react'
import EnvNav from '@/app/components/header/env-nav'
import AccountSection from '@/app/components/main-nav/components/account-section'
import HelpMenu from '@/app/components/main-nav/components/help-menu'
import { useAppContext } from '@/context/app-context'
import { useDetailSidebarMode } from './storage'
type DetailSidebarRenderProps = {
expand: boolean
onToggle: () => void
}
type DetailSidebarFrameProps = {
className?: string
renderTop: (props: DetailSidebarRenderProps) => ReactNode
renderSection: (props: Pick<DetailSidebarRenderProps, 'expand'>) => ReactNode
}
const secondarySidebarHelpTriggerIcon = <span aria-hidden className="i-ri-question-line size-4 shrink-0" />
function SecondarySidebarHelpMenu({
triggerClassName,
}: {
triggerClassName?: string
}) {
return (
<HelpMenu
triggerIcon={secondarySidebarHelpTriggerIcon}
triggerClassName={triggerClassName}
/>
)
}
export function DetailSidebarFrame({
className,
renderTop,
renderSection,
}: DetailSidebarFrameProps) {
const { langGeniusVersionInfo } = useAppContext()
const [storedDetailSidebarExpand, setStoredDetailSidebarExpand] = useDetailSidebarMode()
const detailNavigationMode = storedDetailSidebarExpand === 'collapse' ? 'collapse' : 'expand'
const detailNavigationExpanded = detailNavigationMode === 'expand'
const [detailNavigationHoverPreviewOpen, setDetailNavigationHoverPreviewOpen] = useState(false)
const [detailNavigationTransitionDisabled, setDetailNavigationTransitionDisabled] = useState(false)
const closeDetailNavigationHoverPreviewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const detailNavigationTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isDetailNavigationHoverPreviewOpen = !detailNavigationExpanded && detailNavigationHoverPreviewOpen
const detailNavigationVisibleExpanded = detailNavigationExpanded || isDetailNavigationHoverPreviewOpen
const bottomNavigationExpanded = detailNavigationVisibleExpanded
const currentEnv = langGeniusVersionInfo?.current_env
const showEnvTag = currentEnv === 'TESTING' || currentEnv === 'DEVELOPMENT'
const handleToggleDetailNavigation = useCallback(() => {
if (isDetailNavigationHoverPreviewOpen) {
if (detailNavigationTransitionTimerRef.current)
clearTimeout(detailNavigationTransitionTimerRef.current)
setDetailNavigationTransitionDisabled(true)
setDetailNavigationHoverPreviewOpen(false)
setStoredDetailSidebarExpand('expand')
detailNavigationTransitionTimerRef.current = setTimeout(() => {
setDetailNavigationTransitionDisabled(false)
}, 200)
return
}
const nextMode = detailNavigationExpanded ? 'collapse' : 'expand'
setDetailNavigationHoverPreviewOpen(false)
setStoredDetailSidebarExpand(nextMode)
}, [detailNavigationExpanded, isDetailNavigationHoverPreviewOpen, setStoredDetailSidebarExpand])
const openDetailNavigationHoverPreview = useCallback(() => {
if (detailNavigationExpanded)
return
if (closeDetailNavigationHoverPreviewTimerRef.current)
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
setDetailNavigationHoverPreviewOpen(true)
}, [detailNavigationExpanded])
const closeDetailNavigationHoverPreview = useCallback(() => {
if (closeDetailNavigationHoverPreviewTimerRef.current)
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
closeDetailNavigationHoverPreviewTimerRef.current = setTimeout(() => {
setDetailNavigationHoverPreviewOpen(false)
}, 120)
}, [])
useEffect(() => {
return () => {
if (closeDetailNavigationHoverPreviewTimerRef.current)
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
if (detailNavigationTransitionTimerRef.current)
clearTimeout(detailNavigationTransitionTimerRef.current)
}
}, [])
useHotkey('Mod+B', (e) => {
e.preventDefault()
handleToggleDetailNavigation()
}, {
ignoreInputs: false,
})
return (
<aside
className={cn(
'relative flex h-full shrink-0 bg-background-body p-1',
detailNavigationTransitionDisabled ? 'transition-none' : 'transition-all',
isDetailNavigationHoverPreviewOpen ? 'w-16 overflow-visible' : detailNavigationExpanded ? 'w-62 overflow-hidden' : 'w-16 overflow-hidden',
className,
)}
>
<div
className={cn(
'flex min-h-0 flex-1 flex-col',
isDetailNavigationHoverPreviewOpen
? 'absolute top-1 bottom-1 left-1 z-40 w-60 overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg shadow-lg'
: 'overflow-hidden rounded-lg bg-components-panel-bg',
detailNavigationVisibleExpanded ? 'w-60' : 'w-14',
)}
onMouseEnter={!detailNavigationExpanded ? openDetailNavigationHoverPreview : undefined}
onMouseLeave={!detailNavigationExpanded ? closeDetailNavigationHoverPreview : undefined}
>
<div className="flex min-h-0 flex-1 flex-col">
{renderTop({
expand: detailNavigationVisibleExpanded,
onToggle: handleToggleDetailNavigation,
})}
{renderSection({
expand: detailNavigationVisibleExpanded,
})}
{showEnvTag && detailNavigationVisibleExpanded && (
<div className="relative z-30 mt-auto shrink-0 px-3 pb-2">
<EnvNav />
</div>
)}
</div>
<div className={cn(
!bottomNavigationExpanded
? 'flex w-full shrink-0 flex-col items-center gap-0.5 rounded-lg px-2 pt-1 pb-3'
: 'flex w-60 items-center justify-between bg-components-panel-bg py-3 pr-1 pl-3',
)}
>
{!bottomNavigationExpanded
? (
<>
<SecondarySidebarHelpMenu triggerClassName="mb-2" />
<AccountSection compact />
</>
)
: (
<>
<div className="flex min-w-0 items-center gap-1 overflow-hidden">
<AccountSection />
</div>
<div className="flex shrink-0 items-center justify-center rounded-full p-1">
<SecondarySidebarHelpMenu />
</div>
</>
)}
</div>
</div>
</aside>
)
}

View File

@ -5,52 +5,30 @@ import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'
import type { IWorkspace } from '@/models/common'
import type { InstalledApp } from '@/models/explore'
import type { SnippetDetail, SnippetInputField } from '@/models/snippet'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { createTestQueryClient, renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useStore as useAppStore } from '@/app/components/app/store'
import { Plan } from '@/app/components/billing/type'
import { DETAIL_SIDEBAR_STORAGE_KEY } from '@/app/components/detail-sidebar/storage'
import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '@/app/components/explore/learn-dify/storage'
import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { PipelineInputVarType } from '@/models/pipeline'
import { usePathname, useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
import { MainNav } from '../index'
import { DETAIL_SIDEBAR_STORAGE_KEY } from '../storage'
const activeGradientMaskClassName = 'aria-[current=page]:dify-blue-glass-surface'
const activeStackingClassName = 'aria-[current=page]:z-1'
type SnippetNavigationTestState = {
onFieldsChange?: (fields: SnippetInputField[]) => void
readonly: boolean
snippet?: SnippetDetail
}
const { mockIsAgentV2Enabled, mockSnippetFieldsChange, mockSwitchWorkspace, mockToastSuccess, hotkeyRegistrations, snippetDraftState, snippetNavigationState } = vi.hoisted(() => ({
const { mockIsAgentV2Enabled, mockSwitchWorkspace, mockToastSuccess } = vi.hoisted(() => ({
mockSwitchWorkspace: vi.fn(),
mockSnippetFieldsChange: vi.fn(),
mockToastSuccess: vi.fn(),
mockIsAgentV2Enabled: vi.fn(() => true),
hotkeyRegistrations: new Map<string, {
handler: (event: { preventDefault: () => void }) => void
options?: { ignoreInputs?: boolean }
}>(),
snippetDraftState: {
inputFields: [],
} as { inputFields: SnippetInputField[] },
snippetNavigationState: {
readonly: true,
snippet: undefined,
onFieldsChange: undefined,
} as SnippetNavigationTestState,
}))
vi.mock('@/features/agent-v2/feature-flag', () => ({
@ -142,102 +120,10 @@ vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
}
})
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
return {
...actual,
useHotkey: (
hotkey: string,
handler: (event: { preventDefault: () => void }) => void,
options?: { ignoreInputs?: boolean },
) => {
hotkeyRegistrations.set(hotkey, { handler, options })
},
}
})
vi.mock('@/app/components/header/github-star', () => ({
default: ({ className }: { className?: string }) => <span className={className}>1,234</span>,
}))
vi.mock('@/app/components/app-sidebar/app-detail-section', () => ({
default: ({ expand }: { expand: boolean }) => <div data-testid="app-detail-section" data-expand={expand} />,
}))
vi.mock('@/app/components/app-sidebar/app-detail-top', () => ({
default: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => (
<div data-testid="app-detail-top" data-expand={expand}>
<button type="button" data-testid="app-detail-toggle" onClick={onToggle}>Toggle</button>
</div>
),
}))
vi.mock('@/app/components/app-sidebar/dataset-detail-section', () => ({
default: ({ expand }: { expand: boolean }) => <div data-testid="dataset-detail-section" data-expand={expand} />,
}))
vi.mock('@/app/components/app-sidebar/dataset-detail-top', () => ({
default: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => (
<div data-testid="dataset-detail-top" data-expand={expand}>
<button type="button" data-testid="dataset-detail-toggle" onClick={onToggle}>Toggle</button>
</div>
),
}))
vi.mock('@/features/agent-v2/agent-detail/navigation', () => ({
AgentDetailSection: ({ expand }: { expand: boolean }) => <div data-testid="agent-detail-section" data-expand={expand} />,
AgentDetailTop: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => (
<div data-testid="agent-detail-top" data-expand={expand}>
<button type="button" data-testid="agent-detail-toggle" onClick={onToggle}>Toggle</button>
</div>
),
}))
vi.mock('@/features/deployments/detail/deployment-sidebar', () => ({
DeploymentDetailSection: ({ expand }: { expand: boolean }) => <div data-testid="deployment-detail-section" data-expand={expand} />,
DeploymentDetailTop: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => (
<div data-testid="deployment-detail-top" data-expand={expand}>
<button type="button" data-testid="deployment-detail-toggle" onClick={onToggle}>Toggle</button>
</div>
),
}))
vi.mock('@/app/components/snippets/store', () => ({
useSnippetDetailStore: (selector: (state: SnippetNavigationTestState) => unknown) => selector(snippetNavigationState),
}))
vi.mock('@/app/components/snippets/draft-store', () => ({
useSnippetDraftStore: (selector: (state: typeof snippetDraftState) => unknown) => selector(snippetDraftState),
}))
vi.mock('@/app/components/snippets/components/snippet-sidebar', () => ({
SnippetSidebarContent: ({
fields,
onFieldsChange,
readonly,
snippet,
}: {
fields: SnippetInputField[]
onFieldsChange: (fields: SnippetInputField[]) => void
readonly: boolean
snippet: SnippetDetail
}) => (
<div data-testid="snippet-sidebar-content" data-readonly={String(readonly)}>
<span>{snippet.name}</span>
<span>{fields.map(field => field.variable).join(',')}</span>
<button type="button" onClick={() => onFieldsChange([])}>change snippet fields</button>
</div>
),
}))
vi.mock('../components/snippet-detail-top', () => ({
default: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => (
<div data-testid="snippet-detail-top" data-expand={expand}>
<button type="button" data-testid="snippet-detail-toggle" onClick={onToggle}>Toggle</button>
</div>
),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
@ -297,24 +183,6 @@ const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp
},
})
const snippet: SnippetDetail = {
id: 'snippet-1',
name: 'Snippet',
description: 'Description',
updatedAt: '2026-03-29 10:00',
usage: '0',
tags: [],
}
const snippetFields: SnippetInputField[] = [
{
label: 'Query',
variable: 'query',
type: PipelineInputVarType.textInput,
required: true,
},
]
const appContextValue: AppContextValue = {
userProfile: {
id: 'user-1',
@ -443,12 +311,6 @@ describe('MainNav', () => {
mutateAsync: mockUpdatePinStatus,
})
mockSwitchWorkspace.mockReturnValue(new Promise(() => {}))
hotkeyRegistrations.clear()
snippetDraftState.inputFields = []
snippetNavigationState.onFieldsChange = undefined
snippetNavigationState.readonly = true
snippetNavigationState.snippet = undefined
useAppStore.getState().setAppDetail()
})
it('renders primary navigation with the planned routes', () => {
@ -551,23 +413,6 @@ describe('MainNav', () => {
expect(container.querySelector('.relative.z-30')).not.toBeInTheDocument()
})
it('hides the environment tag when app detail navigation is collapsed', async () => {
mockPathname = '/app/app-1/overview'
;(useAppContext as Mock).mockReturnValue({
...appContextValue,
langGeniusVersionInfo: {
...appContextValue.langGeniusVersionInfo,
current_env: 'TESTING',
},
})
const { container } = renderMainNav()
fireEvent.click(await screen.findByTestId('app-detail-toggle'))
expect(screen.queryByText('common.environment.testing')).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,
@ -659,194 +504,6 @@ describe('MainNav', () => {
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute('aria-current')
})
it('replaces global navigation with snippet detail navigation on snippet routes', async () => {
mockPathname = '/snippets/snippet-1/orchestrate'
snippetDraftState.inputFields = snippetFields
snippetNavigationState.onFieldsChange = mockSnippetFieldsChange
snippetNavigationState.readonly = false
snippetNavigationState.snippet = snippet
renderMainNav()
expect(screen.getByRole('complementary')).toHaveClass('w-62')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByRole('complementary')).toHaveClass('bg-background-body')
expect(await screen.findByTestId('snippet-detail-top')).toHaveAttribute('data-expand', 'true')
expect(await screen.findByTestId('snippet-sidebar-content')).toHaveAttribute('data-readonly', 'false')
expect(screen.getByText(snippet.name)).toBeInTheDocument()
expect(screen.getByText('query')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'change snippet fields' }))
expect(mockSnippetFieldsChange).toHaveBeenCalledWith([])
expect(screen.queryByLabelText('Dify')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.mainNav.home/ })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.menus.apps/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.account.account' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })).toBeInTheDocument()
})
it('collapses snippet detail navigation from the top-right toggle', async () => {
mockPathname = '/snippets/snippet-1/orchestrate'
snippetDraftState.inputFields = snippetFields
snippetNavigationState.onFieldsChange = mockSnippetFieldsChange
snippetNavigationState.snippet = snippet
renderMainNav()
fireEvent.click(await screen.findByTestId('snippet-detail-toggle'))
expect(screen.getByRole('complementary')).toHaveClass('w-16')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByTestId('snippet-detail-top')).toHaveAttribute('data-expand', 'false')
expect(screen.queryByTestId('snippet-sidebar-content')).not.toBeInTheDocument()
expect(screen.getByLabelText('Snippet collapsed preview')).toBeInTheDocument()
expect(screen.getByLabelText('1 input fields')).toBeInTheDocument()
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
})
it('replaces global navigation with app detail navigation on app routes', async () => {
mockPathname = '/app/app-1/overview'
renderMainNav()
expect(await screen.findByTestId('app-detail-top')).toBeInTheDocument()
expect(await screen.findByTestId('app-detail-section')).toBeInTheDocument()
expect(screen.getByTestId('app-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('app-detail-section')).toHaveAttribute('data-expand', 'true')
expect(screen.getByRole('complementary')).toHaveClass('w-62')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByRole('complementary')).toHaveClass('bg-background-body')
expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.mainNav.home/ })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.menus.apps/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument()
})
it('keeps app detail store when rendering app detail navigation', () => {
mockPathname = '/app/app-1/logs'
useAppStore.getState().setAppDetail({ id: 'app-1' } as ReturnType<typeof useAppStore.getState>['appDetail'])
renderMainNav()
expect(useAppStore.getState().appDetail?.id).toBe('app-1')
})
it('clears app detail store after leaving app routes', async () => {
mockPathname = '/apps'
useAppStore.getState().setAppDetail({ id: 'app-1' } as ReturnType<typeof useAppStore.getState>['appDetail'])
renderMainNav()
await waitFor(() => {
expect(useAppStore.getState().appDetail).toBeUndefined()
})
})
it('collapses app detail navigation from the top-right toggle', async () => {
mockPathname = '/app/app-1/overview'
renderMainNav()
fireEvent.click(await screen.findByTestId('app-detail-toggle'))
expect(screen.getByRole('complementary')).toHaveClass('w-16')
expect(screen.getByRole('complementary')).not.toHaveClass('transition-none')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByTestId('app-detail-top')).toHaveAttribute('data-expand', 'false')
expect(await screen.findByTestId('app-detail-section')).toHaveAttribute('data-expand', 'false')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
})
it('shows app detail navigation as a floating preview when hovering the collapsed top toggle', async () => {
mockPathname = '/app/app-1/overview'
renderMainNav()
fireEvent.click(await screen.findByTestId('app-detail-toggle'))
fireEvent.mouseEnter(screen.getByTestId('app-detail-top').parentElement!)
expect(screen.getByRole('complementary')).toHaveClass('w-16', 'overflow-visible')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
expect(screen.getAllByTestId('app-detail-top')).toHaveLength(1)
expect(screen.getByTestId('app-detail-top')).toHaveAttribute('data-expand', 'true')
expect(await screen.findByTestId('app-detail-section')).toHaveAttribute('data-expand', 'true')
})
it('persists expanded app detail navigation without width animation when clicking the hovered toggle', async () => {
mockPathname = '/app/app-1/overview'
renderMainNav()
fireEvent.click(await screen.findByTestId('app-detail-toggle'))
fireEvent.mouseEnter(screen.getByTestId('app-detail-top').parentElement!)
fireEvent.click(screen.getByTestId('app-detail-toggle'))
expect(screen.getByRole('complementary')).toHaveClass('w-62', 'transition-none')
expect(screen.getByRole('complementary')).not.toHaveClass('overflow-visible')
expect(screen.getByTestId('app-detail-top')).toHaveAttribute('data-expand', 'true')
expect(await screen.findByTestId('app-detail-section')).toHaveAttribute('data-expand', 'true')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('expand')
})
it('replaces global navigation with dataset detail navigation on dataset routes', async () => {
mockPathname = '/datasets/dataset-1/documents'
renderMainNav()
expect(await screen.findByTestId('dataset-detail-top')).toBeInTheDocument()
expect(await screen.findByTestId('dataset-detail-section')).toBeInTheDocument()
expect(screen.getByTestId('dataset-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('dataset-detail-section')).toHaveAttribute('data-expand', 'true')
expect(screen.getByRole('complementary')).toHaveClass('w-62')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByRole('complementary')).toHaveClass('bg-background-body')
expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.mainNav.home/ })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.menus.datasets/ })).not.toBeInTheDocument()
})
it('collapses dataset detail navigation from the top-right toggle', async () => {
mockPathname = '/datasets/dataset-1/documents'
renderMainNav()
fireEvent.click(await screen.findByTestId('dataset-detail-toggle'))
expect(screen.getByRole('complementary')).toHaveClass('w-16')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByTestId('dataset-detail-top')).toHaveAttribute('data-expand', 'false')
expect(await screen.findByTestId('dataset-detail-section')).toHaveAttribute('data-expand', 'false')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
})
it('shows dataset detail navigation as a floating preview when hovering the collapsed top toggle', async () => {
mockPathname = '/datasets/dataset-1/documents'
renderMainNav()
fireEvent.click(await screen.findByTestId('dataset-detail-toggle'))
fireEvent.mouseEnter(screen.getByTestId('dataset-detail-top').parentElement!)
expect(screen.getByRole('complementary')).toHaveClass('w-16', 'overflow-visible')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
expect(screen.getAllByTestId('dataset-detail-top')).toHaveLength(1)
expect(screen.getByTestId('dataset-detail-top')).toHaveAttribute('data-expand', 'true')
expect(await screen.findByTestId('dataset-detail-section')).toHaveAttribute('data-expand', 'true')
})
it('replaces global navigation with agent detail navigation on roster detail routes', async () => {
mockPathname = '/roster/agent/agent-1/configure'
renderMainNav()
expect(await screen.findByTestId('agent-detail-top')).toBeInTheDocument()
const agentDetailNavigation = await screen.findByRole('navigation', { name: 'agentV2.agentDetail.navigationLabel' })
expect(agentDetailNavigation).toBeInTheDocument()
expect(screen.getByRole('link', { name: /agentV2.agentDetail.sections.configure/ })).toHaveAttribute('href', '/roster/agent/agent-1/configure')
expect(screen.getByTestId('agent-detail-top')).toHaveAttribute('data-expand', 'true')
expect(agentDetailNavigation).toHaveClass('px-1')
expect(screen.getByRole('complementary')).toHaveClass('w-62')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByRole('complementary')).toHaveClass('bg-background-body')
expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.menus.roster/ })).not.toBeInTheDocument()
})
it('keeps roster detail navigation hidden when Agent v2 is disabled', () => {
mockIsAgentV2Enabled.mockReturnValue(false)
mockPathname = '/roster/agent/agent-1/configure'
@ -858,49 +515,6 @@ describe('MainNav', () => {
expect(screen.queryByRole('link', { name: /common.menus.roster/ })).not.toBeInTheDocument()
})
it('replaces global navigation with deployment detail navigation on deployment routes', () => {
mockPathname = '/deployments/app-instance-1/releases'
renderMainNav({ branding: { enabled: false }, enable_app_deploy: true })
expect(screen.getByTestId('deployment-detail-top')).toBeInTheDocument()
expect(screen.getByTestId('deployment-detail-section')).toBeInTheDocument()
expect(screen.getByTestId('deployment-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('deployment-detail-section')).toHaveAttribute('data-expand', 'true')
expect(screen.getByRole('complementary')).toHaveClass('w-62')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByRole('complementary')).toHaveClass('bg-background-body')
expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.mainNav.home/ })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument()
})
it('collapses agent detail navigation from the top-right toggle', async () => {
mockPathname = '/roster/agent/agent-1/configure'
renderMainNav()
fireEvent.click(await screen.findByTestId('agent-detail-toggle'))
expect(screen.getByRole('complementary')).toHaveClass('w-16')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByTestId('agent-detail-top')).toHaveAttribute('data-expand', 'false')
expect(await screen.findByRole('navigation', { name: 'agentV2.agentDetail.navigationLabel' })).toHaveClass('px-3')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
})
it('collapses deployment detail navigation from the top-right toggle', () => {
mockPathname = '/deployments/app-instance-1/releases'
renderMainNav({ branding: { enabled: false }, enable_app_deploy: true })
fireEvent.click(screen.getByTestId('deployment-detail-toggle'))
expect(screen.getByRole('complementary')).toHaveClass('w-16')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByTestId('deployment-detail-top')).toHaveAttribute('data-expand', 'false')
expect(screen.getByTestId('deployment-detail-section')).toHaveAttribute('data-expand', 'false')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
})
it.each([
'/deployments',
'/deployments/create',
@ -915,30 +529,6 @@ describe('MainNav', () => {
expect(screen.getByRole('link', { name: /common.menus.deployments/ })).toHaveAttribute('href', '/deployments')
})
it('registers the detail navigation shortcut to run while inputs are focused', () => {
mockPathname = '/app/app-1/overview'
renderMainNav()
expect(hotkeyRegistrations.get('Mod+B')?.options).toEqual(
expect.objectContaining({ ignoreInputs: false }),
)
})
it('shows agent detail navigation as a floating preview when hovering the collapsed top toggle', async () => {
mockPathname = '/roster/agent/agent-1/configure'
renderMainNav()
fireEvent.click(await screen.findByTestId('agent-detail-toggle'))
fireEvent.mouseEnter(screen.getByTestId('agent-detail-top').parentElement!)
expect(screen.getByRole('complementary')).toHaveClass('w-16', 'overflow-visible')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
expect(screen.getAllByTestId('agent-detail-top')).toHaveLength(1)
expect(screen.getByTestId('agent-detail-top')).toHaveAttribute('data-expand', 'true')
expect(await screen.findByRole('navigation', { name: 'agentV2.agentDetail.navigationLabel' })).toHaveClass('px-1')
})
it.each([
'/datasets/create',
'/datasets/create-from-pipeline',

View File

@ -1,5 +1,11 @@
import type { ReactNode } from 'react'
import type { Mock } from 'vitest'
import { useSuspenseQuery } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
import { usePathname } from '@/next/navigation'
import MainNavLayout from '../layout'
vi.mock('@/app/components/header', () => ({
@ -16,13 +22,50 @@ vi.mock('react-i18next', () => ({
}),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useSuspenseQuery: vi.fn(),
}
})
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/features/agent-v2/feature-flag', () => ({
isAgentV2Enabled: vi.fn(),
}))
vi.mock('@/next/navigation', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/next/navigation')>()
return {
...actual,
usePathname: vi.fn(),
}
})
vi.mock('../index', () => ({
MainNav: ({ className }: { className?: string }) => <aside className={className} data-testid="main-nav">MainNav</aside>,
}))
describe('MainNavLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
useAppStore.getState().setAppDetail()
;(usePathname as Mock).mockReturnValue('/apps')
;(useAppContext as Mock).mockReturnValue({
isCurrentWorkspaceDatasetOperator: false,
isCurrentWorkspaceEditor: true,
})
;(useSuspenseQuery as Mock).mockReturnValue({
data: {
enable_app_deploy: true,
},
})
;(isAgentV2Enabled as Mock).mockReturnValue(true)
})
it('renders desktop main nav instead of the desktop header', () => {
@ -73,4 +116,105 @@ describe('MainNavLayout', () => {
expect(main).toHaveFocus()
})
it.each([
'/datasets/dataset-1/documents',
'/datasets/dataset-1/documents/document-1/settings',
])('renders the detail sidebar slot outside the single skip navigation target on route %s', (pathname) => {
;(usePathname as Mock).mockReturnValue(pathname)
render(
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
<div>dataset detail</div>
</MainNavLayout>,
)
const main = screen.getByRole('main')
const detailSidebar = screen.getByRole('complementary', { name: 'Detail sidebar' })
expect(screen.queryByTestId('main-nav')).not.toBeInTheDocument()
expect(screen.getAllByRole('main')).toHaveLength(1)
expect(main).toHaveAttribute('id', 'main-content')
expect(main).toHaveTextContent('dataset detail')
expect(main).not.toContainElement(detailSidebar)
})
it.each([
'/datasets/create',
'/datasets/dataset-1/documents/create',
'/deployments/create',
])('keeps the global main nav on collection and creation route %s', (pathname) => {
;(usePathname as Mock).mockReturnValue(pathname)
render(
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
<div>content</div>
</MainNavLayout>,
)
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
})
it.each([
{
label: 'agent detail route for dataset operators',
pathname: '/roster/agent/agent-1/configure',
appContext: {
isCurrentWorkspaceDatasetOperator: true,
isCurrentWorkspaceEditor: true,
},
systemFeatures: {
enable_app_deploy: true,
},
},
{
label: 'deployment detail route for non-editor workspaces',
pathname: '/deployments/app-instance-1/overview',
appContext: {
isCurrentWorkspaceDatasetOperator: false,
isCurrentWorkspaceEditor: false,
},
systemFeatures: {
enable_app_deploy: true,
},
},
{
label: 'deployment detail route when deployment is disabled',
pathname: '/deployments/app-instance-1/overview',
appContext: {
isCurrentWorkspaceDatasetOperator: false,
isCurrentWorkspaceEditor: true,
},
systemFeatures: {
enable_app_deploy: false,
},
},
])('keeps the global main nav on $label', ({ pathname, appContext, systemFeatures }) => {
;(usePathname as Mock).mockReturnValue(pathname)
;(useAppContext as Mock).mockReturnValue(appContext)
;(useSuspenseQuery as Mock).mockReturnValue({
data: systemFeatures,
})
render(
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
<div>detail route content</div>
</MainNavLayout>,
)
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
expect(screen.getAllByRole('main')).toHaveLength(1)
expect(screen.getByRole('main')).toHaveTextContent('detail route content')
})
it('clears app detail state after leaving app routes', () => {
useAppStore.getState().setAppDetail({ id: 'app-1' } as ReturnType<typeof useAppStore.getState>['appDetail'])
;(usePathname as Mock).mockReturnValue('/datasets')
render(<MainNavLayout><div>content</div></MainNavLayout>)
expect(useAppStore.getState().appDetail).toBeUndefined()
})
})

View File

@ -2,18 +2,14 @@
import type { MainNavItem, MainNavProps } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { useHotkey } from '@tanstack/react-hotkeys'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import Badge from '@/app/components/base/badge'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import EnvNav from '@/app/components/header/env-nav'
import { useAppContext } from '@/context/app-context'
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
import { DeploymentDetailSection, DeploymentDetailTop } from '@/features/deployments/detail/deployment-sidebar'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import dynamic from '@/next/dynamic'
import Link from '@/next/link'
@ -24,69 +20,9 @@ import MainNavLink from './components/nav-link'
import { MainNavSearchButton } from './components/search-button'
import { WorkspaceCard } from './components/workspace-card'
import { isMainNavRouteVisible, MAIN_NAV_ROUTES } from './routes'
import { useDetailSidebarMode } from './storage'
const DATASET_COLLECTION_ROUTES = new Set(['create', 'create-from-pipeline', 'connect'])
const DATASET_DOCUMENT_CREATION_ROUTES = new Set(['create', 'create-from-pipeline'])
const DEPLOYMENT_COLLECTION_ROUTES = new Set(['create'])
const secondarySidebarHelpTriggerIcon = <span aria-hidden className="i-ri-question-line size-4 shrink-0" />
const AppDetailSection = dynamic(() => import('@/app/components/app-sidebar/app-detail-section'), { ssr: false })
const AppDetailTop = dynamic(() => import('@/app/components/app-sidebar/app-detail-top'), { ssr: false })
const DatasetDetailSection = dynamic(() => import('@/app/components/app-sidebar/dataset-detail-section'), { ssr: false })
const DatasetDetailTop = dynamic(() => import('@/app/components/app-sidebar/dataset-detail-top'), { ssr: false })
const AgentDetailSection = dynamic(() => import('@/features/agent-v2/agent-detail/navigation').then(mod => mod.AgentDetailSection), { ssr: false })
const AgentDetailTop = dynamic(() => import('@/features/agent-v2/agent-detail/navigation').then(mod => mod.AgentDetailTop), { ssr: false })
const SnippetDetailTop = dynamic(() => import('./components/snippet-detail-top'), { ssr: false })
const SnippetDetailSection = dynamic(() => import('./components/snippet-detail-section').then(mod => mod.SnippetDetailSection), { ssr: false })
const WebAppsSection = dynamic(() => import('./components/web-apps-section'), { ssr: false })
function SecondarySidebarHelpMenu({
triggerClassName,
}: {
triggerClassName?: string
}) {
return (
<HelpMenu
triggerIcon={secondarySidebarHelpTriggerIcon}
triggerClassName={triggerClassName}
/>
)
}
const isDatasetDetailPathname = (pathname: string) => {
const [section, datasetId, subSection, action] = pathname.split('/').filter(Boolean)
if (section !== 'datasets' || !datasetId)
return false
if (DATASET_COLLECTION_ROUTES.has(datasetId))
return false
if (subSection === 'documents' && action && DATASET_DOCUMENT_CREATION_ROUTES.has(action))
return false
return true
}
const isAgentDetailPathname = (pathname: string) => {
const [section, type, agentId] = pathname.split('/').filter(Boolean)
return section === 'roster' && type === 'agent' && !!agentId
}
const isDeploymentDetailPathname = (pathname: string) => {
const [section, appInstanceId] = pathname.split('/').filter(Boolean)
return section === 'deployments' && !!appInstanceId && !DEPLOYMENT_COLLECTION_ROUTES.has(appInstanceId)
}
const isSnippetDetailPathname = (pathname: string) => {
const [section, snippetId] = pathname.split('/').filter(Boolean)
return section === 'snippets' && !!snippetId
}
export function MainNav({
className,
}: MainNavProps) {
@ -97,88 +33,6 @@ export function MainNav({
const agentV2Enabled = isAgentV2Enabled()
const showEnvTag = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT'
const canUseAppDeploy = isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy
const showAppDetailNavigation = !isCurrentWorkspaceDatasetOperator && pathname.startsWith('/app/')
const showDatasetDetailNavigation = isDatasetDetailPathname(pathname)
const showAgentDetailNavigation = agentV2Enabled && !isCurrentWorkspaceDatasetOperator && isAgentDetailPathname(pathname)
const showDeploymentDetailNavigation = canUseAppDeploy && !isCurrentWorkspaceDatasetOperator && isDeploymentDetailPathname(pathname)
const showSnippetDetailNavigation = isSnippetDetailPathname(pathname)
const showDetailNavigation = showAppDetailNavigation || showDatasetDetailNavigation || showAgentDetailNavigation || showDeploymentDetailNavigation || showSnippetDetailNavigation
const { hasAppDetail, setAppDetail } = useAppStore(useShallow(state => ({
hasAppDetail: !!state.appDetail,
setAppDetail: state.setAppDetail,
})))
const [storedDetailSidebarExpand, setStoredDetailSidebarExpand] = useDetailSidebarMode()
const detailNavigationMode = storedDetailSidebarExpand === 'collapse' ? 'collapse' : 'expand'
const detailNavigationExpanded = detailNavigationMode === 'expand'
const isCollapsedDetailNavigation = showDetailNavigation && !detailNavigationExpanded
const [detailNavigationHoverPreviewOpen, setDetailNavigationHoverPreviewOpen] = useState(false)
const [detailNavigationTransitionDisabled, setDetailNavigationTransitionDisabled] = useState(false)
const closeDetailNavigationHoverPreviewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const detailNavigationTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isDetailNavigationHoverPreviewOpen = isCollapsedDetailNavigation && detailNavigationHoverPreviewOpen
const detailNavigationVisibleExpanded = detailNavigationExpanded || isDetailNavigationHoverPreviewOpen
const bottomNavigationExpanded = !showDetailNavigation || detailNavigationVisibleExpanded
const handleToggleDetailNavigation = useCallback(() => {
if (isDetailNavigationHoverPreviewOpen) {
if (detailNavigationTransitionTimerRef.current)
clearTimeout(detailNavigationTransitionTimerRef.current)
setDetailNavigationTransitionDisabled(true)
setDetailNavigationHoverPreviewOpen(false)
setStoredDetailSidebarExpand('expand')
detailNavigationTransitionTimerRef.current = setTimeout(() => {
setDetailNavigationTransitionDisabled(false)
}, 200)
return
}
const nextMode = detailNavigationExpanded ? 'collapse' : 'expand'
setDetailNavigationHoverPreviewOpen(false)
setStoredDetailSidebarExpand(nextMode)
}, [detailNavigationExpanded, isDetailNavigationHoverPreviewOpen, setStoredDetailSidebarExpand])
const openDetailNavigationHoverPreview = useCallback(() => {
if (!isCollapsedDetailNavigation)
return
if (closeDetailNavigationHoverPreviewTimerRef.current)
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
setDetailNavigationHoverPreviewOpen(true)
}, [isCollapsedDetailNavigation])
const closeDetailNavigationHoverPreview = useCallback(() => {
if (closeDetailNavigationHoverPreviewTimerRef.current)
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
closeDetailNavigationHoverPreviewTimerRef.current = setTimeout(() => {
setDetailNavigationHoverPreviewOpen(false)
}, 120)
}, [])
useEffect(() => {
return () => {
if (closeDetailNavigationHoverPreviewTimerRef.current)
clearTimeout(closeDetailNavigationHoverPreviewTimerRef.current)
if (detailNavigationTransitionTimerRef.current)
clearTimeout(detailNavigationTransitionTimerRef.current)
}
}, [])
useEffect(() => {
if (pathname.startsWith('/app/') || !hasAppDetail)
return
setAppDetail()
}, [hasAppDetail, pathname, setAppDetail])
useHotkey('Mod+B', (e) => {
if (!showDetailNavigation)
return
e.preventDefault()
handleToggleDetailNavigation()
}, {
ignoreInputs: false,
})
const navItems = useMemo<MainNavItem[]>(() => MAIN_NAV_ROUTES
.filter(route => isMainNavRouteVisible(route, {
@ -220,143 +74,45 @@ export function MainNav({
return (
<aside
className={cn(
'relative flex h-full shrink-0',
detailNavigationTransitionDisabled ? 'transition-none' : 'transition-all',
isDetailNavigationHoverPreviewOpen ? 'overflow-visible' : 'overflow-hidden',
showDetailNavigation
? detailNavigationExpanded
? 'w-62 bg-background-body p-1'
: 'w-16 bg-background-body p-1'
: 'w-60 flex-col',
'bg-background-body',
'relative flex h-full w-60 shrink-0 flex-col overflow-hidden bg-background-body transition-all',
className,
)}
>
<div
className={cn(
'flex min-h-0 flex-1 flex-col',
showDetailNavigation && (
isDetailNavigationHoverPreviewOpen
? 'absolute top-1 bottom-1 left-1 z-40 w-60 overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg shadow-lg'
: 'overflow-hidden rounded-lg bg-components-panel-bg'
),
showDetailNavigation && (detailNavigationVisibleExpanded ? 'w-60' : 'w-14'),
)}
onMouseEnter={isCollapsedDetailNavigation ? openDetailNavigationHoverPreview : undefined}
onMouseLeave={isCollapsedDetailNavigation ? closeDetailNavigationHoverPreview : undefined}
>
<div className="flex min-h-0 flex-1 flex-col">
{showDetailNavigation
? showAppDetailNavigation
? (
<AppDetailTop
expand={detailNavigationVisibleExpanded}
onToggle={handleToggleDetailNavigation}
/>
)
: showDatasetDetailNavigation
? (
<DatasetDetailTop
expand={detailNavigationVisibleExpanded}
onToggle={handleToggleDetailNavigation}
/>
)
: showAgentDetailNavigation
? (
<AgentDetailTop
expand={detailNavigationVisibleExpanded}
onToggle={handleToggleDetailNavigation}
/>
)
: showDeploymentDetailNavigation
? (
<DeploymentDetailTop
expand={detailNavigationVisibleExpanded}
onToggle={handleToggleDetailNavigation}
/>
)
: (
<SnippetDetailTop
expand={detailNavigationVisibleExpanded}
onToggle={handleToggleDetailNavigation}
/>
)
: (
<>
<div className="flex items-center justify-between pt-3 pr-2 pb-2 pl-4">
{renderLogo()}
<MainNavSearchButton />
</div>
<div className="p-2">
<WorkspaceCard />
</div>
</>
)}
{showDetailNavigation
? showAppDetailNavigation
? <AppDetailSection expand={detailNavigationVisibleExpanded} />
: showDatasetDetailNavigation
? <DatasetDetailSection expand={detailNavigationVisibleExpanded} />
: showAgentDetailNavigation
? <AgentDetailSection expand={detailNavigationVisibleExpanded} />
: showDeploymentDetailNavigation
? <DeploymentDetailSection expand={detailNavigationVisibleExpanded} />
: <SnippetDetailSection expand={detailNavigationVisibleExpanded} />
: (
<>
<nav className="isolate flex flex-col gap-px p-2">
{navItems.map(item => (
<MainNavLink key={item.href} item={item} pathname={pathname}>
{item.href === '/roster' && (
<Badge
size="xs"
variant="dimm"
text={t('menus.status', { ns: 'common' })}
className="ml-auto shrink-0"
/>
)}
</MainNavLink>
))}
</nav>
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
</>
)}
{showEnvTag && detailNavigationVisibleExpanded && (
<div className="relative z-30 mt-auto shrink-0 px-3 pb-2">
<EnvNav />
</div>
)}
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center justify-between pt-3 pr-2 pb-2 pl-4">
{renderLogo()}
<MainNavSearchButton />
</div>
<div className={cn(
!bottomNavigationExpanded
? 'flex w-full shrink-0 flex-col items-center gap-0.5 rounded-lg px-2 pt-1 pb-3'
: cn(
'flex w-60 items-center justify-between py-3 pr-1 pl-3',
showDetailNavigation
? 'bg-components-panel-bg'
: 'bg-gradient-to-b from-background-body-transparent to-background-body to-50% backdrop-blur-[2px]',
),
)}
>
{!bottomNavigationExpanded
? (
<>
<SecondarySidebarHelpMenu triggerClassName="mb-2" />
<AccountSection compact />
</>
)
: (
<>
<div className="flex min-w-0 items-center gap-1 overflow-hidden">
<AccountSection />
</div>
{(!showDetailNavigation || detailNavigationVisibleExpanded) && (
<div className="flex shrink-0 items-center justify-center rounded-full p-1">
{showDetailNavigation ? <SecondarySidebarHelpMenu /> : <HelpMenu />}
</div>
)}
</>
<div className="p-2">
<WorkspaceCard />
</div>
<nav className="isolate flex flex-col gap-px p-2">
{navItems.map(item => (
<MainNavLink key={item.href} item={item} pathname={pathname}>
{item.href === '/roster' && (
<Badge
size="xs"
variant="dimm"
text={t('menus.status', { ns: 'common' })}
className="ml-auto shrink-0"
/>
)}
</MainNavLink>
))}
</nav>
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
{showEnvTag && (
<div className="relative z-30 mt-auto shrink-0 px-3 pb-2">
<EnvNav />
</div>
)}
</div>
<div className="flex w-60 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]">
<div className="flex min-w-0 items-center gap-1 overflow-hidden">
<AccountSection />
</div>
<div className="flex shrink-0 items-center justify-center rounded-full p-1">
<HelpMenu />
</div>
</div>
</aside>

View File

@ -1,27 +1,64 @@
'use client'
import type { ReactNode } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { usePathname } from '@/next/navigation'
import { MainNav } from '.'
import { shouldUseDetailSidebar } from './routes'
import { MAIN_CONTENT_ID, SkipNav } from './skip-nav'
type MainNavLayoutProps = {
children: ReactNode
detailSidebar?: ReactNode
}
function AppDetailStoreCleanup() {
const pathname = usePathname()
const { hasAppDetail, setAppDetail } = useAppStore(useShallow(state => ({
hasAppDetail: !!state.appDetail,
setAppDetail: state.setAppDetail,
})))
useEffect(() => {
if (pathname.startsWith('/app/') || !hasAppDetail)
return
setAppDetail()
}, [hasAppDetail, pathname, setAppDetail])
return null
}
const MainNavLayout = ({
children,
detailSidebar,
}: MainNavLayoutProps) => {
const { t } = useTranslation('common')
const pathname = usePathname()
const { isCurrentWorkspaceDatasetOperator, isCurrentWorkspaceEditor } = useAppContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const shouldHideMainNav = shouldUseDetailSidebar(pathname, {
agentV2Enabled: isAgentV2Enabled(),
canUseAppDeploy: isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy,
isCurrentWorkspaceDatasetOperator,
})
return (
<div className="flex h-0 min-h-0 grow overflow-hidden bg-background-body">
<div className="flex h-0 min-h-0 min-w-0 grow overflow-hidden bg-background-body">
<SkipNav>{t('navigation.skipToMain')}</SkipNav>
<MainNav />
<AppDetailStoreCleanup />
{shouldHideMainNav ? detailSidebar : <MainNav />}
<main
id={MAIN_CONTENT_ID}
tabIndex={-1}
className="flex min-w-0 grow flex-col overflow-hidden outline-hidden focus:outline-hidden focus-visible:outline-hidden"
className="flex min-h-0 min-w-0 grow flex-col overflow-hidden outline-hidden focus:outline-hidden focus-visible:outline-hidden"
>
{children}
</main>

View File

@ -2,6 +2,10 @@ import { buildIntegrationPath } from '@/app/components/integrations/routes'
type MainNavRouteVisibility = 'all' | 'notDatasetOperator' | 'appDeployEditor'
const DATASET_COLLECTION_ROUTES = new Set(['create', 'create-from-pipeline', 'connect'])
const DATASET_DOCUMENT_CREATION_ROUTES = new Set(['create', 'create-from-pipeline'])
const DEPLOYMENT_COLLECTION_ROUTES = new Set(['create'])
export type MainNavRouteConfig = {
key: string
href: string
@ -20,6 +24,11 @@ export type MainNavRouteVisibilityOptions = {
marketplaceEnabled: boolean
}
export type DetailSidebarVisibilityOptions = Pick<
MainNavRouteVisibilityOptions,
'agentV2Enabled' | 'canUseAppDeploy' | 'isCurrentWorkspaceDatasetOperator'
>
function isPathUnderRoute(pathname: string, route: string) {
return pathname === route || pathname.startsWith(`${route}/`)
}
@ -107,3 +116,56 @@ export function isMainNavRouteVisible(route: MainNavRouteConfig, options: MainNa
return options.canUseAppDeploy
}
function isAppDetailPathname(pathname: string) {
return pathname.startsWith('/app/')
}
function isDatasetDetailPathname(pathname: string) {
const [section, datasetId, subSection, action] = pathname.split('/').filter(Boolean)
if (section !== 'datasets' || !datasetId)
return false
if (DATASET_COLLECTION_ROUTES.has(datasetId))
return false
if (subSection === 'documents' && action && DATASET_DOCUMENT_CREATION_ROUTES.has(action))
return false
return true
}
function isAgentDetailPathname(pathname: string) {
const [section, type, agentId] = pathname.split('/').filter(Boolean)
return section === 'roster' && type === 'agent' && !!agentId
}
function isDeploymentDetailPathname(pathname: string) {
const [section, appInstanceId] = pathname.split('/').filter(Boolean)
return section === 'deployments' && !!appInstanceId && !DEPLOYMENT_COLLECTION_ROUTES.has(appInstanceId)
}
function isSnippetDetailPathname(pathname: string) {
const [section, snippetId] = pathname.split('/').filter(Boolean)
return section === 'snippets' && !!snippetId
}
export function shouldUseDetailSidebar(pathname: string, options: DetailSidebarVisibilityOptions) {
if (isDatasetDetailPathname(pathname) || isSnippetDetailPathname(pathname))
return true
if (options.isCurrentWorkspaceDatasetOperator)
return false
if (isAppDetailPathname(pathname))
return true
if (options.agentV2Enabled && isAgentDetailPathname(pathname))
return true
return options.canUseAppDeploy && isDeploymentDetailPathname(pathname)
}

View File

@ -155,12 +155,13 @@ describe('SnippetPage', () => {
})
})
it('should render the orchestrate route shell with independent main content', () => {
it('should render the orchestrate route shell without owning the main landmark', () => {
render(<SnippetPage snippetId="snippet-1" />)
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument()
expect(screen.getByTestId('snippet-main')).toHaveTextContent('snippet-1')
expect(screen.queryByRole('main')).not.toBeInTheDocument()
})
it('should initialize workflow context with published graph data when the published workflow exists', () => {
@ -203,4 +204,16 @@ describe('SnippetPage', () => {
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should keep the detail route shell while orchestrate data is loading', () => {
mockUseSnippetInit.mockReturnValue({
data: undefined,
isLoading: true,
})
render(<SnippetPage snippetId="snippet-1" />)
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByRole('main')).not.toBeInTheDocument()
})
})

View File

@ -42,7 +42,7 @@ describe('SnippetLayout', () => {
})
describe('Layout', () => {
it('should render the detail content without the app detail sidebar navigation', () => {
it('should render the detail content without owning sidebar navigation', () => {
render(
<SnippetLayout
snippetId="snippet-1"

View File

@ -0,0 +1,19 @@
'use client'
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
import { SnippetDetailSection } from './snippet-detail-section'
import SnippetDetailTop from './snippet-detail-top'
export function SnippetDetailSidebar() {
return (
<DetailSidebarFrame
renderTop={({ expand, onToggle }) => (
<SnippetDetailTop
expand={expand}
onToggle={onToggle}
/>
)}
renderSection={({ expand }) => <SnippetDetailSection expand={expand} />}
/>
)
}

View File

@ -8,7 +8,7 @@ import useDocumentTitle from '@/hooks/use-document-title'
type SnippetLayoutProps = {
children: ReactNode
section: SnippetSection
snippet: SnippetDetail
snippet?: SnippetDetail
snippetId: string
}
@ -18,10 +18,10 @@ const SnippetLayout = ({
}: SnippetLayoutProps) => {
const { t } = useTranslation('snippet')
useDocumentTitle(snippet.name || t('typeLabel'))
useDocumentTitle(snippet?.name || t('typeLabel'))
return (
<div className="relative flex h-full overflow-hidden bg-background-body">
<div className="relative flex h-full min-h-0 min-w-0 overflow-hidden bg-background-body">
<div className="relative min-h-0 min-w-0 grow overflow-hidden">
<div className="absolute inset-0 min-h-0 min-w-0 overflow-hidden">
{children}

View File

@ -16,11 +16,16 @@ type SnippetPageProps = {
snippetId: string
}
const SnippetPageLoading = () => {
const SnippetPageLoading = ({ snippetId }: SnippetPageProps) => {
return (
<div className="flex h-full items-center justify-center bg-background-body">
<Loading />
</div>
<SnippetLayout
snippetId={snippetId}
section="orchestrate"
>
<div className="flex h-full items-center justify-center bg-background-body">
<Loading />
</div>
</SnippetLayout>
)
}
@ -52,7 +57,7 @@ const SnippetPage = ({ snippetId }: SnippetPageProps) => {
}, [data])
if (!data || isLoading) {
return <SnippetPageLoading />
return <SnippetPageLoading snippetId={snippetId} />
}
const hasPublishedWorkflow = !!data.publishedWorkflow

View File

@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react'
import { AgentDetailLayout } from '../layout'
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: vi.fn(() => ({
data: {
name: 'Agent',
},
})),
}
})
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
agent: {
byAgentId: {
get: {
queryOptions: vi.fn(input => input),
},
},
},
},
}))
describe('AgentDetailLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render detail content without owning navigation landmarks', () => {
render(
<AgentDetailLayout agentId="agent-1">
<div>Agent detail content</div>
</AgentDetailLayout>,
)
expect(screen.getByText('Agent detail content')).toBeInTheDocument()
expect(screen.queryByRole('main')).not.toBeInTheDocument()
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
})
})

View File

@ -430,6 +430,28 @@ describe('AgentConfigurePage', () => {
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
expect(screen.queryByRole('region', { name: 'orchestrate-panel' })).not.toBeInTheDocument()
})
it('should expose a single configure workspace region after loading', () => {
const queryClient = new QueryClient()
mocks.queryState.composer = {
data: {},
isFetching: false,
isError: false,
isPending: false,
isSuccess: true,
refetch: vi.fn(),
}
render(
<QueryClientProvider client={queryClient}>
<AgentConfigurePage agentId="agent-1" />
</QueryClientProvider>,
)
expect(screen.getAllByRole('region', { name: 'agentV2.agentDetail.sections.configure' })).toHaveLength(1)
expect(screen.getByRole('region', { name: 'agentV2.agentDetail.sections.configure' })).toBeVisible()
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toBeInTheDocument()
})
})
describe('Right panel mode', () => {

View File

@ -128,7 +128,6 @@ export function AgentOrchestratePanel({
<ScrollArea
className="min-h-0 flex-1 overflow-hidden"
label={showHeader ? undefined : orchestrateLabel}
labelledBy={showHeader ? orchestrateHeadingId : undefined}
slotClassNames={{
viewport: 'overscroll-contain',
content: cn('min-h-full px-4 py-3', hasBottomAction && 'pb-20'),

View File

@ -0,0 +1,18 @@
'use client'
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
import { AgentDetailSection, AgentDetailTop } from './navigation'
export function AgentDetailSidebar() {
return (
<DetailSidebarFrame
renderTop={({ expand, onToggle }) => (
<AgentDetailTop
expand={expand}
onToggle={onToggle}
/>
)}
renderSection={({ expand }) => <AgentDetailSection expand={expand} />}
/>
)
}

View File

@ -27,7 +27,7 @@ export function AgentDetailLayout({
useDocumentTitle(agentQuery.data?.name ?? t('agentDetail.documentTitle'))
return (
<div className="relative flex h-full min-w-0 flex-col overflow-hidden">
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 min-w-0 flex-1 overflow-auto">
{children}
</div>

View File

@ -0,0 +1,62 @@
import type { Mock } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { NextRouteStateBridge } from '@/app/components/next-route-state'
import { useParams, usePathname } from '@/next/navigation'
import { InstanceDetail } from '..'
vi.mock('@/next/navigation', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/next/navigation')>()
return {
...actual,
useParams: vi.fn(),
usePathname: vi.fn(),
}
})
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('../api-tokens/developer-api-header-switch', () => ({
DeveloperApiHeaderSwitch: () => null,
}))
vi.mock('../instances/header-actions/new-deployment-button', () => ({
NewDeploymentHeaderAction: () => null,
}))
vi.mock('../../create-release', () => ({
CreateReleaseControl: () => null,
}))
function renderInstanceDetail() {
;(usePathname as Mock).mockReturnValue('/deployments/app-instance-1/overview')
;(useParams as Mock).mockReturnValue({
appInstanceId: 'app-instance-1',
})
return render(
<JotaiProvider>
<NextRouteStateBridge>
<InstanceDetail>
<div>Deployment detail content</div>
</InstanceDetail>
</NextRouteStateBridge>
</JotaiProvider>,
)
}
describe('InstanceDetail', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render detail content without owning navigation landmarks', () => {
renderInstanceDetail()
expect(screen.getByText('Deployment detail content')).toBeInTheDocument()
expect(screen.queryByRole('main')).not.toBeInTheDocument()
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,18 @@
'use client'
import { DetailSidebarFrame } from '@/app/components/detail-sidebar'
import { DeploymentDetailSection, DeploymentDetailTop } from './deployment-sidebar'
export function DeploymentDetailSidebar() {
return (
<DetailSidebarFrame
renderTop={({ expand, onToggle }) => (
<DeploymentDetailTop
expand={expand}
onToggle={onToggle}
/>
)}
renderSection={({ expand }) => <DeploymentDetailSection expand={expand} />}
/>
)
}

View File

@ -57,7 +57,7 @@ export function InstanceDetail({ children }: {
return null
return (
<div className="relative m-1 ml-0 flex min-h-0 flex-1 overflow-hidden rounded-lg shadow-xs">
<div className="relative m-1 ml-0 flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-lg shadow-xs">
<div className="min-w-0 grow overflow-hidden bg-components-panel-bg">
<div className="h-full min-w-0 overflow-y-auto">
<div className="flex min-h-full w-full flex-col">