Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/cockpit/src/components/cockpit-shell.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ const seedMode = (activeMode: 'Run' | 'Code' | 'Docs' | 'API') => {
}));
};

const renderShellFor = (slug: string[]) => {
const pageModel = getCockpitPageModel(slug);
return render(
<ThemeProvider theme="light">
<CockpitShell
navigationTree={pageModel.navigationTree}
presentation={pageModel.presentation}
entryTitle={pageModel.entry.title}
contentBundle={contentBundle}
/>
</ThemeProvider>,
);
};

const renderShell = () => render(
<ThemeProvider theme="light">
<CockpitShell
Expand Down Expand Up @@ -85,3 +99,29 @@ describe('CockpitShell control-plane mode state', () => {
expect(screen.getByRole('region', { name: 'Code mode' })).toBeTruthy();
});
});

describe('CockpitShell documentation link', () => {
beforeEach(() => {
window.localStorage.clear();
window.history.replaceState({}, '', '/');
});

it('links a capability to its page on the docs site', () => {
renderShellFor(['langgraph', 'core-capabilities', 'streaming', 'overview', 'python']);

const link = screen.getByRole('link', { name: /read docs/i });
expect(link.getAttribute('href')).toBe(
'https://threadplane.ai/docs/langgraph/guides/streaming'
);
expect(link.getAttribute('target')).toBe('_blank');
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
});

it('renders no link for a capability with no published docs page', () => {
// deep-agents carries the NO_COCKPIT_DOCS_LINK sentinel: the website has no
// deep-agents library yet, so there is nothing to link to.
renderShellFor(['deep-agents', 'core-capabilities', 'planning', 'overview', 'python']);

expect(screen.queryByRole('link', { name: /read docs/i })).toBeNull();
});
});
17 changes: 16 additions & 1 deletion apps/cockpit/src/components/cockpit-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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

