diff --git a/apps/cockpit/cockpit-capability-wiring.spec.ts b/apps/cockpit/cockpit-capability-wiring.spec.ts index f15186f11..3bbc3f764 100644 --- a/apps/cockpit/cockpit-capability-wiring.spec.ts +++ b/apps/cockpit/cockpit-capability-wiring.spec.ts @@ -1,16 +1,18 @@ -import { cockpitManifest } from '@threadplane/cockpit-registry'; -import { capabilities } from './scripts/capability-registry'; import { - buildNavigationTree, capabilityModules, -} from './src/lib/route-resolution'; + cockpitManifest, +} from '@threadplane/cockpit-registry'; +import { capabilities } from './scripts/capability-registry'; +import { buildNavigationTree } from '@threadplane/cockpit-shell'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; /** * The cockpit site is assembled from three lists that nothing forced to agree: * * - `apps/cockpit/scripts/capability-registry.ts` — what serve/build/deploy know about; * - `libs/cockpit-registry` `cockpitManifest` — what the Next route can resolve; - * - `capabilityModules` in `route-resolution.ts` — what supplies a page's assets. + * - registry-owned `capabilityModules` — what supplies a page's assets. * * When the `runtimes` product shipped, only the first list learned about it, so * `/runtimes/core-capabilities//overview/` threw @@ -18,26 +20,46 @@ import { * the whole suite stayed green. These assertions are the missing coupling. */ describe('cockpit capability wiring', () => { - const manifestKey = (e: { product: string; section: string; topic: string }) => - `${e.product}/${e.section}/${e.topic}`; + const resolveCockpitConfig = (fileName: string): string => { + const workspaceConfigPath = resolve( + process.cwd(), + 'apps/cockpit', + fileName + ); + return existsSync(workspaceConfigPath) + ? workspaceConfigPath + : resolve(process.cwd(), fileName); + }; + + const manifestKey = (e: { + product: string; + section: string; + topic: string; + }) => `${e.product}/${e.section}/${e.topic}`; it('gives every registered capability a resolvable manifest entry', () => { const manifestKeys = new Set(cockpitManifest.map(manifestKey)); const unroutable = capabilities - .map((capability) => `${capability.product}/core-capabilities/${capability.topic}`) + .map( + (capability) => + `${capability.product}/core-capabilities/${capability.topic}` + ) .filter((key) => !manifestKeys.has(key)); expect(unroutable).toEqual([]); }); - it('gives every registered capability a cockpit module in route-resolution', () => { + it('gives every registered capability a registry-owned content descriptor', () => { const moduleKeys = new Set( capabilityModules.map((module) => manifestKey(module.manifestIdentity)) ); const unwired = capabilities - .map((capability) => `${capability.product}/core-capabilities/${capability.topic}`) + .map( + (capability) => + `${capability.product}/core-capabilities/${capability.topic}` + ) .filter((key) => !moduleKeys.has(key)); expect(unwired).toEqual([]); @@ -46,7 +68,8 @@ describe('cockpit capability wiring', () => { it('points every cockpit module at a capability that still exists', () => { const capabilityKeys = new Set( capabilities.map( - (capability) => `${capability.product}/core-capabilities/${capability.topic}` + (capability) => + `${capability.product}/core-capabilities/${capability.topic}` ) ); @@ -58,19 +81,25 @@ describe('cockpit capability wiring', () => { }); it('surfaces every manifest product in the navigation tree', () => { - const manifestProducts = [...new Set(cockpitManifest.map((entry) => entry.product))]; + const manifestProducts = [ + ...new Set(cockpitManifest.map((entry) => entry.product)), + ]; const navigationProducts = buildNavigationTree(cockpitManifest).map( (product) => product.product ); - expect([...manifestProducts].sort()).toEqual([...navigationProducts].sort()); + expect([...manifestProducts].sort()).toEqual( + [...navigationProducts].sort() + ); for (const product of buildNavigationTree(cockpitManifest)) { const entries = product.sections.flatMap((section) => section.entries); - expect({ product: product.product, empty: entries.length === 0 }).toEqual({ - product: product.product, - empty: false, - }); + expect({ product: product.product, empty: entries.length === 0 }).toEqual( + { + product: product.product, + empty: false, + } + ); } }); @@ -78,9 +107,48 @@ describe('cockpit capability wiring', () => { // `cockpitManifest` is typed `CockpitManifestEntry[]`, so a product that is // not in the union cannot appear here — the runtime check is that the // registry's products are all representable in the manifest. - const manifestProducts = new Set(cockpitManifest.map((entry) => entry.product)); + const manifestProducts = new Set( + cockpitManifest.map((entry) => entry.product) + ); const registryProducts = [...new Set(capabilities.map((c) => c.product))]; - expect(registryProducts.filter((p) => !manifestProducts.has(p))).toEqual([]); + expect(registryProducts.filter((p) => !manifestProducts.has(p))).toEqual( + [] + ); + }); + + it('has no direct project references to capability example lanes', () => { + const tsconfig = JSON.parse( + readFileSync(resolveCockpitConfig('tsconfig.json'), 'utf8') + ) as { references?: Array<{ path: string }> }; + + expect( + tsconfig.references?.filter((reference) => + reference.path.startsWith('../../cockpit/') + ) + ).toEqual([]); + }); + + it('includes external capability content assets in the Cockpit build inputs', () => { + const project = JSON.parse( + readFileSync(resolveCockpitConfig('project.json'), 'utf8') + ) as { + targets: { build: { inputs: string[] } }; + namedInputs: Record; + }; + + expect(project.targets.build.inputs).toEqual([ + 'default', + 'deploymentConfig', + 'contentAssets', + '^default', + ]); + expect(project.namedInputs['contentAssets']).toEqual([ + '{workspaceRoot}/cockpit/**/prompts/**', + '{workspaceRoot}/cockpit/**/angular/src/**', + '{workspaceRoot}/cockpit/**/python/src/**', + '{workspaceRoot}/cockpit/**/docs/**', + '{workspaceRoot}/deployments/ag-ui-mastra/*.mjs', + ]); }); }); diff --git a/apps/cockpit/cockpit-e2e-wiring.spec.ts b/apps/cockpit/cockpit-e2e-wiring.spec.ts index 32aec4244..7f766d87f 100644 --- a/apps/cockpit/cockpit-e2e-wiring.spec.ts +++ b/apps/cockpit/cockpit-e2e-wiring.spec.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import { capabilities } from './scripts/capability-registry'; // @ts-expect-error — .mjs ES module without .d.ts; the e2e tsconfig uses // allowJs:true but this top-level test file doesn't go through that config. +// eslint-disable-next-line @nx/enforce-module-boundaries -- repo-root port registry is intentionally outside an Nx project. import { portsFor } from '../../cockpit/ports.mjs'; interface E2eWiring { diff --git a/apps/cockpit/e2e/control-plane.spec.ts b/apps/cockpit/e2e/control-plane.spec.ts index 490cd4b53..649ded2b7 100644 --- a/apps/cockpit/e2e/control-plane.spec.ts +++ b/apps/cockpit/e2e/control-plane.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; const route = '/langgraph/core-capabilities/streaming/overview/python'; +const RUN_RAIL_ITEM = /^Run(?:,|$)/; declare global { interface Window { @@ -108,12 +109,43 @@ test.describe('Cockpit operational control plane', () => { await expect(desktopNavigation).toBeVisible(); await expect(mobileTrigger).toBeHidden(); await expect( - page.getByRole('button', { name: 'Runtime', exact: true }) - ).toBeVisible(); - await page.getByRole('button', { name: 'Activity' }).click(); - await expect( - page.getByRole('heading', { name: 'Activity' }) + desktopNavigation.getByRole('button', { name: RUN_RAIL_ITEM }) ).toBeVisible(); + if (viewport.width >= 1024) { + await expect( + page.getByRole('button', { name: 'Runtime', exact: true }) + ).toBeVisible(); + await page.getByRole('button', { name: 'Activity' }).click(); + await expect( + page.getByRole('heading', { name: 'Activity' }) + ).toBeVisible(); + } else { + const contextTrigger = page.getByRole('button', { + name: 'Open context', + }); + await expect(contextTrigger).toBeVisible(); + await contextTrigger.click(); + const contextDialog = page.getByRole('dialog', { + name: 'Cockpit control plane context', + }); + await expect( + contextDialog.getByRole('button', { + name: 'Runtime', + exact: true, + }) + ).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(contextDialog).toBeHidden(); + await expect(contextTrigger).toBeFocused(); + + await desktopNavigation + .getByRole('button', { name: 'Activity' }) + .click(); + await expect(contextDialog).toBeVisible(); + await expect( + contextDialog.getByRole('heading', { name: 'Activity' }) + ).toBeVisible(); + } } else { await expect(desktopNavigation).toBeHidden(); await expect(mobileTrigger).toBeVisible(); @@ -131,13 +163,16 @@ test.describe('Cockpit operational control plane', () => { '' ); await expect( - dialog.getByRole('button', { name: 'Runtime', exact: true }) + dialog.getByRole('button', { name: RUN_RAIL_ITEM }) ).toBeVisible(); await dialog.getByRole('button', { name: 'Activity' }).click(); await expect( dialog.getByRole('heading', { name: 'Activity' }) ).toBeVisible(); await dialog.getByRole('button', { name: 'Close Activity' }).click(); + await expect( + dialog.getByRole('button', { name: RUN_RAIL_ITEM }) + ).toBeVisible(); await expect( dialog.getByRole('button', { name: 'Runtime', exact: true }) ).toBeVisible(); diff --git a/apps/cockpit/e2e/production-smoke.spec.ts b/apps/cockpit/e2e/production-smoke.spec.ts index 8d088239e..84080a1bc 100644 --- a/apps/cockpit/e2e/production-smoke.spec.ts +++ b/apps/cockpit/e2e/production-smoke.spec.ts @@ -1,5 +1,9 @@ import { expect, test } from '@playwright/test'; import { capabilities } from '../scripts/capability-registry'; +import { + getRedirectDisabledProbePath, + getRegistryWebsiteDestinations, +} from '../scripts/deploy-smoke'; /** * Production smoke test: verifies the deployed cockpit shell and deployed @@ -20,6 +24,7 @@ const COCKPIT_URL = process.env['BASE_URL'] ?? 'https://cockpit.threadplane.ai'; const EXAMPLES_URL = process.env['EXAMPLES_URL'] ?? 'https://examples.threadplane.ai'; const DEMO_URL = process.env['DEMO_URL'] ?? 'https://demo.threadplane.ai'; +const WEBSITE_URL = process.env['WEBSITE_URL'] ?? 'https://threadplane.ai'; const CHAT_CAPABILITIES = [ 'langgraph/streaming', @@ -85,6 +90,20 @@ const AG_UI_TOPICS = capabilities .sort(); const SEND_RECEIVE_TIMEOUT_MS = 30_000; +const WEBSITE_DESTINATIONS = getRegistryWebsiteDestinations(); + +test.describe('Production: registry-owned Website destinations load', () => { + for (const destination of WEBSITE_DESTINATIONS) { + test(`${destination} is reachable`, async ({ request }) => { + const response = await request.get( + new URL(destination, WEBSITE_URL).toString() + ); + + expect(response.status()).toBeLessThan(400); + }); + } +}); + test.describe('Production: Angular chat example apps load', () => { for (const cap of CHAT_CAPABILITIES) { test(`${cap} loads at examples URL`, async ({ page }) => { @@ -176,6 +195,18 @@ test.describe('Production: cockpit shell loads', () => { expect(response.status()).toBeLessThan(400); }); + + test('legacy workspace redirects remain disabled before opt-in activation', async ({ + request, + }) => { + const response = await request.get( + new URL(getRedirectDisabledProbePath(), COCKPIT_URL).toString(), + { maxRedirects: 0 } + ); + + expect(response.status()).toBe(200); + expect(response.headers()['location']).toBeUndefined(); + }); }); test.describe('Production: canonical demo sends runtime telemetry', () => { diff --git a/apps/cockpit/package.json b/apps/cockpit/package.json index 7b15cf322..68a1e837e 100644 --- a/apps/cockpit/package.json +++ b/apps/cockpit/package.json @@ -4,10 +4,9 @@ "private": true, "dependencies": { "@radix-ui/react-slot": "^1.1.0", - "@radix-ui/react-tabs": "^1.1.0", + "@threadplane/workspace-react": "*", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", - "marked": "^15.0.0", "next": "~16.1.6", "posthog-js": "^1.372.6", "react": "^19.0.0", diff --git a/apps/cockpit/project.json b/apps/cockpit/project.json index 885ae6fc7..5cad9513d 100644 --- a/apps/cockpit/project.json +++ b/apps/cockpit/project.json @@ -30,6 +30,7 @@ "inputs": [ "default", "deploymentConfig", + "contentAssets", "^default" ] }, @@ -58,6 +59,10 @@ "configFile": "apps/cockpit/vite.config.mts" } }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + }, "e2e": { "executor": "@nx/playwright:playwright", "options": { @@ -164,6 +169,13 @@ } }, "namedInputs": { + "contentAssets": [ + "{workspaceRoot}/cockpit/**/prompts/**", + "{workspaceRoot}/cockpit/**/angular/src/**", + "{workspaceRoot}/cockpit/**/python/src/**", + "{workspaceRoot}/cockpit/**/docs/**", + "{workspaceRoot}/deployments/ag-ui-mastra/*.mjs" + ], "deploymentConfig": [ "{workspaceRoot}/vercel.cockpit.json", "{workspaceRoot}/vercel.examples.json", diff --git a/apps/cockpit/scripts/deploy-smoke.spec.ts b/apps/cockpit/scripts/deploy-smoke.spec.ts index de71b6db9..659b5767d 100644 --- a/apps/cockpit/scripts/deploy-smoke.spec.ts +++ b/apps/cockpit/scripts/deploy-smoke.spec.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; -import { parseDeploySmokeArgs, runDeploySmoke } from './deploy-smoke'; +import { + getRegistryWebsiteDestinations, + getRedirectDisabledProbePath, + parseDeploySmokeArgs, + runDeploySmoke, +} from './deploy-smoke'; describe('deploy smoke helper', () => { it('parses the deploy smoke command line', () => { @@ -12,9 +17,12 @@ describe('deploy smoke helper', () => { '5', '--retry-delay-ms', '1000', + '--website-url', + 'https://threadplane.ai', ]) ).toEqual({ url: 'https://cockpit.threadplane.ai', + websiteUrl: 'https://threadplane.ai', expectedTitle: 'Cockpit', dryRun: true, retries: 5, @@ -22,6 +30,25 @@ describe('deploy smoke helper', () => { }); }); + it('derives unique canonical Website destinations from the registry', () => { + const destinations = getRegistryWebsiteDestinations(); + + expect(destinations).toContain('/docs/langgraph/guides/streaming'); + expect(destinations).toContain('/workspace/langgraph/durable-execution'); + expect(destinations).toContain( + '/docs/deep-agents/capabilities/planning' + ); + expect(destinations).not.toContain('/workspace/deep-agents/overview'); + expect(destinations).toEqual([...destinations].sort()); + expect(new Set(destinations).size).toBe(destinations.length); + }); + + it('uses a registry-owned legacy route to prove redirects remain disabled', () => { + expect(getRedirectDisabledProbePath()).toBe( + '/langgraph/core-capabilities/streaming/overview/python' + ); + }); + it('formats dry-run output without performing a network request', async () => { await expect( runDeploySmoke({ @@ -57,4 +84,42 @@ describe('deploy smoke helper', () => { expect(fetchImpl).toHaveBeenCalledTimes(2); expect(sleep).toHaveBeenCalledTimes(1); }); + + it('verifies every canonical Website destination and the default-off redirect gate', async () => { + const cockpitUrl = 'https://cockpit.threadplane.ai'; + const websiteUrl = 'https://threadplane.ai'; + const destinations = getRegistryWebsiteDestinations(); + const redirectProbe = getRedirectDisabledProbePath(); + const fetchImpl = vi.fn( + async (input: string | URL | Request, init?: RequestInit) => { + const requestedUrl = String(input); + if (requestedUrl === cockpitUrl) { + return new Response('Cockpit', { status: 200 }); + } + if (requestedUrl === `${cockpitUrl}${redirectProbe}`) { + expect(init?.redirect).toBe('manual'); + return new Response('Cockpit', { status: 200 }); + } + return new Response('Threadplane', { status: 200 }); + } + ) as unknown as typeof fetch; + + await expect( + runDeploySmoke({ + url: cockpitUrl, + websiteUrl, + fetchImpl, + }) + ).resolves.toBe( + `pass:${cockpitUrl}:Cockpit:website:${destinations.length}:redirects-off` + ); + + expect(fetchImpl).toHaveBeenCalledTimes(destinations.length + 2); + for (const destination of destinations) { + expect(fetchImpl).toHaveBeenCalledWith(`${websiteUrl}${destination}`); + } + expect(fetchImpl).toHaveBeenCalledWith(`${cockpitUrl}${redirectProbe}`, { + redirect: 'manual', + }); + }); }); diff --git a/apps/cockpit/scripts/deploy-smoke.ts b/apps/cockpit/scripts/deploy-smoke.ts index dbf64db32..1e22a4602 100644 --- a/apps/cockpit/scripts/deploy-smoke.ts +++ b/apps/cockpit/scripts/deploy-smoke.ts @@ -1,7 +1,12 @@ import { resolve } from 'node:path'; +import { + cockpitManifest, + getWorkspaceDestinationPath, +} from '@threadplane/cockpit-registry'; export interface DeploySmokeOptions { url: string; + websiteUrl?: string; expectedTitle?: string; dryRun?: boolean; retries?: number; @@ -10,7 +15,7 @@ export interface DeploySmokeOptions { sleep?: (delayMs: number) => Promise; } -export interface ParsedDeploySmokeArgs extends DeploySmokeOptions {} +export type ParsedDeploySmokeArgs = DeploySmokeOptions; const DEFAULT_EXPECTED_TITLE = 'Cockpit'; const DEFAULT_RETRIES = 0; @@ -20,6 +25,27 @@ const defaultSleep = (delayMs: number): Promise => setTimeout(resolvePromise, delayMs); }); +export const getRegistryWebsiteDestinations = (): string[] => + [ + ...new Set( + cockpitManifest + .filter((entry) => entry.availableModes.length > 0) + .map(getWorkspaceDestinationPath) + ), + ].sort(); + +export const getRedirectDisabledProbePath = (): string => { + const streaming = cockpitManifest.find( + (entry) => entry.product === 'langgraph' && entry.topic === 'streaming' + ); + if (!streaming) { + throw new Error( + 'Deploy smoke requires the registry-owned LangGraph streaming route' + ); + } + return streaming.legacyPath; +}; + export const parseDeploySmokeArgs = (argv: string[]): ParsedDeploySmokeArgs => { const options: ParsedDeploySmokeArgs = { url: 'http://127.0.0.1:3000', @@ -44,6 +70,12 @@ export const parseDeploySmokeArgs = (argv: string[]): ParsedDeploySmokeArgs => { continue; } + if (current === '--website-url' && argv[index + 1]) { + options.websiteUrl = argv[index + 1]; + index += 1; + continue; + } + if (current === '--dry-run') { options.dryRun = true; continue; @@ -66,6 +98,7 @@ export const parseDeploySmokeArgs = (argv: string[]): ParsedDeploySmokeArgs => { export const runDeploySmoke = async ({ url, + websiteUrl, expectedTitle = DEFAULT_EXPECTED_TITLE, dryRun = false, retries = DEFAULT_RETRIES, @@ -94,6 +127,38 @@ export const runDeploySmoke = async ({ throw new Error(`Deploy smoke failed for ${url}: missing title ${expectedTitle}`); } + if (websiteUrl) { + const destinations = getRegistryWebsiteDestinations(); + for (const destination of destinations) { + const destinationUrl = new URL(destination, websiteUrl).toString(); + const destinationResponse = await fetchImpl(destinationUrl); + if (!destinationResponse.ok) { + throw new Error( + `Deploy smoke failed for ${destinationUrl}: ${destinationResponse.status} ${destinationResponse.statusText}` + ); + } + } + + const redirectProbeUrl = new URL( + getRedirectDisabledProbePath(), + url + ).toString(); + const redirectProbeResponse = await fetchImpl(redirectProbeUrl, { + redirect: 'manual', + }); + if ( + !redirectProbeResponse.ok || + (redirectProbeResponse.status >= 300 && + redirectProbeResponse.status < 400) + ) { + throw new Error( + `Deploy smoke failed for ${redirectProbeUrl}: legacy redirects must remain disabled before activation` + ); + } + + return `pass:${url}:${expectedTitle}:website:${destinations.length}:redirects-off`; + } + return `pass:${url}:${expectedTitle}`; } catch (error: unknown) { lastError = error instanceof Error ? error : new Error(String(error)); diff --git a/apps/cockpit/src/app/[...slug]/page.spec.tsx b/apps/cockpit/src/app/[...slug]/page.spec.tsx index da4ab68ed..fa9dd7915 100644 --- a/apps/cockpit/src/app/[...slug]/page.spec.tsx +++ b/apps/cockpit/src/app/[...slug]/page.spec.tsx @@ -7,19 +7,39 @@ vi.mock('next/navigation', () => ({ }), })); -vi.mock('../../lib/content-bundle', () => ({ - getContentBundle: vi.fn().mockResolvedValue({ - codeFiles: {}, - promptFiles: {}, - runtimeUrl: null, - docSections: [], - narrativeDocs: [], - }), -})); +vi.mock('@threadplane/cockpit-shell', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getContentBundle: vi.fn().mockResolvedValue({ + codeFiles: {}, + promptFiles: {}, + runtimeUrl: null, + docSections: [], + narrativeDocs: [], + }), + }; +}); -import CockpitRoutePage from './page'; +import CockpitRoutePage, { + getCockpitRouteRedirect, + getLegacyRouteRedirect, +} from './page'; import { getCockpitPageModel } from '../../lib/cockpit-page'; +const enabledEnv = { + UNIFIED_WORKSPACE_REDIRECTS_ENABLED: 'true', + NEXT_PUBLIC_WEBSITE_ORIGIN: 'https://threadplane.ai', + NODE_ENV: 'production', +}; + +const renderRoute = (slug: string[]) => + CockpitRoutePage({ + params: Promise.resolve({ slug }), + searchParams: Promise.resolve({}), + }); + describe('CockpitRoutePage', () => { it('keys the rendered CockpitShell on the canonical path', async () => { const slug = [ @@ -31,9 +51,7 @@ describe('CockpitRoutePage', () => { ]; const { canonicalPath } = getCockpitPageModel(slug); - const element = await CockpitRoutePage({ - params: Promise.resolve({ slug }), - }); + const element = await renderRoute(slug); expect(element.key).toBe(canonicalPath); }); @@ -54,12 +72,8 @@ describe('CockpitRoutePage', () => { 'python', ]; - const streamingElement = await CockpitRoutePage({ - params: Promise.resolve({ slug: streamingSlug }), - }); - const persistenceElement = await CockpitRoutePage({ - params: Promise.resolve({ slug: persistenceSlug }), - }); + const streamingElement = await renderRoute(streamingSlug); + const persistenceElement = await renderRoute(persistenceSlug); expect(streamingElement.key).not.toBe(persistenceElement.key); expect(streamingElement.key).toBe( @@ -70,3 +84,50 @@ describe('CockpitRoutePage', () => { ); }); }); + +describe('canonical Cockpit route redirects', () => { + it('preserves a valid mode query that is available on the canonical entry', () => { + expect( + getCockpitRouteRedirect( + [ + 'langgraph', + 'core-capabilities', + 'streaming', + 'overview', + 'python', + 'extra', + ], + 'code' + ) + ).toBe('/langgraph/core-capabilities/streaming/overview/python?mode=code'); + }); + + it('keeps the external adapter disabled by default', () => { + expect( + getLegacyRouteRedirect( + ['langgraph', 'core-capabilities', 'streaming', 'overview', 'python'], + 'run', + {} + ) + ).toBeNull(); + }); + + it('redirects only exact registry legacy routes when enabled', () => { + expect( + getLegacyRouteRedirect( + ['deep-agents', 'core-capabilities', 'planning', 'overview', 'python'], + 'api', + enabledEnv + ) + ).toBe( + 'https://threadplane.ai/docs/deep-agents/capabilities/planning?mode=api' + ); + expect( + getLegacyRouteRedirect( + ['deep-agents', 'core-capabilities', 'planning'], + 'run', + enabledEnv + ) + ).toBeNull(); + }); +}); diff --git a/apps/cockpit/src/app/[...slug]/page.tsx b/apps/cockpit/src/app/[...slug]/page.tsx index 9d67b5324..a253b98d6 100644 --- a/apps/cockpit/src/app/[...slug]/page.tsx +++ b/apps/cockpit/src/app/[...slug]/page.tsx @@ -1,27 +1,66 @@ import React from 'react'; import { redirect } from 'next/navigation'; import { CockpitShell } from '../../components/cockpit-shell'; -import { getContentBundle } from '../../lib/content-bundle'; -import { cockpitManifest, getCockpitPageModel } from '../../lib/cockpit-page'; +import { getContentBundle } from '@threadplane/cockpit-shell'; +import { + cockpitManifest, + getCanonicalCockpitRedirect, + getCockpitPageModel, + getLegacyWebsiteRedirect, + normalizeRequestedMode, + type UnifiedWorkspaceRedirectEnvironment, +} from '../../lib/cockpit-page'; export async function generateStaticParams() { return cockpitManifest.map((entry) => ({ - slug: [entry.product, entry.section, entry.topic, entry.page, entry.language], + slug: [ + entry.product, + entry.section, + entry.topic, + entry.page, + entry.language, + ], })); } +export function getCockpitRouteRedirect( + slug: string[], + mode: string | string[] | undefined +): string | null { + const model = getCockpitPageModel(slug); + const requestedPath = `/${slug.join('/')}`; + return slug.length > 0 && requestedPath !== model.canonicalPath + ? getCanonicalCockpitRedirect(model, mode) + : null; +} + +export function getLegacyRouteRedirect( + slug: string[], + mode: string | string[] | undefined, + environment: UnifiedWorkspaceRedirectEnvironment = process.env +): string | null { + if (slug.length === 0) return null; + return getLegacyWebsiteRedirect(`/${slug.join('/')}`, mode, environment); +} + export default async function CockpitRoutePage({ params, + searchParams, }: { params: Promise<{ slug?: string[] }>; + searchParams: Promise<{ mode?: string | string[] }>; }) { const { slug = [] } = await params; - const { entry, presentation, navigationTree, canonicalPath } = - getCockpitPageModel(slug); - const requestedPath = `/${slug.join('/')}`; - - if (slug.length > 0 && requestedPath !== canonicalPath) { - redirect(canonicalPath); + const { mode } = await searchParams; + const legacyRedirectDestination = getLegacyRouteRedirect(slug, mode); + if (legacyRedirectDestination) { + redirect(legacyRedirectDestination); + } + const model = getCockpitPageModel(slug); + const { resolution, presentation, navigationTree, canonicalPath } = model; + const redirectDestination = getCockpitRouteRedirect(slug, mode); + if (redirectDestination) { + redirect(redirectDestination); } const contentBundle = await getContentBundle(presentation); @@ -30,9 +69,11 @@ export default async function CockpitRoutePage({ ); } diff --git a/apps/cockpit/src/app/cockpit.css b/apps/cockpit/src/app/cockpit.css index 5e3a2db69..fa43b0677 100644 --- a/apps/cockpit/src/app/cockpit.css +++ b/apps/cockpit/src/app/cockpit.css @@ -1,1073 +1,2 @@ @import "tailwindcss"; - -/* Shiki code blocks — preserve dark background from theme */ -pre.shiki { - padding: 1rem; - border-radius: 0.5rem; - overflow-x: auto; - font-size: 0.85rem; - line-height: 1.6; -} - -/* ── Doc components ────────────────────────────────────────── */ - -.doc-summary { - background: var(--ds-accent-surface); - border: 1px solid var(--ds-accent-border); - border-radius: 0.5rem; - padding: 0.75rem 1rem; - margin-bottom: 1.5rem; - font-size: 0.9rem; - color: var(--ds-text-secondary); - line-height: 1.6; -} - -.doc-callout { - border-radius: 0.5rem; - padding: 0.75rem 1rem; - margin: 1.25rem 0; - font-size: 0.85rem; - line-height: 1.6; -} -.doc-callout__label { - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - margin-bottom: 0.25rem; -} -.doc-callout__content { color: var(--ds-text-secondary); } -.doc-callout--tip { - background: var(--ds-accent-surface); - border: 1px solid var(--ds-accent-border); -} -.doc-callout--tip .doc-callout__label { color: var(--ds-accent); } -.doc-callout--note { - background: rgba(250, 204, 21, 0.06); - border: 1px solid rgba(250, 204, 21, 0.2); -} -.doc-callout--note .doc-callout__label { color: #b8960f; } -.doc-callout--warning { - background: rgba(255, 107, 107, 0.06); - border: 1px solid rgba(255, 107, 107, 0.2); -} -.doc-callout--warning .doc-callout__label { color: #e04545; } - -.doc-steps { margin: 1.5rem 0; } -.doc-step { display: flex; gap: 0.75rem; } -.doc-step__indicator { - display: flex; - flex-direction: column; - align-items: center; - flex-shrink: 0; -} -.doc-step__number { - width: 1.5rem; - height: 1.5rem; - border-radius: 50%; - background: var(--ds-accent); - color: #fff; - font-size: 0.7rem; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; -} -.doc-step__line { - width: 2px; - flex: 1; - background: var(--ds-accent-border); - margin: 0.375rem 0; - min-height: 1rem; -} -.doc-step:last-child .doc-step__line { display: none; } -.doc-step__body { flex: 1; padding-bottom: 1.5rem; } -.doc-step:last-child .doc-step__body { padding-bottom: 0; } -.doc-step__title { - font-size: 0.95rem; - font-weight: 600; - color: var(--ds-text-primary); - margin-bottom: 0.25rem; -} -.doc-step__content { - font-size: 0.85rem; - color: var(--ds-text-secondary); - line-height: 1.7; -} -.doc-step__content p { margin: 0.5rem 0; } -.doc-step__content pre.shiki { margin: 0.5rem 0; border-radius: 0.5rem; } - -.doc-codeblock { - border: 1px solid var(--ds-accent-border); - border-radius: 0.5rem; - overflow: hidden; - margin: 0.75rem 0; - max-width: 100%; -} -.doc-codeblock__header { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.4rem 0.75rem; - border-bottom: 1px solid var(--ds-border); - background: var(--ds-surface-tinted); - font-size: 0.7rem; -} -.doc-codeblock__file { color: var(--ds-text-secondary); font-family: var(--font-mono), "JetBrains Mono", monospace; } -.doc-codeblock__lang { - padding: 0.1rem 0.35rem; - border-radius: 0.2rem; - background: var(--ds-accent-surface); - color: var(--ds-accent); - font-size: 0.6rem; - font-family: var(--font-mono), "JetBrains Mono", monospace; -} -.doc-codeblock__copy { - margin-left: auto; - padding: 0.1rem 0.5rem; - border: 1px solid var(--ds-border); - border-radius: 0.25rem; - background: transparent; - color: var(--ds-text-muted); - cursor: pointer; -} -.doc-codeblock__copy:hover { color: var(--ds-text-primary); border-color: var(--ds-border-strong); } -.doc-codeblock pre.shiki { margin: 0; border-radius: 0; border: none; overflow-x: auto; } - -.doc-prompt { - background: rgba(168, 85, 247, 0.04); - border: 1px solid rgba(168, 85, 247, 0.2); - border-radius: 0.5rem; - overflow: hidden; - margin: 1.25rem 0; -} -.doc-prompt__header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.5rem 0.75rem; - border-bottom: 1px solid rgba(168, 85, 247, 0.15); - background: rgba(168, 85, 247, 0.06); -} -.doc-prompt__label { - font-size: 0.7rem; - font-weight: 600; - color: #9333ea; - text-transform: uppercase; - letter-spacing: 0.06em; -} -.doc-prompt__copy { - font-size: 0.65rem; - color: #9333ea; - padding: 0.1rem 0.5rem; - border: 1px solid rgba(168, 85, 247, 0.25); - border-radius: 0.25rem; - background: rgba(168, 85, 247, 0.08); - cursor: pointer; -} -.doc-prompt__copy:hover { background: rgba(168, 85, 247, 0.15); } -.doc-prompt__content { - padding: 0.75rem; - font-size: 0.85rem; - color: var(--ds-text-secondary); - line-height: 1.7; -} -.doc-prompt__content code { - background: rgba(168, 85, 247, 0.1); - padding: 0.1rem 0.3rem; - border-radius: 0.2rem; - color: #9333ea; - font-size: 0.8rem; -} - -.doc-api-table { margin: 1.25rem 0; } -.doc-api-table table { width: 100%; border-collapse: collapse; font-size: 0.8rem; } -.doc-api-table th { - text-align: left; - padding: 0.5rem 0.75rem; - color: var(--ds-text-muted); - font-weight: 500; - font-size: 0.65rem; - text-transform: uppercase; - letter-spacing: 0.06em; - border-bottom: 1px solid var(--ds-border); -} -.doc-api-table td { - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--ds-accent-border); - color: var(--ds-text-secondary); -} -.doc-api-table code { - background: var(--ds-accent-surface); - padding: 0.1rem 0.3rem; - border-radius: 0.2rem; - color: var(--ds-accent); - font-size: 0.75rem; -} - -/* Shared prose layer — docs + api + code mode content */ -.cockpit-prose { - max-width: 42rem; - font-size: 0.9rem; - line-height: 1.7; - color: var(--ds-text-secondary); -} -.cockpit-prose--wide { max-width: 48rem; } -.cockpit-prose--code { max-width: 56rem; } -.cockpit-prose h1, .cockpit-prose h2, .cockpit-prose h3 { - font-family: var(--font-garamond), var(--ds-font-serif); - color: var(--ds-text-primary); - letter-spacing: -0.01em; -} -.cockpit-prose h1 { font-size: 1.875rem; line-height: 1.1; margin: 0 0 0.5rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--ds-accent-border); } -.cockpit-prose h2 { font-size: 1.5rem; margin: 2.25rem 0 0.75rem; } -.cockpit-prose h3 { font-size: 1.25rem; margin: 1.5rem 0 0.5rem; } -.cockpit-prose .cockpit-api-heading { - font-family: var(--font-inter), var(--ds-font-sans); - letter-spacing: normal; - text-transform: none; -} -.cockpit-prose p { margin: 0 0 0.75rem; } -.cockpit-prose ul { margin: 0 0 0.75rem; padding-left: 1.25rem; list-style: disc; } -.cockpit-prose li { margin-bottom: 0.25rem; } -.cockpit-prose a { color: var(--ds-accent); text-decoration: none; } -.cockpit-prose a:hover { text-decoration: underline; } -.cockpit-prose code { color: var(--ds-accent); background: var(--ds-accent-surface); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.85em; font-family: var(--font-mono), "JetBrains Mono", monospace; } -.cockpit-prose strong { color: var(--ds-text-primary); font-weight: 600; } - -.cockpit-prose table.params { border-collapse: collapse; margin: 0.5rem 0; } -.cockpit-prose table.params th { font-family: var(--font-mono), monospace; font-size: 0.6rem; letter-spacing: 0.06em; text-transform: uppercase; padding-bottom: 0.5rem; border-bottom: 1px solid var(--ds-border); } -.cockpit-prose table.params td { padding: 0.5rem 0.75rem 0.5rem 0; border-bottom: 1px solid var(--ds-border); } - -/* Sidebar navigation items — bg-only active/hover, no left border */ -.cockpit-nav-item { - display: block; - padding: 5px 14px; - margin: 0 8px; - border-radius: 6px; - font-size: 0.825rem; - color: var(--ds-text-secondary); - text-decoration: none; - transition: background 0.15s ease, color 0.15s ease; -} -.cockpit-nav-item:hover { background: var(--ds-surface-dim); color: var(--ds-text-primary); } -.cockpit-nav-item[aria-current="page"] { background: var(--ds-accent-surface); color: var(--ds-accent); } - -/* Sidebar group caret — matches the file-tree chevron */ -.cockpit-nav-caret { - display: inline-flex; - align-items: center; - justify-content: center; - width: 0.85rem; - height: 0.85rem; - color: var(--ds-text-muted); - flex: none; - transition: transform 150ms ease; -} -.cockpit-nav-caret svg { display: block; } -.cockpit-nav-caret--open { transform: rotate(90deg); } - -/* Code-mode file tree */ -.cockpit-file-tree { list-style: none; padding: 0; margin: 0; font-size: 12px; line-height: 1.7; } -.cockpit-file-tree ul { list-style: none; padding: 0; margin: 0; } -.cockpit-file-tree__file, -.cockpit-file-tree__folder { - display: flex; align-items: center; gap: 0.4rem; flex: 1; min-width: 0; - padding: 3px 0.75rem 3px 0.75rem; background: transparent; border: 0; text-align: left; cursor: pointer; - color: var(--ds-text-secondary); font-family: var(--font-mono), "JetBrains Mono", monospace; font-size: 12px; - border-left: 2px solid transparent; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} -.cockpit-file-tree__folder { color: var(--ds-text-muted); display: flex; align-items: center; } -.cockpit-file-tree__caret { - display: inline-flex; - align-items: center; - justify-content: center; - width: 0.85rem; - height: 0.85rem; - color: var(--ds-text-muted); - flex: none; - transition: transform 150ms ease; -} -.cockpit-file-tree__caret svg { display: block; } -.cockpit-file-tree__caret--open { transform: rotate(90deg); } -.cockpit-file-tree__label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.cockpit-file-tree__chip { - font-family: var(--font-mono), monospace; font-size: 9px; - padding: 1px 5px; border-radius: 3px; margin-right: 0.5rem; flex-shrink: 0; - background: var(--ds-accent-surface); color: var(--ds-accent); - opacity: 0.85; -} -.cockpit-file-tree__file:hover { color: var(--ds-text-primary); } -.cockpit-file-tree__file[aria-current="true"] { - background: var(--ds-accent-surface); - color: var(--ds-text-primary); - border-left-color: var(--ds-accent); -} - -/* Tab close (×) on Code-mode tabs */ -.cockpit-tab-trigger { display: inline-flex; align-items: center; gap: 0.4rem; } -.cockpit-tab-trigger__close { - display: inline-flex; align-items: center; justify-content: center; - width: 0.95rem; height: 0.95rem; border-radius: 0.2rem; - color: var(--ds-text-muted); font-size: 0.85rem; line-height: 1; - opacity: 0; cursor: pointer; -} -.cockpit-tab-trigger:hover .cockpit-tab-trigger__close, -.cockpit-tab-trigger[data-state="active"] .cockpit-tab-trigger__close { opacity: 1; } -.cockpit-tab-trigger__close:hover { background: var(--ds-accent-surface); color: var(--ds-text-primary); } - -/* Code-mode editor pane — no chrome, no separate background, full-bleed under the tab strip. */ -.cockpit-code-pane { - min-width: 0; -} -.cockpit-code-pane pre.shiki { - background: transparent !important; - margin: 0; - border-radius: 0; - padding: 1rem 1.25rem; - font-size: 0.8125rem; - white-space: pre; - overflow-x: auto; - max-width: 100%; -} - -/* Shiki dual-theme: light mode uses inline `color` (github-light), dark mode swaps to the --shiki-dark CSS variable (tokyo-night). */ -[data-theme="dark"] .shiki, -[data-theme="dark"] .shiki span { color: var(--shiki-dark) !important; } -[data-theme="dark"] .shiki { background-color: var(--shiki-dark-bg) !important; } -[data-theme="dark"] .cockpit-code-pane .shiki { background-color: transparent !important; } -.cockpit-code-pane--plain { - margin: 0; - padding: 1rem 1.25rem; - color: var(--ds-text-secondary); - font-family: var(--font-mono), "JetBrains Mono", monospace; - font-size: 0.8125rem; - line-height: 1.6; - white-space: pre-wrap; - max-width: 100%; -} -.cockpit-code-pane__empty { - padding: 1rem 1.25rem; - color: var(--ds-text-muted); - font-size: 0.875rem; -} - -/* Unified sidebar control plane */ -.cockpit-shell { - display: grid; - grid-template-columns: minmax(0, 1fr); -} -@media (min-width: 48rem) { - .cockpit-shell { grid-template-columns: 328px minmax(0, 1fr); } -} -.cockpit-control-plane { - --cockpit-state-error: #b42318; - --cockpit-state-success: #1a7a40; - --cockpit-state-working: #9a6700; - display: grid; - grid-template-columns: 56px minmax(0, 272px); - height: 100%; - min-width: 0; - color: var(--ds-text-secondary); - background: var(--ds-surface); -} -[data-theme="dark"] .cockpit-control-plane { - --cockpit-state-error: #ff6369; - --cockpit-state-success: #4cc38a; - --cockpit-state-working: #e0a02f; -} -.cockpit-control-plane [data-control-plane-rail] { - min-width: 0; - padding: 10px 6px; - border-right: 1px solid var(--ds-border); - background: var(--ds-surface-tinted); - display: flex; - flex-direction: column; -} -.cockpit-control-plane [data-control-plane-rail-group] { - display: flex; - flex-direction: column; - gap: 4px; -} -.cockpit-control-plane [data-control-plane-rail-group="utilities"] { - margin-top: auto; - padding-top: 8px; - /* --ds-border is a 1-value difference from --ds-surface-tinted (the rail - background) in dark mode -- rgb(45,45,45) on rgb(44,44,44), effectively - invisible. --ds-border-strong is the token the pane divider already - uses for the same reason (cockpit.css ~L460). */ - border-top: 1px solid var(--ds-border-strong); -} -.cockpit-control-plane [data-control-plane-rail-group-label] { - display: block; - padding-bottom: 4px; - color: var(--ds-text-secondary); - font-size: 10px; - font-weight: 600; - letter-spacing: 0.09em; - text-transform: uppercase; - text-align: center; -} -.cockpit-control-plane-utility-anchor { display: contents; } -.cockpit-control-plane [data-control-plane-rail-item] { - --cockpit-rail-status-ring: var(--ds-surface-tinted); - position: relative; - min-height: 48px; - padding: 6px 2px; - border: 0; - border-radius: 8px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; - color: var(--ds-text-secondary); - background: transparent; - text-decoration: none; - cursor: pointer; - transition: background 120ms ease, color 120ms ease; -} -.cockpit-control-plane [data-control-plane-rail-item]:hover { - --cockpit-rail-status-ring: var(--ds-surface); - color: var(--ds-text-primary); - background: var(--ds-surface); -} -.cockpit-control-plane [data-control-plane-rail-item][data-control-plane-active] { - color: var(--ds-accent); - background: var(--ds-accent-surface); -} -.cockpit-control-plane [data-control-plane-rail-label] { - font-size: 10px; - line-height: 1; - font-weight: 600; -} -.cockpit-control-plane [data-control-plane-rail-status] { - position: absolute; - top: 7px; - right: 11px; - width: 7px; - height: 7px; - border: 2px solid var(--cockpit-rail-status-ring); - border-radius: 999px; -} -.cockpit-control-plane [data-control-plane-rail-status="success"] { - background: var(--cockpit-state-success); -} -.cockpit-control-plane [data-control-plane-rail-status="working"] { - background: var(--cockpit-state-working); -} -.cockpit-control-plane [data-control-plane-rail-status="error"] { - background: var(--cockpit-state-error); -} -.cockpit-control-plane [data-control-plane-rail-icon], -[data-cockpit-activity-icon] { - display: inline-flex; - align-items: center; - justify-content: center; -} -.cockpit-control-plane [data-control-plane-rail-icon] > svg, -[data-cockpit-activity-icon] > svg { - width: 18px; - height: 18px; -} -[data-cockpit-activity-icon] { position: relative; } -[data-cockpit-activity-attention] { - position: absolute; - top: -2px; - right: -3px; - width: 6px; - height: 6px; - border: 1px solid var(--ds-surface-tinted); - border-radius: 999px; - background: var(--cockpit-state-error); -} -.cockpit-control-plane [data-control-plane-pane] { - min-width: 0; - overflow-y: auto; - border-right: 1px solid var(--ds-border-strong); -} -[data-cockpit-context-content] { - display: flex; - flex-direction: column; - gap: 2px; - padding: 12px 10px 20px; -} -[data-cockpit-context-content] [data-control-plane-section] { padding: 3px 0; } -[data-cockpit-context-content] [data-control-plane-section-trigger] { - width: 100%; - min-height: 34px; - padding: 6px 8px; - border: 0; - border-radius: 7px; - display: flex; - align-items: center; - justify-content: space-between; - color: var(--ds-text-muted); - background: transparent; - cursor: pointer; - font-size: 12px; - font-weight: 600; - text-align: left; -} -[data-cockpit-context-content] [data-control-plane-section-trigger]:hover { - color: var(--ds-text-primary); - background: var(--ds-surface-tinted); -} -[data-cockpit-context-content] [data-control-plane-section-chevron] { - flex: none; - transition: transform 150ms ease; -} -[data-cockpit-context-content] [data-control-plane-section-trigger][aria-expanded="true"] [data-control-plane-section-chevron] { - transform: rotate(90deg); -} -[data-cockpit-context-content] [data-control-plane-section-heading] { - margin: 0; - padding: 8px; - color: var(--ds-text-muted); - font-size: 12px; - line-height: 1.2; - font-weight: 600; -} -[data-cockpit-context-content] [data-control-plane-section-content] { padding: 2px 0 8px; } - -/* Runtime remains a compact, unboxed operational summary. */ -[data-runtime-section] [data-control-plane-section-trigger] { - justify-content: flex-start; - gap: 8px; -} -[data-runtime-section] [data-control-plane-section-title] { flex: none; } -[data-runtime-section] [data-control-plane-section-end] { - min-width: 0; - flex: 1; - display: flex; - align-items: center; - gap: 6px; -} -[data-runtime-section] [data-control-plane-section-summary] { min-width: 0; } -[data-runtime-section] [data-control-plane-section-chevron] { - flex: none; - margin-left: auto; -} -[data-runtime-status] { - min-width: 0; - display: inline-flex; - align-items: center; - gap: 4px; - color: var(--ds-text-muted); - font-size: 10px; - font-weight: 500; - white-space: nowrap; -} -[data-runtime-status-icon] { display: inline-flex; } -[data-runtime-status][data-runtime-phase="ready"] { - color: var(--cockpit-state-success); -} -[data-runtime-status]:is( - [data-runtime-phase="invalid_configuration"], - [data-runtime-phase="unresponsive"], - [data-runtime-phase="error"] -) { - color: var(--cockpit-state-error); -} -[data-runtime-status]:is( - [data-runtime-phase="connecting"], - [data-runtime-phase="checking"], - [data-runtime-phase="reloading"] -) { - color: var(--ds-accent); -} -@keyframes cockpit-runtime-status-spin { - to { transform: rotate(1turn); } -} -.cockpit-runtime-status-loader { - animation: cockpit-runtime-status-spin 900ms linear infinite; -} -[data-runtime-metadata] { - min-width: 0; - margin: 0 8px 6px; - padding: 2px 0; - display: grid; - gap: 3px; - color: var(--ds-text-muted); - font-size: 10px; - line-height: 1.35; -} -[data-runtime-metadata] > span { min-width: 0; } -[data-runtime-target] { - display: block; - overflow: hidden; - color: var(--ds-text-secondary); - font-family: var(--ds-font-mono); - text-overflow: ellipsis; - white-space: nowrap; -} -[data-runtime-checked-at] { color: var(--ds-text-muted); } -[data-runtime-announcement] { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} -.cockpit-control-plane-scope { - margin: 0 8px; - padding: 10px; - border-radius: 8px; - background: var(--ds-surface-tinted); - display: grid; - gap: 3px; - color: var(--ds-text-muted); - font-size: 11px; -} -.cockpit-control-plane-scope strong { - color: var(--ds-text-primary); - font-size: 13px; - font-weight: 600; -} -.cockpit-nav-group-label { - color: var(--ds-text-secondary); - font-size: 12px; - font-weight: 600; -} -[data-cockpit-context-content] [aria-label^="Collapse"], -[data-cockpit-context-content] [aria-label^="Expand"] { - border-radius: 7px; - min-height: 32px; -} -[data-cockpit-context-content] [aria-label^="Collapse"]:hover, -[data-cockpit-context-content] [aria-label^="Expand"]:hover { background: var(--ds-surface-tinted) !important; } -.cockpit-nav-item { - padding: 7px 9px; - margin: 1px 8px; - border-radius: 7px; - font-size: 13px; -} -[data-cockpit-context-content] [data-control-plane-environment-list] { - display: grid; - gap: 2px; - margin: 0 8px; -} -[data-cockpit-context-content] [data-control-plane-environment-row] { - min-height: 32px; - padding: 6px 8px; - border-radius: 7px; - display: grid; - grid-template-columns: 18px minmax(0, 1fr) auto; - align-items: center; - gap: 6px; - font-size: 11px; -} -[data-cockpit-context-content] [data-control-plane-environment-row]:hover { background: var(--ds-surface-tinted); } -[data-cockpit-context-content] [data-control-plane-environment-row] dt { color: var(--ds-text-muted); } -[data-cockpit-context-content] [data-control-plane-environment-row] dd { - margin: 0; - color: var(--ds-text-primary); - font-family: var(--ds-font-mono); - font-size: 10px; -} -[data-cockpit-context-content] [data-control-plane-environment-icon] { - display: inline-flex; - color: var(--ds-text-muted); -} -[data-cockpit-context-content] [data-control-plane-action-bar] { - display: flex; - gap: 4px; - margin: 0 8px; -} -[data-cockpit-context-content] [data-control-plane-action] { - position: relative; - width: 34px; - height: 34px; - border: 0; - border-radius: 8px; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--ds-text-muted); - background: transparent; -} -[data-cockpit-context-content] [data-control-plane-action] > svg { - width: 16px; - height: 16px; -} -[data-cockpit-context-content] [data-control-plane-action]:hover { - color: var(--ds-text-primary); - background: var(--ds-surface-tinted); -} -.cockpit-control-plane [data-control-plane-tooltip] { - position: absolute; - z-index: 60; - padding: 5px 7px; - border-radius: 6px; - color: var(--ds-surface); - background: var(--ds-text-primary); - box-shadow: var(--ds-shadow-sm); - font-size: 11px; - font-weight: 500; - line-height: 1; - white-space: nowrap; - pointer-events: none; - opacity: 0; - visibility: hidden; - transition: opacity 120ms ease, visibility 120ms ease; -} -.cockpit-control-plane [data-control-plane-rail-item] [data-control-plane-tooltip] { - left: calc(100% + 8px); - top: 50%; - transform: translateY(-50%); -} -.cockpit-control-plane [data-control-plane-action] [data-control-plane-tooltip] { - left: 50%; - bottom: calc(100% + 6px); - transform: translateX(-50%); -} -.cockpit-control-plane [data-control-plane-rail-item]:is(:hover, :focus-visible) [data-control-plane-tooltip], -.cockpit-control-plane [data-control-plane-action]:is(:hover, :focus-visible) [data-control-plane-tooltip] { - opacity: 1; - visibility: visible; -} -.cockpit-control-plane [data-control-plane-overflow-menu-root] { position: relative; } -.cockpit-control-plane [data-control-plane-overflow-menu] { - position: absolute; - z-index: 70; - top: calc(100% + 5px); - right: 0; - min-width: 184px; - padding: 5px; - border: 1px solid var(--ds-border-strong); - border-radius: 8px; - background: var(--ds-surface); - box-shadow: var(--ds-shadow-md); -} -.cockpit-control-plane [data-control-plane-overflow-menu-root][data-overflow-placement="start"] > [data-control-plane-overflow-menu] { - left: 0; - right: auto; -} -.cockpit-control-plane [data-control-plane-overflow-menu-root][data-overflow-placement="center"] > [data-control-plane-overflow-menu] { - left: 50%; - right: auto; - transform: translateX(-50%); -} -.cockpit-control-plane [data-control-plane-overflow-item] { - width: 100%; - min-height: 34px; - padding: 7px 9px; - border: 0; - border-radius: 7px; - display: flex; - align-items: center; - color: var(--ds-text-secondary); - background: transparent; - font-size: 12px; - text-align: left; - cursor: pointer; -} -.cockpit-control-plane [data-control-plane-overflow-item]:is(:hover, :focus-visible) { - color: var(--ds-text-primary); - background: var(--ds-surface-tinted); -} -.cockpit-control-plane [data-control-plane-utility-panel] { padding: 14px 12px; } -.cockpit-control-plane [data-control-plane-utility-header] { - min-height: 36px; - display: flex; - align-items: center; - justify-content: space-between; -} -.cockpit-control-plane [data-control-plane-utility-header] h2 { - margin: 0; - color: var(--ds-text-primary); - font-size: 14px; - font-weight: 600; -} -.cockpit-control-plane [data-control-plane-utility-header] button, -.cockpit-control-plane-theme { - width: 34px; - height: 34px; - border: 0; - border-radius: 8px; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--ds-text-muted); - background: transparent; - cursor: pointer; -} -.cockpit-control-plane [data-control-plane-utility-header] button:hover, -.cockpit-control-plane-theme:hover { background: var(--ds-surface-tinted); color: var(--ds-text-primary); } -.cockpit-control-plane [data-control-plane-utility-header] button > svg, -.cockpit-control-plane-theme > svg { - width: 18px; - height: 18px; -} - -/* Session Activity is newest-first in markup; styling keeps the chronology quiet. */ -[data-activity-empty] { - margin: 14px 0 0; - color: var(--ds-text-muted); - font-size: 12px; - line-height: 1.5; -} -[data-activity-timeline] { - margin: 12px 0 0; - padding: 0; - display: grid; - gap: 0; - list-style: none; -} -[data-activity-event] { - position: relative; - min-width: 0; - padding: 0 0 16px 25px; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 3px 8px; - color: var(--ds-text-secondary); - font-size: 11px; - line-height: 1.35; -} -[data-activity-event]:last-child { padding-bottom: 0; } -[data-activity-severity-icon] { - position: absolute; - top: 0; - left: 0; - z-index: 1; - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--ds-text-muted); - background: var(--ds-surface); -} -[data-activity-connector] { - position: absolute; - top: 15px; - bottom: -1px; - left: 7px; - width: 1px; - background: var(--ds-border-strong); -} -[data-activity-timestamp] { - grid-column: 2; - grid-row: 1; - justify-self: end; - color: var(--ds-text-muted); - font-family: var(--ds-font-mono); - font-size: 9px; -} -[data-activity-summary] { - min-width: 0; - grid-column: 1; - grid-row: 1; - overflow-wrap: anywhere; -} -[data-activity-capability] { - grid-column: 1 / -1; - grid-row: 2; - color: var(--ds-text-muted); - font-family: var(--ds-font-mono); - font-size: 9px; -} -[data-activity-severity="error"] [data-activity-severity-icon], -[data-activity-severity="error"] [data-activity-summary] { - color: var(--cockpit-state-error); -} -[data-activity-kind="runtime_recovered"] [data-activity-severity-icon], -[data-activity-kind="runtime_recovered"] [data-activity-summary] { - color: var(--cockpit-state-success); -} -.cockpit-control-plane-setting { - min-height: 48px; - padding: 8px 0; - border-bottom: 1px solid var(--ds-border); - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - color: var(--ds-text-muted); - font-size: 12px; -} -.cockpit-control-plane button:focus-visible, -.cockpit-control-plane a:focus-visible, -.cockpit-mobile-navigation-trigger:focus-visible, -.cockpit-mobile-control-plane button:focus-visible, -.cockpit-mobile-control-plane a:focus-visible { - outline: 2px solid var(--ds-accent); - outline-offset: 2px; -} - -/* Adaptive mobile drawer */ -.cockpit-mobile-navigation-trigger { - width: 44px; - height: 44px; - padding: 0; - border: 0; - border-radius: 8px; - color: var(--ds-text-secondary); - background: transparent; - cursor: pointer; -} -.cockpit-mobile-control-plane { - background: color-mix(in srgb, var(--ds-text-primary) 18%, transparent); - opacity: 1; - transition: opacity 150ms ease; -} -.cockpit-mobile-control-plane[data-state="closing"] { opacity: 0; } -.cockpit-mobile-control-plane-panel { - width: 100%; - max-width: 360px; - height: 100%; - min-width: 0; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - background: var(--ds-surface); - transform: translateY(0); - transition: transform 200ms ease-out; -} -.cockpit-mobile-control-plane[data-state="closing"] .cockpit-mobile-control-plane-panel { - transform: translateY(8px); -} -.cockpit-mobile-control-plane-header { - min-height: 48px; - padding: 8px 12px 8px 16px; - border-bottom: 1px solid var(--ds-border); - display: flex; - align-items: center; - justify-content: space-between; - color: var(--ds-text-secondary); - font-size: 13px; - font-weight: 600; -} -.cockpit-mobile-control-plane-header button { - width: 44px; - height: 44px; - border: 0; - border-radius: 8px; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--ds-text-muted); - background: transparent; -} -.cockpit-control-plane[data-mobile] { - grid-template-columns: 56px minmax(0, 1fr); - min-height: 0; -} -.cockpit-control-plane[data-mobile] [data-control-plane-pane] { border-right: 0; } -@media (forced-colors: active) { - .cockpit-control-plane, - .cockpit-control-plane [data-control-plane-rail], - .cockpit-control-plane [data-control-plane-pane], - .cockpit-mobile-control-plane-panel { - color: CanvasText; - background: Canvas; - border-color: CanvasText; - } - .cockpit-control-plane [data-control-plane-rail-item], - .cockpit-control-plane [data-control-plane-section-trigger], - .cockpit-control-plane [data-control-plane-action], - .cockpit-control-plane [data-control-plane-utility-header] button, - .cockpit-control-plane-theme, - .cockpit-mobile-navigation-trigger, - .cockpit-mobile-control-plane-close { - border: 1px solid CanvasText; - color: CanvasText; - background: Canvas; - } - .cockpit-control-plane [data-control-plane-rail-item][data-control-plane-active] { - border-color: Highlight; - color: HighlightText; - background: Highlight; - } - [data-runtime-status][data-runtime-phase] { - padding: 1px 4px; - border: 1px solid CanvasText; - border-radius: 7px; - color: CanvasText; - background: Canvas; - } - .cockpit-control-plane [data-control-plane-rail-status] { - border: 1px solid CanvasText; - background: Canvas; - } - .cockpit-control-plane [data-control-plane-overflow-menu] { - border: 1px solid CanvasText; - color: CanvasText; - background: Canvas; - box-shadow: none; - } - .cockpit-control-plane [data-control-plane-overflow-item] { - border: 1px solid CanvasText; - color: CanvasText; - background: Canvas; - } - .cockpit-control-plane [data-control-plane-overflow-item]:is(:hover, :focus-visible) { - border-color: Highlight; - color: HighlightText; - background: Highlight; - } - [data-cockpit-activity-attention] { - border-color: Canvas; - background: Highlight; - } - [data-activity-severity-icon] { - color: CanvasText; - background: Canvas; - } - [data-activity-connector] { background: CanvasText; } - [data-activity-severity="error"] [data-activity-severity-icon], - [data-activity-severity="error"] [data-activity-summary], - [data-activity-kind="runtime_recovered"] [data-activity-severity-icon], - [data-activity-kind="runtime_recovered"] [data-activity-summary] { - color: CanvasText; - } - .cockpit-control-plane button:focus-visible, - .cockpit-control-plane a:focus-visible, - .cockpit-mobile-navigation-trigger:focus-visible, - .cockpit-mobile-control-plane button:focus-visible, - .cockpit-mobile-control-plane a:focus-visible { - outline: 2px solid Highlight; - outline-offset: 2px; - box-shadow: none; - } -} -@media (pointer: coarse) { - .cockpit-mobile-navigation-trigger, - .cockpit-mobile-control-plane-close { - min-width: 44px; - min-height: 44px; - } - .cockpit-control-plane [data-control-plane-rail-item], - .cockpit-control-plane [data-control-plane-section-trigger], - .cockpit-control-plane [data-control-plane-action], - .cockpit-nav-item { min-height: 44px; } - .cockpit-control-plane [data-control-plane-action] { - width: 44px; - height: 44px; - } - .cockpit-control-plane [data-control-plane-overflow-menu-root][data-overflow-placement="center"] > [data-control-plane-overflow-menu] { - left: auto; - right: 0; - transform: none; - } -} -@media (prefers-reduced-motion: reduce) { - .cockpit-control-plane * { - scroll-behavior: auto !important; - transition: none !important; - animation: none !important; - } - .cockpit-mobile-control-plane, - .cockpit-mobile-control-plane-panel { - scroll-behavior: auto !important; - transition: none !important; - animation: none !important; - } - .cockpit-runtime-status-loader { - animation: none !important; - } -} +@import "../../../../libs/workspace-react/src/styles/workspace.css"; diff --git a/apps/cockpit/src/app/page.tsx b/apps/cockpit/src/app/page.tsx index c808f2590..eca0ccf94 100644 --- a/apps/cockpit/src/app/page.tsx +++ b/apps/cockpit/src/app/page.tsx @@ -1,18 +1,34 @@ import React from 'react'; +import { redirect } from 'next/navigation'; import { CockpitShell } from '../components/cockpit-shell'; -import { getContentBundle } from '../lib/content-bundle'; -import { getCockpitPageModel } from '../lib/cockpit-page'; +import { getContentBundle } from '@threadplane/cockpit-shell'; +import { + getCockpitPageModel, + getRootWebsiteRedirect, + normalizeRequestedMode, +} from '../lib/cockpit-page'; -export default async function CockpitHomePage() { - const { entry, presentation, navigationTree } = getCockpitPageModel(); +export default async function CockpitHomePage({ + searchParams, +}: { + searchParams: Promise<{ mode?: string | string[] }>; +}) { + const { mode } = await searchParams; + const websiteRedirect = getRootWebsiteRedirect(mode); + if (websiteRedirect) { + redirect(websiteRedirect); + } + const { resolution, presentation, navigationTree } = getCockpitPageModel(); const contentBundle = await getContentBundle(presentation); return ( ); } diff --git a/apps/cockpit/src/components/cockpit-shell.spec.tsx b/apps/cockpit/src/components/cockpit-shell.spec.tsx index b9afbc183..163f7ac6b 100644 --- a/apps/cockpit/src/components/cockpit-shell.spec.tsx +++ b/apps/cockpit/src/components/cockpit-shell.spec.tsx @@ -13,24 +13,36 @@ import { ThemeProvider, } from '@threadplane/ui-react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { NO_COCKPIT_DOCS_LINK } from '@threadplane/cockpit-registry'; import { getCockpitPageModel } from '../lib/cockpit-page'; -import type { CockpitPageModel } from '../lib/cockpit-page'; -import type { UseRuntimeControllerOptions } from '../lib/runtime/use-runtime-controller'; +import type { + UseRuntimeControllerOptions, + WorkspaceProviderProps, + WorkspaceShellProps, +} from '@threadplane/workspace-react'; + +type CockpitSharedShellProps = WorkspaceShellProps & { + modeNavigationLabel?: string; + contextPaneLabel?: string; + mobileDialogLabel?: string; + mobileTitle?: string; +}; const operationalMocks = vi.hoisted(() => ({ controllerInstances: 0, latestControllerOptions: null as UseRuntimeControllerOptions | null, activityShouldThrow: false, + latestProviderProps: null as WorkspaceProviderProps | null, + latestShellProps: null as CockpitSharedShellProps | null, track: vi.fn(), push: vi.fn(), + replace: vi.fn(), })); vi.mock('next/navigation', () => ({ useRouter: () => ({ push: operationalMocks.push, refresh: vi.fn(), - replace: vi.fn(), + replace: operationalMocks.replace, back: vi.fn(), forward: vi.fn(), prefetch: vi.fn(), @@ -39,43 +51,67 @@ vi.mock('next/navigation', () => ({ vi.mock('../lib/analytics/client', () => ({ track: operationalMocks.track })); -vi.mock('../lib/runtime/use-runtime-controller', async (importOriginal) => { +vi.mock('@threadplane/workspace-react', async (importOriginal) => { const ReactModule = await import('react'); const actual = await importOriginal< - typeof import('../lib/runtime/use-runtime-controller') + typeof import('@threadplane/workspace-react') >(); return { ...actual, - useRuntimeController(options: UseRuntimeControllerOptions) { - const mounted = ReactModule.useRef(false); - if (!mounted.current) { - mounted.current = true; - operationalMocks.controllerInstances += 1; - } - ReactModule.useLayoutEffect(() => { - operationalMocks.latestControllerOptions = options; - }, [options]); - return actual.useRuntimeController(options); + WorkspaceProvider(props: WorkspaceProviderProps) { + operationalMocks.latestProviderProps = props; + return ReactModule.createElement(actual.WorkspaceProvider, props); }, - }; -}); - -vi.mock('./control-plane/activity-panel', async (importOriginal) => { - const ReactModule = await import('react'); - const actual = await importOriginal< - typeof import('./control-plane/activity-panel') - >(); - return { - ...actual, - ActivityPanel(props: React.ComponentProps) { - if (operationalMocks.activityShouldThrow) { - throw new Error('sensitive activity render failure'); - } - return ReactModule.createElement(actual.ActivityPanel, props); + WorkspaceShell(props: WorkspaceShellProps) { + operationalMocks.latestShellProps = props; + return ReactModule.createElement(actual.WorkspaceShell, props); }, }; }); +vi.mock( + '../../../../libs/workspace-react/src/lib/runtime/use-runtime-controller', + async (importOriginal) => { + const ReactModule = await import('react'); + const actual = await importOriginal< + typeof import('../../../../libs/workspace-react/src/lib/runtime/use-runtime-controller') + >(); + return { + ...actual, + useRuntimeController(options: UseRuntimeControllerOptions) { + const mounted = ReactModule.useRef(false); + if (!mounted.current) { + mounted.current = true; + operationalMocks.controllerInstances += 1; + } + ReactModule.useLayoutEffect(() => { + operationalMocks.latestControllerOptions = options; + }, [options]); + return actual.useRuntimeController(options); + }, + }; + } +); + +vi.mock( + '../../../../libs/workspace-react/src/lib/components/control-plane/activity-panel', + async (importOriginal) => { + const ReactModule = await import('react'); + const actual = await importOriginal< + typeof import('../../../../libs/workspace-react/src/lib/components/control-plane/activity-panel') + >(); + return { + ...actual, + ActivityPanel(props: React.ComponentProps) { + if (operationalMocks.activityShouldThrow) { + throw new Error('sensitive activity render failure'); + } + return ReactModule.createElement(actual.ActivityPanel, props); + }, + }; + } +); + import { CockpitShell } from './cockpit-shell'; const model = getCockpitPageModel(); @@ -115,25 +151,26 @@ const renderShell = (runtimeUrl: string | null = null) => ); -const renderShellFor = ( - slug: string[], - presentationOverrides: Partial = {} -) => { +const renderShellFor = (slug: string[]) => { const pageModel = getCockpitPageModel(slug); return render( ); @@ -159,8 +196,11 @@ describe('CockpitShell operational composition', () => { operationalMocks.controllerInstances = 0; operationalMocks.latestControllerOptions = null; operationalMocks.activityShouldThrow = false; + operationalMocks.latestProviderProps = null; + operationalMocks.latestShellProps = null; operationalMocks.track.mockClear(); operationalMocks.push.mockClear(); + operationalMocks.replace.mockClear(); document.documentElement.dataset.theme = 'light'; vi.stubGlobal('fetch', vi.fn().mockResolvedValue({})); }); @@ -172,50 +212,69 @@ describe('CockpitShell operational composition', () => { vi.restoreAllMocks(); }); - it('always opens in Run, ignoring a stored activeMode from an older visit', async () => { - window.localStorage.setItem( - CONTROL_PLANE_STORAGE_KEY, - JSON.stringify({ - version: 1, - docs: { expanded: { Learn: true, Environment: false } }, - cockpit: { - activeMode: 'Code', - expanded: { Capability: true, Runtime: true }, - }, - }) - ); + it('adapts Cockpit route, content, navigation, analytics, session, telemetry, theme, and labels into the shared workspace', () => { renderShell(); - await waitFor(() => { - expect( - screen - .getByRole('button', { name: RUN_RAIL_ITEM }) - .getAttribute('aria-pressed') - ).toBe('true'); + expect(operationalMocks.latestProviderProps).toMatchObject({ + contentBundle: baseContentBundle, + routeKind: 'workspace', + routePath: model.canonicalPath, + requestedMode: null, + runtimeTelemetry: { + posthogToken: process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_TOKEN, + ingestHost: process.env.NEXT_PUBLIC_COCKPIT_INGEST_HOST, + }, }); + expect(operationalMocks.latestProviderProps?.resolution.kind).toBe( + 'mapped' + ); expect( - screen.getByRole('button', { name: 'Code' }).getAttribute('aria-pressed') - ).toBe('false'); - }); - - it('consumes a valid mode query once and lands in that mode', async () => { - seedExpanded(); - window.history.replaceState({}, '', '/?mode=code&keep=1'); - renderShell(); - - await waitFor(() => { - expect( - screen - .getByRole('button', { name: 'Code' }) - .getAttribute('aria-pressed') - ).toBe('true'); + operationalMocks.latestProviderProps?.resolution.kind === 'mapped' + ? operationalMocks.latestProviderProps.resolution.identity.id + : null + ).toBe('langgraph:core-capabilities:streaming:overview:python'); + expect(operationalMocks.latestProviderProps?.presentation.kind).toBe( + 'capability' + ); + expect(operationalMocks.latestProviderProps?.getSessionId).toBeTypeOf( + 'function' + ); + expect(operationalMocks.latestProviderProps?.pushIdentity).toBeTypeOf( + 'function' + ); + expect(operationalMocks.latestProviderProps?.pushMode).toBeTypeOf( + 'function' + ); + expect(operationalMocks.latestProviderProps?.replaceMode).toBeTypeOf( + 'function' + ); + expect(operationalMocks.latestProviderProps?.trackNavigation).toBeTypeOf( + 'function' + ); + expect( + operationalMocks.latestProviderProps?.trackNarrativeAction + ).toBeTypeOf('function'); + expect(operationalMocks.latestProviderProps?.trackModeChange).toBeTypeOf( + 'function' + ); + expect(operationalMocks.latestProviderProps?.trackRuntimeAction).toBeTypeOf( + 'function' + ); + expect( + operationalMocks.latestProviderProps?.trackRuntimeTransition + ).toBeTypeOf('function'); + expect(operationalMocks.latestShellProps).toMatchObject({ + navigationTree: model.navigationTree, + ariaLabel: 'Cockpit shell', + modeNavigationLabel: 'Cockpit modes', + contextPaneLabel: 'Cockpit context', + mobileDialogLabel: 'Cockpit control plane', + mobileTitle: 'Cockpit', }); - expect(window.location.search).toBe('?keep=1'); + expect(operationalMocks.latestShellProps?.themeControl).toBeTruthy(); }); - it('ignores invalid mode queries and falls back to Run', async () => { - seedExpanded(); - window.history.replaceState({}, '', '/?mode=preview'); + it('uses the truthful workspace route default when no mode query is present', async () => { renderShell(); await waitFor(() => { @@ -225,23 +284,13 @@ describe('CockpitShell operational composition', () => { .getAttribute('aria-pressed') ).toBe('true'); }); - expect(window.location.search).toBe(''); + expect(screen.getByRole('region', { name: 'Run mode' })).toBeTruthy(); }); - it('lands a newly navigated-to capability on Run even after switching to Code, when the shell remounts on the route key', async () => { - const { rerender } = render( - - - - ); + it('keeps a valid mode query as route state without persisting the mode', async () => { + window.history.replaceState({}, '', '/?mode=code&keep=1'); + renderShell(); - fireEvent.click(screen.getByRole('button', { name: 'Code' })); await waitFor(() => { expect( screen @@ -249,18 +298,13 @@ describe('CockpitShell operational composition', () => { .getAttribute('aria-pressed') ).toBe('true'); }); + expect(window.location.search).toBe('?mode=code&keep=1'); + expect(window.localStorage.getItem(CONTROL_PLANE_STORAGE_KEY)).toBeNull(); + }); - rerender( - - - - ); + it('normalizes invalid mode queries to the truthful route default', async () => { + window.history.replaceState({}, '', '/?mode=preview'); + renderShell(); await waitFor(() => { expect( @@ -269,9 +313,7 @@ describe('CockpitShell operational composition', () => { .getAttribute('aria-pressed') ).toBe('true'); }); - expect( - screen.getByRole('button', { name: 'Code' }).getAttribute('aria-pressed') - ).toBe('false'); + expect(operationalMocks.replace).toHaveBeenCalledWith('/?mode=run'); }); it('owns one controller and one Activity store shared by desktop and mobile adapters', async () => { @@ -469,7 +511,7 @@ describe('CockpitShell operational composition', () => { vi.useRealTimers(); }); - it('closes and restores focus before routing an internal capability exactly once', async () => { + it('closes before routing and restores destination-panel focus after navigation exactly once', async () => { vi.useFakeTimers(); vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 16) @@ -482,6 +524,9 @@ describe('CockpitShell operational composition', () => { await vi.runAllTimersAsync(); }); const trigger = screen.getByRole('button', { name: 'Open navigation' }); + const panel = screen.getByRole('heading', { + name: 'LangGraph Streaming Run', + }); fireEvent.click(trigger); const overlay = screen.getByRole('dialog', { name: 'Cockpit control plane', @@ -495,12 +540,8 @@ describe('CockpitShell operational composition', () => { destination.getAttribute('href') ?? '', window.location.href ).pathname; - const inertAtFocusAttempt: boolean[] = []; - const nativeFocus = trigger.focus.bind(trigger); - vi.spyOn(trigger, 'focus').mockImplementation(() => { - inertAtFocusAttempt.push(Boolean(trigger.closest('[inert]'))); - nativeFocus(); - }); + const triggerFocus = vi.spyOn(trigger, 'focus'); + const panelFocus = vi.spyOn(panel, 'focus'); operationalMocks.track.mockClear(); expect(fireEvent.click(destination)).toBe(false); @@ -518,8 +559,8 @@ describe('CockpitShell operational composition', () => { expect(operationalMocks.push).not.toHaveBeenCalled(); act(() => vi.advanceTimersByTime(16)); - expect(inertAtFocusAttempt).toEqual([false]); - expect(document.activeElement).toBe(trigger); + expect(triggerFocus).not.toHaveBeenCalled(); + expect(panelFocus).not.toHaveBeenCalled(); expect(operationalMocks.push).toHaveBeenCalledTimes(1); expect(operationalMocks.push).toHaveBeenCalledWith(destinationPath); @@ -528,21 +569,80 @@ describe('CockpitShell operational composition', () => { ); act(() => vi.advanceTimersByTime(16)); - expect(inertAtFocusAttempt).toEqual([false, false]); - expect(document.activeElement).toBe(trigger); + expect(triggerFocus).not.toHaveBeenCalled(); + expect(panelFocus).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(panel); expect(operationalMocks.push).toHaveBeenCalledTimes(1); rendered.unmount(); vi.useRealTimers(); }); + it('focuses the selected mobile destination panel instead of the navigation trigger', () => { + vi.useFakeTimers(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 16) + ); + vi.stubGlobal('cancelAnimationFrame', (handle: number) => + window.clearTimeout(handle) + ); + renderShell(); + const trigger = screen.getByRole('button', { name: 'Open navigation' }); + const triggerFocus = vi.spyOn(trigger, 'focus'); + + fireEvent.click(trigger); + fireEvent.click( + within( + screen.getByRole('dialog', { name: 'Cockpit control plane' }) + ).getByRole('button', { name: 'Code' }) + ); + const codePanel = screen.getByRole('heading', { + name: 'LangGraph Streaming Code', + hidden: true, + }); + act(() => vi.advanceTimersByTime(150)); + act(() => vi.advanceTimersByTime(16)); + + expect(triggerFocus).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(codePanel); + }); + + it('uses the Cockpit host adapter for desktop capability navigation', async () => { + const rendered = renderShell(); + await waitFor(() => + expect(screen.getByRole('link', { name: 'Persistence' })).toBeTruthy() + ); + const destination = screen.getByRole('link', { name: 'Persistence' }); + const destinationPath = new URL( + destination.getAttribute('href') ?? '', + window.location.href + ).pathname; + + expect(fireEvent.click(destination)).toBe(false); + + expect(operationalMocks.push).toHaveBeenCalledWith(destinationPath); + expect( + JSON.parse( + window.sessionStorage.getItem( + 'threadplane:cockpit:workspace-panel-focus' + ) ?? '{}' + ) + ).toEqual({ + destination: destinationPath, + requestedAt: expect.any(Number), + }); + rendered.unmount(); + }); + it('does not focus the mobile trigger on an ordinary shell load', async () => { const rendered = renderShell(); await waitFor(() => @@ -556,7 +656,7 @@ describe('CockpitShell operational composition', () => { rendered.unmount(); }); - it('does not consume a navigation focus intent into the hidden desktop trigger', () => { + it('consumes a cross-route focus intent into the active destination panel', () => { vi.useFakeTimers(); vi.stubGlobal( 'matchMedia', @@ -574,7 +674,7 @@ describe('CockpitShell operational composition', () => { ); window.history.replaceState({}, '', persistenceModel.canonicalPath); window.sessionStorage.setItem( - 'threadplane:cockpit:mobile-navigation-focus', + 'threadplane:cockpit:workspace-panel-focus', JSON.stringify({ destination: persistenceModel.canonicalPath, requestedAt: Date.now(), @@ -584,9 +684,11 @@ describe('CockpitShell operational composition', () => { ); @@ -595,15 +697,140 @@ describe('CockpitShell operational composition', () => { hidden: true, }); const focus = vi.spyOn(trigger, 'focus'); + const panel = screen.getByRole('heading', { + name: 'LangGraph Persistence Run', + }); + const panelFocus = vi.spyOn(panel, 'focus'); act(() => vi.advanceTimersByTime(16)); expect(focus).not.toHaveBeenCalled(); - expect(document.activeElement).not.toBe(trigger); + expect(panelFocus).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(panel); rendered.unmount(); vi.useRealTimers(); }); + it('uses a persistent tablet rail while Activity and Settings replace the context surface', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: + query === '(min-width: 48rem)' || + query === '(min-width: 48rem) and (max-width: 63.999rem)', + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + })) + ); + renderShell(); + + const settings = screen.getByRole('button', { name: 'Settings' }); + fireEvent.click(settings); + const surface = screen.getByRole('dialog', { + name: 'Cockpit control plane context', + }); + expect( + within(surface).getByRole('heading', { name: 'Settings' }) + ).toBeTruthy(); + expect( + screen.getByRole('navigation', { name: 'Cockpit modes' }) + ).toBeTruthy(); + + const activity = screen.getByRole('button', { name: 'Activity' }); + fireEvent.click(activity); + expect( + within(surface).getByRole('heading', { name: 'Activity' }) + ).toBeTruthy(); + fireEvent.click( + within(surface).getByRole('button', { name: 'Close Activity' }) + ); + + expect(document.activeElement).toBe(activity); + expect( + within(surface).getByRole('button', { name: 'Capability' }) + ).toBeTruthy(); + }); + + it('closes the tablet context surface and focuses the selected mode panel', () => { + vi.useFakeTimers(); + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: + query === '(min-width: 48rem)' || + query === '(min-width: 48rem) and (max-width: 63.999rem)', + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + })) + ); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 16) + ); + vi.stubGlobal('cancelAnimationFrame', (handle: number) => + window.clearTimeout(handle) + ); + renderShell(); + + fireEvent.click(screen.getByRole('button', { name: 'Open context' })); + expect( + screen.getByRole('dialog', { name: 'Cockpit control plane context' }) + ).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Code' })); + const codePanel = screen.getByRole('heading', { + name: 'LangGraph Streaming Code', + hidden: true, + }); + + act(() => vi.advanceTimersByTime(150)); + act(() => vi.advanceTimersByTime(16)); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(document.activeElement).toBe(codePanel); + }); + + it('restores the tablet context trigger after explicit dismissal', () => { + vi.useFakeTimers(); + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: + query === '(min-width: 48rem)' || + query === '(min-width: 48rem) and (max-width: 63.999rem)', + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + })) + ); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 16) + ); + vi.stubGlobal('cancelAnimationFrame', (handle: number) => + window.clearTimeout(handle) + ); + renderShell(); + + const trigger = screen.getByRole('button', { name: 'Open context' }); + fireEvent.click(trigger); + fireEvent.click( + within( + screen.getByRole('dialog', { + name: 'Cockpit control plane context', + }) + ).getByRole('button', { name: 'Close navigation' }) + ); + act(() => vi.advanceTimersByTime(150)); + act(() => vi.advanceTimersByTime(16)); + + expect(document.activeElement).toBe(trigger); + }); + it('records one fixed Activity event and one existing analytics event only for an actual mode change', async () => { renderShell(); await waitFor(() => @@ -624,6 +851,9 @@ describe('CockpitShell operational composition', () => { to_mode: 'code', } ); + expect(operationalMocks.push).toHaveBeenCalledTimes(1); + expect(operationalMocks.push).toHaveBeenCalledWith('/?mode=code'); + expect(operationalMocks.replace).not.toHaveBeenCalled(); }); it('keeps the exact Run iframe mounted while Activity and Settings replace only context', async () => { @@ -636,6 +866,79 @@ describe('CockpitShell operational composition', () => { expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); }); + it('renders non-empty Run, Code, narrative Docs, and API fixtures through the shared panels without remounting Run', async () => { + if (model.presentation.kind !== 'capability') { + throw new Error('Expected the streaming capability presentation'); + } + const codePath = model.presentation.codeAssetPaths[0]; + if (!codePath) throw new Error('Expected a streaming code asset'); + window.history.replaceState({}, '', '/?mode=run'); + + render( + + const adapterFixture = true;', + }, + promptFiles: {}, + runtimeUrl: 'https://runtime.test/parity', + narrativeDocs: [ + { + title: 'Adapter narrative', + html: '

Adapter narrative

Shared Docs fixture.

', + sourceFile: 'adapter.md', + }, + ], + docSections: [ + { + title: 'adapterApi', + signature: 'adapterApi(value: string): boolean', + description: 'Shared API fixture.', + params: [{ name: 'value', description: 'Fixture input.' }], + returns: 'Whether the fixture is active.', + sourceFile: 'adapter.ts', + language: 'typescript', + }, + ], + }} + routePath={model.canonicalPath} + requestedMode="run" + /> +
+ ); + + const frame = await screen.findByTitle('LangGraph Streaming live example'); + expect(screen.getByRole('region', { name: 'Run mode' })).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Code' })); + expect(screen.getByRole('region', { name: 'Code mode' })).toBeTruthy(); + expect(screen.getByText('const adapterFixture = true;')).toBeTruthy(); + expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); + + fireEvent.click(screen.getByRole('button', { name: 'Docs' })); + expect(screen.getByRole('region', { name: 'Docs mode' })).toBeTruthy(); + expect( + screen.getByRole('heading', { name: 'Adapter narrative' }) + ).toBeTruthy(); + expect(screen.getByText('Shared Docs fixture.')).toBeTruthy(); + expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); + + fireEvent.click(screen.getByRole('button', { name: 'API' })); + expect(screen.getByRole('region', { name: 'API mode' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'adapterApi' })).toBeTruthy(); + expect(screen.getByText('Shared API fixture.')).toBeTruthy(); + expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); + + fireEvent.click(screen.getByRole('button', { name: RUN_RAIL_ITEM })); + expect(screen.getByRole('region', { name: 'Run mode' })).toBeTruthy(); + expect(screen.getByTitle('LangGraph Streaming live example')).toBe(frame); + }); + it('reloads only the iframe while preserving shell state and session Activity', async () => { seedExpanded({ Capability: true, Runtime: true }); renderShell('https://runtime.test/path?secret=hidden'); @@ -882,6 +1185,22 @@ describe('CockpitShell documentation link', () => { expect(link.getAttribute('rel')).toBe('noopener noreferrer'); }); + it('links a docs-only legacy entry to its published canonical page', () => { + renderShellFor([ + 'langgraph', + 'getting-started', + 'overview', + 'overview', + 'python', + ]); + + expect( + screen.getByRole('link', { name: /read docs/i }).getAttribute('href') + ).toBe( + 'https://threadplane.ai/docs/langgraph/getting-started/introduction' + ); + }); + it('links a deep-agents capability at the deep-agents docs library', () => { renderShellFor([ 'deep-agents', @@ -897,15 +1216,4 @@ describe('CockpitShell documentation link', () => { ); }); - it('renders no link for a capability with no published docs page', () => { - // Every mapped capability now points at a published page, so the sentinel - // branch is exercised through a presentation carrying it rather than - // through a table entry that happens to be blank today. - renderShellFor( - ['deep-agents', 'core-capabilities', 'planning', 'overview', 'python'], - { docsPath: NO_COCKPIT_DOCS_LINK } - ); - - expect(screen.queryByRole('link', { name: /read docs/i })).toBeNull(); - }); }); diff --git a/apps/cockpit/src/components/cockpit-shell.tsx b/apps/cockpit/src/components/cockpit-shell.tsx index 3f3a286ca..103874e89 100644 --- a/apps/cockpit/src/components/cockpit-shell.tsx +++ b/apps/cockpit/src/components/cockpit-shell.tsx @@ -1,94 +1,59 @@ 'use client'; -import React, { - useCallback, - useEffect, - useMemo, - useReducer, - useRef, - useState, -} from 'react'; -import { cockpitManifest } from '@threadplane/cockpit-registry'; -import { BookOpen, Menu } from 'lucide-react'; -import { useRouter } from 'next/navigation'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { - parseControlPlaneMode, - useControlPlanePreferences, - type ControlPlaneMode, -} from '@threadplane/ui-react'; -import type { ContentBundle } from '../lib/content-bundle'; -import type { - CapabilityPresentation, - NavigationProduct, -} from '../lib/route-resolution'; -import { PRODUCT_LABELS } from '../lib/navigation-labels'; -import { track } from '../lib/analytics/client'; -import type { - CockpitRuntimeActionProps, - CockpitRuntimeStatusChangedProps, -} from '../lib/analytics/events'; + cockpitManifest, + type CockpitManifestEntry, + type WorkspaceMode, + type WorkspaceResolution, +} from '@threadplane/cockpit-registry'; import { - activityReducer, - countUnseenProblems, - createSessionActivityEvent, - type ActivityMode, - type RuntimeActivityInput, -} from '../lib/runtime/session-activity'; -import { copyRuntimeDiagnostics } from '../lib/runtime/runtime-diagnostics'; -import type { RuntimeTerminalTransition } from '../lib/runtime/runtime-state'; -import { useRuntimeController } from '../lib/runtime/use-runtime-controller'; -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'; -import { RunMode } from './run-mode/run-mode'; -import { MobileNavOverlay } from './mobile-nav-overlay'; + toCockpitPath, + type ContentBundle, + type NavigationProduct, + type WorkspacePresentation, +} from '@threadplane/cockpit-shell'; +import { ThemeToggle } from '@threadplane/ui-react'; import { - CockpitControlPlane, - type CockpitControlPlaneProps, - type CockpitUtility, -} from './control-plane/cockpit-control-plane'; + WorkspaceProvider, + WorkspaceShell, + resolveDocsUrl, + type RuntimeTerminalTransition, + type TrackModeChange, + type TrackNarrativeAction, + type TrackNavigation, + type TrackRuntimeAction, + type TrackRuntimeTransition, +} from '@threadplane/workspace-react'; +import { BookOpen } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { track } from '../lib/analytics/client'; +import { getCockpitSessionId } from '../lib/analytics/distinct-id'; +import type { CockpitRuntimeStatusChangedProps } from '../lib/analytics/events'; -interface CockpitShellProps { - navigationTree: NavigationProduct[]; - presentation: CapabilityPresentation; - entryTitle: string; - contentBundle: ContentBundle; +export interface CockpitShellProps { + readonly navigationTree: NavigationProduct[]; + readonly resolution: WorkspaceResolution; + readonly presentation: WorkspacePresentation; + readonly contentBundle: ContentBundle; + readonly routePath: string; + readonly requestedMode: string | null; } -const MODE_ANALYTICS: Record< - ControlPlaneMode, - 'run' | 'code' | 'docs' | 'api' -> = { +const MODE_ANALYTICS: Record = { Run: 'run', Code: 'code', Docs: 'docs', API: 'api', }; -const MOBILE_NAVIGATION_FOCUS_INTENT = - 'threadplane:cockpit:mobile-navigation-focus'; -const MOBILE_NAVIGATION_FOCUS_MAX_AGE_MS = 10_000; - -const toLabel = (value: string) => - value - .split('-') - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' '); - -function createLocalActivityInput( - capability: string, - input: - | { kind: 'mode_changed'; mode: ActivityMode } - | { kind: 'diagnostics_copied' | 'diagnostics_copy_failed' } -): RuntimeActivityInput { - return { - id: globalThis.crypto.randomUUID(), - at: new Date().toISOString(), - capability, - ...input, - }; -} +const WORKSPACE_PANEL_FOCUS_INTENT = + 'threadplane:cockpit:workspace-panel-focus'; +const WORKSPACE_PANEL_FOCUS_MAX_AGE_MS = 10_000; +const RUNTIME_FRAME_TELEMETRY = { + posthogToken: process.env.NEXT_PUBLIC_COCKPIT_POSTHOG_TOKEN, + ingestHost: process.env.NEXT_PUBLIC_COCKPIT_INGEST_HOST, +}; function toRuntimeStatusChangedProps( transition: RuntimeTerminalTransition @@ -102,24 +67,16 @@ function toRuntimeStatusChangedProps( }; switch (transition.toState) { - case 'ready': { - if ( - transition.fromState === 'unresponsive' || + case 'ready': + return transition.fromState === 'unresponsive' || transition.fromState === 'error' - ) { - return { - ...common, - from_state: transition.fromState, - to_state: 'ready', - transition: 'recovered', - }; - } - return { - ...common, - from_state: transition.fromState, - to_state: 'ready', - }; - } + ? { + ...common, + from_state: transition.fromState, + to_state: 'ready', + transition: 'recovered', + } + : { ...common, from_state: transition.fromState, to_state: 'ready' }; case 'unresponsive': return { ...common, @@ -147,137 +104,127 @@ function toRuntimeStatusChangedProps( } } +const trackNavigation: TrackNavigation = ({ + capability, + category, + fromCapability, +}) => { + track('cockpit:recipe_opened', { + capability, + category, + from_capability: fromCapability, + }); +}; + +const trackNarrativeAction: TrackNarrativeAction = ({ + capability, + surface, +}) => { + track('cockpit:code_copied', { capability, surface }); +}; + +const trackModeChange: TrackModeChange = ({ capability, fromMode, toMode }) => { + track('cockpit:mode_switched', { + capability, + from_mode: MODE_ANALYTICS[fromMode], + to_mode: MODE_ANALYTICS[toMode], + }); +}; + +const trackRuntimeAction: TrackRuntimeAction = (event) => { + switch (event.action) { + case 'recheck': + case 'reload': + track('cockpit:runtime_action', { + capability: event.capability, + action: event.action, + state_before: event.stateBefore, + outcome: event.outcome, + }); + break; + case 'open': + track('cockpit:runtime_action', { + capability: event.capability, + action: event.action, + state_before: event.stateBefore, + outcome: event.outcome, + }); + break; + case 'copy_diagnostics': + track('cockpit:runtime_action', { + capability: event.capability, + action: event.action, + state_before: event.stateBefore, + outcome: event.outcome, + }); + break; + } +}; + +const trackRuntimeTransition: TrackRuntimeTransition = (transition) => { + track( + 'cockpit:runtime_status_changed', + toRuntimeStatusChangedProps(transition) + ); +}; + +const modeHref = (mode: WorkspaceMode): string => { + const url = new URL(window.location.href); + url.searchParams.set('mode', mode.toLowerCase()); + return `${url.pathname}${url.search}${url.hash}`; +}; + export function CockpitShell({ navigationTree, + resolution, presentation, - entryTitle, contentBundle, + routePath, + requestedMode, }: CockpitShellProps) { const router = useRouter(); const routerRef = useRef(router); routerRef.current = router; - const preferences = useControlPlanePreferences('cockpit'); - const queryHandled = useRef(false); - const mobileTriggerRef = useRef(null); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const [activeMode, setActiveMode] = useState('Run'); - const [isMobileOverlayPresent, setIsMobileOverlayPresent] = useState(false); - const [activeUtility, setActiveUtility] = useState(null); - const [activityOpenCycle, setActivityOpenCycle] = useState(0); - const [seenActivityCount, setSeenActivityCount] = useState(0); - const [events, dispatchActivity] = useReducer(activityReducer, []); - const isCapability = presentation.kind === 'capability'; - const codeAssetPaths = isCapability ? presentation.codeAssetPaths : []; - const backendAssetPaths = isCapability - ? presentation.backendAssetPaths ?? [] - : []; - const entry = presentation.entry; - const contextLabel = [ - PRODUCT_LABELS[entry.product] ?? toLabel(entry.product), - toLabel(entry.section), - toLabel(entry.topic), - ].join(' / '); - - const appendActivity = useCallback((input: RuntimeActivityInput) => { - dispatchActivity({ - type: 'add', - event: createSessionActivityEvent(input), - }); - }, []); - const handleTerminalTransition = useCallback( - (transition: RuntimeTerminalTransition) => { - track( - 'cockpit:runtime_status_changed', - toRuntimeStatusChangedProps(transition) - ); - }, - [] - ); - - const controller = useRuntimeController({ - runtimeUrl: contentBundle.runtimeUrl, - capability: entry.topic, - onActivity: appendActivity, - onTerminalTransition: handleTerminalTransition, - }); - // 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 (queryHandled.current) return; - queryHandled.current = true; - const url = new URL(window.location.href); - const rawMode = url.searchParams.get('mode'); - const requestedMode = parseControlPlaneMode(rawMode); - if (requestedMode) setActiveMode(requestedMode); - if (rawMode !== null) { - url.searchParams.delete('mode'); - window.history.replaceState( - window.history.state, - '', - url.pathname + url.search + url.hash - ); - } - }, []); - - const isMobileModalActive = isSidebarOpen || isMobileOverlayPresent; - - const handleModeChange = useCallback( - (mode: ControlPlaneMode) => { - if (mode === activeMode) return; - setActiveMode(mode); - appendActivity( - createLocalActivityInput(entry.topic, { - kind: 'mode_changed', - mode, - }) - ); - track('cockpit:mode_switched', { - capability: entry.topic, - from_mode: MODE_ANALYTICS[activeMode], - to_mode: MODE_ANALYTICS[mode], - }); - }, - [activeMode, appendActivity, entry.topic] - ); - - const handleActiveUtilityChange = useCallback( - (utility: CockpitUtility) => { - if (utility === 'activity' && activeUtility !== 'activity') { - setActivityOpenCycle((cycle) => cycle + 1); - setSeenActivityCount(events.length); + const pushIdentity = useCallback( + ( + href: string, + options?: { + restoreFocus?: 'mobile-navigation-trigger' | 'workspace-panel'; + } + ) => { + if (options?.restoreFocus === 'workspace-panel') { + const currentDestination = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (href !== currentDestination) { + try { + window.sessionStorage.setItem( + WORKSPACE_PANEL_FOCUS_INTENT, + JSON.stringify({ destination: href, requestedAt: Date.now() }) + ); + } catch { + // Client navigation still works if session storage is unavailable. + } + } } - setActiveUtility(utility); + routerRef.current.push(href); }, - [activeUtility, events.length] + [] ); - - const closeMobileNavigation = useCallback(() => { - setIsSidebarOpen(false); + const pushMode = useCallback((mode: WorkspaceMode) => { + routerRef.current.push(modeHref(mode)); }, []); - - const handleCapabilityNavigate = useCallback((destination: string) => { - const currentDestination = - window.location.pathname + window.location.search + window.location.hash; - if (destination !== currentDestination) { - try { - window.sessionStorage.setItem( - MOBILE_NAVIGATION_FOCUS_INTENT, - JSON.stringify({ destination, requestedAt: Date.now() }) - ); - } catch { - // Client navigation still works if session storage is unavailable. - } - } - routerRef.current.push(destination); + const replaceMode = useCallback((mode: WorkspaceMode) => { + routerRef.current.replace(modeHref(mode)); }, []); + const resolveIdentityHref = useCallback( + (entry: CockpitManifestEntry) => toCockpitPath(entry), + [] + ); useEffect(() => { let rawIntent: string | null = null; try { - rawIntent = window.sessionStorage.getItem(MOBILE_NAVIGATION_FOCUS_INTENT); + rawIntent = window.sessionStorage.getItem(WORKSPACE_PANEL_FOCUS_INTENT); } catch { return undefined; } @@ -287,242 +234,82 @@ export function CockpitShell({ try { intent = JSON.parse(rawIntent) as typeof intent; } catch { - window.sessionStorage.removeItem(MOBILE_NAVIGATION_FOCUS_INTENT); + window.sessionStorage.removeItem(WORKSPACE_PANEL_FOCUS_INTENT); return undefined; } - const currentDestination = - window.location.pathname + window.location.search + window.location.hash; + const currentDestination = `${window.location.pathname}${window.location.search}${window.location.hash}`; const isFresh = typeof intent.requestedAt === 'number' && - Date.now() - intent.requestedAt <= MOBILE_NAVIGATION_FOCUS_MAX_AGE_MS; + Date.now() - intent.requestedAt <= WORKSPACE_PANEL_FOCUS_MAX_AGE_MS; if (!isFresh) { - window.sessionStorage.removeItem(MOBILE_NAVIGATION_FOCUS_INTENT); + window.sessionStorage.removeItem(WORKSPACE_PANEL_FOCUS_INTENT); return undefined; } if (intent.destination !== currentDestination) return undefined; - window.sessionStorage.removeItem(MOBILE_NAVIGATION_FOCUS_INTENT); - const focusTrigger = () => { - if ( - typeof window.matchMedia === 'function' && - window.matchMedia('(min-width: 48rem)').matches - ) { - return; - } - const trigger = mobileTriggerRef.current; - if (!trigger?.closest('[inert]')) trigger?.focus(); + window.sessionStorage.removeItem(WORKSPACE_PANEL_FOCUS_INTENT); + const focusPanel = () => { + const panel = document.querySelector( + '[data-workspace-panel-target]:not([aria-hidden="true"])' + ); + if (!panel?.closest('[inert]')) panel?.focus(); }; if (typeof window.requestAnimationFrame === 'function') { - const frame = window.requestAnimationFrame(focusTrigger); + const frame = window.requestAnimationFrame(focusPanel); return () => window.cancelAnimationFrame(frame); } - const timer = window.setTimeout(focusTrigger, 0); + const timer = window.setTimeout(focusPanel, 0); return () => window.clearTimeout(timer); - }, [entry.page, entry.product, entry.section, entry.topic]); + }, [routePath]); - const handleClearActivity = useCallback(() => { - dispatchActivity({ type: 'clear' }); - setSeenActivityCount(0); - }, []); - - const handleRecheck = useCallback(() => { - const stateBefore = controller.snapshot.phase; - controller.recheck(); - track('cockpit:runtime_action', { - capability: entry.topic, - action: 'recheck', - state_before: stateBefore, - outcome: 'requested', - } satisfies CockpitRuntimeActionProps); - return 'requested' as const; - }, [controller, entry.topic]); - - const handleReload = useCallback(() => { - const stateBefore = controller.snapshot.phase; - controller.reload(); - track('cockpit:runtime_action', { - capability: entry.topic, - action: 'reload', - state_before: stateBefore, - outcome: 'requested', - } satisfies CockpitRuntimeActionProps); - return 'requested' as const; - }, [controller, entry.topic]); - - const handleOpenRuntime = useCallback(() => { - const stateBefore = controller.snapshot.phase; - const outcome = controller.open(); - track('cockpit:runtime_action', { - capability: entry.topic, - action: 'open', - state_before: stateBefore, - outcome, - } satisfies CockpitRuntimeActionProps); - return outcome; - }, [controller, entry.topic]); - - const handleCopyDiagnostics = useCallback(async () => { - const snapshot = controller.snapshot; - const stateBefore = snapshot.phase; - const outcome = await copyRuntimeDiagnostics(snapshot, events); - appendActivity( - createLocalActivityInput(entry.topic, { - kind: - outcome === 'succeeded' - ? 'diagnostics_copied' - : 'diagnostics_copy_failed', - }) - ); - track('cockpit:runtime_action', { - capability: entry.topic, - action: 'copy_diagnostics', - state_before: stateBefore, - outcome, - } satisfies CockpitRuntimeActionProps); - return outcome; - }, [appendActivity, controller.snapshot, entry.topic, events]); - - const controlPlaneProps = useMemo< - Omit - >( - () => ({ - navigationTree, - manifest: cockpitManifest, - entry, - activeMode, - onModeChange: handleModeChange, - activeUtility, - onActiveUtilityChange: handleActiveUtilityChange, - activityOpenCycle, - runtimeSnapshot: controller.snapshot, - events, - unseenProblems: countUnseenProblems(events, seenActivityCount), - expanded: preferences.expanded, - onExpandedChange: preferences.setExpanded, - onClearActivity: handleClearActivity, - onRecheck: handleRecheck, - onReload: handleReload, - onOpenRuntime: handleOpenRuntime, - onCopyDiagnostics: handleCopyDiagnostics, - }), - [ - activeMode, - activeUtility, - activityOpenCycle, - controller.snapshot, - entry, - events, - handleActiveUtilityChange, - handleClearActivity, - handleCopyDiagnostics, - handleModeChange, - handleOpenRuntime, - handleRecheck, - handleReload, - navigationTree, - preferences.expanded, - preferences.setExpanded, - seenActivityCount, - ] + const docsUrl = resolveDocsUrl(presentation.docsPath); + const headerActions = useMemo( + () => + docsUrl ? ( + + + ) : null, + [docsUrl] ); return ( -
-
- -
- - } + headerActions={headerActions} + ariaLabel="Cockpit shell" + modeNavigationLabel="Cockpit modes" + contextPaneLabel="Cockpit context" + mobileDialogLabel="Cockpit control plane" + mobileTitle="Cockpit" /> - -
-
-
- -

- {contextLabel} -

-
- {docsUrl ? ( - - - ) : null} -
- -
-
- -
- {activeMode === 'Code' ? ( - - ) : null} - {activeMode === 'Docs' ? ( - - ) : null} - {activeMode === 'API' ? ( - - ) : null} -
-
-
+ ); } diff --git a/apps/cockpit/src/components/control-plane/cockpit-control-plane.tsx b/apps/cockpit/src/components/control-plane/cockpit-control-plane.tsx deleted file mode 100644 index 35a284e3f..000000000 --- a/apps/cockpit/src/components/control-plane/cockpit-control-plane.tsx +++ /dev/null @@ -1,261 +0,0 @@ -'use client'; - -import React, { useRef } from 'react'; -import type { CockpitManifestEntry } from '@threadplane/cockpit-registry'; -import { - Activity as ActivityIcon, - BookOpen, - Braces, - Code2, - Play, - Settings, -} from 'lucide-react'; -import { - ControlPlanePane, - ControlPlaneRail, - ControlPlaneRailItem, - ControlPlaneUtilityPanel, - ThemeToggle, - type ControlPlaneMode, -} from '@threadplane/ui-react'; -import type { NavigationProduct } from '../../lib/route-resolution'; -import { PRODUCT_LABELS } from '../../lib/navigation-labels'; -import type { SessionActivityEvent } from '../../lib/runtime/session-activity'; -import { - runtimeRailStatus, - type RuntimeSnapshot, -} from '../../lib/runtime/runtime-state'; -import { CockpitSidebar } from '../sidebar/cockpit-sidebar'; -import { LanguagePicker } from '../sidebar/language-picker'; -import { ActivityPanel } from './activity-panel'; -import { ActivityPanelBoundary } from './activity-panel-boundary'; -import { RuntimeSection } from './runtime-section'; - -const MODES: Array<{ - label: ControlPlaneMode; - icon: typeof Play; -}> = [ - { label: 'Docs', icon: BookOpen }, - { label: 'Run', icon: Play }, - { label: 'Code', icon: Code2 }, - { label: 'API', icon: Braces }, -]; - -type RuntimeCommandOutcome = void | 'requested' | 'succeeded' | 'failed'; -type RuntimeCommand = () => - | RuntimeCommandOutcome - | PromiseLike; - -export type CockpitUtility = 'activity' | 'settings' | null; - -export interface CockpitControlPlaneProps { - navigationTree: NavigationProduct[]; - manifest: CockpitManifestEntry[]; - entry: CockpitManifestEntry; - activeMode: ControlPlaneMode; - onModeChange(mode: ControlPlaneMode): void; - activeUtility: CockpitUtility; - onActiveUtilityChange(utility: CockpitUtility): void; - activityOpenCycle: number; - runtimeSnapshot: RuntimeSnapshot; - events: readonly SessionActivityEvent[]; - unseenProblems: number; - expanded: Record; - onExpandedChange(key: string, open: boolean): void; - onClearActivity(): void; - onRecheck: RuntimeCommand; - onReload: RuntimeCommand; - onOpenRuntime: RuntimeCommand; - onCopyDiagnostics: RuntimeCommand; - mobile?: boolean; - onModeSelected?: () => void; - onNavigate?: () => void; -} - -function focusUtilityInvoker(ref: React.RefObject) { - ref.current?.querySelector('button')?.focus(); -} - -export function CockpitControlPlane({ - navigationTree, - manifest, - entry, - activeMode, - onModeChange, - activeUtility, - onActiveUtilityChange, - activityOpenCycle, - runtimeSnapshot, - events, - unseenProblems, - expanded, - onExpandedChange, - onClearActivity, - onRecheck, - onReload, - onOpenRuntime, - onCopyDiagnostics, - mobile = false, - onModeSelected, - onNavigate, -}: CockpitControlPlaneProps) { - const activityRef = useRef(null); - const settingsRef = useRef(null); - const railStatus = runtimeRailStatus(runtimeSnapshot.phase); - const attention = unseenProblems > 0; - const activityLabel = attention - ? `Activity, ${unseenProblems} unread problem${ - unseenProblems === 1 ? '' : 's' - }` - : 'Activity'; - const product = PRODUCT_LABELS[entry.product] ?? entry.product; - const language = entry.language === 'typescript' ? 'TypeScript' : 'Python'; - - const closeUtility = ( - utility: Exclude, - invokerRef: React.RefObject - ) => { - if (activeUtility !== utility) return; - onActiveUtilityChange(null); - focusUtilityInvoker(invokerRef); - }; - - const selectUtility = ( - utility: Exclude, - invokerRef: React.RefObject - ) => { - if (activeUtility === utility) { - closeUtility(utility, invokerRef); - return; - } - onActiveUtilityChange(utility); - }; - - const selectMode = (mode: ControlPlaneMode) => { - if (activeUtility !== null) { - onActiveUtilityChange(null); - } - onModeChange(mode); - onModeSelected?.(); - }; - - let paneContent: React.ReactNode; - if (activeUtility === 'activity') { - paneContent = ( - closeUtility('activity', activityRef)} - > - closeUtility('activity', activityRef)} - onClear={onClearActivity} - /> - - ); - } else if (activeUtility === 'settings') { - paneContent = ( - closeUtility('settings', settingsRef)} - > -
- Language - -
-
- Theme - -
-
- ); - } else { - paneContent = ( - <> - - onExpandedChange('Runtime', open)} - onRecheck={onRecheck} - onReload={onReload} - onOpenRuntime={onOpenRuntime} - onCopyDiagnostics={onCopyDiagnostics} - /> - - ); - } - - return ( -
- ( -
- ); -} diff --git a/apps/cockpit/src/components/pane-rendering.spec.tsx b/apps/cockpit/src/components/pane-rendering.spec.tsx index abdbff690..450a831f7 100644 --- a/apps/cockpit/src/components/pane-rendering.spec.tsx +++ b/apps/cockpit/src/components/pane-rendering.spec.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; -import { CodeMode } from './code-mode/code-mode'; -import { CodePane } from './code-pane/code-pane'; +import { CodeMode, CodePane } from '@threadplane/workspace-react'; import { CockpitShell } from './cockpit-shell'; import { getCockpitPageModel } from '../lib/cockpit-page'; @@ -25,9 +24,17 @@ describe('cockpit shell contract', () => { const html = renderToStaticMarkup( ); @@ -51,9 +58,17 @@ describe('refreshed shell structure', () => {
{ - afterEach(() => { - globalThis.document?.body.replaceChildren(); - }); - - it('shows the current language in the trigger and opens a custom menu', () => { - const dom = new JSDOM(''); - const { window } = dom; - - globalThis.window = window as unknown as Window & typeof globalThis; - globalThis.document = window.document; - globalThis.HTMLElement = window.HTMLElement; - globalThis.Node = window.Node; - globalThis.MouseEvent = window.MouseEvent; - - const entry = cockpitManifest.find( - (candidate) => - candidate.product === 'langgraph' && - candidate.section === 'core-capabilities' && - candidate.topic === 'streaming' && - candidate.language === 'python' - )!; - - const container = document.createElement('div'); - document.body.appendChild(container); - - const root = createRoot(container); - - act(() => { - root.render(); - }); - - expect(container.querySelector('select')).toBeNull(); - expect(container.textContent).toContain('Python'); - - const trigger = container.querySelector('button'); - expect(trigger).not.toBeNull(); - - act(() => { - trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(container.querySelector('[role="menu"]')).not.toBeNull(); - expect(container.textContent).toContain('TypeScript'); - - act(() => { - root.unmount(); - }); - }); -}); diff --git a/apps/cockpit/src/components/sidebar/navigation-groups.tsx b/apps/cockpit/src/components/sidebar/navigation-groups.tsx deleted file mode 100644 index b9830123d..000000000 --- a/apps/cockpit/src/components/sidebar/navigation-groups.tsx +++ /dev/null @@ -1,128 +0,0 @@ -'use client'; - -import React, { useId } from 'react'; -import { ChevronRight } from 'lucide-react'; -import type { CockpitManifestEntry } from '@threadplane/cockpit-registry'; -import type { NavigationProduct } from '../../lib/route-resolution'; -import { toCockpitPath } from '../../lib/route-resolution'; -import { PRODUCT_LABELS, stripProductPrefix } from '../../lib/navigation-labels'; -import { track } from '../../lib/analytics/client'; - -interface NavigationGroupsProps { - tree: NavigationProduct[]; - currentEntry: CockpitManifestEntry; - expanded?: Record; - onExpandedChange?: (key: string, open: boolean) => void; - onNavigate?: () => void; -} - -function ProductGroup({ - product, - currentEntry, - open, - onOpenChange, - onNavigate, -}: { - product: NavigationProduct; - currentEntry: CockpitManifestEntry; - open: boolean; - onOpenChange: (open: boolean) => void; - onNavigate?: () => void; -}) { - const label = PRODUCT_LABELS[product.product] ?? product.product; - const contentId = useId(); - - return ( -
- - - {open && ( -
- {product.sections.flatMap((section) => - section.entries - .filter((entry) => entry.topic !== 'overview') - .map((entry) => { - const isActive = - entry.product === currentEntry.product && - entry.section === currentEntry.section && - entry.topic === currentEntry.topic && - entry.page === currentEntry.page; - - return ( - { - onNavigate?.(); - track('cockpit:recipe_opened', { - capability: entry.topic, - category: entry.product, - from_capability: currentEntry.topic, - }); - }} - aria-current={isActive ? 'page' : undefined} - className="cockpit-nav-item" - > - {stripProductPrefix(entry.title)} - - ); - }) - )} -
- )} -
- ); -} - -export function NavigationGroups({ - tree, - currentEntry, - expanded = {}, - onExpandedChange, - onNavigate, -}: NavigationGroupsProps) { - return ( - - ); -} diff --git a/apps/cockpit/src/lib/analytics/events.ts b/apps/cockpit/src/lib/analytics/events.ts index c4e3e13ae..2fa99e8e6 100644 --- a/apps/cockpit/src/lib/analytics/events.ts +++ b/apps/cockpit/src/lib/analytics/events.ts @@ -2,7 +2,7 @@ import type { RuntimePhase, RuntimeTerminalPhase, -} from '../runtime/runtime-state'; +} from '@threadplane/workspace-react'; export type CockpitShellEvent = | 'cockpit:recipe_opened' diff --git a/apps/cockpit/src/lib/cockpit-page.spec.ts b/apps/cockpit/src/lib/cockpit-page.spec.ts new file mode 100644 index 000000000..9f9e41741 --- /dev/null +++ b/apps/cockpit/src/lib/cockpit-page.spec.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest'; +import { + getCanonicalCockpitRedirect, + getCockpitPageModel, + getLegacyWebsiteRedirect, + getRootWebsiteRedirect, + getUnifiedWorkspaceRedirectOrigin, + normalizeRequestedMode, +} from './cockpit-page'; +import { cockpitManifest } from '@threadplane/cockpit-registry'; +import { getWorkspaceDestinationPath } from '@threadplane/cockpit-registry'; + +const enabledProductionEnv = { + UNIFIED_WORKSPACE_REDIRECTS_ENABLED: 'true', + NEXT_PUBLIC_WEBSITE_ORIGIN: 'https://threadplane.ai', + NODE_ENV: 'production', +}; + +describe('Cockpit page query normalization', () => { + it('keeps repeated mode params explicitly invalid for provider normalization', () => { + expect(normalizeRequestedMode(['code', 'docs'])).toBe('code,docs'); + expect(normalizeRequestedMode('code')).toBe('code'); + expect(normalizeRequestedMode(undefined)).toBeNull(); + }); + + it('preserves only a syntactically valid mode available on the canonical entry', () => { + const model = getCockpitPageModel([ + 'langgraph', + 'core-capabilities', + 'streaming', + 'overview', + 'python', + ]); + expect(getCanonicalCockpitRedirect(model, 'code')).toBe( + `${model.canonicalPath}?mode=code` + ); + expect(getCanonicalCockpitRedirect(model, 'preview')).toBe( + model.canonicalPath + ); + expect(getCanonicalCockpitRedirect(model, ['code', 'docs'])).toBe( + model.canonicalPath + ); + + const docsOnly = getCockpitPageModel([ + 'langgraph', + 'getting-started', + 'overview', + 'overview', + 'python', + ]); + expect(getCanonicalCockpitRedirect(docsOnly, 'run')).toBe( + docsOnly.canonicalPath + ); + }); +}); + +describe('unified Website redirect gate', () => { + it('is disabled unless the explicit flag and a valid origin are both present', () => { + expect( + getUnifiedWorkspaceRedirectOrigin({ + NEXT_PUBLIC_WEBSITE_ORIGIN: 'https://threadplane.ai', + NODE_ENV: 'production', + }) + ).toBeNull(); + expect( + getUnifiedWorkspaceRedirectOrigin({ + ...enabledProductionEnv, + NEXT_PUBLIC_WEBSITE_ORIGIN: 'http://threadplane.ai', + }) + ).toBeNull(); + expect( + getUnifiedWorkspaceRedirectOrigin({ + ...enabledProductionEnv, + NEXT_PUBLIC_WEBSITE_ORIGIN: 'https://threadplane.ai/docs', + }) + ).toBeNull(); + expect(getUnifiedWorkspaceRedirectOrigin(enabledProductionEnv)).toBe( + 'https://threadplane.ai' + ); + }); + + it('allows HTTP localhost only in development', () => { + const localhost = { + UNIFIED_WORKSPACE_REDIRECTS_ENABLED: 'true', + NEXT_PUBLIC_WEBSITE_ORIGIN: 'http://localhost:3000', + }; + expect( + getUnifiedWorkspaceRedirectOrigin({ + ...localhost, + NODE_ENV: 'development', + }) + ).toBe('http://localhost:3000'); + expect( + getUnifiedWorkspaceRedirectOrigin({ + ...localhost, + NODE_ENV: 'production', + }) + ).toBeNull(); + }); +}); + +describe('registry-derived legacy Website redirects', () => { + it('maps every legacy path to its registry-owned Website destination', () => { + for (const entry of cockpitManifest) { + expect( + getLegacyWebsiteRedirect( + entry.legacyPath, + undefined, + enabledProductionEnv + ) + ).toBe( + `https://threadplane.ai${getWorkspaceDestinationPath(entry)}` + ); + } + }); + + it('preserves only a single valid mode available at the destination', () => { + const streaming = cockpitManifest.find( + (entry) => + entry.id === 'langgraph:core-capabilities:streaming:overview:python' + ); + const overview = cockpitManifest.find( + (entry) => + entry.id === 'langgraph:getting-started:overview:overview:python' + ); + if (!streaming || !overview) throw new Error('Expected fixture entries'); + + expect( + getLegacyWebsiteRedirect( + streaming.legacyPath, + 'code', + enabledProductionEnv + ) + ).toBe('https://threadplane.ai/docs/langgraph/guides/streaming?mode=code'); + expect( + getLegacyWebsiteRedirect( + streaming.legacyPath, + ['code', 'run'], + enabledProductionEnv + ) + ).toBe('https://threadplane.ai/docs/langgraph/guides/streaming'); + expect( + getLegacyWebsiteRedirect(overview.legacyPath, 'run', enabledProductionEnv) + ).toBe( + 'https://threadplane.ai/docs/langgraph/getting-started/introduction' + ); + expect( + getLegacyWebsiteRedirect( + streaming.legacyPath, + 'preview', + enabledProductionEnv + ) + ).toBe('https://threadplane.ai/docs/langgraph/guides/streaming'); + }); + + it('preserves secondary capability identity and its available modes', () => { + const jsonRender = cockpitManifest.find( + (entry) => + entry.id === 'ag-ui:core-capabilities:json-render:overview:python' + ); + if (!jsonRender) throw new Error('Expected AG-UI JSON Render fixture'); + + expect(jsonRender.availableModes).toContain('Run'); + expect( + getLegacyWebsiteRedirect( + jsonRender.legacyPath, + 'run', + enabledProductionEnv + ) + ).toBe('https://threadplane.ai/workspace/ag-ui/json-render?mode=run'); + expect( + getLegacyWebsiteRedirect( + jsonRender.legacyPath, + 'docs', + enabledProductionEnv + ) + ).toBe('https://threadplane.ai/workspace/ag-ui/json-render?mode=docs'); + }); + + it('does not redirect invalid or unmapped legacy paths', () => { + expect( + getLegacyWebsiteRedirect( + '/langgraph/core-capabilities/not-real/overview/python', + 'run', + enabledProductionEnv + ) + ).toBeNull(); + }); + + it('redirects the Cockpit root through its default registry identity', () => { + expect(getRootWebsiteRedirect('run', enabledProductionEnv)).toBe( + 'https://threadplane.ai/docs/langgraph/guides/streaming?mode=run' + ); + }); +}); diff --git a/apps/cockpit/src/lib/cockpit-page.ts b/apps/cockpit/src/lib/cockpit-page.ts index 44c106386..d9b2b7dc6 100644 --- a/apps/cockpit/src/lib/cockpit-page.ts +++ b/apps/cockpit/src/lib/cockpit-page.ts @@ -1,17 +1,30 @@ -import { cockpitManifest, type CockpitProduct, type CockpitSection, type CockpitPageId, type CockpitLanguage } from '@threadplane/cockpit-registry'; +import { + cockpitManifest, + getWorkspaceDestinationPath, + resolveLegacyPath, + toWorkspaceIdentity, + type CockpitProduct, + type CockpitSection, + type CockpitPageId, + type CockpitLanguage, + type WorkspaceMode, + type WorkspaceResolution, +} from '@threadplane/cockpit-registry'; import { buildNavigationTree, - getCapabilityPresentation, + getWorkspacePresentation, resolveCockpitEntry, toCockpitPath, type NavigationProduct, -} from './route-resolution'; + type WorkspacePresentation, +} from '@threadplane/cockpit-shell'; export { cockpitManifest }; export interface CockpitPageModel { entry: ReturnType; - presentation: ReturnType; + resolution: WorkspaceResolution; + presentation: WorkspacePresentation; navigationTree: NavigationProduct[]; canonicalPath: string; } @@ -24,6 +37,119 @@ const DEFAULT_COCKPIT_SLUG = [ 'python', ] as const; +const QUERY_MODES: Record = { + docs: 'Docs', + run: 'Run', + code: 'Code', + api: 'API', +}; + +export interface UnifiedWorkspaceRedirectEnvironment { + readonly UNIFIED_WORKSPACE_REDIRECTS_ENABLED?: string; + readonly NEXT_PUBLIC_WEBSITE_ORIGIN?: string; + readonly NODE_ENV?: string; +} + +export function getUnifiedWorkspaceRedirectOrigin( + environment: UnifiedWorkspaceRedirectEnvironment = process.env +): string | null { + if (environment.UNIFIED_WORKSPACE_REDIRECTS_ENABLED !== 'true') return null; + const rawOrigin = environment.NEXT_PUBLIC_WEBSITE_ORIGIN; + if (!rawOrigin) return null; + + try { + const url = new URL(rawOrigin); + if ( + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ) { + return null; + } + + const secure = url.protocol === 'https:'; + const developmentLocalhost = + environment.NODE_ENV === 'development' && + url.protocol === 'http:' && + url.hostname === 'localhost'; + return secure || developmentLocalhost ? url.origin : null; + } catch { + return null; + } +} + +const appendAvailableMode = ( + destinationPath: string, + mode: string | string[] | undefined, + availableModes: readonly WorkspaceMode[] +): string => { + if (typeof mode !== 'string') return destinationPath; + const parsed = QUERY_MODES[mode.toLowerCase()]; + if (!parsed || !availableModes.includes(parsed)) return destinationPath; + return `${destinationPath}?mode=${parsed.toLowerCase()}`; +}; + +const toWebsiteRedirect = ( + origin: string, + resolution: WorkspaceResolution, + mode: string | string[] | undefined +): string | null => { + if (resolution.kind !== 'mapped') return null; + const destinationPath = getWorkspaceDestinationPath(resolution.identity); + return new URL( + appendAvailableMode( + destinationPath, + mode, + resolution.identity.availableModes + ), + `${origin}/` + ).toString(); +}; + +export function getLegacyWebsiteRedirect( + legacyPath: string, + mode: string | string[] | undefined, + environment: UnifiedWorkspaceRedirectEnvironment = process.env +): string | null { + const origin = getUnifiedWorkspaceRedirectOrigin(environment); + if (!origin) return null; + const resolution = resolveLegacyPath(legacyPath); + return resolution ? toWebsiteRedirect(origin, resolution, mode) : null; +} + +export function getRootWebsiteRedirect( + mode: string | string[] | undefined, + environment: UnifiedWorkspaceRedirectEnvironment = process.env +): string | null { + const origin = getUnifiedWorkspaceRedirectOrigin(environment); + if (!origin) return null; + return toWebsiteRedirect(origin, getCockpitPageModel().resolution, mode); +} + +export function normalizeRequestedMode( + mode: string | string[] | undefined +): string | null { + return Array.isArray(mode) ? mode.join(',') : mode ?? null; +} + +export function getCanonicalCockpitRedirect( + model: CockpitPageModel, + mode: string | string[] | undefined +): string { + if (typeof mode !== 'string') return model.canonicalPath; + const parsed = QUERY_MODES[mode.toLowerCase()]; + if ( + !parsed || + model.resolution.kind !== 'mapped' || + !model.resolution.identity.availableModes.includes(parsed) + ) { + return model.canonicalPath; + } + return `${model.canonicalPath}?mode=${parsed.toLowerCase()}`; +} + export function getCockpitPageModel(slug: string[] = []): CockpitPageModel { const resolvedEntry = resolveCockpitEntry({ manifest: cockpitManifest, @@ -33,10 +159,15 @@ export function getCockpitPageModel(slug: string[] = []): CockpitPageModel { page: (slug[3] ?? DEFAULT_COCKPIT_SLUG[3]) as CockpitPageId, language: (slug[4] ?? DEFAULT_COCKPIT_SLUG[4]) as CockpitLanguage, }); + const resolution: WorkspaceResolution = { + kind: 'mapped', + identity: toWorkspaceIdentity(resolvedEntry), + }; return { entry: resolvedEntry, - presentation: getCapabilityPresentation(resolvedEntry), + resolution, + presentation: getWorkspacePresentation(resolution), navigationTree: buildNavigationTree(cockpitManifest), canonicalPath: toCockpitPath(resolvedEntry), }; diff --git a/apps/cockpit/src/lib/content-bundle.spec.ts b/apps/cockpit/src/lib/content-bundle.spec.ts deleted file mode 100644 index 070553220..000000000 --- a/apps/cockpit/src/lib/content-bundle.spec.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { resolveRuntimeUrl, getContentBundle } from './content-bundle'; -import type { CapabilityPresentation } from './route-resolution'; - -// Stable mock function references, hoisted so vi.mock factories can access them -const { mockReadFileSync, mockCodeToHtml } = vi.hoisted(() => ({ - mockReadFileSync: vi.fn(), - mockCodeToHtml: vi.fn(), -})); - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - default: { ...actual, readFileSync: mockReadFileSync }, - readFileSync: mockReadFileSync, - }; -}); - -vi.mock('shiki', () => ({ - default: { codeToHtml: mockCodeToHtml }, - codeToHtml: mockCodeToHtml, -})); - -describe('resolveRuntimeUrl', () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('uses NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL when set', () => { - vi.stubEnv('NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL', 'https://examples.threadplane.ai'); - expect( - resolveRuntimeUrl({ runtimeUrl: 'langgraph/streaming', devPort: 4300 }) - ).toBe('https://examples.threadplane.ai/langgraph/streaming'); - }); - - it('falls back to localhost with devPort when no env var is set', () => { - vi.stubEnv('NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL', ''); - expect( - resolveRuntimeUrl({ runtimeUrl: 'langgraph/streaming', devPort: 4300 }) - ).toBe('http://localhost:4300'); - }); - - it('returns null when neither env var nor devPort is available', () => { - vi.stubEnv('NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL', ''); - expect( - resolveRuntimeUrl({ runtimeUrl: undefined, devPort: undefined }) - ).toBeNull(); - }); - - it('returns null when runtimeUrl is undefined even with env var set', () => { - vi.stubEnv('NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL', 'https://examples.threadplane.ai'); - expect( - resolveRuntimeUrl({ runtimeUrl: undefined, devPort: undefined }) - ).toBeNull(); - }); -}); - -describe('getContentBundle', () => { - afterEach(() => { - mockReadFileSync.mockReset(); - mockCodeToHtml.mockReset(); - vi.unstubAllEnvs(); - }); - - it('returns highlighted code and raw prompt content for a capability presentation', async () => { - mockReadFileSync.mockImplementation((filePath: unknown) => { - const p = String(filePath); - if (p.includes('index.ts')) return 'const x = 1;'; - if (p.includes('streaming.md')) return '# Streaming prompt'; - throw new Error(`ENOENT: ${filePath}`); - }); - mockCodeToHtml.mockResolvedValue('
highlighted
'); - - const presentation: CapabilityPresentation = { - kind: 'capability', - entry: {} as any, - docsPath: '/docs/test', - promptAssetPaths: ['cockpit/langgraph/streaming/python/prompts/streaming.md'], - codeAssetPaths: ['cockpit/langgraph/streaming/python/src/index.ts'], - runtimeUrl: 'langgraph/streaming', - devPort: 4300, - } as any; - - vi.stubEnv('NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL', ''); - const bundle = await getContentBundle(presentation); - - expect(Object.keys(bundle.codeFiles)).toContain('cockpit/langgraph/streaming/python/src/index.ts'); - expect(bundle.codeFiles['cockpit/langgraph/streaming/python/src/index.ts']).toBe( - '
highlighted
' - ); - expect(bundle.promptFiles).toEqual({ - 'cockpit/langgraph/streaming/python/prompts/streaming.md': '# Streaming prompt', - }); - expect(bundle.runtimeUrl).toBe('http://localhost:4300'); - expect(bundle.docSections).toEqual([]); - expect(bundle.narrativeDocs).toEqual([]); - }); - - it('returns a placeholder string when a code file is missing', async () => { - mockReadFileSync.mockImplementation(() => { - const err = new Error('ENOENT') as NodeJS.ErrnoException; - err.code = 'ENOENT'; - throw err; - }); - - const presentation: CapabilityPresentation = { - kind: 'capability', - entry: {} as any, - docsPath: '/docs/test', - promptAssetPaths: [], - codeAssetPaths: ['missing/file.ts'], - runtimeUrl: undefined, - devPort: undefined, - } as any; - - const bundle = await getContentBundle(presentation); - - expect(bundle.codeFiles['missing/file.ts']).toBe('File not found: missing/file.ts'); - expect(bundle.runtimeUrl).toBeNull(); - expect(bundle.docSections).toEqual([]); - expect(bundle.narrativeDocs).toEqual([]); - }); - - it('falls back to unhighlighted code when Shiki fails', async () => { - mockReadFileSync.mockReturnValue('const y = 2;'); - mockCodeToHtml.mockRejectedValue(new Error('Shiki error')); - - const presentation: CapabilityPresentation = { - kind: 'capability', - entry: {} as any, - docsPath: '/docs/test', - promptAssetPaths: [], - codeAssetPaths: ['some/file.ts'], - runtimeUrl: undefined, - devPort: undefined, - } as any; - - const bundle = await getContentBundle(presentation); - - expect(bundle.codeFiles['some/file.ts']).toBe( - '
const y = 2;
' - ); - expect(bundle.docSections).toEqual([]); - expect(bundle.narrativeDocs).toEqual([]); - }); - - it('returns empty maps for a docs-only presentation', async () => { - const presentation: CapabilityPresentation = { - kind: 'docs-only', - entry: {} as any, - docsPath: '/docs/test', - }; - - const bundle = await getContentBundle(presentation); - - expect(bundle.codeFiles).toEqual({}); - expect(bundle.promptFiles).toEqual({}); - expect(bundle.runtimeUrl).toBeNull(); - expect(bundle.docSections).toEqual([]); - expect(bundle.narrativeDocs).toEqual([]); - }); - - it('extracts docSections from code and backend files', async () => { - mockReadFileSync.mockImplementation((filePath: unknown) => { - const p = String(filePath); - if (p.includes('streaming.component.ts')) return '/**\n * StreamingComponent renders a chat UI.\n */\nexport class StreamingComponent {}'; - if (p.includes('graph.py')) return 'class StreamingGraph:\n """Streams LLM responses."""\n pass'; - if (p.includes('streaming.md')) return '# Prompt'; - throw new Error('ENOENT'); - }); - mockCodeToHtml.mockResolvedValue('
highlighted
'); - - const presentation = { - kind: 'capability' as const, - entry: {} as any, - docsPath: '/docs/test', - promptAssetPaths: ['prompts/streaming.md'], - codeAssetPaths: ['src/streaming.component.ts'], - backendAssetPaths: ['src/graph.py'], - runtimeUrl: undefined, - devPort: undefined, - }; - - vi.stubEnv('NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL', ''); - const bundle = await getContentBundle(presentation); - - expect(bundle.docSections).toHaveLength(2); - expect(bundle.docSections[0].title).toBe('StreamingComponent'); - expect(bundle.docSections[0].language).toBe('typescript'); - expect(bundle.docSections[1].title).toBe('StreamingGraph'); - expect(bundle.docSections[1].language).toBe('python'); - expect(bundle.narrativeDocs).toEqual([]); - }); -}); diff --git a/apps/cockpit/src/lib/extract-docs.ts b/apps/cockpit/src/lib/extract-docs.ts deleted file mode 100644 index 25002d5fd..000000000 --- a/apps/cockpit/src/lib/extract-docs.ts +++ /dev/null @@ -1,161 +0,0 @@ -export interface DocParam { - name: string; - description: string; -} - -export interface DocSection { - title: string; - signature: string; - description: string; - params: DocParam[]; - returns: string | null; - sourceFile: string; - language: 'typescript' | 'python'; -} - -function parseJsDocContent(raw: string): { description: string; params: DocParam[]; returns: string | null } { - const lines = raw.split('\n').map((line) => line.replace(/^\s*\*\s?/, '')); - const params: DocParam[] = []; - let returns: string | null = null; - const descriptionLines: string[] = []; - - for (const line of lines) { - const paramMatch = line.match(/^@param\s+(?:\{[^}]*\}\s+)?(?:-\s+)?(\w+)\s*[-–—]?\s*(.*)/); - const returnsMatch = line.match(/^@returns?\s+(.*)/); - - if (paramMatch) { - params.push({ name: paramMatch[1], description: paramMatch[2].trim() }); - } else if (returnsMatch) { - returns = returnsMatch[1].trim(); - } else if (!line.startsWith('@')) { - descriptionLines.push(line); - } - } - - return { description: descriptionLines.join('\n').trim(), params, returns }; -} - -/** - * Extracts JSDoc blocks that precede export declarations or named members. - * Captures the full signature line following the JSDoc block. - */ -export function extractTsDocSections(source: string, filePath: string): DocSection[] { - const sections: DocSection[] = []; - const lines = source.split('\n'); - - let i = 0; - while (i < lines.length) { - // Find JSDoc start - if (!lines[i].trimStart().startsWith('/**')) { i++; continue; } - - // Collect JSDoc block - const jsDocLines: string[] = []; - let j = i; - while (j < lines.length) { - jsDocLines.push(lines[j]); - if (lines[j].includes('*/')) break; - j++; - } - j++; // move past */ - - // Skip blank lines after JSDoc - while (j < lines.length && lines[j].trim() === '') j++; - - // Check if next non-blank line is a declaration we care about - if (j < lines.length) { - const nextLine = lines[j].trim(); - const declMatch = nextLine.match( - /^(?:export\s+)?(?:class|function|interface|const|type|abstract\s+class)\s+(\w+)|^(?:(?:protected|private|public|readonly)\s+)*(\w+)\s*[=(]/ - ); - - if (declMatch) { - const name = declMatch[1] ?? declMatch[2] ?? 'unknown'; - // Signature is just this one line, cleaned up - const signature = nextLine.replace(/\s*[{=]\s*$/, '').replace(/\s*\{$/, ''); - - const rawComment = jsDocLines - .join('\n') - .replace(/^\s*\/\*\*\s*/, '') - .replace(/\s*\*\/\s*$/, ''); - - const { description, params, returns } = parseJsDocContent(rawComment); - - if (description) { - sections.push({ - title: name, - signature, - description, - params, - returns, - sourceFile: filePath, - language: 'typescript', - }); - } - } - } - - i = j > i ? j : i + 1; - } - - return sections; -} - -/** - * Extracts Python docstrings from class and def declarations. - * Captures the full def/class signature line. - */ -export function extractPyDocSections(source: string, filePath: string): DocSection[] { - const sections: DocSection[] = []; - const pattern = /((?:class|def)\s+(\w+)[^\n]*):\s*\n\s*"""([\s\S]*?)"""/g; - - let match: RegExpExecArray | null; - while ((match = pattern.exec(source)) !== null) { - const signatureLine = match[1].trim(); - const name = match[2]; - const rawDocstring = match[3] - .split('\n') - .map((line) => line.replace(/^\s{4}/, '')) - .join('\n') - .trim(); - - // Parse simple rst-style params (Args: / Returns:) or just use as description - const lines = rawDocstring.split('\n'); - const descriptionLines: string[] = []; - const params: DocParam[] = []; - let returns: string | null = null; - let inArgs = false; - let inReturns = false; - - for (const line of lines) { - if (/^(Args|Arguments|Parameters)\s*:/.test(line)) { inArgs = true; inReturns = false; continue; } - if (/^(Returns?)\s*:/.test(line)) { inReturns = true; inArgs = false; continue; } - if (/^(Attributes)\s*:/.test(line)) { inArgs = true; inReturns = false; continue; } - if (/^\S/.test(line) && !inArgs && !inReturns) { - descriptionLines.push(line); - } else if (inArgs) { - const paramMatch = line.match(/^\s+(\w+)\s*(?:\([^)]*\))?\s*[-:]\s*(.*)/); - if (paramMatch) params.push({ name: paramMatch[1], description: paramMatch[2].trim() }); - } else if (inReturns) { - if (line.trim()) returns = (returns ? returns + ' ' : '') + line.trim(); - } else { - descriptionLines.push(line); - } - } - - const description = descriptionLines.join('\n').trim(); - - if (description) { - sections.push({ - title: name, - signature: signatureLine, - description, - params, - returns, - sourceFile: filePath, - language: 'python', - }); - } - } - - return sections; -} diff --git a/apps/cockpit/src/lib/route-resolution.ts b/apps/cockpit/src/lib/route-resolution.ts deleted file mode 100644 index 380a8bb1d..000000000 --- a/apps/cockpit/src/lib/route-resolution.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { - resolveManifestLanguage, - type CockpitLanguage, - type CockpitManifestEntry, -} from '@threadplane/cockpit-registry'; -import { langgraphStreamingPythonModule } from '../../../../cockpit/langgraph/streaming/python/src/index'; -import { langgraphPersistencePythonModule } from '../../../../cockpit/langgraph/persistence/python/src/index'; -import { langgraphInterruptsPythonModule } from '../../../../cockpit/langgraph/interrupts/python/src/index'; -import { langgraphMemoryPythonModule } from '../../../../cockpit/langgraph/memory/python/src/index'; -import { langgraphDurableExecutionPythonModule } from '../../../../cockpit/langgraph/durable-execution/python/src/index'; -import { langgraphSubgraphsPythonModule } from '../../../../cockpit/langgraph/subgraphs/python/src/index'; -import { langgraphTimeTravelPythonModule } from '../../../../cockpit/langgraph/time-travel/python/src/index'; -import { langgraphDeploymentRuntimePythonModule } from '../../../../cockpit/langgraph/deployment-runtime/python/src/index'; -import { langgraphClientToolsPythonModule } from '../../../../cockpit/langgraph/client-tools/python/src/index'; -import { agUiInterruptsPythonModule } from '../../../../cockpit/ag-ui/interrupts/python/src/index'; -import { agUiStreamingPythonModule } from '../../../../cockpit/ag-ui/streaming/python/src/index'; -import { agUiToolViewsPythonModule } from '../../../../cockpit/ag-ui/tool-views/python/src/index'; -import { agUiJsonRenderPythonModule } from '../../../../cockpit/ag-ui/json-render/python/src/index'; -import { agUiClientToolsPythonModule } from '../../../../cockpit/ag-ui/client-tools/python/src/index'; -import { agUiA2uiPythonModule } from '../../../../cockpit/ag-ui/a2ui/python/src/index'; -import { agUiSubagentsPythonModule } from '../../../../cockpit/ag-ui/subagents/python/src/index'; -import { deepAgentsMemoryPythonModule } from '../../../../cockpit/deep-agents/memory/python/src/index'; -import { deepAgentsPlanningPythonModule } from '../../../../cockpit/deep-agents/planning/python/src/index'; -import { deepAgentsFilesystemPythonModule } from '../../../../cockpit/deep-agents/filesystem/python/src/index'; -import { deepAgentsSubagentsPythonModule } from '../../../../cockpit/deep-agents/subagents/python/src/index'; -import { deepAgentsSkillsPythonModule } from '../../../../cockpit/deep-agents/skills/python/src/index'; -import { renderSpecRenderingPythonModule } from '../../../../cockpit/render/spec-rendering/python/src/index'; -import { renderElementRenderingPythonModule } from '../../../../cockpit/render/element-rendering/python/src/index'; -import { renderStateManagementPythonModule } from '../../../../cockpit/render/state-management/python/src/index'; -import { renderRegistryPythonModule } from '../../../../cockpit/render/registry/python/src/index'; -import { renderRepeatLoopsPythonModule } from '../../../../cockpit/render/repeat-loops/python/src/index'; -import { renderComputedFunctionsPythonModule } from '../../../../cockpit/render/computed-functions/python/src/index'; -import { chatMessagesPythonModule } from '../../../../cockpit/chat/messages/python/src/index'; -import { chatInputPythonModule } from '../../../../cockpit/chat/input/python/src/index'; -import { chatInterruptsPythonModule } from '../../../../cockpit/chat/interrupts/python/src/index'; -import { chatToolCallsPythonModule } from '../../../../cockpit/chat/tool-calls/python/src/index'; -import { chatSubagentsPythonModule } from '../../../../cockpit/chat/subagents/python/src/index'; -import { chatThreadsPythonModule } from '../../../../cockpit/chat/threads/python/src/index'; -import { chatTimelinePythonModule } from '../../../../cockpit/chat/timeline/python/src/index'; -import { chatGenerativeUiPythonModule } from '../../../../cockpit/chat/generative-ui/python/src/index'; -import { chatDebugPythonModule } from '../../../../cockpit/chat/debug/python/src/index'; -import { chatThemingPythonModule } from '../../../../cockpit/chat/theming/python/src/index'; -import { chatA2uiPythonModule } from '../../../../cockpit/chat/a2ui/python/src/index'; -import { runtimesMicrosoftAgentFrameworkPythonModule } from '../../../../cockpit/runtimes/microsoft-agent-framework/python/src/index'; -import { runtimesAwsStrandsPythonModule } from '../../../../cockpit/runtimes/aws-strands/python/src/index'; -// Mastra has no Python lane — its backend is the Node AG-UI service -// deployments/ag-ui-mastra — so its descriptor lives beside the Angular app. -import { runtimesMastraAngularModule } from '../../../../cockpit/runtimes/mastra/angular/src/index'; - -export interface ResolveCockpitEntryOptions { - manifest: CockpitManifestEntry[]; - product: CockpitManifestEntry['product']; - section: CockpitManifestEntry['section']; - topic: string; - page: CockpitManifestEntry['page']; - language: CockpitLanguage; -} - -export interface NavigationSection { - section: CockpitManifestEntry['section']; - entries: CockpitManifestEntry[]; -} - -export interface NavigationProduct { - product: CockpitManifestEntry['product']; - sections: NavigationSection[]; -} - -export type CapabilityPresentation = - | { - kind: 'docs-only'; - entry: CockpitManifestEntry; - docsPath: string; - } - | { - kind: 'capability'; - entry: CockpitManifestEntry; - docsPath: string; - promptAssetPaths: string[]; - codeAssetPaths: string[]; - backendAssetPaths: string[]; - docsAssetPaths: string[]; - runtimeUrl?: string; - devPort?: number; - }; - -/** - * Shape a `cockpit/**\/src/index.ts` descriptor must satisfy to be wired into - * the cockpit. Each example declares its own structural copy of this interface - * (standalone-examples rule), so the fields diverge: the Angular lane carries - * no backend/docs assets. Declaring the element type here keeps the registry - * heterogeneous without widening every reader to a union. - */ -export interface RegisteredCapabilityModule { - id: string; - manifestIdentity: { - product: string; - section: string; - topic: string; - page: string; - language: string; - }; - title: string; - docsPath: string; - promptAssetPaths: string[]; - codeAssetPaths: string[]; - backendAssetPaths?: string[]; - docsAssetPaths?: string[]; - runtimeUrl?: string; - devPort?: number; -} - -export const capabilityModules: RegisteredCapabilityModule[] = [ - langgraphStreamingPythonModule, - langgraphPersistencePythonModule, - langgraphInterruptsPythonModule, - langgraphMemoryPythonModule, - langgraphDurableExecutionPythonModule, - langgraphSubgraphsPythonModule, - langgraphTimeTravelPythonModule, - langgraphDeploymentRuntimePythonModule, - langgraphClientToolsPythonModule, - agUiInterruptsPythonModule, - agUiStreamingPythonModule, - agUiToolViewsPythonModule, - agUiJsonRenderPythonModule, - agUiClientToolsPythonModule, - agUiA2uiPythonModule, - agUiSubagentsPythonModule, - deepAgentsMemoryPythonModule, - deepAgentsPlanningPythonModule, - deepAgentsFilesystemPythonModule, - deepAgentsSubagentsPythonModule, - deepAgentsSkillsPythonModule, - renderSpecRenderingPythonModule, - renderElementRenderingPythonModule, - renderStateManagementPythonModule, - renderRegistryPythonModule, - renderRepeatLoopsPythonModule, - renderComputedFunctionsPythonModule, - chatMessagesPythonModule, - chatInputPythonModule, - chatInterruptsPythonModule, - chatToolCallsPythonModule, - chatSubagentsPythonModule, - chatThreadsPythonModule, - chatTimelinePythonModule, - chatGenerativeUiPythonModule, - chatDebugPythonModule, - chatThemingPythonModule, - chatA2uiPythonModule, - runtimesMicrosoftAgentFrameworkPythonModule, - runtimesAwsStrandsPythonModule, - runtimesMastraAngularModule, -]; - -export const toCockpitPath = (entry: CockpitManifestEntry): string => - `/${entry.product}/${entry.section}/${entry.topic}/${entry.page}/${entry.language}`; - -export const resolveCockpitEntry = ({ - manifest, - product, - section, - topic, - page, - language, -}: ResolveCockpitEntryOptions): CockpitManifestEntry => { - const exactEntry = manifest.find( - (entry) => - entry.product === product && - entry.section === section && - entry.topic === topic && - entry.page === page && - entry.language === language - ); - - if (exactEntry) { - return exactEntry; - } - - const canonicalEntry = manifest.find( - (entry) => - entry.product === product && - entry.section === section && - entry.topic === topic && - entry.page === page - ); - - if (canonicalEntry) { - return resolveManifestLanguage({ - manifest, - entry: canonicalEntry, - language, - }); - } - - const fallbackOverview = manifest.find( - (entry) => - entry.product === product && - entry.section === 'getting-started' && - entry.topic === 'overview' && - entry.page === 'overview' && - entry.language === 'python' - ); - - if (!fallbackOverview) { - throw new Error(`No manifest entry found for ${product}/${section}/${topic}/${page}`); - } - - return resolveManifestLanguage({ - manifest, - entry: fallbackOverview, - language, - }); -}; - -export const buildNavigationTree = ( - manifest: CockpitManifestEntry[] -): NavigationProduct[] => { - const products: CockpitManifestEntry['product'][] = [ - 'deep-agents', - 'langgraph', - 'ag-ui', - 'render', - 'chat', - 'runtimes', - ]; - const sections: CockpitManifestEntry['section'][] = [ - 'getting-started', - 'core-capabilities', - ]; - const uniqueEntries = manifest.filter( - (entry, index, entries) => - entries.findIndex( - (candidate) => - candidate.product === entry.product && - candidate.section === entry.section && - candidate.topic === entry.topic && - candidate.page === entry.page - ) === index - ); - - return products.map((product) => ({ - product, - sections: sections.map((section) => ({ - section, - entries: uniqueEntries.filter( - (entry) => entry.product === product && entry.section === section - ), - })), - })); -}; - -export const getCapabilityPresentation = ( - entry: CockpitManifestEntry -): CapabilityPresentation => { - if (entry.entryKind === 'docs-only') { - return { - kind: 'docs-only', - entry, - docsPath: entry.docsPath, - }; - } - - const matchesIdentity = (candidate: RegisteredCapabilityModule): boolean => - candidate.manifestIdentity.product === entry.product && - candidate.manifestIdentity.section === entry.section && - candidate.manifestIdentity.topic === entry.topic && - candidate.manifestIdentity.page === entry.page; - - // Prefer the module whose lane matches the requested language. Fall back to - // the topic's only module when no lane matches: a topic with no Python lane - // (runtimes/mastra) still resolves to its real assets instead of silently - // falling through to the manifest's generic, non-existent Python paths. - const module = - capabilityModules.find( - (candidate) => - matchesIdentity(candidate) && - candidate.manifestIdentity.language === entry.language - ) ?? capabilityModules.find(matchesIdentity); - - return { - kind: 'capability', - entry, - docsPath: module?.docsPath ?? entry.docsPath, - promptAssetPaths: module?.promptAssetPaths ?? entry.promptAssetPaths, - codeAssetPaths: module?.codeAssetPaths ?? entry.codeAssetPaths, - backendAssetPaths: module?.backendAssetPaths ?? [], - docsAssetPaths: module?.docsAssetPaths ?? [], - runtimeUrl: module?.runtimeUrl, - devPort: module?.devPort, - }; -}; diff --git a/apps/cockpit/src/lib/verify-shared-deployment.spec.ts b/apps/cockpit/src/lib/verify-shared-deployment.spec.ts index 5e52f8cd4..18a96b354 100644 --- a/apps/cockpit/src/lib/verify-shared-deployment.spec.ts +++ b/apps/cockpit/src/lib/verify-shared-deployment.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +// eslint-disable-next-line @nx/enforce-module-boundaries -- repo-root deployment verifier is intentionally outside an Nx project. import { DEFAULT_SMOKE_ASSISTANT_STREAM_TIMEOUT_MS, getSmokeAssistantStreamTimeoutMs, diff --git a/apps/cockpit/tsconfig.json b/apps/cockpit/tsconfig.json index 67aed5d6f..e36918031 100644 --- a/apps/cockpit/tsconfig.json +++ b/apps/cockpit/tsconfig.json @@ -14,7 +14,13 @@ "@/*": ["./src/*"], "@threadplane/design-tokens": ["../../libs/design-tokens/src/index.ts"], "@threadplane/ui-react": ["../../libs/ui-react/src/index.ts"], - "@threadplane/cockpit-registry": ["../../libs/cockpit-registry/src/index.ts"], + "@threadplane/cockpit-registry": [ + "../../libs/cockpit-registry/src/index.ts" + ], + "@threadplane/cockpit-shell": ["../../libs/cockpit-shell/src/index.ts"], + "@threadplane/workspace-react": [ + "../../libs/workspace-react/src/index.ts" + ], "@threadplane/cockpit-runtime-bridge": [ "../../libs/cockpit-runtime-bridge/src/index.ts" ], @@ -29,45 +35,6 @@ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"], "references": [ - { - "path": "../../cockpit/deep-agents/skills/python" - }, - { - "path": "../../cockpit/deep-agents/subagents/python" - }, - { - "path": "../../cockpit/deep-agents/filesystem/python" - }, - { - "path": "../../cockpit/deep-agents/planning/python" - }, - { - "path": "../../cockpit/deep-agents/memory/python" - }, - { - "path": "../../cockpit/langgraph/deployment-runtime/python" - }, - { - "path": "../../cockpit/langgraph/time-travel/python" - }, - { - "path": "../../cockpit/langgraph/subgraphs/python" - }, - { - "path": "../../cockpit/langgraph/durable-execution/python" - }, - { - "path": "../../cockpit/langgraph/memory/python" - }, - { - "path": "../../cockpit/langgraph/interrupts/python" - }, - { - "path": "../../cockpit/langgraph/persistence/python" - }, - { - "path": "../../cockpit/langgraph/streaming/python" - }, { "path": "../../libs/design-tokens" }, diff --git a/apps/website/e2e/docs-shell.spec.ts b/apps/website/e2e/docs-shell.spec.ts index 0939a345c..b0fcd811d 100644 --- a/apps/website/e2e/docs-shell.spec.ts +++ b/apps/website/e2e/docs-shell.spec.ts @@ -2,6 +2,13 @@ import { test, expect } from '@playwright/test'; const ARTICLE = '/docs/langgraph/getting-started/introduction'; +async function expectWorkspaceReady(page: import('@playwright/test').Page) { + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true', + ); +} + /** * The docs shell is one reading pane: a sticky control plane on the left, one * prose column, and a sticky TOC rail on the right. These guard the parts of @@ -13,12 +20,16 @@ test.describe('DocsTOC rail', () => { test('tracks the reading position on a hard load', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(ARTICLE); + await expectWorkspaceReady(page); await expect(page.locator('.docs-toc-link').first()).toBeVisible(); // Nothing is active at the top: the first heading is below the reading line. await expect(page.locator('.docs-toc-link[data-active]')).toHaveCount(0); - await page.evaluate(() => window.scrollTo({ top: 4000, behavior: 'instant' })); + const articleScroller = page.locator('.docs-workspace-article'); + await articleScroller.evaluate((element) => + element.scrollTo({ top: 4000, behavior: 'instant' }), + ); await expect .poll(() => page @@ -28,7 +39,9 @@ test.describe('DocsTOC rail', () => { .toEqual(['#connect-with-angular']); // ...and it follows the scroll rather than latching on the first match. - await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); + await articleScroller.evaluate((element) => + element.scrollTo({ top: 0, behavior: 'instant' }), + ); await expect.poll(() => page.locator('.docs-toc-link[data-active]').count()).toBe(0); }); @@ -53,23 +66,32 @@ test.describe('DocsTOC rail', () => { }); test.describe('docs shell layout', () => { - test('the sticky rails hold through a full-page scroll', async ({ page }) => { + test('the workspace navigation and TOC hold through a full article scroll', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(ARTICLE); + await expectWorkspaceReady(page); const navH = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')), ); const tops = async () => ({ - plane: await page.locator('.docs-control-plane').evaluate((el) => Math.round(el.getBoundingClientRect().top)), + plane: await page.locator('[data-cockpit-desktop-navigation]').evaluate((el) => Math.round(el.getBoundingClientRect().top)), toc: await page.locator('.docs-toc').evaluate((el) => Math.round(el.getBoundingClientRect().top)), }); - expect(await tops()).toEqual({ plane: navH, toc: navH }); - await page.evaluate(() => window.scrollTo({ top: 4000, behavior: 'instant' })); - expect(await tops()).toEqual({ plane: navH, toc: navH }); - await page.evaluate(() => window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' })); - expect(await tops()).toEqual({ plane: navH, toc: navH }); + const initial = await tops(); + const articleScroller = page.locator('.docs-workspace-article'); + + expect(initial.plane).toBe(navH); + expect(initial.toc).toBeGreaterThan(navH); + await articleScroller.evaluate((element) => + element.scrollTo({ top: 4000, behavior: 'instant' }), + ); + expect(await tops()).toEqual(initial); + await articleScroller.evaluate((element) => + element.scrollTo({ top: element.scrollHeight, behavior: 'instant' }), + ); + expect(await tops()).toEqual(initial); }); test('breadcrumb, prose and prev/next share one right edge', async ({ page }) => { @@ -78,9 +100,11 @@ test.describe('docs shell layout', () => { // ~500px right of the column it belongs to. await page.setViewportSize({ width: 1920, height: 1000 }); await page.goto(ARTICLE); + await expectWorkspaceReady(page); + const docsPanel = page.getByRole('region', { name: 'Docs workspace panel' }); const right = (selector: string) => - page.locator(selector).first().evaluate((el) => Math.round(el.getBoundingClientRect().right)); + docsPanel.locator(selector).evaluate((el) => Math.round(el.getBoundingClientRect().right)); const header = await right('.docs-page-header'); const article = await right('article'); diff --git a/apps/website/e2e/docs.spec.ts b/apps/website/e2e/docs.spec.ts index 3294220f1..594edc15c 100644 --- a/apps/website/e2e/docs.spec.ts +++ b/apps/website/e2e/docs.spec.ts @@ -54,8 +54,13 @@ test.describe('Docs slug page', () => { await page.setViewportSize({ width: 1024, height: 900 }); await page.goto(route); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true', + ); + const pane = page.locator( - '[data-docs-control-plane] [data-control-plane-pane]', + '[data-cockpit-desktop-navigation] [data-control-plane-pane]', ); const search = pane.getByRole('button', { name: 'Search docs' }); await expect(pane).toBeVisible(); diff --git a/apps/website/e2e/nav-height.spec.ts b/apps/website/e2e/nav-height.spec.ts index efb2eee54..d60b5ec70 100644 --- a/apps/website/e2e/nav-height.spec.ts +++ b/apps/website/e2e/nav-height.spec.ts @@ -47,26 +47,34 @@ test('the docs column starts directly under the nav at a tablet width', async ({ // The 15px overshoot showed up here as dead space above the breadcrumb. await page.setViewportSize({ width: 900, height: 800 }); await page.goto('/docs/langgraph/getting-started/introduction'); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true', + ); const navBottom = await page .locator('nav') .first() .evaluate((el) => el.getBoundingClientRect().bottom); const shellTop = await page - .locator('.docs-shell-page') - .evaluate((el) => el.getBoundingClientRect().top + parseFloat(getComputedStyle(el).paddingTop)); + .locator('.website-workspace-host .cockpit-shell') + .evaluate((el) => el.getBoundingClientRect().top); expect(Math.abs(shellTop - navBottom)).toBeLessThanOrEqual(1); }); -test('the mobile drawer hangs flush off the nav on a tablet width', async ({ page }) => { - // The drawer is positioned at `top: calc(var(--nav-h) - 1px)`, so a wrong - // --nav-h shows up here as a visible gap between the nav and the panel. +test('the workspace context drawer hangs flush off the nav at tablet width', async ({ page }) => { await page.setViewportSize({ width: 900, height: 800 }); await page.goto('/docs/langgraph/getting-started/introduction'); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true', + ); - await page.locator('.nav-hamburger').click(); - const overlay = page.locator('.nav-mobile-overlay'); + await page.getByRole('button', { name: 'Open context' }).click(); + const overlay = page.getByRole('dialog', { + name: 'Documentation control plane context', + }); await expect(overlay).toBeVisible(); const navBottom = await page @@ -75,7 +83,5 @@ test('the mobile drawer hangs flush off the nav on a tablet width', async ({ pag .evaluate((el) => el.getBoundingClientRect().bottom); const overlayTop = await overlay.evaluate((el) => el.getBoundingClientRect().top); - // Flush or overlapping the nav's bottom border — never a gap below it. - expect(overlayTop - navBottom).toBeLessThanOrEqual(0); - expect(overlayTop - navBottom).toBeGreaterThanOrEqual(-2); + expect(Math.abs(overlayTop - navBottom)).toBeLessThanOrEqual(1); }); diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index 12f0be82a..53b437899 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -225,54 +225,72 @@ for (const viewport of [ }) => { await page.setViewportSize(viewport); await page.goto(docsRoute); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true', + ); await expectNoHorizontalOverflow(page, `Docs at ${viewport.width}px`); - const desktopControlPlane = page.locator('[data-docs-control-plane]'); - const mobileTrigger = page.getByRole('button', { name: 'Open menu' }); + const desktopControlPlane = page.locator('[data-cockpit-desktop-navigation]'); + await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden(); if (viewport.width >= 1024) { await expect(desktopControlPlane).toBeVisible(); - await expect(mobileTrigger).toBeHidden(); - const runtime = desktopControlPlane.getByRole('button', { - name: 'Runtime', - exact: true, - }); - await runtime.click(); await expect( - desktopControlPlane.getByRole('link', { - name: 'Open controls in Cockpit', - }), + desktopControlPlane.locator('[data-control-plane-pane]'), + ).toBeVisible(); + await expect( + desktopControlPlane.getByRole('button', { name: 'Docs', exact: true }), + ).toBeVisible(); + await expect( + desktopControlPlane.getByRole('button', { name: 'Search docs' }), ).toBeVisible(); + } else if (viewport.width >= 768) { + await expect(desktopControlPlane).toBeVisible(); + await expect( + desktopControlPlane.locator('[data-control-plane-pane]'), + ).toBeHidden(); + const contextTrigger = page.getByRole('button', { name: 'Open context' }); + await expect(contextTrigger).toBeVisible(); + await contextTrigger.click(); + const dialog = page.getByRole('dialog', { + name: 'Documentation control plane context', + }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Search docs' })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(contextTrigger).toBeFocused(); } else { await expect(desktopControlPlane).toBeHidden(); - await expect(mobileTrigger).toBeVisible(); - const triggerBox = await mobileTrigger.boundingBox(); + const navigationTrigger = page.getByRole('button', { + name: 'Open navigation', + }); + await expect(navigationTrigger).toBeVisible(); + const triggerBox = await navigationTrigger.boundingBox(); expect(triggerBox?.width).toBeGreaterThanOrEqual(44); expect(triggerBox?.height).toBeGreaterThanOrEqual(44); - await mobileTrigger.click(); - const dialog = page.getByRole('dialog', { name: 'Mobile navigation' }); + await navigationTrigger.click(); + const dialog = page.getByRole('dialog', { + name: 'Documentation control plane', + }); await expect(dialog).toBeVisible(); - await expect(page.locator('#site-content')).toHaveAttribute('inert', ''); + await expect(page.locator('[data-cockpit-workspace]')).toHaveAttribute('inert', ''); await expect(page.locator('nav.nav-bar')).toHaveAttribute('inert', ''); - const close = dialog.getByRole('button', { name: 'Close menu' }); + const close = dialog.getByRole('button', { name: 'Close navigation' }); const closeBox = await close.boundingBox(); expect(closeBox?.width).toBeGreaterThanOrEqual(44); expect(closeBox?.height).toBeGreaterThanOrEqual(44); - const runtime = dialog.getByRole('button', { - name: 'Runtime', - exact: true, - }); - await runtime.click(); await expect( - dialog.getByRole('link', { name: 'Open controls in Cockpit' }), + dialog.getByRole('button', { name: 'Docs', exact: true }), ).toBeVisible(); await expect(dialog.getByRole('button', { name: 'Search docs' })).toBeVisible(); await page.keyboard.press('Escape'); - await expect(dialog).toBeHidden(); - await expect(mobileTrigger).toBeFocused(); + await expect(dialog).toHaveCount(0); + await expect(navigationTrigger).toBeFocused(); } }); } @@ -283,12 +301,16 @@ test('docs forced colors preserve control boundaries and keyboard focus', async await page.emulateMedia({ forcedColors: 'active' }); await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(docsRoute); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true', + ); - const runtime = page - .locator('[data-docs-control-plane]') - .getByRole('button', { name: 'Runtime', exact: true }); - await runtime.focus(); - const styles = await runtime.evaluate((element) => { + const run = page + .locator('[data-cockpit-desktop-navigation]') + .getByRole('button', { name: 'Run', exact: true }); + await run.focus(); + const styles = await run.evaluate((element) => { const style = getComputedStyle(element); return { borderWidth: style.borderTopWidth, @@ -307,19 +329,33 @@ test('docs reduced motion disables mobile drawer transitions and animations', as await page.emulateMedia({ reducedMotion: 'reduce' }); await page.setViewportSize({ width: 390, height: 844 }); await page.goto(docsRoute); - await page.getByRole('button', { name: 'Open menu' }).click(); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-hydrated', + 'true', + ); + await page.getByRole('button', { name: 'Open navigation' }).click(); - const overlay = page.locator('.nav-mobile-overlay'); + const overlay = page.getByRole('dialog', { + name: 'Documentation control plane', + }); await expect(overlay).toBeVisible(); const motion = await overlay.evaluate((element) => { - const style = getComputedStyle(element); + const panel = element.querySelector('.cockpit-mobile-control-plane-panel'); + const overlayStyle = getComputedStyle(element); + const panelStyle = panel ? getComputedStyle(panel) : null; return { - animationName: style.animationName, - transitionDuration: style.transitionDuration, + overlayAnimation: overlayStyle.animationName, + overlayTransition: overlayStyle.transitionDuration, + panelAnimation: panelStyle?.animationName, + panelTransition: panelStyle?.transitionDuration, }; }); - expect(motion.animationName).toBe('none'); - expect(motion.transitionDuration).toBe('0s'); + expect(motion).toEqual({ + overlayAnimation: 'none', + overlayTransition: '0s', + panelAnimation: 'none', + panelTransition: '0s', + }); }); test('/llms.txt returns plain text', async ({ page }) => { diff --git a/apps/website/e2e/workspace-shell.spec.ts b/apps/website/e2e/workspace-shell.spec.ts new file mode 100644 index 000000000..0f5a3cd4f --- /dev/null +++ b/apps/website/e2e/workspace-shell.spec.ts @@ -0,0 +1,480 @@ +import { expect, test, type Locator, type Page } from '@playwright/test'; + +const streamingDocsPath = '/docs/langgraph/guides/streaming'; +const persistenceDocsPath = '/docs/langgraph/guides/persistence'; +const mappedDocsOnlyPath = '/docs/langgraph/getting-started/introduction'; +const unmappedDocsOnlyPath = '/docs/langgraph/getting-started/installation'; +const workspaceOnlyPath = '/workspace/langgraph/durable-execution'; +const deepAgentsDocsPath = '/docs/deep-agents/capabilities/planning'; +const RUN_RAIL_ITEM = /^Run(?:,|$)/; + +const modeButton = (page: Page, mode: 'Docs' | 'Run' | 'Code' | 'API') => + page.locator('[data-cockpit-desktop-navigation]').getByRole('button', { + name: mode === 'Run' ? RUN_RAIL_ITEM : mode, + exact: mode !== 'Run', + }); + +const visiblePanel = (page: Page, mode: 'Docs' | 'Run' | 'Code' | 'API') => + page.locator(`[data-workspace-panel-target="${mode}"]`).filter({ + visible: true, + }); + +async function expectMode(page: Page, mode: 'Docs' | 'Run' | 'Code' | 'API') { + const shell = page.locator('[data-workspace-shell]'); + await expect(shell).toHaveAttribute('data-hydrated', 'true'); + await expect(shell).toHaveAttribute('data-workspace-mode', mode); + await expect(visiblePanel(page, mode)).toBeVisible(); +} + +async function expectNoHorizontalOverflow(page: Page, label: string) { + const overflow = await page.evaluate( + () => + document.documentElement.scrollWidth - + document.documentElement.clientWidth + ); + expect(overflow, label).toBeLessThanOrEqual(1); +} + +async function markRuntimeFrame(frame: Locator) { + await frame.evaluate((element) => { + element.setAttribute('data-e2e-runtime-frame', crypto.randomUUID()); + }); + return frame.getAttribute('data-e2e-runtime-frame'); +} + +test.describe('workspace shell', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + const hideDevelopmentIndicator = () => { + document + .querySelectorAll('nextjs-portal') + .forEach((portal) => { + portal.style.setProperty('display', 'none', 'important'); + }); + }; + document.addEventListener( + 'DOMContentLoaded', + () => { + hideDevelopmentIndicator(); + new MutationObserver(hideDevelopmentIndicator).observe( + document.documentElement, + { childList: true, subtree: true } + ); + }, + { once: true } + ); + }); + }); + test('moves Docs to Run to Code to API to Docs without replacing the runtime frame', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(streamingDocsPath); + await expectMode(page, 'Docs'); + + await modeButton(page, 'Run').click(); + await expect(page).toHaveURL(`${streamingDocsPath}?mode=run`); + await expectMode(page, 'Run'); + await expect(page.getByText('Ready', { exact: true })).toBeVisible(); + + const frame = page.locator( + 'iframe[title="LangGraph Streaming live example"]' + ); + await expect(frame).toBeVisible(); + const frameIdentity = await markRuntimeFrame(frame); + expect(frameIdentity).toBeTruthy(); + + for (const mode of ['Code', 'API', 'Docs'] as const) { + await modeButton(page, mode).click(); + await expect(page).toHaveURL( + mode === 'Docs' + ? streamingDocsPath + : `${streamingDocsPath}?mode=${mode.toLowerCase()}` + ); + await expectMode(page, mode); + await expect( + page.locator(`iframe[data-e2e-runtime-frame="${frameIdentity}"]`) + ).toBeAttached(); + } + }); + + test('restores mode and capability navigation through Back and Forward', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(streamingDocsPath); + const shell = page.locator('[data-workspace-shell]'); + await shell.evaluate((element) => { + element.setAttribute('data-e2e-shell-lifetime', 'original'); + }); + + await modeButton(page, 'Run').click(); + await expect(page).toHaveURL(`${streamingDocsPath}?mode=run`); + await expectMode(page, 'Run'); + await modeButton(page, 'Code').click(); + await expect(page).toHaveURL(`${streamingDocsPath}?mode=code`); + await expectMode(page, 'Code'); + + await page.goBack(); + await expect(page).toHaveURL(`${streamingDocsPath}?mode=run`); + await expectMode(page, 'Run'); + await page.goForward(); + await expect(page).toHaveURL(`${streamingDocsPath}?mode=code`); + await expectMode(page, 'Code'); + + await page.getByRole('link', { name: 'Persistence', exact: true }).click(); + await expect(page).toHaveURL(persistenceDocsPath); + await expectMode(page, 'Docs'); + await expect(shell).toHaveAttribute('data-e2e-shell-lifetime', 'original'); + await page + .locator('[data-cockpit-desktop-navigation]') + .getByRole('button', { name: 'Activity', exact: true }) + .click(); + await expect(page.getByText('Mode changed to Code')).toBeVisible(); + await expect( + page.locator('[data-activity-capability]').first() + ).toContainText('streaming'); + await page + .locator('[data-cockpit-desktop-navigation]') + .getByRole('button', { name: 'Activity', exact: true }) + .click(); + await page.goBack(); + await expect(page).toHaveURL(`${streamingDocsPath}?mode=code`); + await expectMode(page, 'Code'); + await expect(shell).toHaveAttribute('data-e2e-shell-lifetime', 'original'); + await page.goForward(); + await expect(page).toHaveURL(persistenceDocsPath); + await expectMode(page, 'Docs'); + await expect(shell).toHaveAttribute('data-e2e-shell-lifetime', 'original'); + }); + + for (const query of ['mode=run&mode=code', 'mode=invalid']) { + test(`normalizes ${query} to the canonical Docs URL`, async ({ page }) => { + await page.goto(`${streamingDocsPath}?${query}`); + await expect(page).toHaveURL(streamingDocsPath); + await expectMode(page, 'Docs'); + }); + } + + test('normalizes a valid but unavailable mode and explains docs-only controls', async ({ + page, + }) => { + await page.goto(`${mappedDocsOnlyPath}?mode=run`); + await expect(page).toHaveURL(mappedDocsOnlyPath); + await expectMode(page, 'Docs'); + await expect(modeButton(page, 'Run')).toHaveAttribute( + 'aria-disabled', + 'true' + ); + + await page.goto(`${unmappedDocsOnlyPath}?mode=api`); + await expect(page).toHaveURL(unmappedDocsOnlyPath); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'data-workspace-kind', + 'docs-only' + ); + await expectMode(page, 'Docs'); + for (const mode of ['Run', 'Code', 'API'] as const) { + await expect(modeButton(page, mode)).toHaveAttribute( + 'aria-disabled', + 'true' + ); + await expect(modeButton(page, mode)).toHaveAccessibleDescription( + new RegExp( + `${mode} is unavailable because this page has no workspace capability`, + 'i' + ) + ); + } + }); + + test('uses workspace fallbacks only when a shared Docs path would lose identity', async ({ + page, + }) => { + const response = await page.goto(workspaceOnlyPath); + expect(response?.status()).toBe(200); + await expect(page).toHaveURL(workspaceOnlyPath); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'aria-label', + 'Website workspace' + ); + await expectMode(page, 'Run'); + + await page.goto(deepAgentsDocsPath); + await expect(page).toHaveURL(deepAgentsDocsPath); + await expect(page.locator('[data-workspace-shell]')).toHaveAttribute( + 'aria-label', + 'Documentation workspace' + ); + await expectMode(page, 'Docs'); + await expect( + page.locator('iframe[title="Deep Agents Planning live example"]') + ).toBeAttached(); + }); + + test('renders the full desktop rail and context at the 64rem breakpoint', async ({ + page, + }) => { + await page.setViewportSize({ width: 1024, height: 900 }); + await page.goto(streamingDocsPath); + await expectNoHorizontalOverflow(page, 'desktop workspace'); + + const desktop = page.locator('[data-cockpit-desktop-navigation]'); + await expect(desktop).toBeVisible(); + await expect(desktop.locator('[data-control-plane-rail]')).toBeVisible(); + await expect(desktop.locator('[data-control-plane-pane]')).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Open context' }) + ).toBeHidden(); + await expect( + page.getByRole('button', { name: 'Open navigation' }) + ).toBeHidden(); + }); + + test('uses tablet disclosure, focuses destinations, and restores utility focus', async ({ + page, + }) => { + await page.setViewportSize({ width: 800, height: 900 }); + await page.goto(streamingDocsPath); + await expectNoHorizontalOverflow(page, 'tablet workspace'); + + const desktop = page.locator('[data-cockpit-desktop-navigation]'); + await expect(desktop.locator('[data-control-plane-rail]')).toBeVisible(); + await expect(desktop.locator('[data-control-plane-pane]')).toBeHidden(); + const contextTrigger = page.getByRole('button', { name: 'Open context' }); + await expect(contextTrigger).toBeVisible(); + + const activity = desktop.getByRole('button', { name: 'Activity' }); + await activity.click(); + const dialog = page.getByRole('dialog', { + name: 'Documentation control plane context', + }); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole('heading', { name: 'Activity' }) + ).toBeFocused(); + + const settings = desktop.getByRole('button', { name: 'Settings' }); + await settings.click(); + await expect( + dialog.getByRole('heading', { name: 'Settings' }) + ).toBeFocused(); + await page.keyboard.press('Escape'); + await expect( + dialog.getByRole('heading', { name: 'Settings' }) + ).toBeHidden(); + await expect(settings).toBeFocused(); + + await modeButton(page, 'Code').click(); + await expect(dialog).toBeHidden(); + await expectMode(page, 'Code'); + await expect(visiblePanel(page, 'Code')).toBeFocused(); + + await contextTrigger.click(); + await dialog + .getByRole('link', { name: 'Persistence', exact: true }) + .click(); + await expect(page).toHaveURL(persistenceDocsPath); + await expect(dialog).toBeHidden(); + await expectMode(page, 'Docs'); + await expect(visiblePanel(page, 'Docs')).toBeFocused(); + }); + + test('uses a modal control plane below 48rem and restores Escape focus', async ({ + page, + }) => { + await page.setViewportSize({ width: 767, height: 844 }); + await page.goto(streamingDocsPath); + await expectNoHorizontalOverflow(page, 'mobile workspace'); + + await expect( + page.locator('[data-cockpit-desktop-navigation]') + ).toBeHidden(); + await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden(); + const trigger = page.getByRole('button', { name: 'Open navigation' }); + await expect(trigger).toBeVisible(); + await page.evaluate(() => { + const element = document.createElement('div'); + element.className = 'toast-root'; + element.setAttribute('data-announcement-toast', ''); + element.setAttribute('data-mounted', ''); + element.textContent = 'Visible announcement fixture'; + document + .querySelector('[data-announcement-region]') + ?.appendChild(element); + }); + const announcement = page.locator('[data-announcement-toast]'); + const announcementRegion = page.locator('[data-announcement-region]'); + await expect(announcement).toBeVisible(); + await trigger.click(); + + const dialog = page.getByRole('dialog', { + name: 'Documentation control plane', + }); + const globalNavigation = page.locator('[data-site-navigation]'); + await expect(dialog).toBeVisible(); + await expect(dialog).toHaveAttribute('aria-modal', 'true'); + await expect(globalNavigation).toHaveAttribute('inert', ''); + await expect(announcementRegion).toHaveAttribute('inert', ''); + await expect(announcementRegion).toHaveAttribute( + 'data-workspace-modal-hidden', + '' + ); + await expect(announcement).toBeHidden(); + await page.evaluate(() => { + const lateToast = document.createElement('button'); + lateToast.setAttribute('data-late-announcement', ''); + lateToast.textContent = 'Late announcement fixture'; + document + .querySelector('[data-announcement-region]') + ?.appendChild(lateToast); + }); + const lateAnnouncement = page.locator('[data-late-announcement]'); + await expect(lateAnnouncement).toBeHidden(); + await expect(page.locator('[data-cockpit-workspace]')).toHaveAttribute( + 'inert', + '' + ); + + await dialog.getByRole('button', { name: RUN_RAIL_ITEM }).click(); + await expect(dialog).toBeHidden(); + await expectMode(page, 'Run'); + await expect(visiblePanel(page, 'Run')).toBeFocused(); + + await trigger.click(); + await expect(dialog).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveAttribute('data-state', 'closing'); + await expect(globalNavigation).toHaveAttribute('inert', ''); + await expect(announcementRegion).toHaveAttribute('inert', ''); + await expect(lateAnnouncement).toBeHidden(); + await expect(dialog).toHaveCount(0); + await expect(globalNavigation).not.toHaveAttribute('inert', ''); + await expect(announcementRegion).not.toHaveAttribute('inert', ''); + await expect(announcementRegion).not.toHaveAttribute( + 'data-workspace-modal-hidden', + '' + ); + await expect(announcement).toBeVisible(); + await expect(lateAnnouncement).toBeVisible(); + await expect(trigger).toBeFocused(); + await announcement.evaluate((element) => element.remove()); + await lateAnnouncement.evaluate((element) => element.remove()); + }); + + test('defers mobile Learn navigation and focuses each destination heading', async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(streamingDocsPath); + + await page.getByRole('button', { name: 'Open navigation' }).click(); + let dialog = page.getByRole('dialog', { + name: 'Documentation control plane', + }); + await dialog.getByRole('link', { name: 'Streaming', exact: true }).click(); + await expect(page).toHaveURL(streamingDocsPath); + await expect(dialog).toHaveCount(0); + await expect(visiblePanel(page, 'Docs')).toBeFocused(); + + await page.getByRole('button', { name: 'Open navigation' }).click(); + dialog = page.getByRole('dialog', { + name: 'Documentation control plane', + }); + await dialog + .getByRole('link', { name: 'Persistence', exact: true }) + .click(); + await expect(page).toHaveURL(persistenceDocsPath); + await expect(dialog).toHaveCount(0); + await expect(visiblePanel(page, 'Docs')).toBeFocused(); + + await page.getByRole('button', { name: 'Open navigation' }).click(); + dialog = page.getByRole('dialog', { + name: 'Documentation control plane', + }); + await dialog + .getByRole('link', { name: 'Choosing an adapter', exact: true }) + .click(); + await expect(page).toHaveURL('/docs/choosing-an-adapter'); + await expect(dialog).toHaveCount(0); + await expect(page.locator('main h1').first()).toBeFocused(); + }); + + test('keeps Learn and visible Search in mapped and unmapped mobile Docs context', async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + + for (const path of [streamingDocsPath, unmappedDocsOnlyPath]) { + await page.goto(path); + await page.getByRole('button', { name: 'Open navigation' }).click(); + const dialog = page.getByRole('dialog', { + name: 'Documentation control plane', + }); + await expect(dialog.getByText('Learn', { exact: true })).toBeVisible(); + await dialog.getByRole('button', { name: 'Search docs' }).click(); + await expect(dialog).toHaveCount(0); + await expect( + page.getByRole('dialog', { name: 'Search documentation' }) + ).toBeVisible(); + await page.keyboard.press('Escape'); + } + }); + + test('preserves visible boundaries and focus in forced colors', async ({ + page, + }) => { + await page.emulateMedia({ forcedColors: 'active' }); + await page.setViewportSize({ width: 1024, height: 900 }); + await page.goto(streamingDocsPath); + + const run = modeButton(page, 'Run'); + await run.focus(); + const styles = await run.evaluate((element) => { + const style = getComputedStyle(element); + return { + borderWidth: style.borderTopWidth, + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + }; + }); + expect(Number.parseFloat(styles.borderWidth)).toBeGreaterThan(0); + expect(styles.outlineStyle).not.toBe('none'); + expect(Number.parseFloat(styles.outlineWidth)).toBeGreaterThan(0); + }); + + test('removes mobile control-plane motion when reduced motion is requested', async ({ + page, + }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(streamingDocsPath); + await page.getByRole('button', { name: 'Open navigation' }).click(); + + const dialog = page.getByRole('dialog', { + name: 'Documentation control plane', + }); + await expect(dialog).toBeVisible(); + const motion = await dialog.evaluate((element) => { + const panel = element.querySelector( + '.cockpit-mobile-control-plane-panel' + ); + const overlayStyle = getComputedStyle(element); + const panelStyle = panel ? getComputedStyle(panel) : null; + return { + overlayAnimation: overlayStyle.animationName, + overlayTransition: overlayStyle.transitionDuration, + panelAnimation: panelStyle?.animationName, + panelTransition: panelStyle?.transitionDuration, + }; + }); + expect(motion).toEqual({ + overlayAnimation: 'none', + overlayTransition: '0s', + panelAnimation: 'none', + panelTransition: '0s', + }); + }); +}); diff --git a/apps/website/next.config.ts b/apps/website/next.config.ts index b67ebc604..d448ea2d9 100644 --- a/apps/website/next.config.ts +++ b/apps/website/next.config.ts @@ -1,10 +1,24 @@ import { composePlugins, withNx } from '@nx/next'; import type { WithNxOptions } from '@nx/next/plugins/with-nx'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const websiteAppDir = dirname(fileURLToPath(import.meta.url)); export const nextConfig: WithNxOptions = { // Use this to set Nx-specific options // See: https://nx.dev/recipes/next/next-config-setup nx: {}, + outputFileTracingRoot: join(websiteAppDir, '../..'), + outputFileTracingIncludes: { + '/*': [ + '../../cockpit/**/*.md', + '../../cockpit/**/*.py', + '../../cockpit/**/*.ts', + '../../deployments/ag-ui-mastra/*.mjs', + '../../nx.json', + ], + }, skipTrailingSlashRedirect: true, rewrites: async () => [ { @@ -16,6 +30,20 @@ export const nextConfig: WithNxOptions = { destination: 'https://us.i.posthog.com/:path*', }, ], + headers: async () => [ + { + source: '/ingest/:path*', + headers: [ + { key: 'Access-Control-Allow-Origin', value: '*' }, + { key: 'Access-Control-Allow-Methods', value: 'POST, OPTIONS' }, + { + key: 'Access-Control-Allow-Headers', + value: 'Content-Type, Authorization', + }, + { key: 'Access-Control-Max-Age', value: '86400' }, + ], + }, + ], }; const plugins = [ diff --git a/apps/website/playwright.config.ts b/apps/website/playwright.config.ts index d87b83c4e..7e7d834b3 100644 --- a/apps/website/playwright.config.ts +++ b/apps/website/playwright.config.ts @@ -3,9 +3,11 @@ import { defineConfig, devices } from '@playwright/test'; const localHost = '127.0.0.1'; const localPort = process.env['WEBSITE_E2E_PORT'] ?? '4308'; const localURL = `http://${localHost}:${localPort}`; +const runtimeURL = 'http://localhost:4300'; const baseURL = process.env['BASE_URL'] ?? localURL; const shouldStartLocalServer = !process.env['BASE_URL']; -const reuseExistingServer = process.env['PLAYWRIGHT_REUSE_EXISTING_SERVER'] === 'true'; +const reuseExistingServer = + process.env['PLAYWRIGHT_REUSE_EXISTING_SERVER'] === 'true'; export default defineConfig({ testDir: './e2e', @@ -26,10 +28,20 @@ export default defineConfig({ }, ], webServer: shouldStartLocalServer - ? { - command: `npx next dev . --hostname ${localHost} --port ${localPort}`, - url: localURL, - reuseExistingServer, - } + ? [ + { + command: `NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL='' npx next dev apps/website --hostname ${localHost} --port ${localPort}`, + cwd: '../..', + url: localURL, + reuseExistingServer, + }, + { + command: + 'npx nx run cockpit-langgraph-streaming-angular:serve:cockpit --port 4300', + cwd: '../..', + url: runtimeURL, + reuseExistingServer, + }, + ] : undefined, }); diff --git a/apps/website/project.json b/apps/website/project.json index f14abd9a8..3352773ec 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -3,6 +3,10 @@ "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/website/src", "projectType": "application", + "implicitDependencies": [ + "workspace-react", + "cockpit-langgraph-streaming-angular" + ], "tags": [ "scope:website", "scope:website-e2e", diff --git a/apps/website/src/app/api/ingest/route.spec.ts b/apps/website/src/app/api/ingest/route.spec.ts index f526f1606..4212b79ff 100644 --- a/apps/website/src/app/api/ingest/route.spec.ts +++ b/apps/website/src/app/api/ingest/route.spec.ts @@ -9,7 +9,7 @@ vi.mock('posthog-node', () => ({ }), })); -import { POST } from './route'; +import { OPTIONS, POST } from './route'; describe('/api/ingest', () => { beforeEach(() => { @@ -30,6 +30,7 @@ describe('/api/ingest', () => { }) as never); expect(response.status).toBe(202); + expect(response.headers.get('access-control-allow-origin')).toBe('*'); expect(capture).toHaveBeenCalledWith({ distinctId: 'browser:test', event: 'tplane:browser_chat_init', @@ -40,4 +41,30 @@ describe('/api/ingest', () => { }, }); }); + + it('answers runtime telemetry preflight with the complete CORS contract', async () => { + const response = await OPTIONS(); + + expect(response.status).toBe(204); + expect(response.headers.get('access-control-allow-origin')).toBe('*'); + expect(response.headers.get('access-control-allow-methods')).toBe( + 'POST, OPTIONS' + ); + expect(response.headers.get('access-control-allow-headers')).toBe( + 'Content-Type, Authorization' + ); + expect(response.headers.get('access-control-max-age')).toBe('86400'); + }); + + it('returns CORS headers on rejected telemetry requests too', async () => { + const response = await POST( + new Request('https://threadplane.ai/api/ingest', { + method: 'POST', + body: '{bad json', + }) as never + ); + + expect(response.status).toBe(400); + expect(response.headers.get('access-control-allow-origin')).toBe('*'); + }); }); diff --git a/apps/website/src/app/api/ingest/route.ts b/apps/website/src/app/api/ingest/route.ts index c14dd09ec..6bb36ffdf 100644 --- a/apps/website/src/app/api/ingest/route.ts +++ b/apps/website/src/app/api/ingest/route.ts @@ -3,6 +3,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { normalizePostHogHost, toSafeAnalyticsString } from '@threadplane/telemetry/shared'; const PUBLIC_INGEST_KEY = 'phc_public_cacheplane_telemetry'; +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', +} as const; interface TelemetryIngestPayload { key?: unknown; @@ -45,19 +51,40 @@ function readPayload(value: unknown): { }; } +function jsonWithCors(body: unknown, init: { status: number }): NextResponse { + return NextResponse.json(body, { + ...init, + headers: CORS_HEADERS, + }); +} + +export function OPTIONS(): NextResponse { + return new NextResponse(null, { status: 204, headers: CORS_HEADERS }); +} + export async function POST(req: NextRequest) { let body: unknown; try { body = await req.json(); } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + return jsonWithCors({ error: 'Invalid JSON' }, { status: 400 }); } const payload = readPayload(body); - if (!payload) return NextResponse.json({ error: 'Invalid telemetry payload' }, { status: 400 }); + if (!payload) { + return jsonWithCors( + { error: 'Invalid telemetry payload' }, + { status: 400 } + ); + } const posthog = getPostHogClient(); - if (!posthog) return NextResponse.json({ error: 'Telemetry ingest is not configured' }, { status: 503 }); + if (!posthog) { + return jsonWithCors( + { error: 'Telemetry ingest is not configured' }, + { status: 503 } + ); + } try { posthog.capture({ @@ -70,10 +97,13 @@ export async function POST(req: NextRequest) { }, }); await posthog.shutdown(); - return NextResponse.json({ ok: true }, { status: 202 }); + return jsonWithCors({ ok: true }, { status: 202 }); } catch (err) { console.error('[telemetry-ingest] capture failed:', err); await posthog.shutdown().catch(() => undefined); - return NextResponse.json({ error: 'Telemetry ingest failed' }, { status: 502 }); + return jsonWithCors( + { error: 'Telemetry ingest failed' }, + { status: 502 } + ); } } diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx new file mode 100644 index 000000000..25f9cd321 --- /dev/null +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.spec.tsx @@ -0,0 +1,101 @@ +import { isValidElement, type ComponentType, type ReactNode } from 'react'; +import { describe, expect, it } from 'vitest'; +import { DocsBreadcrumb } from '../../../../../components/docs/DocsBreadcrumb'; +import { DocsPageHeader } from '../../../../../components/docs/DocsPageHeader'; +import { DocsTOC } from '../../../../../components/docs/DocsTOC'; +import { MdxRenderer } from '../../../../../components/docs/MdxRenderer'; +import { WebsiteWorkspace } from '../../../../../components/workspace/WebsiteWorkspace'; +import DocsPage, { generateMetadata } from './page'; + +interface ElementProps { + children?: ReactNode; + docsSlot?: ReactNode; + requestedMode?: string | null; + resolution?: { kind?: string; identity?: { availableModes?: string[] } }; + contentBundle?: { runtimeUrl?: string | null }; +} + +function findElement( + node: ReactNode, + type: ComponentType +): React.ReactElement | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = findElement(child, type); + if (found) return found; + } + return null; + } + if (!isValidElement(node)) return null; + if (node.type === type) return node; + return findElement(node.props.children, type); +} + +const route = (library: string, section: string, slug: string, mode?: string) => + DocsPage({ + params: Promise.resolve({ library, section, slug }), + searchParams: Promise.resolve(mode ? { mode } : {}), + } as never); + +describe('unified docs workspace route', () => { + it('passes mapped descriptor-backed content and the requested mode to the client boundary', async () => { + const tree = await route('langgraph', 'guides', 'streaming', 'code'); + const workspace = findElement( + tree, + WebsiteWorkspace as ComponentType + ); + + expect(workspace).toBeTruthy(); + // Search state belongs to the client workspace adapter so this canonical + // Docs route remains statically generated. + expect(workspace?.props.requestedMode).toBeUndefined(); + expect(workspace?.props.resolution).toMatchObject({ + kind: 'mapped', + identity: { availableModes: ['Docs', 'Run', 'Code', 'API'] }, + }); + expect(workspace?.props.contentBundle?.runtimeUrl).toMatch( + /(?:langgraph\/streaming|localhost:4300)$/ + ); + expect(workspace?.props.docsContext).toEqual({ + activeLibrary: 'langgraph', + activeSection: 'guides', + activeSlug: 'streaming', + pageTitle: 'Streaming', + }); + }); + + it('keeps an unmapped page as a complete server Docs slot', async () => { + const tree = await route('langgraph', 'guides', 'testing', 'run'); + const workspace = findElement( + tree, + WebsiteWorkspace as ComponentType + ); + const slot = workspace?.props.docsSlot; + + expect(workspace?.props.resolution).toMatchObject({ kind: 'docs-only' }); + expect( + findElement(slot, DocsBreadcrumb as ComponentType) + ).toBeTruthy(); + expect( + findElement(slot, DocsPageHeader as ComponentType) + ).toBeTruthy(); + expect(findElement(slot, MdxRenderer as ComponentType)).toBeTruthy(); + expect(findElement(slot, DocsTOC as ComponentType)).toBeTruthy(); + }); + + it('keeps canonical metadata independent of the workspace mode query', async () => { + const metadata = await generateMetadata({ + params: Promise.resolve({ + library: 'langgraph', + section: 'guides', + slug: 'streaming', + }), + searchParams: Promise.resolve({ mode: 'run' }), + } as never); + + expect(metadata.alternates?.canonical).toBe( + '/docs/langgraph/guides/streaming' + ); + expect(String(metadata.alternates?.canonical)).not.toContain('mode'); + }); +}); diff --git a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx index eef5319c5..5f3c9ae1f 100644 --- a/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx +++ b/apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { DocsControlPlane } from '../../../../../components/docs/DocsControlPlane'; import { MdxRenderer } from '../../../../../components/docs/MdxRenderer'; import { DocsSearch } from '../../../../../components/docs/DocsSearch'; import { DocsBreadcrumb } from '../../../../../components/docs/DocsBreadcrumb'; @@ -15,19 +14,48 @@ import { resolveDocDescription, } from '../../../../../lib/docs'; import { JsonLd } from '../../../../../components/shared/JsonLd'; -import { breadcrumbJsonLd, techArticleJsonLd } from '../../../../../lib/structured-data'; +import { + breadcrumbJsonLd, + techArticleJsonLd, +} from '../../../../../lib/structured-data'; import { getDocLastModified } from '../../../../../lib/sitemap-dates'; -import { ApiDocRenderer, type ApiDocEntry } from '../../../../../components/docs/ApiDocRenderer'; +import { + ApiDocRenderer, + type ApiDocEntry, +} from '../../../../../components/docs/ApiDocRenderer'; import { DocsTOC } from '../../../../../components/docs/DocsTOC'; import { extractHeadings } from '../../../../../lib/extract-headings'; -import { findDocsPage, getLibraryConfig, libraryIntroPath, type LibraryId } from '../../../../../lib/docs-config'; +import { + findDocsPage, + getLibraryConfig, + libraryIntroPath, + type LibraryId, +} from '../../../../../lib/docs-config'; +import { WebsiteWorkspace } from '../../../../../components/workspace/WebsiteWorkspace'; +import { getWebsiteWorkspacePage } from '../../../../../lib/workspace-page'; import fs from 'fs'; import path from 'path'; function loadApiDocs(library: string): ApiDocEntry[] { const candidates = [ - path.join(process.cwd(), 'apps', 'website', 'content', 'docs', library, 'api', 'api-docs.json'), - path.join(process.cwd(), 'content', 'docs', library, 'api', 'api-docs.json'), + path.join( + process.cwd(), + 'apps', + 'website', + 'content', + 'docs', + library, + 'api', + 'api-docs.json' + ), + path.join( + process.cwd(), + 'content', + 'docs', + library, + 'api', + 'api-docs.json' + ), ]; for (const p of candidates) { if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf8')); @@ -40,15 +68,23 @@ interface DocsRouteProps { } export function generateStaticParams() { - return getAllDocSlugs().map(({ library, section, slug }) => ({ library, section, slug })); + return getAllDocSlugs().map(({ library, section, slug }) => ({ + library, + section, + slug, + })); } -export async function generateMetadata({ params }: DocsRouteProps): Promise { +export async function generateMetadata({ + params, +}: DocsRouteProps): Promise { const { library, section, slug } = await params; - return getDocMetadata(library, section, slug) ?? { - title: 'Docs — Threadplane', - description: DEFAULT_DOCS_DESCRIPTION, - }; + return ( + getDocMetadata(library, section, slug) ?? { + title: 'Docs — Threadplane', + description: DEFAULT_DOCS_DESCRIPTION, + } + ); } export default async function DocsPage({ params }: DocsRouteProps) { @@ -62,6 +98,10 @@ export default async function DocsPage({ params }: DocsRouteProps) { const pathname = `/docs/${library}/${section}/${slug}`; const headings = extractHeadings(doc.body); + const workspacePage = await getWebsiteWorkspacePage({ + docsPath: pathname, + title: doc.title, + }); const articleData = techArticleJsonLd({ title: doc.title, @@ -85,62 +125,100 @@ export default async function DocsPage({ params }: DocsRouteProps) { { name: doc.title, pathname }, ]); - return ( -
- - + const docsSlot = ( +
- -
+
{/* Same measure as the article and the prev/next rail below it, so the - * whole column shares one right edge. Without md:max-w-3xl this - * block stretched to the full content width and PageActions floated - * ~500px right of the prose it belongs to (1272px vs 768px at - * 1920). */} + * whole column shares one right edge. Without md:max-w-3xl this + * block stretched to the full content width and PageActions floated + * ~500px right of the prose it belongs to (1272px vs 768px at + * 1920). */}
- + } + actions={ + + } />
- {section === 'api' && (() => { - const entries = loadApiDocs(library); - const target = doc.title.replace(/\(\)$/, ''); - const byName = (name: string) => - entries.find((e: ApiDocEntry) => e.name === name); + {section === 'api' && + (() => { + const entries = loadApiDocs(library); + const target = doc.title.replace(/\(\)$/, ''); + const byName = (name: string) => + entries.find((e: ApiDocEntry) => e.name === name); - // A page normally documents the one export named by its H1. Pages - // covering a group of exports declare them via `apiEntries`. - const configured = findDocsPage(library, section, slug)?.apiEntries; - const rendered = configured - ? configured.map(byName).filter((e): e is ApiDocEntry => Boolean(e)) - : [byName(target) ?? byName(doc.title)].filter((e): e is ApiDocEntry => Boolean(e)); + // A page normally documents the one export named by its H1. Pages + // covering a group of exports declare them via `apiEntries`. + const configured = findDocsPage( + library, + section, + slug + )?.apiEntries; + const rendered = configured + ? configured + .map(byName) + .filter((e): e is ApiDocEntry => Boolean(e)) + : [byName(target) ?? byName(doc.title)].filter( + (e): e is ApiDocEntry => Boolean(e) + ); - return rendered.length > 0 ? ( -
- {rendered.map((entry) => ( - - ))} -
- ) : null; - })()} + return rendered.length > 0 ? ( +
+ {rendered.map((entry) => ( + + ))} +
+ ) : null; + })()}
- +
); + + return ( + <> + + + + + ); } diff --git a/apps/website/src/app/global.css b/apps/website/src/app/global.css index f93e1e61e..be756c59d 100644 --- a/apps/website/src/app/global.css +++ b/apps/website/src/app/global.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "@threadplane/design-tokens/theme.css"; +@import "../../../../libs/workspace-react/src/styles/workspace.css"; /* * Scope files for the inline-style migration. These MUST sit with the other @@ -15,6 +16,10 @@ @import "../styles/marketing.css"; @import "../styles/pages.css"; +/* Shared workspace components live outside this app's automatic content + * boundary. Keep their Tailwind utilities in the Website production build. */ +@source "../../../../libs/workspace-react/src"; + * { box-sizing: border-box; } diff --git a/apps/website/src/app/layout.tsx b/apps/website/src/app/layout.tsx index b535dcb84..dd412c7a1 100644 --- a/apps/website/src/app/layout.tsx +++ b/apps/website/src/app/layout.tsx @@ -1,9 +1,11 @@ import type { Metadata } from 'next'; import { EB_Garamond, Inter, JetBrains_Mono } from 'next/font/google'; +import '@threadplane/design-tokens/tokens.css'; import './global.css'; import { Nav } from '../components/shared/Nav'; import { SiteFooter } from '../components/shared/SiteFooter'; import { AnnouncementToast } from '../components/shared/AnnouncementToast'; +import { WebsiteWorkspaceLayout } from '../components/workspace/WebsiteWorkspace'; import { JsonLd } from '../components/shared/JsonLd'; import { rootJsonLd } from '../lib/structured-data'; import { @@ -55,9 +57,16 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { return ( - + {/* Site-wide structured data, mounted once here so it is present on every @@ -69,9 +78,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })