diff --git a/src/__tests__/routes/BundleDetail.test.tsx b/src/__tests__/routes/BundleDetail.test.tsx index 762c205..c115e63 100644 --- a/src/__tests__/routes/BundleDetail.test.tsx +++ b/src/__tests__/routes/BundleDetail.test.tsx @@ -14,6 +14,7 @@ import { BundleDetailRoute } from '@/routes/BundleDetail'; import { loadFixtureRegistry } from '../fixtures'; const useBundleManifestMock = vi.hoisted(() => vi.fn()); +const useBundleReadmeMock = vi.hoisted(() => vi.fn()); const useRegistryMock = vi.hoisted(() => vi.fn()); const useDownloadBundleMock = vi.hoisted(() => vi.fn(() => ({ download: vi.fn().mockResolvedValue(undefined), isDownloading: () => false })), @@ -29,6 +30,7 @@ const useManifestGraphMock = vi.hoisted(() => ); vi.mock('@/hooks/useBundleManifest', () => ({ useBundleManifest: useBundleManifestMock })); +vi.mock('@/hooks/useBundleReadme', () => ({ useBundleReadme: useBundleReadmeMock })); vi.mock('@/hooks/useRegistry', () => ({ useRegistry: useRegistryMock })); vi.mock('@/hooks/useDownloadBundle', () => ({ useDownloadBundle: useDownloadBundleMock })); vi.mock('@/hooks/useManifestGraph', () => ({ @@ -38,6 +40,7 @@ vi.mock('@/hooks/useManifestGraph', () => ({ })); type BundleQueryShape = Partial>; +type ReadmeQueryShape = Partial>; type RegistryQueryShape = Partial>; function renderAt(path: string) { @@ -68,6 +71,17 @@ function setBundle(state: BundleQueryShape) { }); } +function setReadme(state: ReadmeQueryShape) { + useBundleReadmeMock.mockReturnValue({ + data: undefined, + error: null, + isError: false, + isLoading: false, + isSuccess: false, + ...state, + }); +} + function setRegistry(state: RegistryQueryShape) { useRegistryMock.mockReturnValue({ data: undefined, @@ -96,6 +110,7 @@ const FULL_BUNDLE: Bundle = { describe('BundleDetailRoute', () => { afterEach(() => { useBundleManifestMock.mockReset(); + useBundleReadmeMock.mockReset(); useRegistryMock.mockReset(); }); @@ -108,6 +123,7 @@ describe('BundleDetailRoute', () => { it('renders every field of a fully populated bundle manifest', () => { setBundle({ data: FULL_BUNDLE, isSuccess: true }); + setReadme({ data: '# Feature Workflow\n\nUsage notes.', isSuccess: true }); setRegistry({ data: loadFixtureRegistry(), isSuccess: true }); renderAt('/bundles/feature-workflow'); @@ -132,11 +148,36 @@ describe('BundleDetailRoute', () => { const setup = screen.getByTestId('bundle-detail-setup'); expect(within(setup).getByRole('heading', { level: 2, name: 'Setup' })).toBeInTheDocument(); + + const readme = screen.getByTestId('bundle-detail-readme'); + expect(within(readme).getByRole('heading', { level: 1, name: 'Feature Workflow' })).toBeInTheDocument(); + expect(within(readme).getByText('Usage notes.')).toBeInTheDocument(); + }); + + it('shows a missing-README message when the bundle has none', () => { + setBundle({ data: FULL_BUNDLE, isSuccess: true }); + setReadme({ data: null, isSuccess: true }); + setRegistry({ data: loadFixtureRegistry(), isSuccess: true }); + + renderAt('/bundles/feature-workflow'); + + expect(screen.getByTestId('bundle-detail-readme-missing')).toBeInTheDocument(); + }); + + it('shows a loading skeleton while the README is inflight', () => { + setBundle({ data: FULL_BUNDLE, isSuccess: true }); + setReadme({ isLoading: true }); + setRegistry({ data: loadFixtureRegistry(), isSuccess: true }); + + renderAt('/bundles/feature-workflow'); + + expect(screen.getByRole('status', { name: /loading readme/i })).toBeInTheDocument(); }); it('lists bundle.json plus each member file from the API listing under the bundle group', () => { setBundle({ data: FULL_BUNDLE, isSuccess: true }); setRegistry({ data: loadFixtureRegistry(), isSuccess: true }); + setReadme({ data: null, isSuccess: true }); const validateKey = 'agent:agentic-toolkit:validate:1.1.0'; useManifestGraphMock.mockReturnValue({ error: null, @@ -179,6 +220,7 @@ describe('BundleDetailRoute', () => { const download = vi.fn().mockResolvedValue(undefined); useDownloadBundleMock.mockReturnValueOnce({ download, isDownloading: () => false }); setBundle({ data: FULL_BUNDLE, isSuccess: true }); + setReadme({ data: null, isSuccess: true }); setRegistry({ data: loadFixtureRegistry(), isSuccess: true }); renderAt('/bundles/feature-workflow'); @@ -231,6 +273,7 @@ describe('BundleDetailRoute', () => { version: '2.0.0', }); setBundle({ data: orgBundle, isSuccess: true }); + setReadme({ data: null, isSuccess: true }); setRegistry({ data: registry, isSuccess: true }); renderAt('/bundles/cupay/qa-bundle'); @@ -265,6 +308,7 @@ describe('BundleDetailRoute', () => { version: '2.0.0', }); setBundle({ data: orgBundle, isSuccess: true }); + setReadme({ data: null, isSuccess: true }); setRegistry({ data: registry, isSuccess: true }); renderAt('/bundles/cupay/qa-bundle'); @@ -299,6 +343,7 @@ describe('BundleDetailRoute', () => { version: '2.0.0', }); setBundle({ data: orgBundle, isSuccess: true }); + setReadme({ data: null, isSuccess: true }); setRegistry({ data: registry, isSuccess: true }); renderAt('/bundles/cupay/qa-bundle'); diff --git a/src/components/ReadmeView.tsx b/src/components/ReadmeView.tsx new file mode 100644 index 0000000..6ba8c60 --- /dev/null +++ b/src/components/ReadmeView.tsx @@ -0,0 +1,32 @@ +import { LoadingIndicator } from '@/components/LoadingIndicator'; +import { MarkdownRenderer } from '@/components/MarkdownRenderer'; + +/** + * Renders a fetched README as markdown, with loading/missing states. + * Shared between the asset and bundle detail pages so both surfaces stay + * in sync — a bundle's README used to never render at all (only its + * `setupInstructions` did) even though every published bundle has one. + */ +export function ReadmeView({ + isError, + isLoading, + missingTestId, + readme, +}: { + isError: boolean; + isLoading: boolean; + missingTestId?: string; + readme: null | string; +}) { + if (isLoading) { + return ; + } + if (isError || !readme) { + return ( +

+ No README is available. +

+ ); + } + return ; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index eddd67f..6bb1091 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,6 +2,7 @@ export { useAssetFiles } from './useAssetFiles'; export { useAssetManifest } from './useAssetManifest'; export { useAssetReadme } from './useAssetReadme'; export { useBundleManifest } from './useBundleManifest'; +export { useBundleReadme } from './useBundleReadme'; export { useDownloadAsset } from './useDownloadAsset'; export { useDownloadBundle } from './useDownloadBundle'; export { useRegistry } from './useRegistry'; diff --git a/src/hooks/useBundleReadme.ts b/src/hooks/useBundleReadme.ts new file mode 100644 index 0000000..5f9cd51 --- /dev/null +++ b/src/hooks/useBundleReadme.ts @@ -0,0 +1,28 @@ +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; + +import { useSession } from '@/hooks/useSession'; +import { queryKeys } from '@/lib/query-keys'; +import { type BundleManifestRef, fetchBundleReadme } from '@/lib/registry-client'; + +/** + * Fetch and cache a bundle's README.md. Requires an authenticated session. + * Resolves to `null` when the README is missing (HTTP 404) — mirrors + * {@link useAssetReadme}. + */ +export function useBundleReadme(ref: Partial): UseQueryResult { + const { api } = useSession(); + const enabled = Boolean(api && ref.name && ref.version); + + return useQuery({ + enabled, + queryFn: ({ signal }) => { + if (!api) throw new Error('useBundleReadme: no authenticated API client available'); + return fetchBundleReadme(ref as BundleManifestRef, { client: api, signal }); + }, + queryKey: queryKeys.bundleReadme({ + name: ref.name ?? '', + org: ref.org, + version: ref.version ?? '', + }), + }); +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index ad085d0..af0a1a6 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -11,6 +11,8 @@ export const queryKeys = { ['registry', 'asset-readme', ref.type, ref.org ?? '', ref.name, ref.version] as const, bundleManifest: (ref: { name: string; org?: string; version?: string }) => ['registry', 'bundle-manifest', ref.org ?? '', ref.name, ref.version ?? ''] as const, + bundleReadme: (ref: { name: string; org?: string; version: string }) => + ['registry', 'bundle-readme', ref.org ?? '', ref.name, ref.version] as const, registry: () => ['registry', 'index'] as const, session: { membership: (org: string, username: string) => ['session', 'membership', org, username] as const, diff --git a/src/lib/registry-client.ts b/src/lib/registry-client.ts index 05f3fb2..064d519 100644 --- a/src/lib/registry-client.ts +++ b/src/lib/registry-client.ts @@ -2,7 +2,7 @@ import type { ZodType } from 'zod'; import { z } from 'zod'; -import { getAssetManifest, getAssetReadme, getBundleManifest, getRegistry, listAssetFiles } from './api'; +import { getAssetManifest, getAssetReadme, getBundleManifest, getBundleReadme, getRegistry, listAssetFiles } from './api'; import { type ApiClient, ApiRequestError, type ApiResult, unwrap } from './api-client'; import { RegistryFetchError, RegistryNotFoundError, RegistryParseError } from './registry-errors'; import { AssetType, type Bundle, BundleSchema, type Manifest, ManifestSchema } from './schemas'; @@ -114,6 +114,25 @@ export async function fetchBundleManifest(ref: BundleManifestRef, options: Regis return parseResponse(unwrapRegistry(result, label), BundleSchema, label); } +/** + * Fetch a bundle's `README.md` as raw markdown. Returns null when the README + * is absent (HTTP 404) so callers can degrade gracefully — mirrors + * {@link fetchAssetReadme}. + */ +export async function fetchBundleReadme(ref: BundleManifestRef, options: RegistryClientOptions): Promise { + const label = `bundle ${ref.org ? `@${ref.org}/` : ''}${ref.name}@${ref.version} README`; + const result = await getBundleReadme({ + client: options.client, + parseAs: 'text', + path: { name: ref.name, version: ref.version }, + query: orgQuery(ref.org), + signal: options.signal, + }); + if (result.response?.status === 404) return null; + const data = unwrapRegistry(result, label); + return typeof data === 'string' ? data : String(data); +} + /** Fetch and validate the registry index (`registry.json`) from the ATK API. */ export async function fetchRegistry(options: RegistryClientOptions): Promise { const label = 'the registry index'; diff --git a/src/routes/AssetDetail.tsx b/src/routes/AssetDetail.tsx index 33bce6b..5233216 100644 --- a/src/routes/AssetDetail.tsx +++ b/src/routes/AssetDetail.tsx @@ -10,8 +10,8 @@ import { DownloadMenu } from '@/components/DownloadMenu'; import { EmptyState } from '@/components/EmptyState'; import { type FileGroup, FilesCard } from '@/components/FilesCard'; import { LoadingIndicator } from '@/components/LoadingIndicator'; -import { MarkdownRenderer } from '@/components/MarkdownRenderer'; import { PageHeader } from '@/components/PageHeader'; +import { ReadmeView } from '@/components/ReadmeView'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useAssetFiles } from '@/hooks/useAssetFiles'; @@ -232,7 +232,12 @@ export function AssetDetailRoute() {

README

- +
); @@ -318,20 +323,6 @@ function MetadataRow({ children, label }: { children: React.ReactNode; label: st ); } -function ReadmeView({ isError, isLoading, readme }: { isError: boolean; isLoading: boolean; readme: null | string }) { - if (isLoading) { - return ; - } - if (isError || !readme) { - return ( -

- No README is available for this asset. -

- ); - } - return ; -} - function SecurityBlockView({ security }: { security: NonNullable }) { return (
diff --git a/src/routes/BundleDetail.tsx b/src/routes/BundleDetail.tsx index 2af2dd1..03f3fe8 100644 --- a/src/routes/BundleDetail.tsx +++ b/src/routes/BundleDetail.tsx @@ -11,10 +11,12 @@ import { type FileGroup, FilesCard } from '@/components/FilesCard'; import { LoadingIndicator } from '@/components/LoadingIndicator'; import { MarkdownRenderer } from '@/components/MarkdownRenderer'; import { PageHeader } from '@/components/PageHeader'; +import { ReadmeView } from '@/components/ReadmeView'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useBundleManifest } from '@/hooks/useBundleManifest'; +import { useBundleReadme } from '@/hooks/useBundleReadme'; import { useDownloadBundle } from '@/hooks/useDownloadBundle'; import { refKey as manifestRefKey, useManifestGraph } from '@/hooks/useManifestGraph'; import { useRegistry } from '@/hooks/useRegistry'; @@ -46,6 +48,7 @@ export function BundleDetailRoute() { [registryQuery.data, bundleName, bundleOrg], ); const manifestQuery = useBundleManifest({ name: bundleName, org: bundleOrg, version: bundleVersion }); + const readmeQuery = useBundleReadme({ name: bundleName, org: bundleOrg, version: bundleVersion }); const { download, isDownloading } = useDownloadBundle(); const displayName = bundleOrg && bundleName ? `@${bundleOrg}/${bundleName}` : bundleName; @@ -249,6 +252,16 @@ export function BundleDetailRoute() { ) : null} + +
+

README

+ +
); }