useEffect(() => {
if (!preferences.hydrated || queryHandled.current) return;
Expand Down Expand Up @@ -111,6 +115,17 @@ export function CockpitShell({
</button>
<p className="hidden md:block text-[var(--ds-text-muted)] font-mono text-xs truncate">{contextLabel}</p>
</div>
{docsUrl ? (
<a
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"
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
>
<BookOpen size={14} aria-hidden="true" />
Read docs
</a>
) : null}
</header>

<div className="min-h-0 relative">
Expand Down
146 changes: 146 additions & 0 deletions apps/cockpit/src/lib/docs-links.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
COCKPIT_DOCS_LINKS,
COCKPIT_TOPICS_WITHOUT_DOCS,
NO_COCKPIT_DOCS_LINK,
cockpitManifest,
} from '@threadplane/cockpit-registry';
import { docsConfig } from '../../../website/src/lib/docs-config';

/**
* Guard for the cockpit -> website documentation links.
*
* `docsPath` used to be generated from a five-segment formula that matched no
* route the website has ever served, so every link 404'd and nothing noticed:
* the shape was asserted against a regex, never against reality. This spec
* checks each declared path against the website's real content tree and its
* real nav config, so a docs rename breaks a test instead of a link.
*/

const findWorkspaceRoot = (): string => {
let dir = process.cwd();
while (dir !== resolve(dir, '..')) {
if (existsSync(join(dir, 'nx.json'))) return dir;
dir = resolve(dir, '..');
}
throw new Error('workspace root (nx.json) not found');
};

const WORKSPACE_ROOT = findWorkspaceRoot();
const DOCS_CONTENT_ROOT = join(WORKSPACE_ROOT, 'apps/website/content/docs');

/** Every `/docs/<library>/<section>/<slug>` the website's nav actually offers. */
const navRoutes = new Set(
docsConfig.flatMap((library) =>
library.sections.flatMap((section) =>
section.pages.map((page) => `/docs/${library.id}/${section.id}/${page.slug}`)
)
)
);

/** Every `/docs/<library>/<section>/<slug>` backed by an `.mdx` file on disk. */
const contentRoutes = new Set<string>();
for (const library of readdirSync(DOCS_CONTENT_ROOT, { withFileTypes: true })) {
if (!library.isDirectory()) continue;
const libraryDir = join(DOCS_CONTENT_ROOT, library.name);
for (const section of readdirSync(libraryDir, { withFileTypes: true })) {
if (!section.isDirectory()) continue;
const sectionDir = join(libraryDir, section.name);
for (const file of readdirSync(sectionDir)) {
if (!file.endsWith('.mdx')) continue;
contentRoutes.add(
`/docs/${library.name}/${section.name}/${file.slice(0, -'.mdx'.length)}`
);
}
}
}

/**
* Descriptors are duplicated per example (cockpit examples are standalone), so
* they are read off disk rather than imported — an example whose module nobody
* imports still has to declare a link that resolves.
*/
const readDescriptorDocsPaths = (): { file: string; key: string; docsPath: string }[] => {
const results: { file: string; key: string; docsPath: string }[] = [];
const cockpitRoot = join(WORKSPACE_ROOT, 'cockpit');
for (const product of readdirSync(cockpitRoot, { withFileTypes: true })) {
if (!product.isDirectory()) continue;
const productDir = join(cockpitRoot, product.name);
for (const topic of readdirSync(productDir, { withFileTypes: true })) {
if (!topic.isDirectory()) continue;
for (const lane of readdirSync(join(productDir, topic.name), { withFileTypes: true })) {
if (!lane.isDirectory()) continue;
const file = join(productDir, topic.name, lane.name, 'src/index.ts');
if (!existsSync(file)) continue;
const source = readFileSync(file, 'utf-8');
const identity = /manifestIdentity:\s*\{[^}]*?product:\s*'([^']+)'[^}]*?section:\s*'([^']+)'[^}]*?topic:\s*'([^']+)'/s.exec(
source
);
const declared = /\n {2}docsPath: '([^']*)',/.exec(source);
if (!identity || !declared) continue;
results.push({
file: file.slice(WORKSPACE_ROOT.length + 1),
key: `${identity[1]}/${identity[2]}/${identity[3]}`,
docsPath: declared[1],
});
}
}
}
return results;
};

const descriptors = readDescriptorDocsPaths();

describe('cockpit docs links', () => {
it('reads a docs route list from the website that is not empty', () => {
// Guards the guard: an empty derived list would let everything below pass.
expect(navRoutes.size).toBeGreaterThan(50);
expect(contentRoutes.size).toBeGreaterThan(50);
});

it('points every mapped capability at a page the website actually serves', () => {
const broken = Object.entries(COCKPIT_DOCS_LINKS)
.filter(([, path]) => path !== NO_COCKPIT_DOCS_LINK)
.filter(([, path]) => !contentRoutes.has(path) || !navRoutes.has(path))
.map(([key, path]) => `${key} -> ${path}`);

expect(broken).toEqual([]);
});

it('blanks only the capabilities that are known to have no docs page', () => {
const blanked = Object.entries(COCKPIT_DOCS_LINKS)
.filter(([, path]) => path === NO_COCKPIT_DOCS_LINK)
.map(([key]) => key)
.sort();

expect(blanked).toEqual([...COCKPIT_TOPICS_WITHOUT_DOCS].sort());
});

it('maps every manifest entry', () => {
const unmapped = cockpitManifest
.filter((entry) => !(`${entry.product}/${entry.section}/${entry.topic}` in COCKPIT_DOCS_LINKS))
.map((entry) => `${entry.product}/${entry.section}/${entry.topic}`);

expect(unmapped).toEqual([]);
});

it('keeps every per-example descriptor in step with the shared table', () => {
expect(descriptors.length).toBeGreaterThan(60);

const drifted = descriptors
.filter(({ key, docsPath }) => docsPath !== COCKPIT_DOCS_LINKS[key])
.map(({ file, key, docsPath }) => `${file}: ${key} declares ${docsPath || '(blank)'}`);

expect(drifted).toEqual([]);
});

it('declares no five-segment legacy docs path anywhere', () => {
const legacy = descriptors
.filter(({ docsPath }) => docsPath.split('/').filter(Boolean).length > 4)
.map(({ file, docsPath }) => `${file}: ${docsPath}`);

expect(legacy).toEqual([]);
});
});
22 changes: 22 additions & 0 deletions apps/cockpit/src/lib/docs-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NO_COCKPIT_DOCS_LINK } from '@threadplane/cockpit-registry';

