From edf21269b3f8e146703b984672daf311ed14fe2f Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Wed, 1 Jul 2026 14:07:07 +0800 Subject: [PATCH] refactor(web): decouple detail sidebars from main nav (#38241) --- .../app/[appId]/access-config/page.tsx | 5 + .../app/[appId]/annotations/page.tsx | 5 + .../app/[appId]/configuration/page.tsx | 5 + .../app/[appId]/develop/page.tsx | 5 + .../@detailSidebar/app/[appId]/logs/page.tsx | 5 + .../app/[appId]/overview/page.tsx | 5 + .../app/[appId]/sidebar-page.tsx | 5 + .../app/[appId]/workflow/page.tsx | 5 + .../[datasetId]/access-config/page.tsx | 5 + .../datasets/[datasetId]/api/page.tsx | 5 + .../documents/[documentId]/page.tsx | 5 + .../documents/[documentId]/settings/page.tsx | 5 + .../documents/create-from-pipeline/page.tsx | 3 + .../[datasetId]/documents/create/page.tsx | 3 + .../datasets/[datasetId]/documents/page.tsx | 5 + .../datasets/[datasetId]/hitTesting/page.tsx | 5 + .../datasets/[datasetId]/pipeline/page.tsx | 5 + .../datasets/[datasetId]/settings/page.tsx | 5 + .../datasets/[datasetId]/sidebar-page.tsx | 5 + .../@detailSidebar/datasets/connect/page.tsx | 3 + .../datasets/create-from-pipeline/page.tsx | 3 + .../@detailSidebar/datasets/create/page.tsx | 3 + .../(commonLayout)/@detailSidebar/default.tsx | 3 + .../[appInstanceId]/access/page.tsx | 5 + .../[appInstanceId]/api-tokens/page.tsx | 5 + .../[appInstanceId]/instances/page.tsx | 5 + .../[appInstanceId]/overview/page.tsx | 5 + .../deployments/[appInstanceId]/page.tsx | 5 + .../[appInstanceId]/releases/page.tsx | 5 + .../[appInstanceId]/sidebar-page.tsx | 5 + .../deployments/create/page.tsx | 3 + .../roster/agent/[agentId]/access/page.tsx | 5 + .../roster/agent/[agentId]/configure/page.tsx | 5 + .../roster/agent/[agentId]/logs/page.tsx | 5 + .../agent/[agentId]/monitoring/page.tsx | 5 + .../roster/agent/[agentId]/page.tsx | 5 + .../roster/agent/[agentId]/sidebar-page.tsx | 5 + .../snippets/[snippetId]/orchestrate/page.tsx | 5 + .../snippets/[snippetId]/page.tsx | 5 + .../snippets/[snippetId]/sidebar-page.tsx | 5 + .../[appId]/__tests__/layout-main.spec.tsx | 29 ++ .../(appDetailLayout)/[appId]/layout-main.tsx | 43 +- .../__tests__/layout-main.spec.tsx | 57 +++ .../[datasetId]/layout-main.tsx | 57 ++- web/app/(commonLayout)/layout.tsx | 10 +- .../app-sidebar/app-detail-sidebar.tsx | 19 + .../app-sidebar/dataset-detail-sidebar.tsx | 19 + .../__tests__/use-configuration.spec.tsx | 2 +- .../configuration/hooks/use-configuration.ts | 2 +- .../detail-sidebar/__tests__/index.spec.tsx | 125 ++++++ web/app/components/detail-sidebar/index.tsx | 173 ++++++++ .../{main-nav => detail-sidebar}/storage.ts | 0 .../main-nav/__tests__/index.spec.tsx | 414 +----------------- .../main-nav/__tests__/layout.spec.tsx | 144 ++++++ web/app/components/main-nav/index.tsx | 314 ++----------- web/app/components/main-nav/layout.tsx | 43 +- web/app/components/main-nav/routes.ts | 62 +++ .../snippets/__tests__/index.spec.tsx | 15 +- .../__tests__/snippet-layout.spec.tsx | 2 +- .../components/snippet-detail-section.tsx | 0 .../components/snippet-detail-sidebar.tsx | 19 + .../components/snippet-detail-top.tsx | 0 .../snippets/components/snippet-layout.tsx | 6 +- web/app/components/snippets/index.tsx | 15 +- .../agent-detail/__tests__/layout.spec.tsx | 48 ++ .../configure/__tests__/page.spec.tsx | 22 + .../components/orchestrate/index.tsx | 1 - .../agent-v2/agent-detail/detail-sidebar.tsx | 18 + web/features/agent-v2/agent-detail/layout.tsx | 2 +- .../detail/__tests__/index.spec.tsx | 62 +++ .../deployments/detail/detail-sidebar.tsx | 18 + web/features/deployments/detail/index.tsx | 2 +- 72 files changed, 1169 insertions(+), 760 deletions(-) create mode 100644 web/app/(commonLayout)/@detailSidebar/app/[appId]/access-config/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/app/[appId]/annotations/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/app/[appId]/configuration/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/app/[appId]/develop/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/app/[appId]/logs/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/app/[appId]/overview/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/app/[appId]/sidebar-page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/app/[appId]/workflow/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/access-config/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/api/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/[documentId]/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/[documentId]/settings/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/create-from-pipeline/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/create/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/hitTesting/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/pipeline/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/settings/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/sidebar-page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/datasets/connect/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/datasets/create-from-pipeline/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/datasets/create/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/default.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/access/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/api-tokens/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/instances/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/overview/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/releases/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/sidebar-page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/deployments/create/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/access/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/configure/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/logs/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/monitoring/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/sidebar-page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/snippets/[snippetId]/orchestrate/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/snippets/[snippetId]/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/snippets/[snippetId]/sidebar-page.tsx create mode 100644 web/app/components/app-sidebar/app-detail-sidebar.tsx create mode 100644 web/app/components/app-sidebar/dataset-detail-sidebar.tsx create mode 100644 web/app/components/detail-sidebar/__tests__/index.spec.tsx create mode 100644 web/app/components/detail-sidebar/index.tsx rename web/app/components/{main-nav => detail-sidebar}/storage.ts (100%) rename web/app/components/{main-nav => snippets}/components/snippet-detail-section.tsx (100%) create mode 100644 web/app/components/snippets/components/snippet-detail-sidebar.tsx rename web/app/components/{main-nav => snippets}/components/snippet-detail-top.tsx (100%) create mode 100644 web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx create mode 100644 web/features/agent-v2/agent-detail/detail-sidebar.tsx create mode 100644 web/features/deployments/detail/__tests__/index.spec.tsx create mode 100644 web/features/deployments/detail/detail-sidebar.tsx diff --git a/web/app/(commonLayout)/@detailSidebar/app/[appId]/access-config/page.tsx b/web/app/(commonLayout)/@detailSidebar/app/[appId]/access-config/page.tsx new file mode 100644 index 00000000000..c34c3d38b39 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/app/[appId]/access-config/page.tsx @@ -0,0 +1,5 @@ +import { AppDetailSidebarSlot } from '../sidebar-page' + +export default function AppAccessConfigDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/app/[appId]/annotations/page.tsx b/web/app/(commonLayout)/@detailSidebar/app/[appId]/annotations/page.tsx new file mode 100644 index 00000000000..fe175fa1c16 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/app/[appId]/annotations/page.tsx @@ -0,0 +1,5 @@ +import { AppDetailSidebarSlot } from '../sidebar-page' + +export default function AppAnnotationsDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/app/[appId]/configuration/page.tsx b/web/app/(commonLayout)/@detailSidebar/app/[appId]/configuration/page.tsx new file mode 100644 index 00000000000..b5dd5f449a9 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/app/[appId]/configuration/page.tsx @@ -0,0 +1,5 @@ +import { AppDetailSidebarSlot } from '../sidebar-page' + +export default function AppConfigurationDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/app/[appId]/develop/page.tsx b/web/app/(commonLayout)/@detailSidebar/app/[appId]/develop/page.tsx new file mode 100644 index 00000000000..7a5e2af4267 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/app/[appId]/develop/page.tsx @@ -0,0 +1,5 @@ +import { AppDetailSidebarSlot } from '../sidebar-page' + +export default function AppDevelopDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/app/[appId]/logs/page.tsx b/web/app/(commonLayout)/@detailSidebar/app/[appId]/logs/page.tsx new file mode 100644 index 00000000000..7f7138939eb --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/app/[appId]/logs/page.tsx @@ -0,0 +1,5 @@ +import { AppDetailSidebarSlot } from '../sidebar-page' + +export default function AppLogsDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/app/[appId]/overview/page.tsx b/web/app/(commonLayout)/@detailSidebar/app/[appId]/overview/page.tsx new file mode 100644 index 00000000000..3baedf7c974 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/app/[appId]/overview/page.tsx @@ -0,0 +1,5 @@ +import { AppDetailSidebarSlot } from '../sidebar-page' + +export default function AppOverviewDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/app/[appId]/sidebar-page.tsx b/web/app/(commonLayout)/@detailSidebar/app/[appId]/sidebar-page.tsx new file mode 100644 index 00000000000..47a49a8e1e4 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/app/[appId]/sidebar-page.tsx @@ -0,0 +1,5 @@ +import { AppDetailSidebar } from '@/app/components/app-sidebar/app-detail-sidebar' + +export function AppDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/app/[appId]/workflow/page.tsx b/web/app/(commonLayout)/@detailSidebar/app/[appId]/workflow/page.tsx new file mode 100644 index 00000000000..e3fef8f2945 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/app/[appId]/workflow/page.tsx @@ -0,0 +1,5 @@ +import { AppDetailSidebarSlot } from '../sidebar-page' + +export default function AppWorkflowDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/access-config/page.tsx b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/access-config/page.tsx new file mode 100644 index 00000000000..7618c5ac60e --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/access-config/page.tsx @@ -0,0 +1,5 @@ +import { DatasetDetailSidebarSlot } from '../sidebar-page' + +export default function DatasetAccessConfigDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/api/page.tsx b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/api/page.tsx new file mode 100644 index 00000000000..c22efded7b2 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/api/page.tsx @@ -0,0 +1,5 @@ +import { DatasetDetailSidebarSlot } from '../sidebar-page' + +export default function DatasetApiDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/[documentId]/page.tsx b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/[documentId]/page.tsx new file mode 100644 index 00000000000..75783f07f96 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/[documentId]/page.tsx @@ -0,0 +1,5 @@ +import { DatasetDetailSidebarSlot } from '../../sidebar-page' + +export default function DatasetDocumentDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/[documentId]/settings/page.tsx b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/[documentId]/settings/page.tsx new file mode 100644 index 00000000000..b646aa96dc7 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/[documentId]/settings/page.tsx @@ -0,0 +1,5 @@ +import { DatasetDetailSidebarSlot } from '../../../sidebar-page' + +export default function DatasetDocumentSettingsDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/create-from-pipeline/page.tsx b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/create-from-pipeline/page.tsx new file mode 100644 index 00000000000..4418c174f78 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/create-from-pipeline/page.tsx @@ -0,0 +1,3 @@ +export default function DatasetDocumentCreateFromPipelineDetailSidebarSlot() { + return null +} diff --git a/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/create/page.tsx b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/create/page.tsx new file mode 100644 index 00000000000..9888c2f3197 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/create/page.tsx @@ -0,0 +1,3 @@ +export default function DatasetDocumentCreateDetailSidebarSlot() { + return null +} diff --git a/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/page.tsx b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/page.tsx new file mode 100644 index 00000000000..61b976ba631 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/documents/page.tsx @@ -0,0 +1,5 @@ +import { DatasetDetailSidebarSlot } from '../sidebar-page' + +export default function DatasetDocumentsDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/hitTesting/page.tsx b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/hitTesting/page.tsx new file mode 100644 index 00000000000..30c60433838 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/hitTesting/page.tsx @@ -0,0 +1,5 @@ +import { DatasetDetailSidebarSlot } from '../sidebar-page' + +export default function DatasetHitTestingDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/pipeline/page.tsx b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/pipeline/page.tsx new file mode 100644 index 00000000000..7b16004e9bd --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/pipeline/page.tsx @@ -0,0 +1,5 @@ +import { DatasetDetailSidebarSlot } from '../sidebar-page' + +export default function DatasetPipelineDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/settings/page.tsx b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/settings/page.tsx new file mode 100644 index 00000000000..6912f02f7ee --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/settings/page.tsx @@ -0,0 +1,5 @@ +import { DatasetDetailSidebarSlot } from '../sidebar-page' + +export default function DatasetSettingsDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/sidebar-page.tsx b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/sidebar-page.tsx new file mode 100644 index 00000000000..16646515952 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/datasets/[datasetId]/sidebar-page.tsx @@ -0,0 +1,5 @@ +import { DatasetDetailSidebar } from '@/app/components/app-sidebar/dataset-detail-sidebar' + +export function DatasetDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/datasets/connect/page.tsx b/web/app/(commonLayout)/@detailSidebar/datasets/connect/page.tsx new file mode 100644 index 00000000000..e017b36e0df --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/datasets/connect/page.tsx @@ -0,0 +1,3 @@ +export default function DatasetConnectDetailSidebarSlot() { + return null +} diff --git a/web/app/(commonLayout)/@detailSidebar/datasets/create-from-pipeline/page.tsx b/web/app/(commonLayout)/@detailSidebar/datasets/create-from-pipeline/page.tsx new file mode 100644 index 00000000000..aac8b90b3dd --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/datasets/create-from-pipeline/page.tsx @@ -0,0 +1,3 @@ +export default function DatasetCreateFromPipelineDetailSidebarSlot() { + return null +} diff --git a/web/app/(commonLayout)/@detailSidebar/datasets/create/page.tsx b/web/app/(commonLayout)/@detailSidebar/datasets/create/page.tsx new file mode 100644 index 00000000000..a1a7c4e8871 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/datasets/create/page.tsx @@ -0,0 +1,3 @@ +export default function DatasetCreateDetailSidebarSlot() { + return null +} diff --git a/web/app/(commonLayout)/@detailSidebar/default.tsx b/web/app/(commonLayout)/@detailSidebar/default.tsx new file mode 100644 index 00000000000..2b13df988be --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/default.tsx @@ -0,0 +1,3 @@ +export default function DefaultDetailSidebarSlot() { + return null +} diff --git a/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/access/page.tsx b/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/access/page.tsx new file mode 100644 index 00000000000..20a44044ef7 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/access/page.tsx @@ -0,0 +1,5 @@ +import { DeploymentDetailSidebarSlot } from '../sidebar-page' + +export default function DeploymentAccessDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/api-tokens/page.tsx b/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/api-tokens/page.tsx new file mode 100644 index 00000000000..806e69b471a --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/api-tokens/page.tsx @@ -0,0 +1,5 @@ +import { DeploymentDetailSidebarSlot } from '../sidebar-page' + +export default function DeploymentApiTokensDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/instances/page.tsx b/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/instances/page.tsx new file mode 100644 index 00000000000..0d17b43799c --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/instances/page.tsx @@ -0,0 +1,5 @@ +import { DeploymentDetailSidebarSlot } from '../sidebar-page' + +export default function DeploymentInstancesDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/overview/page.tsx b/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/overview/page.tsx new file mode 100644 index 00000000000..dbbcc7a93fa --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/overview/page.tsx @@ -0,0 +1,5 @@ +import { DeploymentDetailSidebarSlot } from '../sidebar-page' + +export default function DeploymentOverviewDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/page.tsx b/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/page.tsx new file mode 100644 index 00000000000..9672050cee0 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/page.tsx @@ -0,0 +1,5 @@ +import { DeploymentDetailSidebarSlot } from './sidebar-page' + +export default function DeploymentDetailPageSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/releases/page.tsx b/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/releases/page.tsx new file mode 100644 index 00000000000..1e9c907a376 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/releases/page.tsx @@ -0,0 +1,5 @@ +import { DeploymentDetailSidebarSlot } from '../sidebar-page' + +export default function DeploymentReleasesDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/sidebar-page.tsx b/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/sidebar-page.tsx new file mode 100644 index 00000000000..720cef52a6b --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/deployments/[appInstanceId]/sidebar-page.tsx @@ -0,0 +1,5 @@ +import { DeploymentDetailSidebar } from '@/features/deployments/detail/detail-sidebar' + +export function DeploymentDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/deployments/create/page.tsx b/web/app/(commonLayout)/@detailSidebar/deployments/create/page.tsx new file mode 100644 index 00000000000..f552d57c415 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/deployments/create/page.tsx @@ -0,0 +1,3 @@ +export default function DeploymentCreateDetailSidebarSlot() { + return null +} diff --git a/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/access/page.tsx b/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/access/page.tsx new file mode 100644 index 00000000000..bc01d484751 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/access/page.tsx @@ -0,0 +1,5 @@ +import { AgentDetailSidebarSlot } from '../sidebar-page' + +export default function AgentAccessDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/configure/page.tsx b/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/configure/page.tsx new file mode 100644 index 00000000000..6a1057aba8d --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/configure/page.tsx @@ -0,0 +1,5 @@ +import { AgentDetailSidebarSlot } from '../sidebar-page' + +export default function AgentConfigureDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/logs/page.tsx b/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/logs/page.tsx new file mode 100644 index 00000000000..3e34fd95a84 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/logs/page.tsx @@ -0,0 +1,5 @@ +import { AgentDetailSidebarSlot } from '../sidebar-page' + +export default function AgentLogsDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/monitoring/page.tsx b/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/monitoring/page.tsx new file mode 100644 index 00000000000..c5b04afd9bc --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/monitoring/page.tsx @@ -0,0 +1,5 @@ +import { AgentDetailSidebarSlot } from '../sidebar-page' + +export default function AgentMonitoringDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/page.tsx b/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/page.tsx new file mode 100644 index 00000000000..3828d2c3e9a --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/page.tsx @@ -0,0 +1,5 @@ +import { AgentDetailSidebarSlot } from './sidebar-page' + +export default function AgentDetailPageSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/sidebar-page.tsx b/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/sidebar-page.tsx new file mode 100644 index 00000000000..1639cda277f --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/roster/agent/[agentId]/sidebar-page.tsx @@ -0,0 +1,5 @@ +import { AgentDetailSidebar } from '@/features/agent-v2/agent-detail/detail-sidebar' + +export function AgentDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/snippets/[snippetId]/orchestrate/page.tsx b/web/app/(commonLayout)/@detailSidebar/snippets/[snippetId]/orchestrate/page.tsx new file mode 100644 index 00000000000..78fa0e48655 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/snippets/[snippetId]/orchestrate/page.tsx @@ -0,0 +1,5 @@ +import { SnippetDetailSidebarSlot } from '../sidebar-page' + +export default function SnippetOrchestrateDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/snippets/[snippetId]/page.tsx b/web/app/(commonLayout)/@detailSidebar/snippets/[snippetId]/page.tsx new file mode 100644 index 00000000000..5bc2d91b702 --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/snippets/[snippetId]/page.tsx @@ -0,0 +1,5 @@ +import { SnippetDetailSidebarSlot } from './sidebar-page' + +export default function SnippetDetailPageSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/snippets/[snippetId]/sidebar-page.tsx b/web/app/(commonLayout)/@detailSidebar/snippets/[snippetId]/sidebar-page.tsx new file mode 100644 index 00000000000..5685dc603ad --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/snippets/[snippetId]/sidebar-page.tsx @@ -0,0 +1,5 @@ +import { SnippetDetailSidebar } from '@/app/components/snippets/components/snippet-detail-sidebar' + +export function SnippetDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx index 34fb3859958..8d4b0e2831c 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx @@ -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( + +
App page content
+
, + ) + + await waitForAppContent() + + expect(screen.queryByRole('main')).not.toBeInTheDocument() + }) + + it('should preserve the column flex context for full-height workflow content', async () => { + render( + +
App page content
+
, + ) + + 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] })) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index fd1e605d935..7ef9c3e31ea 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -133,29 +133,32 @@ const AppDetailLayout: FC = (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 ( -
- -
- ) - } - const isWorkflowPage = pathname.endsWith('/workflow') + const content = !appDetail + ? ( +
+ +
+ ) + : ( +
+
+ {children} +
+
+ ) return ( -
-
- {children} -
+
+ {content}
) } diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx index d669a675dac..1a566074505 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx @@ -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) + + // Act + render( + +
Pipeline content
+
, + ) + + // 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) + + // Act + render( + +
Create document content
+
, + ) + + // Assert + expect(screen.getByText('Create document content')).toBeInTheDocument() + expect(screen.queryByRole('main')).not.toBeInTheDocument() + }) }) describe('Permission Route Guards', () => { diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index a7df61c6c57..8a505ee3077 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -102,38 +102,37 @@ const DatasetDetailLayout: FC = (props) => { router.replace(getDatasetRedirectionPath(datasetRes, datasetACLCapabilities)) }, [datasetACLCapabilities, datasetRes, router, shouldRedirectUnauthorizedRoute]) - if (!datasetRes && !error) - return - - if (shouldRedirect) - return - - if (isCheckingRouteAccess || shouldRedirectUnauthorizedRoute) - return - const isPipelinePage = pathname.endsWith('/pipeline') || pathname.includes('/create-from-pipeline') + const shouldShowLoading = (!datasetRes && !error) || shouldRedirect || isCheckingRouteAccess || shouldRedirectUnauthorizedRoute + const content = shouldShowLoading + ? + : ( +
+ +
+ {children} +
+
+
+ ) return ( -
- -
- {children} -
-
+
+ {content}
) } diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index e2d29f85d30..d4b9bc599e7 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -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 ( <> @@ -26,7 +32,7 @@ export default async function Layout({ children }: { children: ReactNode }) { - + {children} diff --git a/web/app/components/app-sidebar/app-detail-sidebar.tsx b/web/app/components/app-sidebar/app-detail-sidebar.tsx new file mode 100644 index 00000000000..a9b238406ff --- /dev/null +++ b/web/app/components/app-sidebar/app-detail-sidebar.tsx @@ -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 ( + ( + + )} + renderSection={({ expand }) => } + /> + ) +} diff --git a/web/app/components/app-sidebar/dataset-detail-sidebar.tsx b/web/app/components/app-sidebar/dataset-detail-sidebar.tsx new file mode 100644 index 00000000000..e969177084d --- /dev/null +++ b/web/app/components/app-sidebar/dataset-detail-sidebar.tsx @@ -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 ( + ( + + )} + renderSection={({ expand }) => } + /> + ) +} diff --git a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx index c9f10b71574..86869d513e8 100644 --- a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx +++ b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx @@ -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, })) diff --git a/web/app/components/app/configuration/hooks/use-configuration.ts b/web/app/components/app/configuration/hooks/use-configuration.ts index 53f3364fa9f..f92b0b5c58d 100644 --- a/web/app/components/app/configuration/hooks/use-configuration.ts +++ b/web/app/components/app/configuration/hooks/use-configuration.ts @@ -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' diff --git a/web/app/components/detail-sidebar/__tests__/index.spec.tsx b/web/app/components/detail-sidebar/__tests__/index.spec.tsx new file mode 100644 index 00000000000..40bf2f7e34b --- /dev/null +++ b/web/app/components/detail-sidebar/__tests__/index.spec.tsx @@ -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 void }) => void + options?: { ignoreInputs?: boolean } + }>(), +})) + +vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { + const actual = await importOriginal() + 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 }) => ( + + ), +})) + +vi.mock('@/app/components/main-nav/components/help-menu', () => ({ + default: ({ triggerClassName }: { triggerClassName?: string }) => ( + + ), +})) + +vi.mock('@/app/components/header/env-nav', () => ({ + default: () =>
Environment tag
, +})) + +function renderDetailSidebarFrame() { + return render( + ( +
+ +
+ )} + renderSection={({ expand }) => ( +
Section
+ )} + />, + ) +} + +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') + }) +}) diff --git a/web/app/components/detail-sidebar/index.tsx b/web/app/components/detail-sidebar/index.tsx new file mode 100644 index 00000000000..231966b7b3a --- /dev/null +++ b/web/app/components/detail-sidebar/index.tsx @@ -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) => ReactNode +} + +const secondarySidebarHelpTriggerIcon = + +function SecondarySidebarHelpMenu({ + triggerClassName, +}: { + triggerClassName?: string +}) { + return ( + + ) +} + +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 | null>(null) + const detailNavigationTransitionTimerRef = useRef | 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 ( + + ) +} diff --git a/web/app/components/main-nav/storage.ts b/web/app/components/detail-sidebar/storage.ts similarity index 100% rename from web/app/components/main-nav/storage.ts rename to web/app/components/detail-sidebar/storage.ts diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index d1722533b9a..03f42bf59c3 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -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 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() - 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 }) => 1,234, })) -vi.mock('@/app/components/app-sidebar/app-detail-section', () => ({ - default: ({ expand }: { expand: boolean }) =>
, -})) - -vi.mock('@/app/components/app-sidebar/app-detail-top', () => ({ - default: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => ( -
- -
- ), -})) - -vi.mock('@/app/components/app-sidebar/dataset-detail-section', () => ({ - default: ({ expand }: { expand: boolean }) =>
, -})) - -vi.mock('@/app/components/app-sidebar/dataset-detail-top', () => ({ - default: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => ( -
- -
- ), -})) - -vi.mock('@/features/agent-v2/agent-detail/navigation', () => ({ - AgentDetailSection: ({ expand }: { expand: boolean }) =>
, - AgentDetailTop: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => ( -
- -
- ), -})) - -vi.mock('@/features/deployments/detail/deployment-sidebar', () => ({ - DeploymentDetailSection: ({ expand }: { expand: boolean }) =>
, - DeploymentDetailTop: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => ( -
- -
- ), -})) - -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 - }) => ( -
- {snippet.name} - {fields.map(field => field.variable).join(',')} - -
- ), -})) - -vi.mock('../components/snippet-detail-top', () => ({ - default: ({ expand, onToggle }: { expand: boolean, onToggle: () => void }) => ( -
- -
- ), -})) - vi.mock('@/context/i18n', () => ({ useLocale: () => 'en-US', useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, @@ -297,24 +183,6 @@ const createInstalledApp = (overrides: Partial = {}): 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['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['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', diff --git a/web/app/components/main-nav/__tests__/layout.spec.tsx b/web/app/components/main-nav/__tests__/layout.spec.tsx index 3e22a3ff190..9aa5601ec23 100644 --- a/web/app/components/main-nav/__tests__/layout.spec.tsx +++ b/web/app/components/main-nav/__tests__/layout.spec.tsx @@ -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() + 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() + return { + ...actual, + usePathname: vi.fn(), + } +}) + vi.mock('../index', () => ({ MainNav: ({ className }: { className?: string }) => , })) 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( + Detail sidebar}> +
dataset detail
+
, + ) + + 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( + Detail sidebar}> +
content
+
, + ) + + 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( + Detail sidebar}> +
detail route content
+
, + ) + + 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['appDetail']) + ;(usePathname as Mock).mockReturnValue('/datasets') + + render(
content
) + + expect(useAppStore.getState().appDetail).toBeUndefined() + }) }) diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index 19e0872b7b1..b8bfd854516 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -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 = - -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 ( - - ) -} - -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 | null>(null) - const detailNavigationTransitionTimerRef = useRef | 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(() => MAIN_NAV_ROUTES .filter(route => isMainNavRouteVisible(route, { @@ -220,143 +74,45 @@ export function MainNav({ return ( diff --git a/web/app/components/main-nav/layout.tsx b/web/app/components/main-nav/layout.tsx index e1688b860e1..4954517bac1 100644 --- a/web/app/components/main-nav/layout.tsx +++ b/web/app/components/main-nav/layout.tsx @@ -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 ( -
+
{t('navigation.skipToMain')} - + + {shouldHideMainNav ? detailSidebar : }
{children}
diff --git a/web/app/components/main-nav/routes.ts b/web/app/components/main-nav/routes.ts index fce275974fd..0d2a3f3a6dc 100644 --- a/web/app/components/main-nav/routes.ts +++ b/web/app/components/main-nav/routes.ts @@ -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) +} diff --git a/web/app/components/snippets/__tests__/index.spec.tsx b/web/app/components/snippets/__tests__/index.spec.tsx index 977454c1661..51d2c9a6727 100644 --- a/web/app/components/snippets/__tests__/index.spec.tsx +++ b/web/app/components/snippets/__tests__/index.spec.tsx @@ -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() 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() + + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.queryByRole('main')).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/snippets/components/__tests__/snippet-layout.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-layout.spec.tsx index 0dec988a59d..f5d3404f51d 100644 --- a/web/app/components/snippets/components/__tests__/snippet-layout.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-layout.spec.tsx @@ -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( ( + + )} + renderSection={({ expand }) => } + /> + ) +} diff --git a/web/app/components/main-nav/components/snippet-detail-top.tsx b/web/app/components/snippets/components/snippet-detail-top.tsx similarity index 100% rename from web/app/components/main-nav/components/snippet-detail-top.tsx rename to web/app/components/snippets/components/snippet-detail-top.tsx diff --git a/web/app/components/snippets/components/snippet-layout.tsx b/web/app/components/snippets/components/snippet-layout.tsx index 2dd6a8f4354..0f20c176fd2 100644 --- a/web/app/components/snippets/components/snippet-layout.tsx +++ b/web/app/components/snippets/components/snippet-layout.tsx @@ -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 ( -
+
{children} diff --git a/web/app/components/snippets/index.tsx b/web/app/components/snippets/index.tsx index 4a39e85efb4..6201d66312b 100644 --- a/web/app/components/snippets/index.tsx +++ b/web/app/components/snippets/index.tsx @@ -16,11 +16,16 @@ type SnippetPageProps = { snippetId: string } -const SnippetPageLoading = () => { +const SnippetPageLoading = ({ snippetId }: SnippetPageProps) => { return ( -
- -
+ +
+ +
+
) } @@ -52,7 +57,7 @@ const SnippetPage = ({ snippetId }: SnippetPageProps) => { }, [data]) if (!data || isLoading) { - return + return } const hasPublishedWorkflow = !!data.publishedWorkflow diff --git a/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx b/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx new file mode 100644 index 00000000000..90d2b34166f --- /dev/null +++ b/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx @@ -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() + 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( + +
Agent detail content
+
, + ) + + expect(screen.getByText('Agent detail content')).toBeInTheDocument() + expect(screen.queryByRole('main')).not.toBeInTheDocument() + expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument() + }) +}) diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx index 099a4ae4ee9..fdd0e299929 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx @@ -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( + + + , + ) + + 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', () => { diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx index faf9e57bc55..ac33c14e773 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx @@ -128,7 +128,6 @@ export function AgentOrchestratePanel({ ( + + )} + renderSection={({ expand }) => } + /> + ) +} diff --git a/web/features/agent-v2/agent-detail/layout.tsx b/web/features/agent-v2/agent-detail/layout.tsx index 7088732d9b0..b7247064516 100644 --- a/web/features/agent-v2/agent-detail/layout.tsx +++ b/web/features/agent-v2/agent-detail/layout.tsx @@ -27,7 +27,7 @@ export function AgentDetailLayout({ useDocumentTitle(agentQuery.data?.name ?? t('agentDetail.documentTitle')) return ( -
+
{children}
diff --git a/web/features/deployments/detail/__tests__/index.spec.tsx b/web/features/deployments/detail/__tests__/index.spec.tsx new file mode 100644 index 00000000000..67486675dcd --- /dev/null +++ b/web/features/deployments/detail/__tests__/index.spec.tsx @@ -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() + 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( + + + +
Deployment detail content
+
+
+
, + ) +} + +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() + }) +}) diff --git a/web/features/deployments/detail/detail-sidebar.tsx b/web/features/deployments/detail/detail-sidebar.tsx new file mode 100644 index 00000000000..5d5a4937cd7 --- /dev/null +++ b/web/features/deployments/detail/detail-sidebar.tsx @@ -0,0 +1,18 @@ +'use client' + +import { DetailSidebarFrame } from '@/app/components/detail-sidebar' +import { DeploymentDetailSection, DeploymentDetailTop } from './deployment-sidebar' + +export function DeploymentDetailSidebar() { + return ( + ( + + )} + renderSection={({ expand }) => } + /> + ) +} diff --git a/web/features/deployments/detail/index.tsx b/web/features/deployments/detail/index.tsx index b8fbcf9d5dd..b755e5fd41d 100644 --- a/web/features/deployments/detail/index.tsx +++ b/web/features/deployments/detail/index.tsx @@ -57,7 +57,7 @@ export function InstanceDetail({ children }: { return null return ( -
+