diff --git a/web/e2e/namespace-skill-pagination.spec.ts b/web/e2e/namespace-skill-pagination.spec.ts
new file mode 100644
index 00000000..64e61e09
--- /dev/null
+++ b/web/e2e/namespace-skill-pagination.spec.ts
@@ -0,0 +1,104 @@
+import { expect, test } from '@playwright/test'
+import { setEnglishLocale } from './helpers/auth-fixtures'
+import { registerSession } from './helpers/session'
+import { E2eTestDataBuilder } from './helpers/test-data-builder'
+
+test.describe('Namespace Skill List Pagination (Real API)', () => {
+ test.beforeEach(async ({ page }, testInfo) => {
+ await setEnglishLocale(page)
+ await registerSession(page, testInfo)
+ })
+
+ test('shows namespace page with skills and no pagination when under 20 skills', async ({ page }, testInfo) => {
+ const builder = new E2eTestDataBuilder(page, testInfo)
+ await builder.init()
+
+ try {
+ const namespace = await builder.ensureWritableNamespace()
+ await builder.publishSkill(namespace.slug)
+
+ await page.goto(`/space/${namespace.slug}`)
+
+ await expect(page.getByText(`@${namespace.slug}`).first()).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'Skills', exact: true })).toBeVisible()
+
+ // Verify pagination controls do not appear when there are fewer than 20 skills
+ await expect(page.getByRole('button', { name: 'Previous' })).toHaveCount(0)
+ await expect(page.getByRole('button', { name: 'Next' })).toHaveCount(0)
+ } finally {
+ await builder.cleanup()
+ }
+ })
+
+ test('shows pagination controls when there are more than 20 skills', async ({ page }, testInfo) => {
+ const builder = new E2eTestDataBuilder(page, testInfo)
+ await builder.init()
+
+ try {
+ const namespace = await builder.ensureWritableNamespace()
+
+ // Build a fake skill list to simulate >20 skills without hitting rate limits.
+ const fakeSkill = (i: number) => ({
+ id: 90000 + i,
+ namespace: namespace.slug,
+ slug: `fake-skill-${i}`,
+ displayName: `Fake Skill ${i}`,
+ summary: `Pagination test skill ${i}`,
+ downloadCount: 0,
+ starCount: 0,
+ ratingAvg: 0,
+ ratingCount: 0,
+ versions: [{ id: 90000 + i, version: '1.0.0', status: 'PUBLISHED' }],
+ })
+
+ // Intercept the search API with a regex (glob '?' is ambiguous for literal '?').
+ // Return fully mocked responses to avoid needing real published skills.
+ await page.route(/\/api\/web\/skills\?/, async (route) => {
+ const url = new URL(route.request().url())
+ const reqPage = Number(url.searchParams.get('page') ?? '0')
+ const items = reqPage === 0
+ ? Array.from({ length: 20 }, (_, i) => fakeSkill(i))
+ : [fakeSkill(20)]
+
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ code: 0,
+ msg: 'success',
+ data: { items, total: 21, page: reqPage, size: 20 },
+ }),
+ })
+ })
+
+ await page.goto(`/space/${namespace.slug}`)
+
+ await expect(page.getByText(`@${namespace.slug}`).first()).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'Skills', exact: true })).toBeVisible()
+
+ // Verify pagination controls appear
+ const previousButton = page.getByRole('button', { name: 'Previous' }).first()
+ const nextButton = page.getByRole('button', { name: 'Next' }).first()
+
+ await expect(previousButton).toBeVisible()
+ await expect(nextButton).toBeVisible()
+
+ // First page: Previous should be disabled, Next should be enabled
+ await expect(previousButton).toBeDisabled()
+ await expect(nextButton).toBeEnabled()
+
+ // Navigate to second page
+ await nextButton.click()
+
+ // Second page: Previous should be enabled
+ await expect(previousButton).toBeEnabled()
+
+ // Navigate back to first page
+ await previousButton.click()
+ await expect(previousButton).toBeDisabled()
+ await expect(nextButton).toBeEnabled()
+ } finally {
+ await builder.cleanup()
+ }
+ })
+})
diff --git a/web/src/pages/namespace.tsx b/web/src/pages/namespace.tsx
index 6754e953..69ac0127 100644
--- a/web/src/pages/namespace.tsx
+++ b/web/src/pages/namespace.tsx
@@ -1,12 +1,16 @@
+import { useState, useEffect } from 'react'
import { useNavigate, useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { NamespaceHeader } from '@/features/namespace/namespace-header'
import { SkillCard } from '@/features/skill/skill-card'
import { SkeletonList } from '@/shared/components/skeleton-loader'
import { EmptyState } from '@/shared/components/empty-state'
+import { Pagination } from '@/shared/components/pagination'
import { useSearchSkills } from '@/shared/hooks/use-skill-queries'
import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries'
+const PAGE_SIZE = 20
+
/**
* Public namespace page showing namespace metadata and the skills currently discoverable inside it.
*/
@@ -14,13 +18,22 @@ export function NamespacePage() {
const { t } = useTranslation()
const navigate = useNavigate()
const { namespace } = useParams({ from: '/space/$namespace' })
+ const [page, setPage] = useState(0)
+
+ // Reset page when namespace changes
+ useEffect(() => {
+ setPage(0)
+ }, [namespace])
const { data: namespaceData, isLoading: isLoadingNamespace } = useNamespaceDetail(namespace)
const { data: skillsData, isLoading: isLoadingSkills } = useSearchSkills({
namespace,
- size: 20,
+ page,
+ size: PAGE_SIZE,
})
+ const totalPages = skillsData ? Math.max(Math.ceil(skillsData.total / skillsData.size), 1) : 1
+
const handleSkillClick = (slug: string) => {
navigate({ to: `/space/${namespace}/${encodeURIComponent(slug)}` })
}
@@ -47,16 +60,22 @@ export function NamespacePage() {
{isLoadingSkills ? (