{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 (
-