Skip to content

Commit fa6a4b7

Browse files
bloveclaude
andcommitted
fix(cockpit): point docsPath at docs pages that actually exist
Every cockpit capability declared `docsPath` in a five-segment shape, `/docs/<product>/core-capabilities/<topic>/overview/<lang>`. The website serves docs on a three-segment route, `/docs/<library>/<section>/<slug>`, so every one of those URLs 404s — and has for as long as the field has existed. Nothing caught it because the only assertion was a regex over the shape the code itself generated, never a check against a real route, and the field was never rendered anywhere, so no link ever visibly broke. - `libs/cockpit-registry/src/lib/docs-links.ts` holds the cockpit -> docs mapping as a table, not a formula: the two trees do not share a naming scheme, which is what made a formula wrong in the first place. Empty string is the documented "no published page yet" sentinel, carried by the seven deep-agents entries (the website has no deep-agents library). - All 83 per-example descriptors and the generated ag-ui-dev deps are rewritten from that table. - `apps/cockpit/src/lib/docs-links.spec.ts` checks every mapped path against the website's real content tree and its real nav config, checks every descriptor agrees with the table, and pins the sentinel list, so a docs rename breaks a test instead of a link. - The per-product matrix specs had no test target at all, which is how their assertion drifted; they now run under `nx test cockpit`. - The header renders a "Read docs" link where the path resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6861daf commit fa6a4b7

105 files changed

Lines changed: 554 additions & 105 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/cockpit/src/components/cockpit-shell.spec.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,20 @@ const seedMode = (activeMode: 'Run' | 'Code' | 'Docs' | 'API') => {
2323
}));
2424
};
2525

26+
const renderShellFor = (slug: string[]) => {
27+
const pageModel = getCockpitPageModel(slug);
28+
return render(
29+
<ThemeProvider theme="light">
30+
<CockpitShell
31+
navigationTree={pageModel.navigationTree}
32+
presentation={pageModel.presentation}
33+
entryTitle={pageModel.entry.title}
34+
contentBundle={contentBundle}
35+
/>
36+
</ThemeProvider>,
37+
);
38+
};
39+
2640
const renderShell = () => render(
2741
<ThemeProvider theme="light">
2842
<CockpitShell
@@ -85,3 +99,29 @@ describe('CockpitShell control-plane mode state', () => {
8599
expect(screen.getByRole('region', { name: 'Code mode' })).toBeTruthy();
86100
});
87101
});
102+
103+
describe('CockpitShell documentation link', () => {
104+
beforeEach(() => {
105+
window.localStorage.clear();
106+
window.history.replaceState({}, '', '/');
107+
});
108+
109+
it('links a capability to its page on the docs site', () => {
110+
renderShellFor(['langgraph', 'core-capabilities', 'streaming', 'overview', 'python']);
111+
112+
const link = screen.getByRole('link', { name: /read docs/i });
113+
expect(link.getAttribute('href')).toBe(
114+
'https://threadplane.ai/docs/langgraph/guides/streaming'
115+
);
116+
expect(link.getAttribute('target')).toBe('_blank');
117+
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
118+
});
119+
120+
it('renders no link for a capability with no published docs page', () => {
121+
// deep-agents carries the NO_COCKPIT_DOCS_LINK sentinel: the website has no
122+
// deep-agents library yet, so there is nothing to link to.
123+
renderShellFor(['deep-agents', 'core-capabilities', 'planning', 'overview', 'python']);
124+
125+
expect(screen.queryByRole('link', { name: /read docs/i })).toBeNull();
126+
});
127+
});