/**
* Absolute URL for a capability's `docsPath`.
*
* `docsPath` is a website-relative path (`/docs/<library>/<section>/<slug>`),
* but the cockpit is served from its own origin (cockpit.threadplane.ai), so
* the link has to be absolutised against the docs site.
*
* Returns `null` when the capability has no published docs page — callers
* render no link rather than one that 404s.
*/
export function resolveDocsUrl(docsPath: string | undefined): string | null {
if (!docsPath || docsPath === NO_COCKPIT_DOCS_LINK) return null;
if (/^https?:\/\//.test(docsPath)) return docsPath;

const baseUrl = (
process.env['NEXT_PUBLIC_COCKPIT_DOCS_BASE_URL'] ?? 'https://threadplane.ai'
).replace(/\/$/, '');

return `${baseUrl}${docsPath.startsWith('/') ? docsPath : `/${docsPath}`}`;
}
10 changes: 5 additions & 5 deletions apps/cockpit/src/lib/route-resolution.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,11 @@ describe('getCapabilityPresentation', () => {

expect(getCapabilityPresentation(docsEntry)).toMatchObject({
kind: 'docs-only',
docsPath: '/docs/deep-agents/getting-started/overview/overview/python',
docsPath: '',
});
expect(getCapabilityPresentation(capabilityEntry)).toMatchObject({
kind: 'capability',
docsPath: '/docs/langgraph/core-capabilities/streaming/overview/python',
docsPath: '/docs/langgraph/guides/streaming',
promptAssetPaths: ['cockpit/langgraph/streaming/python/prompts/streaming.md'],
codeAssetPaths: [
'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts',
Expand Down Expand Up @@ -237,7 +237,7 @@ describe('getCapabilityPresentation', () => {

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

expect(presentation).toMatchObject({
kind: 'capability',
docsPath: '/docs/render/core-capabilities/spec-rendering/overview/python',
docsPath: '/docs/render/guides/specs',
});
});

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

expect(presentation).toMatchObject({
kind: 'capability',
docsPath: '/docs/chat/core-capabilities/messages/overview/python',
docsPath: '/docs/chat/concepts/message-model',
});
});

Expand Down
21 changes: 20 additions & 1 deletion apps/cockpit/vite.config.mts
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';

const __dirname = dirname(fileURLToPath(import.meta.url));

export default defineConfig({
plugins: [nxViteTsPaths()],
// The capability matrix specs (see `test.include`) live outside this app's
// directory, and Vite's dev server refuses to serve files above its root
// unless they are allow-listed. Without this the matrix specs fail to load
// with ERR_MODULE_NOT_FOUND on a `/@fs/...` path under `nx test cockpit`.
server: { fs: { allow: [resolve(__dirname, '../..')] } },
test: {
environment: 'jsdom',
globals: true,
include: ['src/**/*.spec.ts', 'src/**/*.spec.tsx', '*.spec.ts', 'scripts/**/*.spec.ts'],
include: [
'src/**/*.spec.ts',
'src/**/*.spec.tsx',
'*.spec.ts',
'scripts/**/*.spec.ts',
// The per-product capability matrix specs live beside the examples they
// describe and had no test target of their own, which is how their
// docsPath assertion drifted into asserting a URL shape the website has
// never served. Run them here so `nx test cockpit` covers them.
'../../cockpit/*/matrix.spec.ts',
],
setupFiles: ['./test-setup.ts'],
},
});
2 changes: 1 addition & 1 deletion cockpit/ag-ui/a2ui/angular/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const agUiA2uiAngularModule: CockpitCapabilityModule = {
language: 'angular',
},
title: 'AG-UI A2UI (Angular)',
docsPath: '/docs/ag-ui/core-capabilities/a2ui/overview/angular',
docsPath: '/docs/a2ui/getting-started/introduction',
promptAssetPaths: [],
codeAssetPaths: [
'cockpit/ag-ui/a2ui/angular/src/app/a2ui.component.ts',
Expand Down
2 changes: 1 addition & 1 deletion cockpit/ag-ui/a2ui/python/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const agUiA2uiPythonModule: CockpitCapabilityModule = {
language: 'python',
},
title: 'AG-UI A2UI (Python)',
docsPath: '/docs/ag-ui/core-capabilities/a2ui/overview/python',
docsPath: '/docs/a2ui/getting-started/introduction',
promptAssetPaths: ['cockpit/ag-ui/a2ui/python/prompts/a2ui.md'],
codeAssetPaths: [
'cockpit/ag-ui/a2ui/angular/src/app/a2ui.component.ts',
Expand Down
2 changes: 1 addition & 1 deletion cockpit/ag-ui/client-tools/angular/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const agUiClientToolsAngularModule: CockpitCapabilityModule = {
id: 'ag-ui-client-tools-angular',
manifestIdentity: { product: 'ag-ui', section: 'core-capabilities', topic: 'client-tools', page: 'overview', language: 'angular' },
title: 'AG-UI Client Tools (Angular)',
docsPath: '/docs/ag-ui/core-capabilities/client-tools/overview/angular',
docsPath: '/docs/chat/guides/client-tools',
promptAssetPaths: ['cockpit/ag-ui/client-tools/angular/prompts/client-tools.md'],
codeAssetPaths: [
'cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts',
Expand Down
2 changes: 1 addition & 1 deletion cockpit/ag-ui/client-tools/python/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const agUiClientToolsPythonModule: CockpitCapabilityModule = {
id: 'ag-ui-client-tools-python',
manifestIdentity: { product: 'ag-ui', section: 'core-capabilities', topic: 'client-tools', page: 'overview', language: 'python' },
title: 'AG-UI Client Tools (Python)',
docsPath: '/docs/ag-ui/core-capabilities/client-tools/overview/python',
docsPath: '/docs/chat/guides/client-tools',
promptAssetPaths: ['cockpit/ag-ui/client-tools/python/prompts/client-tools.md'],
codeAssetPaths: [
'cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts',
Expand Down
2 changes: 1 addition & 1 deletion cockpit/ag-ui/interrupts/angular/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const agUiInterruptsAngularModule: CockpitCapabilityModule = {
language: 'angular',
},
title: 'AG-UI Interrupts (Angular)',
docsPath: '/docs/ag-ui/core-capabilities/interrupts/overview/angular',
docsPath: '/docs/ag-ui/guides/interrupts',
promptAssetPaths: [
'cockpit/ag-ui/interrupts/angular/prompts/interrupts.md',
],
Expand Down
2 changes: 1 addition & 1 deletion cockpit/ag-ui/interrupts/python/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const agUiInterruptsPythonModule: CockpitCapabilityModule = {
language: 'python',
},
title: 'AG-UI Interrupts (Python)',
docsPath: '/docs/ag-ui/core-capabilities/interrupts/overview/python',
docsPath: '/docs/ag-ui/guides/interrupts',
promptAssetPaths: ['cockpit/ag-ui/interrupts/python/prompts/interrupts.md'],
codeAssetPaths: [
'cockpit/ag-ui/interrupts/angular/src/app/interrupts.component.ts',
Expand Down
Loading
Loading