apps/cockpit/src/components/cockpit-shell.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import React, { useEffect, useRef, useState } from 'react';
44
import { cockpitManifest } from '@threadplane/cockpit-registry';
5-
import { Menu } from 'lucide-react';
5+
import { BookOpen, Menu } from 'lucide-react';
66
import {
77
parseControlPlaneMode,
88
useControlPlanePreferences,
@@ -11,6 +11,7 @@ import {
1111
import type { ContentBundle } from '../lib/content-bundle';
1212
import type { CapabilityPresentation, NavigationProduct } from '../lib/route-resolution';
1313
import { PRODUCT_LABELS } from '../lib/navigation-labels';
14+
import { resolveDocsUrl } from '../lib/docs-links';
1415
import { CodeMode } from './code-mode/code-mode';
1516
import { ApiMode } from './api-mode/api-mode';
1617
import { NarrativeDocs } from './narrative-docs/narrative-docs';
@@ -47,6 +48,9 @@ export function CockpitShell({
4748
const backendAssetPaths = isCapability ? (presentation.backendAssetPaths ?? []) : [];
4849
const entry = presentation.entry;
4950
const contextLabel = `${PRODUCT_LABELS[entry.product] ?? toLabel(entry.product)} / ${toLabel(entry.section)} / ${toLabel(entry.topic)}`;
51+
// Null for the capabilities that have no published docs page yet — those
52+
// render no link at all rather than one that 404s.
53+
const docsUrl = resolveDocsUrl(presentation.docsPath);
5054

5155
useEffect(() => {
5256
if (!preferences.hydrated || queryHandled.current) return;
@@ -111,6 +115,17 @@ export function CockpitShell({
111115
</button>
112116
<p className="hidden md:block text-[var(--ds-text-muted)] font-mono text-xs truncate">{contextLabel}</p>
113117
</div>
118+
{docsUrl ? (
119+
<a
120+
className="shrink-0 inline-flex items-center gap-1.5 text-xs text-[var(--ds-text-secondary)] hover:text-[var(--ds-text-primary)] no-underline"
121+
href={docsUrl}
122+
target="_blank"
123+
rel="noopener noreferrer"
124+
>
125+
<BookOpen size={14} aria-hidden="true" />
126+
Read docs
127+
</a>
128+
) : null}
114129
</header>
115130

116131
<div className="min-h-0 relative">
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
2+
import { join, resolve } from 'node:path';
3+
import { describe, expect, it } from 'vitest';
4+
import {
5+
COCKPIT_DOCS_LINKS,
6+
COCKPIT_TOPICS_WITHOUT_DOCS,
7+
NO_COCKPIT_DOCS_LINK,
8+
cockpitManifest,
9+
} from '@threadplane/cockpit-registry';
10+
import { docsConfig } from '../../../website/src/lib/docs-config';
11+
12+
/**
13+
* Guard for the cockpit -> website documentation links.
14+
*
15+
* `docsPath` used to be generated from a five-segment formula that matched no
16+
* route the website has ever served, so every link 404'd and nothing noticed:
17+
* the shape was asserted against a regex, never against reality. This spec
18+
* checks each declared path against the website's real content tree and its
19+
* real nav config, so a docs rename breaks a test instead of a link.
20+
*/
21+
22+
const findWorkspaceRoot = (): string => {
23+
let dir = process.cwd();
24+
while (dir !== resolve(dir, '..')) {
25+
if (existsSync(join(dir, 'nx.json'))) return dir;
26+
dir = resolve(dir, '..');
27+
}
28+
throw new Error('workspace root (nx.json) not found');
29+
};
30+
31+
const WORKSPACE_ROOT = findWorkspaceRoot();
32+
const DOCS_CONTENT_ROOT = join(WORKSPACE_ROOT, 'apps/website/content/docs');
33+
34+
/** Every `/docs/<library>/<section>/<slug>` the website's nav actually offers. */
35+
const navRoutes = new Set(
36+
docsConfig.flatMap((library) =>
37+
library.sections.flatMap((section) =>
38+
section.pages.map((page) => `/docs/${library.id}/${section.id}/${page.slug}`)
39+
)
40+
)
41+
);
42+
43+
/** Every `/docs/<library>/<section>/<slug>` backed by an `.mdx` file on disk. */
44+
const contentRoutes = new Set<string>();
45+
for (const library of readdirSync(DOCS_CONTENT_ROOT, { withFileTypes: true })) {
46+
if (!library.isDirectory()) continue;
47+
const libraryDir = join(DOCS_CONTENT_ROOT, library.name);
48+
for (const section of readdirSync(libraryDir, { withFileTypes: true })) {
49+
if (!section.isDirectory()) continue;
50+
const sectionDir = join(libraryDir, section.name);
51+
for (const file of readdirSync(sectionDir)) {
52+
if (!file.endsWith('.mdx')) continue;
53+
contentRoutes.add(
54+
`/docs/${library.name}/${section.name}/${file.slice(0, -'.mdx'.length)}`
55+
);
56+
}
57+
}
58+
}
59+
60+
/**
61+
* Descriptors are duplicated per example (cockpit examples are standalone), so
62+
* they are read off disk rather than imported — an example whose module nobody
63+
* imports still has to declare a link that resolves.
64+
*/
65+
const readDescriptorDocsPaths = (): { file: string; key: string; docsPath: string }[] => {
66+
const results: { file: string; key: string; docsPath: string }[] = [];
67+
const cockpitRoot = join(WORKSPACE_ROOT, 'cockpit');
68+
for (const product of readdirSync(cockpitRoot, { withFileTypes: true })) {
69+
if (!product.isDirectory()) continue;
70+
const productDir = join(cockpitRoot, product.name);
71+
for (const topic of readdirSync(productDir, { withFileTypes: true })) {
72+
if (!topic.isDirectory()) continue;
73+
for (const lane of readdirSync(join(productDir, topic.name), { withFileTypes: true })) {
74+
if (!lane.isDirectory()) continue;
75+
const file = join(productDir, topic.name, lane.name, 'src/index.ts');
76+
if (!existsSync(file)) continue;
77+
const source = readFileSync(file, 'utf-8');
78+
const identity = /manifestIdentity:\s*\{[^}]*?product:\s*'([^']+)'[^}]*?section:\s*'([^']+)'[^}]*?topic:\s*'([^']+)'/s.exec(
79+
source
80+
);
81+
const declared = /\n {2}docsPath: '([^']*)',/.exec(source);
82+
if (!identity || !declared) continue;
83+
results.push({
84+
file: file.slice(WORKSPACE_ROOT.length + 1),
85+
key: `${identity[1]}/${identity[2]}/${identity[3]}`,
86+
docsPath: declared[1],
87+
});
88+
}
89+
}
90+
}
91+
return results;
92+
};
93+
94+
const descriptors = readDescriptorDocsPaths();
95+
96+
describe('cockpit docs links', () => {
97+
it('reads a docs route list from the website that is not empty', () => {
98+
// Guards the guard: an empty derived list would let everything below pass.
99+
expect(navRoutes.size).toBeGreaterThan(50);
100+
expect(contentRoutes.size).toBeGreaterThan(50);
101+
});
102+
103+
it('points every mapped capability at a page the website actually serves', () => {
104+
const broken = Object.entries(COCKPIT_DOCS_LINKS)
105+
.filter(([, path]) => path !== NO_COCKPIT_DOCS_LINK)
106+
.filter(([, path]) => !contentRoutes.has(path) || !navRoutes.has(path))
107+
.map(([key, path]) => `${key} -> ${path}`);
108+
109+
expect(broken).toEqual([]);
110+
});
111+
112+
it('blanks only the capabilities that are known to have no docs page', () => {
113+
const blanked = Object.entries(COCKPIT_DOCS_LINKS)
114+
.filter(([, path]) => path === NO_COCKPIT_DOCS_LINK)
115+
.map(([key]) => key)
116+
.sort();
117+
118+
expect(blanked).toEqual([...COCKPIT_TOPICS_WITHOUT_DOCS].sort());
119+
});
120+
121+
it('maps every manifest entry', () => {
122+
const unmapped = cockpitManifest
123+
.filter((entry) => !(`${entry.product}/${entry.section}/${entry.topic}` in COCKPIT_DOCS_LINKS))
124+
.map((entry) => `${entry.product}/${entry.section}/${entry.topic}`);
125+
126+
expect(unmapped).toEqual([]);
127+
});
128+
129+
it('keeps every per-example descriptor in step with the shared table', () => {
130+
expect(descriptors.length).toBeGreaterThan(60);
131+
132+
const drifted = descriptors
133+
.filter(({ key, docsPath }) => docsPath !== COCKPIT_DOCS_LINKS[key])
134+
.map(({ file, key, docsPath }) => `${file}: ${key} declares ${docsPath || '(blank)'}`);
135+
136+
expect(drifted).toEqual([]);
137+
});
138+
139+
it('declares no five-segment legacy docs path anywhere', () => {
140+
const legacy = descriptors
141+
.filter(({ docsPath }) => docsPath.split('/').filter(Boolean).length > 4)
142+
.map(({ file, docsPath }) => `${file}: ${docsPath}`);
143+
144+
expect(legacy).toEqual([]);
145+
});
146+
});

apps/cockpit/src/lib/docs-links.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { NO_COCKPIT_DOCS_LINK } from '@threadplane/cockpit-registry';
2+
3+
/**
4+
* Absolute URL for a capability's `docsPath`.
5+
*
6+
* `docsPath` is a website-relative path (`/docs/<library>/<section>/<slug>`),
7+
* but the cockpit is served from its own origin (cockpit.threadplane.ai), so
8+
* the link has to be absolutised against the docs site.
9+
*
10+
* Returns `null` when the capability has no published docs page — callers
11+
* render no link rather than one that 404s.
12+
*/
13+
export function resolveDocsUrl(docsPath: string | undefined): string | null {
14+
if (!docsPath || docsPath === NO_COCKPIT_DOCS_LINK) return null;
15+
if (/^https?:\/\//.test(docsPath)) return docsPath;
16+
17+
const baseUrl = (
18+
process.env['NEXT_PUBLIC_COCKPIT_DOCS_BASE_URL'] ?? 'https://threadplane.ai'
19+
).replace(/\/$/, '');
20+
21+
return `${baseUrl}${docsPath.startsWith('/') ? docsPath : `/${docsPath}`}`;
22+
}

apps/cockpit/src/lib/route-resolution.spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,11 @@ describe('getCapabilityPresentation', () => {
176176

177177
expect(getCapabilityPresentation(docsEntry)).toMatchObject({
178178
kind: 'docs-only',
179-
docsPath: '/docs/deep-agents/getting-started/overview/overview/python',
179+
docsPath: '',
180180
});
181181
expect(getCapabilityPresentation(capabilityEntry)).toMatchObject({
182182
kind: 'capability',
183-
docsPath: '/docs/langgraph/core-capabilities/streaming/overview/python',
183+
docsPath: '/docs/langgraph/guides/streaming',
184184
promptAssetPaths: ['cockpit/langgraph/streaming/python/prompts/streaming.md'],
185185
codeAssetPaths: [
186186
'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts',
@@ -237,7 +237,7 @@ describe('getCapabilityPresentation', () => {
237237

238238
expect(presentation).toMatchObject({
239239
kind: 'capability',
240-
docsPath: '/docs/langgraph/core-capabilities/durable-execution/overview/python',
240+
docsPath: '/docs/langgraph/guides/persistence',
241241
docsAssetPaths: ['cockpit/langgraph/durable-execution/python/docs/guide.md'],
242242
});
243243
});
@@ -255,7 +255,7 @@ describe('getCapabilityPresentation', () => {
255255

256256
expect(presentation).toMatchObject({
257257
kind: 'capability',
258-
docsPath: '/docs/render/core-capabilities/spec-rendering/overview/python',
258+
docsPath: '/docs/render/guides/specs',
259259
});
260260
});
261261

@@ -272,7 +272,7 @@ describe('getCapabilityPresentation', () => {
272272

273273
expect(presentation).toMatchObject({
274274
kind: 'capability',
275-
docsPath: '/docs/chat/core-capabilities/messages/overview/python',
275+
docsPath: '/docs/chat/concepts/message-model',
276276
});
277277
});
278278

apps/cockpit/vite.config.mts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,31 @@
1+
import { dirname, resolve } from 'node:path';
2+
import { fileURLToPath } from 'node:url';
13
import { defineConfig } from 'vite';
24
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
35

6+
const __dirname = dirname(fileURLToPath(import.meta.url));
7+
48
export default defineConfig({
59
plugins: [nxViteTsPaths()],
10+
// The capability matrix specs (see `test.include`) live outside this app's
11+
// directory, and Vite's dev server refuses to serve files above its root
12+
// unless they are allow-listed. Without this the matrix specs fail to load
13+
// with ERR_MODULE_NOT_FOUND on a `/@fs/...` path under `nx test cockpit`.
14+
server: { fs: { allow: [resolve(__dirname, '../..')] } },
615
test: {
716
environment: 'jsdom',
817
globals: true,
9-
include: ['src/**/*.spec.ts', 'src/**/*.spec.tsx', '*.spec.ts', 'scripts/**/*.spec.ts'],
18+
include: [
19+
'src/**/*.spec.ts',
20+
'src/**/*.spec.tsx',
21+
'*.spec.ts',
22+
'scripts/**/*.spec.ts',
23+
// The per-product capability matrix specs live beside the examples they
24+
// describe and had no test target of their own, which is how their
25+
// docsPath assertion drifted into asserting a URL shape the website has
26+
// never served. Run them here so `nx test cockpit` covers them.
27+
'../../cockpit/*/matrix.spec.ts',
28+
],
1029
setupFiles: ['./test-setup.ts'],
1130
},
1231
});

cockpit/ag-ui/a2ui/angular/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export const agUiA2uiAngularModule: CockpitCapabilityModule = {
2424
language: 'angular',
2525
},
2626
title: 'AG-UI A2UI (Angular)',
27-
docsPath: '/docs/ag-ui/core-capabilities/a2ui/overview/angular',
27+
docsPath: '/docs/a2ui/getting-started/introduction',
2828
promptAssetPaths: [],
2929
codeAssetPaths: [
3030
'cockpit/ag-ui/a2ui/angular/src/app/a2ui.component.ts',

cockpit/ag-ui/a2ui/python/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const agUiA2uiPythonModule: CockpitCapabilityModule = {
2727
language: 'python',
2828
},
2929
title: 'AG-UI A2UI (Python)',
30-
docsPath: '/docs/ag-ui/core-capabilities/a2ui/overview/python',
30+
docsPath: '/docs/a2ui/getting-started/introduction',
3131
promptAssetPaths: ['cockpit/ag-ui/a2ui/python/prompts/a2ui.md'],
3232
codeAssetPaths: [
3333
'cockpit/ag-ui/a2ui/angular/src/app/a2ui.component.ts',

cockpit/ag-ui/client-tools/angular/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const agUiClientToolsAngularModule: CockpitCapabilityModule = {
1212
id: 'ag-ui-client-tools-angular',
1313
manifestIdentity: { product: 'ag-ui', section: 'core-capabilities', topic: 'client-tools', page: 'overview', language: 'angular' },
1414
title: 'AG-UI Client Tools (Angular)',
15-
docsPath: '/docs/ag-ui/core-capabilities/client-tools/overview/angular',
15+
docsPath: '/docs/chat/guides/client-tools',
1616
promptAssetPaths: ['cockpit/ag-ui/client-tools/angular/prompts/client-tools.md'],
1717
codeAssetPaths: [
1818
'cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts',

cockpit/ag-ui/client-tools/python/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const agUiClientToolsPythonModule: CockpitCapabilityModule = {
1515
id: 'ag-ui-client-tools-python',
1616
manifestIdentity: { product: 'ag-ui', section: 'core-capabilities', topic: 'client-tools', page: 'overview', language: 'python' },
1717
title: 'AG-UI Client Tools (Python)',
18-
docsPath: '/docs/ag-ui/core-capabilities/client-tools/overview/python',
18+
docsPath: '/docs/chat/guides/client-tools',
1919
promptAssetPaths: ['cockpit/ag-ui/client-tools/python/prompts/client-tools.md'],
2020
codeAssetPaths: [
2121
'cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts',

0 commit comments

Comments
 (0)