diff --git a/.claude/launch.json b/.claude/launch.json index 344aa68fb..ff4c563eb 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,12 @@ "runtimeExecutable": "/bin/bash", "runtimeArgs": ["-c", "export PATH=/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH && npx nx serve website"], "port": 3000 + }, + { + "name": "cockpit", + "runtimeExecutable": "/bin/bash", + "runtimeArgs": ["-c", "export PATH=/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH && npx nx serve cockpit --port 4201"], + "port": 4201 } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c41fa320d..d2f0b0b33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,18 @@ jobs: - run: npx nx build cockpit --skip-nx-cache - run: npx nx test cockpit --skip-nx-cache + cockpit-examples-build: + name: Cockpit β€” build all examples + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6.3.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx nx run-many -t build --projects='cockpit-*-angular' --skip-nx-cache + cockpit-smoke: name: Cockpit β€” representative capability smoke runs-on: ubuntu-latest @@ -150,7 +162,7 @@ jobs: deploy: name: Deploy β†’ Vercel - needs: [library, website, cockpit, cockpit-smoke, cockpit-secret-integration, cockpit-deploy-smoke, mcp, chat-agent-smoke, cockpit-e2e, website-e2e] + needs: [library, website, cockpit, cockpit-examples-build, cockpit-smoke, cockpit-secret-integration, cockpit-deploy-smoke, mcp, chat-agent-smoke, cockpit-e2e, website-e2e] runs-on: ubuntu-latest # Only deploy on pushes to main, not on pull requests if: github.ref == 'refs/heads/main' && github.event_name == 'push' diff --git a/.github/workflows/deploy-langgraph.yml b/.github/workflows/deploy-langgraph.yml new file mode 100644 index 000000000..d1dfd4a98 --- /dev/null +++ b/.github/workflows/deploy-langgraph.yml @@ -0,0 +1,67 @@ +name: Deploy LangGraph + +on: + push: + branches: [main] + paths: + - 'cockpit/**/python/**' + workflow_dispatch: + inputs: + capability: + description: 'Capability path (e.g., langgraph/streaming)' + required: false + type: string + +jobs: + deploy: + name: Deploy to LangGraph Cloud + runs-on: ubuntu-latest + strategy: + matrix: + include: + - name: langgraph-streaming + path: cockpit/langgraph/streaming/python + - name: langgraph-persistence + path: cockpit/langgraph/persistence/python + - name: langgraph-interrupts + path: cockpit/langgraph/interrupts/python + - name: langgraph-memory + path: cockpit/langgraph/memory/python + - name: langgraph-durable-execution + path: cockpit/langgraph/durable-execution/python + - name: langgraph-subgraphs + path: cockpit/langgraph/subgraphs/python + - name: langgraph-time-travel + path: cockpit/langgraph/time-travel/python + - name: langgraph-deployment-runtime + path: cockpit/langgraph/deployment-runtime/python + - name: deep-agents-planning + path: cockpit/deep-agents/planning/python + - name: deep-agents-filesystem + path: cockpit/deep-agents/filesystem/python + - name: deep-agents-subagents + path: cockpit/deep-agents/subagents/python + - name: deep-agents-memory + path: cockpit/deep-agents/memory/python + - name: deep-agents-skills + path: cockpit/deep-agents/skills/python + - name: deep-agents-sandboxes + path: cockpit/deep-agents/sandboxes/python + steps: + - uses: actions/checkout@v6.0.2 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install langgraph-cli + run: pip install langgraph-cli + + - name: Deploy ${{ matrix.name }} + if: | + github.event_name == 'workflow_dispatch' && (inputs.capability == '' || contains(matrix.path, inputs.capability)) + || github.event_name == 'push' + working-directory: ${{ matrix.path }} + run: langgraph deploy + env: + LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} diff --git a/.gitignore b/.gitignore index d0244912e..f6d9a43d9 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,10 @@ apps/website/public/demo/ .next out .vercel + +# LangGraph +.langgraph_api/ +langgraph-combined.json + +# Playwright +test-results/ diff --git a/apps/cockpit/e2e/all-examples-smoke.spec.ts b/apps/cockpit/e2e/all-examples-smoke.spec.ts new file mode 100644 index 000000000..0ee338241 --- /dev/null +++ b/apps/cockpit/e2e/all-examples-smoke.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from '@playwright/test'; + +/** + * Smoke test that verifies every capability example's Angular app is running + * and can render the chat interface. Requires all 14 Angular apps to be served. + * + * Run with: npx playwright test apps/cockpit/e2e/all-examples-smoke.spec.ts + * + * Prerequisites: + * npx tsx apps/cockpit/scripts/serve-example.ts --all + * OR: nx run cockpit:serve-all + */ + +const EXAMPLES = [ + { name: 'streaming', port: 4300, selector: 'app-streaming' }, + { name: 'persistence', port: 4301, selector: 'app-persistence' }, + { name: 'interrupts', port: 4302, selector: 'app-interrupts' }, + { name: 'memory', port: 4303, selector: 'app-memory' }, + { name: 'durable-execution', port: 4304, selector: 'app-durable-execution' }, + { name: 'subgraphs', port: 4305, selector: 'app-subgraphs' }, + { name: 'time-travel', port: 4306, selector: 'app-time-travel' }, + { name: 'deployment-runtime', port: 4307, selector: 'app-deployment-runtime' }, + { name: 'planning', port: 4310, selector: 'app-planning' }, + { name: 'filesystem', port: 4311, selector: 'app-filesystem' }, + { name: 'da-subagents', port: 4312, selector: 'app-subagents' }, + { name: 'da-memory', port: 4313, selector: 'app-da-memory' }, + { name: 'skills', port: 4314, selector: 'app-skills' }, + { name: 'sandboxes', port: 4315, selector: 'app-sandboxes' }, +] as const; + +test.describe('All Examples Smoke Test', () => { + for (const example of EXAMPLES) { + test(`${example.name} (port ${example.port}) renders chat UI`, async ({ page }) => { + await page.goto(`http://localhost:${example.port}`, { timeout: 15000 }); + await page.waitForSelector(example.selector, { state: 'attached', timeout: 10000 }); + + // Verify the chat component renders + await expect(page.locator('cp-chat')).toBeVisible({ timeout: 5000 }); + + // Verify input and send button exist + await expect(page.locator('input[name="prompt"]')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('button[type="submit"]')).toBeVisible({ timeout: 5000 }); + }); + } +}); + +test.describe('All Examples Send Message Test', () => { + // This test requires a running LangGraph backend with OPENAI_API_KEY + test.skip(({ }, testInfo) => !process.env['OPENAI_API_KEY'], 'Requires OPENAI_API_KEY'); + + for (const example of EXAMPLES) { + test(`${example.name} (port ${example.port}) sends and receives a message`, async ({ page }) => { + await page.goto(`http://localhost:${example.port}`, { timeout: 15000 }); + await page.waitForSelector(example.selector, { state: 'attached', timeout: 10000 }); + + // Type and send a message + await page.fill('input[name="prompt"]', 'hello'); + await page.click('button[type="submit"]'); + + // Wait for AI response + await expect(page.locator('.cp-message--ai, [class*="message--ai"]')).toBeVisible({ timeout: 30000 }); + await expect(page.locator('.cp-message--ai .cp-message__content, [class*="message__content"]')).not.toBeEmpty({ timeout: 30000 }); + }); + } +}); diff --git a/apps/cockpit/package.json b/apps/cockpit/package.json index 2611f8aaf..fd8250ec8 100644 --- a/apps/cockpit/package.json +++ b/apps/cockpit/package.json @@ -3,8 +3,14 @@ "version": "0.0.1", "private": true, "dependencies": { + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "marked": "^15.0.0", "next": "~16.1.6", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "tailwind-merge": "^2.5.0" } } diff --git a/apps/cockpit/postcss.config.mjs b/apps/cockpit/postcss.config.mjs new file mode 100644 index 000000000..2e3384d2f --- /dev/null +++ b/apps/cockpit/postcss.config.mjs @@ -0,0 +1,6 @@ +// apps/cockpit/postcss.config.mjs +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; diff --git a/apps/cockpit/project.json b/apps/cockpit/project.json index e7cdf7457..f9272999d 100644 --- a/apps/cockpit/project.json +++ b/apps/cockpit/project.json @@ -51,6 +51,167 @@ "options": { "config": "apps/cockpit/playwright.config.ts" } + }, + "serve-streaming": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npx nx serve cockpit --port 4201", + "npx nx serve cockpit-langgraph-streaming-angular --port 4300", + "cd cockpit/langgraph/streaming/python && source $HOME/.local/bin/env 2>/dev/null; uv sync && uv run langgraph dev --port 8123" + ], + "parallel": true + } + }, + "serve-persistence": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npx nx serve cockpit --port 4201", + "npx nx serve cockpit-langgraph-persistence-angular --port 4301", + "cd cockpit/langgraph/persistence/python && source $HOME/.local/bin/env 2>/dev/null; uv sync && uv run langgraph dev --port 8123" + ], + "parallel": true + } + }, + "serve-interrupts": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npx nx serve cockpit --port 4201", + "npx nx serve cockpit-langgraph-interrupts-angular --port 4302", + "cd cockpit/langgraph/interrupts/python && source $HOME/.local/bin/env 2>/dev/null; uv sync && uv run langgraph dev --port 8123" + ], + "parallel": true + } + }, + "serve-memory": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npx nx serve cockpit --port 4201", + "npx nx serve cockpit-langgraph-memory-angular --port 4303", + "cd cockpit/langgraph/memory/python && source $HOME/.local/bin/env 2>/dev/null; uv sync && uv run langgraph dev --port 8123" + ], + "parallel": true + } + }, + "serve-durable-execution": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npx nx serve cockpit --port 4201", + "npx nx serve cockpit-langgraph-durable-execution-angular --port 4304", + "cd cockpit/langgraph/durable-execution/python && source $HOME/.local/bin/env 2>/dev/null; uv sync && uv run langgraph dev --port 8123" + ], + "parallel": true + } + }, + "serve-subgraphs": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npx nx serve cockpit --port 4201", + "npx nx serve cockpit-langgraph-subgraphs-angular --port 4305", + "cd cockpit/langgraph/subgraphs/python && source $HOME/.local/bin/env 2>/dev/null; uv sync && uv run langgraph dev --port 8123" + ], + "parallel": true + } + }, + "serve-time-travel": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npx nx serve cockpit --port 4201", + "npx nx serve cockpit-langgraph-time-travel-angular --port 4306", + "cd cockpit/langgraph/time-travel/python && source $HOME/.local/bin/env 2>/dev/null; uv sync && uv run langgraph dev --port 8123" + ], + "parallel": true + } + }, + "serve-deployment-runtime": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npx nx serve cockpit --port 4201", + "npx nx serve cockpit-langgraph-deployment-runtime-angular --port 4307", + "cd cockpit/langgraph/deployment-runtime/python && source $HOME/.local/bin/env 2>/dev/null; uv sync && uv run langgraph dev --port 8123" + ], + "parallel": true + } + }, + "serve-planning": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npx nx serve cockpit --port 4201", + "npx nx serve cockpit-deep-agents-planning-angular --port 4310", + "cd cockpit/deep-agents/planning/python && source $HOME/.local/bin/env 2>/dev/null; uv sync && uv run langgraph dev --port 8123" + ], + "parallel": true + } + }, + "serve-filesystem": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npx nx serve cockpit --port 4201", + "npx nx serve cockpit-deep-agents-filesystem-angular --port 4311", + "cd cockpit/deep-agents/filesystem/python && source $HOME/.local/bin/env 2>/dev/null; uv sync && uv run langgraph dev --port 8123" + ], + "parallel": true + } + }, + "serve-da-subagents": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npx nx serve cockpit --port 4201", + "npx nx serve cockpit-deep-agents-subagents-angular --port 4312", + "cd cockpit/deep-agents/subagents/python && source $HOME/.local/bin/env 2>/dev/null; uv sync && uv run langgraph dev --port 8123" + ], + "parallel": true + } + }, + "serve-da-memory": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npx nx serve cockpit --port 4201", + "npx nx serve cockpit-deep-agents-memory-angular --port 4313", + "cd cockpit/deep-agents/memory/python && source $HOME/.local/bin/env 2>/dev/null; uv sync && uv run langgraph dev --port 8123" + ], + "parallel": true + } + }, + "serve-skills": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npx nx serve cockpit --port 4201", + "npx nx serve cockpit-deep-agents-skills-angular --port 4314", + "cd cockpit/deep-agents/skills/python && source $HOME/.local/bin/env 2>/dev/null; uv sync && uv run langgraph dev --port 8123" + ], + "parallel": true + } + }, + "serve-sandboxes": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "npx nx serve cockpit --port 4201", + "npx nx serve cockpit-deep-agents-sandboxes-angular --port 4315", + "cd cockpit/deep-agents/sandboxes/python && source $HOME/.local/bin/env 2>/dev/null; uv sync && uv run langgraph dev --port 8123" + ], + "parallel": true + } + }, + "serve-all": { + "executor": "nx:run-commands", + "options": { + "command": "npx tsx apps/cockpit/scripts/serve-example.ts --all", + "cwd": "." + } } } } diff --git a/apps/cockpit/scripts/capability-registry.ts b/apps/cockpit/scripts/capability-registry.ts new file mode 100644 index 000000000..dfaa13d7a --- /dev/null +++ b/apps/cockpit/scripts/capability-registry.ts @@ -0,0 +1,38 @@ +/** + * Single source of truth for all cockpit capability examples. + * Used by serve, build, test, and deploy scripts. + */ +export interface Capability { + id: string; + product: 'langgraph' | 'deep-agents'; + topic: string; + angularProject: string; + port: number; + pythonDir: string; + graphName: string; +} + +export const capabilities: readonly Capability[] = [ + { id: 'streaming', product: 'langgraph', topic: 'streaming', angularProject: 'cockpit-langgraph-streaming-angular', port: 4300, pythonDir: 'cockpit/langgraph/streaming/python', graphName: 'streaming' }, + { id: 'persistence', product: 'langgraph', topic: 'persistence', angularProject: 'cockpit-langgraph-persistence-angular', port: 4301, pythonDir: 'cockpit/langgraph/persistence/python', graphName: 'persistence' }, + { id: 'interrupts', product: 'langgraph', topic: 'interrupts', angularProject: 'cockpit-langgraph-interrupts-angular', port: 4302, pythonDir: 'cockpit/langgraph/interrupts/python', graphName: 'interrupts' }, + { id: 'memory', product: 'langgraph', topic: 'memory', angularProject: 'cockpit-langgraph-memory-angular', port: 4303, pythonDir: 'cockpit/langgraph/memory/python', graphName: 'memory' }, + { id: 'durable-execution', product: 'langgraph', topic: 'durable-execution', angularProject: 'cockpit-langgraph-durable-execution-angular', port: 4304, pythonDir: 'cockpit/langgraph/durable-execution/python', graphName: 'durable-execution' }, + { id: 'subgraphs', product: 'langgraph', topic: 'subgraphs', angularProject: 'cockpit-langgraph-subgraphs-angular', port: 4305, pythonDir: 'cockpit/langgraph/subgraphs/python', graphName: 'subgraphs' }, + { id: 'time-travel', product: 'langgraph', topic: 'time-travel', angularProject: 'cockpit-langgraph-time-travel-angular', port: 4306, pythonDir: 'cockpit/langgraph/time-travel/python', graphName: 'time-travel' }, + { id: 'deployment-runtime', product: 'langgraph', topic: 'deployment-runtime', angularProject: 'cockpit-langgraph-deployment-runtime-angular', port: 4307, pythonDir: 'cockpit/langgraph/deployment-runtime/python', graphName: 'deployment-runtime' }, + { id: 'planning', product: 'deep-agents', topic: 'planning', angularProject: 'cockpit-deep-agents-planning-angular', port: 4310, pythonDir: 'cockpit/deep-agents/planning/python', graphName: 'planning' }, + { id: 'filesystem', product: 'deep-agents', topic: 'filesystem', angularProject: 'cockpit-deep-agents-filesystem-angular', port: 4311, pythonDir: 'cockpit/deep-agents/filesystem/python', graphName: 'filesystem' }, + { id: 'da-subagents', product: 'deep-agents', topic: 'subagents', angularProject: 'cockpit-deep-agents-subagents-angular', port: 4312, pythonDir: 'cockpit/deep-agents/subagents/python', graphName: 'da-subagents' }, + { id: 'da-memory', product: 'deep-agents', topic: 'memory', angularProject: 'cockpit-deep-agents-memory-angular', port: 4313, pythonDir: 'cockpit/deep-agents/memory/python', graphName: 'da-memory' }, + { id: 'skills', product: 'deep-agents', topic: 'skills', angularProject: 'cockpit-deep-agents-skills-angular', port: 4314, pythonDir: 'cockpit/deep-agents/skills/python', graphName: 'skills' }, + { id: 'sandboxes', product: 'deep-agents', topic: 'sandboxes', angularProject: 'cockpit-deep-agents-sandboxes-angular', port: 4315, pythonDir: 'cockpit/deep-agents/sandboxes/python', graphName: 'sandboxes' }, +] as const; + +export function findCapability(id: string): Capability | undefined { + return capabilities.find((c) => c.id === id); +} + +export function allAngularProjects(): string[] { + return capabilities.map((c) => c.angularProject); +} diff --git a/apps/cockpit/scripts/generate-combined-langgraph.ts b/apps/cockpit/scripts/generate-combined-langgraph.ts new file mode 100644 index 000000000..1c1f44abf --- /dev/null +++ b/apps/cockpit/scripts/generate-combined-langgraph.ts @@ -0,0 +1,13 @@ +import { writeFileSync } from 'fs'; +import { resolve } from 'path'; +import { capabilities } from './capability-registry'; + +const graphs: Record = {}; +for (const c of capabilities) { + graphs[c.graphName] = `./${c.pythonDir}/src/graph.py:graph`; +} + +const config = { graphs, dependencies: capabilities.map((c) => `./${c.pythonDir}/pyproject.toml`), env: '.env' }; +const out = resolve(process.cwd(), 'langgraph-combined.json'); +writeFileSync(out, JSON.stringify(config, null, 2) + '\n'); +console.log(`Generated ${out} with ${Object.keys(graphs).length} graphs`); diff --git a/apps/cockpit/scripts/serve-example.ts b/apps/cockpit/scripts/serve-example.ts new file mode 100644 index 000000000..cca450f9b --- /dev/null +++ b/apps/cockpit/scripts/serve-example.ts @@ -0,0 +1,41 @@ +import { spawn, type ChildProcess } from 'child_process'; +import { capabilities, findCapability } from './capability-registry'; + +const args = process.argv.slice(2); +const capabilityArg = args.find((a) => a.startsWith('--capability='))?.split('=')[1]; +const allMode = args.includes('--all'); + +if (!capabilityArg && !allMode) { + console.log('Usage:'); + console.log(' npx tsx apps/cockpit/scripts/serve-example.ts --capability=streaming'); + console.log(' npx tsx apps/cockpit/scripts/serve-example.ts --all'); + console.log('\nCapabilities:'); + capabilities.forEach((c) => console.log(` ${c.id.padEnd(22)} port ${c.port} ${c.product}/${c.topic}`)); + process.exit(0); +} + +const procs: ChildProcess[] = []; + +function run(label: string, cmd: string, color: string): void { + const proc = spawn('bash', ['-c', cmd], { stdio: ['inherit', 'pipe', 'pipe'], env: { ...process.env } }); + proc.stdout?.on('data', (d) => String(d).split('\n').filter(Boolean).forEach((l) => console.log(`\x1b[${color}m[${label}]\x1b[0m ${l}`))); + proc.stderr?.on('data', (d) => String(d).split('\n').filter(Boolean).forEach((l) => console.error(`\x1b[${color}m[${label}]\x1b[0m ${l}`))); + procs.push(proc); +} + +function cleanup() { procs.forEach((p) => p.kill()); process.exit(0); } +process.on('SIGINT', cleanup); +process.on('SIGTERM', cleanup); + +run('cockpit', 'npx nx serve cockpit --port 4201', '36'); + +if (allMode) { + capabilities.forEach((c) => run(c.id, `npx nx serve ${c.angularProject} --port ${c.port}`, '33')); + console.log('\nπŸš€ Starting cockpit + all 14 examples\n'); +} else { + const cap = findCapability(capabilityArg!); + if (!cap) { console.error(`Unknown: ${capabilityArg}`); process.exit(1); } + run(cap.id, `npx nx serve ${cap.angularProject} --port ${cap.port}`, '33'); + run(`${cap.id}-py`, `cd ${cap.pythonDir} && source $HOME/.local/bin/env 2>/dev/null; uv sync && uv run langgraph dev --port 8123`, '35'); + console.log(`\nπŸš€ ${cap.id}: cockpit=4201 angular=${cap.port} langgraph=8123\n`); +} diff --git a/apps/cockpit/src/app/[...slug]/page.tsx b/apps/cockpit/src/app/[...slug]/page.tsx index 257a47fea..0f2b14af6 100644 --- a/apps/cockpit/src/app/[...slug]/page.tsx +++ b/apps/cockpit/src/app/[...slug]/page.tsx @@ -1,6 +1,13 @@ import { redirect } from 'next/navigation'; import { CockpitShell } from '../../components/cockpit-shell'; -import { getCockpitPageModel } from '../../lib/cockpit-page'; +import { getContentBundle } from '../../lib/content-bundle'; +import { cockpitManifest, getCockpitPageModel } from '../../lib/cockpit-page'; + +export async function generateStaticParams() { + return cockpitManifest.map((entry) => ({ + slug: [entry.product, entry.section, entry.topic, entry.page, entry.language], + })); +} export default async function CockpitRoutePage({ params, @@ -16,11 +23,14 @@ export default async function CockpitRoutePage({ redirect(canonicalPath); } + const contentBundle = await getContentBundle(presentation); + return ( ); } diff --git a/apps/cockpit/src/app/cockpit.css b/apps/cockpit/src/app/cockpit.css index abb520260..875289e17 100644 --- a/apps/cockpit/src/app/cockpit.css +++ b/apps/cockpit/src/app/cockpit.css @@ -1,290 +1,245 @@ -:root { - color-scheme: dark; - --cockpit-bg: #08111f; - --cockpit-panel: #0f1b2d; - --cockpit-panel-border: rgba(138, 170, 214, 0.18); - --cockpit-panel-strong: #14243d; - --cockpit-text: #edf3ff; - --cockpit-text-muted: #96a8c7; - --cockpit-accent: #7dd3fc; - --cockpit-accent-strong: #38bdf8; - --cockpit-shadow: 0 24px 80px rgba(3, 9, 18, 0.45); -} - -* { - box-sizing: border-box; -} - -html, -body { - margin: 0; - min-height: 100%; - background: - radial-gradient(circle at top, rgba(61, 118, 168, 0.18), transparent 30%), - linear-gradient(180deg, #09101a 0%, var(--cockpit-bg) 100%); - color: var(--cockpit-text); - font-family: - Inter, - ui-sans-serif, - system-ui, - -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - sans-serif; +@import "tailwindcss"; + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); } -a { - color: inherit; - text-decoration: none; -} - -button { - font: inherit; -} - -.cockpit-app { - min-height: 100vh; -} - -.cockpit-shell { - display: grid; - grid-template-columns: 18rem minmax(0, 1fr); - min-height: 100vh; -} - -.cockpit-sidebar, -.cockpit-shell__workspace, -.cockpit-prompt-drawer { - background: rgba(8, 17, 31, 0.88); - backdrop-filter: blur(14px); -} - -.cockpit-sidebar { - display: grid; - gap: 1.5rem; - padding: 1.75rem 1.25rem; - border-right: 1px solid var(--cockpit-panel-border); +:root { + /* shadcn semantic vars β€” light palette */ + --background: #f8f9fc; + --foreground: #1a1a2e; + --primary: #004090; + --primary-foreground: #ffffff; + --card: rgba(255, 255, 255, 0.45); + --card-foreground: #1a1a2e; + --muted: rgba(0, 64, 144, 0.06); + --muted-foreground: #555770; + --border: rgba(0, 64, 144, 0.15); + --input: rgba(0, 64, 144, 0.15); + --ring: #004090; +} + +/* 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: rgba(0, 64, 144, 0.04); + border: 1px solid rgba(0, 64, 144, 0.12); + 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; } -.cockpit-sidebar__header, -.cockpit-shell__header { - display: grid; - gap: 0.5rem; +.doc-callout { + border-radius: 0.5rem; + padding: 0.75rem 1rem; + margin: 1.25rem 0; + font-size: 0.85rem; + line-height: 1.6; } - -.cockpit-sidebar h1, -.cockpit-shell__header h2, -.cockpit-run-mode__surface h2, -.cockpit-code-mode__header h2, -.cockpit-prompt-drawer h2, -.cockpit-run-mode__context h3 { - margin: 0; - font-family: - 'Iowan Old Style', - 'Palatino Linotype', - 'Book Antiqua', - Georgia, - serif; +.doc-callout__label { + font-size: 0.7rem; font-weight: 600; - letter-spacing: -0.02em; -} - -.cockpit-shell__workspace { - display: grid; - gap: 1.5rem; - padding: 1.75rem; -} - -.cockpit-shell__actions, -.cockpit-shell__header, -.cockpit-shell__mode-surface, -.cockpit-run-mode, -.cockpit-code-mode, -.cockpit-prompt-drawer__header { - display: grid; - gap: 1rem; -} - -.cockpit-shell__header { - grid-template-columns: minmax(0, 1fr) auto; - align-items: end; -} - -.cockpit-shell__actions { - grid-auto-flow: column; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 0.25rem; +} +.doc-callout__content { color: var(--ds-text-secondary); } +.doc-callout--tip { + background: rgba(0, 64, 144, 0.04); + border: 1px solid rgba(0, 64, 144, 0.12); +} +.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; -} - -.cockpit-shell__actions button, -.cockpit-prompt-drawer button, -.cockpit-code-mode__tabs button, -[aria-label='Primary modes'] button, -[aria-haspopup='menu'] { - border: 1px solid var(--cockpit-panel-border); - background: rgba(15, 27, 45, 0.9); - color: var(--cockpit-text); - border-radius: 999px; - padding: 0.75rem 1rem; -} - -.cockpit-shell__actions button:last-child, -[aria-label='Primary modes'] button[aria-pressed='true'] { - background: linear-gradient(180deg, rgba(56, 189, 248, 0.28), rgba(56, 189, 248, 0.14)); - border-color: rgba(125, 211, 252, 0.35); -} - -.cockpit-eyebrow, -.cockpit-code-path { - margin: 0; - color: var(--cockpit-text-muted); - font-family: - 'SFMono-Regular', - ui-monospace, - 'Cascadia Code', - 'Source Code Pro', - Menlo, - monospace; - font-size: 0.8rem; -} - -.cockpit-shell__mode-surface, -.cockpit-run-mode__surface, -.cockpit-run-mode__context, -.cockpit-code-mode, -.cockpit-prompt-drawer, -[aria-label='Primary modes'], -[role='menu'], -[aria-label='Prompt copy'], -[aria-label='Docs mode'] { - border: 1px solid var(--cockpit-panel-border); - background: rgba(15, 27, 45, 0.72); - box-shadow: var(--cockpit-shadow); -} - -.cockpit-shell__mode-surface, -[aria-label='Docs mode'], -.cockpit-code-mode { - padding: 1.25rem; -} - -[aria-label='Primary modes'] { - display: inline-flex; - gap: 0.75rem; - width: fit-content; - padding: 0.5rem; - border-radius: 999px; -} - -.cockpit-run-mode { - grid-template-columns: minmax(0, 1.8fr) minmax(16rem, 0.95fr); - align-items: start; -} - -.cockpit-run-mode__surface, -.cockpit-run-mode__context { - padding: 1.25rem; - min-height: 100%; -} - -.cockpit-run-mode__viewport { - display: grid; - place-items: center; - min-height: 22rem; - border: 1px dashed rgba(125, 211, 252, 0.24); - background: linear-gradient(180deg, rgba(20, 36, 61, 0.5), rgba(8, 17, 31, 0.3)); -} - -.cockpit-code-mode__tabs, -.cockpit-prompt-drawer__tabs { + 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; - gap: 0.75rem; - flex-wrap: wrap; + align-items: center; + justify-content: center; +} +.doc-step__line { + width: 2px; + flex: 1; + background: rgba(0, 64, 144, 0.15); + 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; } - -.cockpit-code-mode__editor, -.cockpit-prompt-drawer__body, -[aria-label='Docs mode'] section { - border-top: 1px solid var(--cockpit-panel-border); - padding-top: 1rem; +.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; } -.cockpit-prompt-drawer { - position: fixed; - top: 1rem; - right: 1rem; - bottom: 1rem; - width: min(28rem, calc(100vw - 2rem)); - padding: 1.25rem; - border-radius: 1.25rem; - overflow: auto; - z-index: 20; +.doc-codeblock { + border: 1px solid rgba(0, 64, 144, 0.12); + border-radius: 0.5rem; + overflow: hidden; + margin: 0.75rem 0; } - -[aria-label='Language picker'] { - margin-top: 0.75rem; - display: grid; +.doc-codeblock__header { + display: flex; + align-items: center; gap: 0.5rem; + padding: 0.4rem 0.75rem; + border-bottom: 1px solid rgba(138, 170, 214, 0.12); + background: rgba(26, 27, 38, 0.95); + font-size: 0.7rem; +} +.doc-codeblock__file { color: #a9b1d6; font-family: var(--font-mono); } +.doc-codeblock__lang { + padding: 0.1rem 0.35rem; + border-radius: 0.2rem; + background: rgba(0, 64, 144, 0.15); + color: var(--ds-accent-light); + font-size: 0.6rem; +} +.doc-codeblock__copy { + margin-left: auto; + padding: 0.1rem 0.5rem; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 0.25rem; + background: transparent; + color: #a9b1d6; + font-size: 0.65rem; + cursor: pointer; +} +.doc-codeblock__copy:hover { color: #e0e0e0; } +.doc-codeblock pre.shiki { margin: 0; border-radius: 0; border: none; } +.code-mode-block pre.shiki { margin: 0; border-radius: 0; border: none; } + +.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); } - -[aria-label='Cockpit navigation'] { - display: grid; - gap: 1.25rem; -} - -[aria-label='Cockpit navigation'] h2, -[aria-label='Cockpit navigation'] h3 { - margin: 0 0 0.5rem; -} - -[aria-label='Cockpit navigation'] ul { - margin: 0; - padding: 0; - list-style: none; - display: grid; - gap: 0.4rem; -} - -[aria-current='page'] { - color: var(--cockpit-accent); -} - -pre, -code { - font-family: - 'SFMono-Regular', - ui-monospace, - 'Cascadia Code', - 'Source Code Pro', - Menlo, - monospace; -} - -pre { - margin: 0; - white-space: pre-wrap; +.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; } -@media (max-width: 960px) { - .cockpit-shell { - grid-template-columns: 1fr; - } - - .cockpit-shell__header, - .cockpit-run-mode { - grid-template-columns: 1fr; - } - - .cockpit-shell__actions { - grid-auto-flow: row; - justify-items: start; - } - - .cockpit-prompt-drawer { - top: auto; - left: 1rem; - right: 1rem; - bottom: 1rem; - width: auto; - max-height: 70vh; - } -} +.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(--border); +} +.doc-api-table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid rgba(0, 64, 144, 0.08); + 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; +} + +/* Narrative docs heading alignment with website */ +.docs-article h1, +.docs-article h2, +.docs-article h3 { + font-family: var(--ds-font-serif); +} +.docs-article h1 { font-size: 1.875rem; } +.docs-article h2 { font-size: 1.5rem; } +.docs-article h3 { font-size: 1.25rem; } diff --git a/apps/cockpit/src/app/layout.tsx b/apps/cockpit/src/app/layout.tsx index 58ae4634b..e0775a1d7 100644 --- a/apps/cockpit/src/app/layout.tsx +++ b/apps/cockpit/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import { cssVars } from '@cacheplane/ui-react'; import './cockpit.css'; export const metadata = { @@ -12,8 +13,16 @@ interface RootLayoutProps { export default function RootLayout({ children }: RootLayoutProps) { return ( - - {children} + + + {children} + ); } diff --git a/apps/cockpit/src/app/page.tsx b/apps/cockpit/src/app/page.tsx index 795c65d5d..c808f2590 100644 --- a/apps/cockpit/src/app/page.tsx +++ b/apps/cockpit/src/app/page.tsx @@ -1,15 +1,18 @@ import React from 'react'; import { CockpitShell } from '../components/cockpit-shell'; +import { getContentBundle } from '../lib/content-bundle'; import { getCockpitPageModel } from '../lib/cockpit-page'; -export default function CockpitHomePage() { +export default async function CockpitHomePage() { const { entry, presentation, navigationTree } = getCockpitPageModel(); + const contentBundle = await getContentBundle(presentation); return ( ); } diff --git a/apps/cockpit/src/components/api-mode/api-mode.spec.tsx b/apps/cockpit/src/components/api-mode/api-mode.spec.tsx new file mode 100644 index 000000000..d88c218c4 --- /dev/null +++ b/apps/cockpit/src/components/api-mode/api-mode.spec.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { ApiMode } from './api-mode'; + +describe('ApiMode', () => { + it('renders doc sections with signatures, descriptions, params, and returns', () => { + const html = renderToStaticMarkup( + ', + description: 'Streams a response from the backend.', + params: [{ name: 'prompt', description: 'The user message' }], + returns: 'Observable emitting tokens', + sourceFile: 'streaming.service.ts', + language: 'typescript', + }, + { + title: 'StreamingGraph', + signature: 'class StreamingGraph', + description: 'Streams LLM responses.', + params: [], + returns: null, + sourceFile: 'graph.py', + language: 'python', + }, + ]} + /> + ); + + expect(html).toContain('StreamingComponent'); + expect(html).toContain('export class StreamingComponent'); + expect(html).toContain('Renders a streaming chat UI.'); + expect(html).toContain('streaming.component.ts'); + + expect(html).toContain('stream'); + expect(html).toContain('prompt'); + expect(html).toContain('The user message'); + expect(html).toContain('Observable emitting tokens'); + + expect(html).toContain('StreamingGraph'); + expect(html).toContain('class StreamingGraph'); + expect(html).toContain('graph.py'); + + expect(html).toContain('TypeScript'); + expect(html).toContain('Python'); + }); + + it('renders empty state when no doc sections', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('No API documentation extracted'); + }); +}); diff --git a/apps/cockpit/src/components/api-mode/api-mode.tsx b/apps/cockpit/src/components/api-mode/api-mode.tsx new file mode 100644 index 000000000..342e5758b --- /dev/null +++ b/apps/cockpit/src/components/api-mode/api-mode.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import type { DocSection } from '../../lib/extract-docs'; + +interface ApiModeProps { + docSections: DocSection[]; + hasCodeFiles?: boolean; +} + +function renderInlineCode(text: string): React.ReactNode[] { + const parts = text.split(/(`[^`]+`)/g); + return parts.map((part, i) => { + if (part.startsWith('`') && part.endsWith('`')) { + return ( + + {part.slice(1, -1)} + + ); + } + return {part}; + }); +} + +function DocArticle({ section }: { section: DocSection }) { + return ( +
+
+
+

+ {section.title} +

+ + {section.sourceFile} + +
+
+          {section.signature}
+        
+
+ +
+

+ {renderInlineCode(section.description)} +

+ + {section.params.length > 0 ? ( +
+
+ Parameters +
+
+ {section.params.map((param) => ( +
+ + {param.name} + + {renderInlineCode(param.description)} +
+ ))} +
+
+ ) : null} + + {section.returns ? ( +
+
+ Returns +
+

+ {renderInlineCode(section.returns)} +

+
+ ) : null} +
+
+ ); +} + +export function ApiMode({ docSections, hasCodeFiles = false }: ApiModeProps) { + if (docSections.length === 0) { + return ( +
+

+ {hasCodeFiles + ? 'Add JSDoc comments to your TypeScript files or docstrings to your Python files to see API documentation here.' + : 'No API documentation extracted yet β€” add JSDoc to TypeScript files or docstrings to Python files.'} +

+
+ ); + } + + const LANGUAGE_LABELS: Record = { + typescript: 'TypeScript', + python: 'Python', + }; + + const tsSections = docSections.filter((s) => s.language === 'typescript'); + const pySections = docSections.filter((s) => s.language === 'python'); + + return ( +
+ {tsSections.length > 0 ? ( +
+

+ {LANGUAGE_LABELS[tsSections[0]?.language] ?? 'TypeScript'} +

+ {tsSections.map((section) => ( + + ))} +
+ ) : null} + + {pySections.length > 0 ? ( +
+

+ {LANGUAGE_LABELS[pySections[0]?.language] ?? 'Python'} +

+ {pySections.map((section) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/cockpit/src/components/cockpit-shell.tsx b/apps/cockpit/src/components/cockpit-shell.tsx index 7aae1816a..d244638dc 100644 --- a/apps/cockpit/src/components/cockpit-shell.tsx +++ b/apps/cockpit/src/components/cockpit-shell.tsx @@ -1,27 +1,24 @@ 'use client'; -import React, { useEffect, useMemo, useState } from 'react'; -import { cockpitManifest } from '../../../../libs/cockpit-registry/src/index'; +import React, { useEffect, useState } from 'react'; +import { cockpitManifest } from '@cacheplane/cockpit-registry'; +import type { ContentBundle } from '../lib/content-bundle'; import type { CapabilityPresentation, NavigationProduct } from '../lib/route-resolution'; import { CodeMode } from './code-mode/code-mode'; -import { DocsMode } from './docs-mode/docs-mode'; +import { ApiMode } from './api-mode/api-mode'; +import { NarrativeDocs } from './narrative-docs/narrative-docs'; import { ModeSwitcher } from './modes/mode-switcher'; -import { PromptDrawer } from './prompt-drawer/prompt-drawer'; import { RunMode } from './run-mode/run-mode'; import { CockpitSidebar } from './sidebar/cockpit-sidebar'; -const PRIMARY_MODES = ['Run', 'Code', 'Docs'] as const; +const PRIMARY_MODES = ['Run', 'Code', 'Docs', 'API'] as const; type PrimaryMode = (typeof PRIMARY_MODES)[number]; -const DEFAULT_FRONTEND_ASSET_PATHS = [ - 'apps/cockpit/src/app/page.tsx', - 'apps/cockpit/src/components/cockpit-shell.tsx', -] as const; - interface CockpitShellProps { navigationTree: NavigationProduct[]; presentation: CapabilityPresentation; entryTitle: string; + contentBundle: ContentBundle; } const toLabel = (value: string) => @@ -30,40 +27,28 @@ const toLabel = (value: string) => .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); +function MenuIcon() { + return ( + + ); +} + export function CockpitShell({ navigationTree, presentation, entryTitle, + contentBundle, }: CockpitShellProps) { const [isHydrated, setIsHydrated] = useState(false); const [activeMode, setActiveMode] = useState('Run'); - const [isPromptDrawerOpen, setIsPromptDrawerOpen] = useState(false); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); const isCapability = presentation.kind === 'capability'; - const codeAssetPaths = useMemo( - () => - isCapability - ? Array.from(new Set([...DEFAULT_FRONTEND_ASSET_PATHS, ...presentation.codeAssetPaths])) - : [...DEFAULT_FRONTEND_ASSET_PATHS], - [isCapability, presentation] - ); - const promptAssetPaths = isCapability ? presentation.promptAssetPaths : []; + const codeAssetPaths = isCapability ? presentation.codeAssetPaths : []; + const backendAssetPaths = isCapability ? (presentation.backendAssetPaths ?? []) : []; const entry = presentation.entry; const contextLabel = `${toLabel(entry.product)} / ${toLabel(entry.section)} / ${entry.topic}`; - const docsSections = [ - { - title: 'Start from the runnable surface', - body: `Run ${entryTitle} first, then switch to Code to inspect the frontend shell and capability module paths that power it.`, - code: codeAssetPaths[0] ?? presentation.docsPath, - }, - { - title: 'Keep prompts close', - body: - promptAssetPaths.length > 0 - ? 'Use the prompt drawer when you want the prompt path without losing the current workspace mode.' - : 'Prompt assets are not available for this entry, so the guide stays focused on the runnable surface and implementation files.', - code: promptAssetPaths[0], - }, - ].filter((section) => Boolean(section.code || section.body)); useEffect(() => { setIsHydrated(true); @@ -72,69 +57,92 @@ export function CockpitShell({ return (
- + {/* Desktop sidebar β€” hidden on mobile */} +
+ +
-
-
-
-

{contextLabel}

-

{entryTitle}

-

- Start in Run, then move into the implementation files or guided docs as - needed. -

+ {/* Mobile sidebar overlay */} + {isSidebarOpen && ( + <> +
setIsSidebarOpen(false)} + /> +
+
+ + )} -
- - {isCapability ? : null} +

{contextLabel}

+ | +

{entryTitle}

+
+
+
- - -
+
{activeMode === 'Run' ? ( ) : null} {activeMode === 'Code' ? ( - - ) : null} - {activeMode === 'Docs' ? ( - ) : null} + {activeMode === 'Docs' ? ( + + ) : null} + {activeMode === 'API' ? ( + + ) : null}
- - setIsPromptDrawerOpen(false)} - />
); } diff --git a/apps/cockpit/src/components/code-mode/code-mode.spec.tsx b/apps/cockpit/src/components/code-mode/code-mode.spec.tsx index f138254d3..53dc0a0d4 100644 --- a/apps/cockpit/src/components/code-mode/code-mode.spec.tsx +++ b/apps/cockpit/src/components/code-mode/code-mode.spec.tsx @@ -2,63 +2,113 @@ import React from 'react'; import { act } from 'react'; import { createRoot } from 'react-dom/client'; -import { JSDOM } from 'jsdom'; import { afterEach, describe, expect, it } from 'vitest'; import { CodeMode } from './code-mode'; describe('CodeMode', () => { + let container: HTMLDivElement | undefined; + let root: ReturnType | undefined; + afterEach(() => { - globalThis.document?.body.replaceChildren(); + act(() => { + root?.unmount(); + }); + container?.remove(); }); - it('renders file tabs across the top with a single active file and no side file column', () => { - 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 container = document.createElement('div'); + it('renders Shiki-highlighted HTML for the active file', () => { + container = document.createElement('div'); document.body.appendChild(container); - const root = createRoot(container); + root = createRoot(container); + + const codeFiles: Record = { + 'apps/cockpit/src/app/page.tsx': '
export default function Page() {}
', + 'cockpit/langgraph/streaming/python/src/index.ts': '
const x = 1;
', + }; act(() => { - root.render( + root!.render( ); }); - const tabs = Array.from(container.querySelectorAll('[role="tab"]')); + expect(container.querySelector('.shiki')).not.toBeNull(); + expect(container.textContent).toContain('export default function Page() {}'); - expect(container.querySelector('[aria-label="File column"]')).toBeNull(); - expect(container.textContent).toContain('apps/cockpit/src/app/page.tsx'); - expect(container.textContent).not.toContain( - 'cockpit/langgraph/streaming/python/src/index.ts' - ); + const tabs = Array.from(container.querySelectorAll('[role="tab"]')); expect(tabs.map((tab) => tab.textContent)).toEqual(['page.tsx', 'index.ts']); - expect(tabs[0].getAttribute('aria-selected')).toBe('true'); act(() => { - (tabs[1] as HTMLElement).click(); + (tabs[1] as HTMLElement).dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }) + ); }); - expect(container.textContent).toContain( - 'cockpit/langgraph/streaming/python/src/index.ts' - ); - expect(tabs[1].getAttribute('aria-selected')).toBe('true'); - expect(container.textContent).not.toContain('apps/cockpit/src/app/page.tsx'); + expect(container.textContent).toContain('const x = 1;'); + }); + + it('renders a fallback message when codeFiles has no entry for a path', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); act(() => { - root.unmount(); + root!.render( + + ); }); + + expect(container.textContent).toContain('No source available'); + }); + + it('renders prompt files as tabs after a separator', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + const promptFiles: Record = { + 'prompts/system.md': 'You are a helpful assistant.', + }; + + act(() => { + root!.render( + const app = true;' }} + promptFiles={promptFiles} + /> + ); + }); + + const tabs = Array.from(container.querySelectorAll('[role="tab"]')); + const tabLabels = tabs.map((tab) => tab.textContent); + expect(tabLabels).toContain('app.tsx'); + expect(tabLabels).toContain('system.md'); + + act(() => { + const promptTab = tabs.find((tab) => tab.textContent === 'system.md') as HTMLElement; + promptTab.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }) + ); + }); + + expect(container.textContent).toContain('You are a helpful assistant.'); }); }); diff --git a/apps/cockpit/src/components/code-mode/code-mode.tsx b/apps/cockpit/src/components/code-mode/code-mode.tsx index 6b4fd96bf..fb42db482 100644 --- a/apps/cockpit/src/components/code-mode/code-mode.tsx +++ b/apps/cockpit/src/components/code-mode/code-mode.tsx @@ -1,67 +1,115 @@ -import React, { useEffect, useState } from 'react'; +'use client'; + +import React from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; interface CodeModeProps { entryTitle: string; codeAssetPaths: readonly string[]; + backendAssetPaths: readonly string[]; + codeFiles: Record; + promptFiles: Record; } const getTabLabel = (path: string): string => path.split('/').pop() ?? path; -export function CodeMode({ entryTitle, codeAssetPaths }: CodeModeProps) { - const [activePath, setActivePath] = useState(codeAssetPaths[0] ?? ''); +function CodeFileContent({ path, content }: { path: string; content: string | undefined }) { + if (!content) { + return

No source available for {getTabLabel(path)}

; + } + + return ( +
+
+ {path} + +
+
+
+ ); +} - useEffect(() => { - if (!codeAssetPaths.includes(activePath)) { - setActivePath(codeAssetPaths[0] ?? ''); - } - }, [activePath, codeAssetPaths]); +export function CodeMode({ entryTitle, codeAssetPaths, backendAssetPaths, codeFiles, promptFiles }: CodeModeProps) { + const promptPaths = Object.keys(promptFiles); + const allPaths = [...codeAssetPaths, ...backendAssetPaths, ...promptPaths]; - if (codeAssetPaths.length === 0) { + if (allPaths.length === 0) { return ( -
-

Code

-

No code files are available for {entryTitle}.

+
+

No files available for {entryTitle}.

); } - const activeIndex = codeAssetPaths.indexOf(activePath); - const resolvedActivePath = activeIndex >= 0 ? activePath : codeAssetPaths[0]; + const defaultPath = codeAssetPaths[0] ?? backendAssetPaths[0] ?? promptPaths[0]; return ( -
-
-

Code

-

{entryTitle}

-

{resolvedActivePath}

-
+
+ + + {codeAssetPaths.map((path) => ( + + {getTabLabel(path)} + + ))} + {backendAssetPaths.map((path) => ( + + {getTabLabel(path)} + + ))} + {promptPaths.map((path) => ( + + {getTabLabel(path)} + + ))} + -
- {codeAssetPaths.map((path) => { - const isActive = path === resolvedActivePath; + {[...codeAssetPaths, ...backendAssetPaths].map((path) => ( + + + + ))} + {promptPaths.map((path) => { + const content = promptFiles[path]; return ( - + + {content ? ( +
{content}
+ ) : ( +

No content for {getTabLabel(path)}

+ )} +
); })} -
- -
-

Viewing {resolvedActivePath}

-
{`Source preview for ${getTabLabel(resolvedActivePath)}`}
-
+
); } diff --git a/apps/cockpit/src/components/code-pane/code-pane.tsx b/apps/cockpit/src/components/code-pane/code-pane.tsx index 5fe724068..ef1f2b1ca 100644 --- a/apps/cockpit/src/components/code-pane/code-pane.tsx +++ b/apps/cockpit/src/components/code-pane/code-pane.tsx @@ -6,5 +6,5 @@ interface CodePaneProps { } export function CodePane({ paths }: CodePaneProps) { - return ; + return ; } diff --git a/apps/cockpit/src/components/docs-mode/docs-mode.spec.tsx b/apps/cockpit/src/components/docs-mode/docs-mode.spec.tsx deleted file mode 100644 index b59b9a696..000000000 --- a/apps/cockpit/src/components/docs-mode/docs-mode.spec.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; -import { DocsMode } from './docs-mode'; - -describe('DocsMode', () => { - it('renders a documentation-style guide with title, body, code, and prompt copy affordances', () => { - const html = renderToStaticMarkup( - - ); - - expect(html).toContain('

LangGraph Streaming

'); - expect(html).toContain('Use the stream surface to inspect one runnable example end to end.'); - expect(html).toContain('Run'); - expect(html).toContain('npm run cockpit -- --mode=run'); - expect(html).toContain('Open prompt assets'); - expect(html).toContain('Start from the live surface'); - }); -}); diff --git a/apps/cockpit/src/components/docs-mode/docs-mode.tsx b/apps/cockpit/src/components/docs-mode/docs-mode.tsx deleted file mode 100644 index 621b148a2..000000000 --- a/apps/cockpit/src/components/docs-mode/docs-mode.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; - -interface DocsModeSection { - title: string; - body: string; - code?: string; -} - -interface DocsModeProps { - entryTitle: string; - docsPath: string; - summary: string; - sections: DocsModeSection[]; - promptCopy: string; -} - -function toSectionId(title: string) { - return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); -} - -export function DocsMode({ - entryTitle, - docsPath, - summary, - sections, - promptCopy, -}: DocsModeProps) { - return ( -
-
-

Docs

-

{entryTitle}

-

{summary}

-

{docsPath}

-

- Use Run for the live surface, then switch to Code when - you need implementation detail. -

-
- -
- {sections.map((section) => ( -
-

{section.title}

-

{section.body}

- {section.code ? ( -
-                {section.code}
-              
- ) : null} -
- ))} -
- -
-

Prompt assets

-

Keep the prompt close while you read the implementation guide.

- -
-
- ); -} diff --git a/apps/cockpit/src/components/docs-pane/docs-pane.tsx b/apps/cockpit/src/components/docs-pane/docs-pane.tsx deleted file mode 100644 index b6bca0ebc..000000000 --- a/apps/cockpit/src/components/docs-pane/docs-pane.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { DocsMode } from '../docs-mode/docs-mode'; - -interface DocsPaneProps { - path: string; -} - -export function DocsPane({ path }: DocsPaneProps) { - return ( - - ); -} diff --git a/apps/cockpit/src/components/language-switcher.spec.tsx b/apps/cockpit/src/components/language-switcher.spec.tsx deleted file mode 100644 index 1683eec79..000000000 --- a/apps/cockpit/src/components/language-switcher.spec.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; -import { cockpitManifest } from '../../../../libs/cockpit-registry/src/index'; -import { LanguageSwitcher } from './language-switcher'; - -describe('LanguageSwitcher', () => { - it('links to an equivalent page when that language exists', () => { - const manifest = [ - { - ...cockpitManifest.find( - (entry) => - entry.product === 'langgraph' && - entry.topic === 'streaming' && - entry.language === 'python' - )!, - supportedLanguages: ['python', 'typescript'], - equivalentPages: { - python: { - product: 'langgraph', - section: 'core-capabilities', - topic: 'streaming', - page: 'overview', - language: 'python', - }, - typescript: { - product: 'langgraph', - section: 'core-capabilities', - topic: 'streaming', - page: 'overview', - language: 'typescript', - }, - }, - }, - { - ...cockpitManifest.find( - (entry) => - entry.product === 'langgraph' && - entry.topic === 'streaming' && - entry.language === 'python' - )!, - language: 'typescript', - supportedLanguages: ['python', 'typescript'], - equivalentPages: { - python: { - product: 'langgraph', - section: 'core-capabilities', - topic: 'streaming', - page: 'overview', - language: 'python', - }, - typescript: { - product: 'langgraph', - section: 'core-capabilities', - topic: 'streaming', - page: 'overview', - language: 'typescript', - }, - }, - }, - ]; - - const html = renderToStaticMarkup( - - ); - - expect(html).toContain('/langgraph/core-capabilities/streaming/overview/typescript'); - }); - - it('falls back to product overview when no equivalent page exists', () => { - const html = renderToStaticMarkup( - - entry.product === 'langgraph' && - entry.topic === 'streaming' && - entry.language === 'python' - )!} - /> - ); - - expect(html).toContain('/langgraph/getting-started/overview/overview/python'); - }); -}); diff --git a/apps/cockpit/src/components/language-switcher.tsx b/apps/cockpit/src/components/language-switcher.tsx deleted file mode 100644 index efd4e7a4c..000000000 --- a/apps/cockpit/src/components/language-switcher.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import type { CockpitManifestEntry } from '../../../../libs/cockpit-registry/src/index'; -import { resolveManifestLanguage } from '../../../../libs/cockpit-registry/src/index'; -import { toCockpitPath } from '../lib/route-resolution'; - -const COCKPIT_LANGUAGES = ['python', 'typescript'] as const; - -interface LanguageSwitcherProps { - manifest: CockpitManifestEntry[]; - entry: CockpitManifestEntry; -} - -export function LanguageSwitcher({ manifest, entry }: LanguageSwitcherProps) { - return ( -
- {COCKPIT_LANGUAGES.map((language) => { - const resolvedEntry = resolveManifestLanguage({ - manifest, - entry, - language, - }); - - return ( - - {language} - - ); - })} -
- ); -} diff --git a/apps/cockpit/src/components/modes/mode-switcher.spec.tsx b/apps/cockpit/src/components/modes/mode-switcher.spec.tsx index 2479b09e7..0da2fc6ea 100644 --- a/apps/cockpit/src/components/modes/mode-switcher.spec.tsx +++ b/apps/cockpit/src/components/modes/mode-switcher.spec.tsx @@ -5,7 +5,7 @@ import { act } from 'react'; import { afterEach, describe, expect, it } from 'vitest'; import { ModeSwitcher } from './mode-switcher'; -const MODES = ['Run', 'Code', 'Docs'] as const; +const MODES = ['Run', 'Code'] as const; function ModeSwitcherHarness() { const [activeMode, setActiveMode] = useState<(typeof MODES)[number]>('Run'); @@ -33,7 +33,7 @@ describe('ModeSwitcher', () => { container?.remove(); }); - it('shows only Run, Code, and Docs with Run active by default', () => { + it('shows mode buttons with Run active by default', () => { container = document.createElement('div'); document.body.append(container); root = createRoot(container); @@ -42,10 +42,9 @@ describe('ModeSwitcher', () => { root.render(); }); - const buttons = Array.from(container.querySelectorAll('button')); + const buttons = Array.from(container.querySelectorAll('[data-mode-btn]')); - expect(buttons.map((button) => button.textContent)).toEqual(['Run', 'Code', 'Docs']); - expect(buttons[0].getAttribute('aria-pressed')).toBe('true'); + expect(buttons.map((b) => b.textContent)).toEqual(['Run', 'Code']); expect(container.textContent).toContain('Run content'); }); @@ -58,18 +57,17 @@ describe('ModeSwitcher', () => { root.render(); }); - const codeButton = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'Code' + const codeButton = Array.from(container.querySelectorAll('[data-mode-btn]')).find( + (b) => b.textContent === 'Code' ); expect(codeButton).toBeDefined(); act(() => { - codeButton?.click(); + (codeButton as HTMLElement).click(); }); expect(container.textContent).toContain('Code content'); expect(container.textContent).not.toContain('Run content'); - expect(codeButton?.getAttribute('aria-pressed')).toBe('true'); }); }); diff --git a/apps/cockpit/src/components/modes/mode-switcher.tsx b/apps/cockpit/src/components/modes/mode-switcher.tsx index 9f48b3306..2e15c2771 100644 --- a/apps/cockpit/src/components/modes/mode-switcher.tsx +++ b/apps/cockpit/src/components/modes/mode-switcher.tsx @@ -1,4 +1,6 @@ -import React from 'react'; +'use client'; + +import React, { useRef, useEffect, useState } from 'react'; interface ModeSwitcherProps { modes: readonly T[]; @@ -11,17 +13,76 @@ export function ModeSwitcher({ activeMode, onChange, }: ModeSwitcherProps) { + const containerRef = useRef(null); + const [indicatorStyle, setIndicatorStyle] = useState({}); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const activeIndex = modes.indexOf(activeMode); + const buttons = container.querySelectorAll('[data-mode-btn]'); + const btn = buttons[activeIndex]; + if (btn) { + setIndicatorStyle({ + left: btn.offsetLeft, + width: btn.offsetWidth, + }); + } + }, [activeMode, modes]); + return ( -
+
+ {/* Sliding indicator */} +
+ {modes.map((mode) => { const isActive = mode === activeMode; - return ( diff --git a/apps/cockpit/src/components/narrative-docs/narrative-docs.spec.tsx b/apps/cockpit/src/components/narrative-docs/narrative-docs.spec.tsx new file mode 100644 index 000000000..d7392856b --- /dev/null +++ b/apps/cockpit/src/components/narrative-docs/narrative-docs.spec.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { NarrativeDocs } from './narrative-docs'; + +describe('NarrativeDocs', () => { + it('renders narrative HTML content', () => { + const html = renderToStaticMarkup( + Streaming Guide

Learn to stream.

', sourceFile: 'guide.md' }, + ]} + /> + ); + expect(html).toContain('Streaming Guide'); + expect(html).toContain('Learn to stream.'); + }); + + it('renders empty state when no docs', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('No documentation available'); + }); +}); diff --git a/apps/cockpit/src/components/narrative-docs/narrative-docs.tsx b/apps/cockpit/src/components/narrative-docs/narrative-docs.tsx new file mode 100644 index 000000000..d6f8ce8d7 --- /dev/null +++ b/apps/cockpit/src/components/narrative-docs/narrative-docs.tsx @@ -0,0 +1,69 @@ +'use client'; + +import React, { useCallback } from 'react'; + +interface NarrativeDoc { + title: string; + html: string; + sourceFile: string; +} + +interface NarrativeDocsProps { + narrativeDocs: NarrativeDoc[]; +} + +export function NarrativeDocs({ narrativeDocs }: NarrativeDocsProps) { + const handleClick = useCallback((e: React.MouseEvent) => { + const target = e.target as HTMLElement; + + const copyCodeBtn = target.closest('[data-copy-code]') as HTMLElement | null; + if (copyCodeBtn) { + const codeBlock = copyCodeBtn.closest('.doc-codeblock'); + const code = codeBlock?.querySelector('pre code')?.textContent ?? ''; + navigator.clipboard.writeText(code); + copyCodeBtn.textContent = 'Copied!'; + setTimeout(() => { copyCodeBtn.textContent = 'Copy'; }, 1500); + return; + } + + const copyPromptBtn = target.closest('[data-copy-prompt]') as HTMLElement | null; + if (copyPromptBtn) { + const promptBlock = copyPromptBtn.closest('.doc-prompt'); + const text = promptBlock?.querySelector('.doc-prompt__content')?.textContent ?? ''; + navigator.clipboard.writeText(text); + copyPromptBtn.textContent = 'Copied!'; + setTimeout(() => { copyPromptBtn.textContent = 'Copy prompt'; }, 1500); + return; + } + }, []); + + if (narrativeDocs.length === 0) { + return ( +
+

No documentation available for this capability.

+
+ ); + } + + return ( +
+ {narrativeDocs.map((doc) => ( +
+ ))} +
+ ); +} diff --git a/apps/cockpit/src/components/navigation/navigation-tree.tsx b/apps/cockpit/src/components/navigation/navigation-tree.tsx deleted file mode 100644 index cbc4de08a..000000000 --- a/apps/cockpit/src/components/navigation/navigation-tree.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import type { NavigationProduct } from '../../lib/route-resolution'; -import { toCockpitPath } from '../../lib/route-resolution'; - -interface NavigationTreeProps { - tree: NavigationProduct[]; -} - -export function NavigationTree({ tree }: NavigationTreeProps) { - return ( - - ); -} diff --git a/apps/cockpit/src/components/pane-rendering.spec.tsx b/apps/cockpit/src/components/pane-rendering.spec.tsx index 3ee10f1e7..8f56a0b0c 100644 --- a/apps/cockpit/src/components/pane-rendering.spec.tsx +++ b/apps/cockpit/src/components/pane-rendering.spec.tsx @@ -4,55 +4,48 @@ import { describe, expect, it } from 'vitest'; import { CodeMode } from './code-mode/code-mode'; import { CodePane } from './code-pane/code-pane'; import { CockpitShell } from './cockpit-shell'; -import { DocsPane } from './docs-pane/docs-pane'; -import { PromptPane } from './prompt-pane/prompt-pane'; -import { PromptDrawer } from './prompt-drawer/prompt-drawer'; + import { getCockpitPageModel } from '../lib/cockpit-page'; describe('metadata-driven panes', () => { - it('renders code, prompt, and docs panes from metadata values', () => { + it('renders code pane from metadata values', () => { const html = renderToStaticMarkup(
- -
); expect(html).toContain('cockpit/langgraph/streaming/python/src/index.ts'); - expect(html).toContain('cockpit/langgraph/streaming/python/prompts/streaming.md'); - expect(html).toContain('/docs/langgraph/streaming'); }); }); describe('cockpit shell contract', () => { - it('renders a stable shell with a persistent sidebar, run-first modes, and no inline prompt pane by default', () => { + it('renders a simplified shell with sidebar, run/code modes, and compact header', () => { const model = getCockpitPageModel(); const html = renderToStaticMarkup( ); expect(html).toContain('Cockpit'); - expect(html).toContain('Explore the example surface'); expect(html).toContain('Deep Agents'); expect(html).toContain('LangGraph'); expect(html).toContain('Run'); expect(html).toContain('Code'); expect(html).toContain('Docs'); - expect(html).toContain('Run example'); - expect(html).toContain('Open prompt assets'); - expect(html).toContain('Interactive example'); - expect(html).not.toContain('

Prompts

'); + expect(html).not.toContain('Explore the example surface'); + expect(html).not.toContain('Run example'); + expect(html).not.toContain('Open prompt assets'); expect(html).not.toContain('aria-label="Prompt drawer"'); }); }); describe('refreshed shell structure', () => { - it('renders the key class-based structure for the full-height shell, top file tabs, and prompt slide-over', () => { + it('renders the key structure for the full-height shell and file tabs', () => { const model = getCockpitPageModel(); const html = renderToStaticMarkup(
@@ -60,6 +53,7 @@ describe('refreshed shell structure', () => { navigationTree={model.navigationTree} presentation={model.presentation} entryTitle={model.entry.title} + contentBundle={{ codeFiles: {}, promptFiles: {}, runtimeUrl: null, docSections: [], narrativeDocs: [] }} /> { 'apps/cockpit/src/app/page.tsx', 'cockpit/langgraph/streaming/python/src/index.ts', ]} - /> - undefined} + backendAssetPaths={[]} + codeFiles={{}} + promptFiles={{}} />
); - expect(html).toContain('cockpit-shell'); - expect(html).toContain('cockpit-shell__workspace'); - expect(html).toContain('cockpit-sidebar'); - expect(html).toContain('cockpit-code-mode__tabs'); - expect(html).toContain('cockpit-prompt-drawer'); + expect(html).toContain('aria-label="Cockpit shell"'); + expect(html).toContain('aria-label="Cockpit sidebar"'); + expect(html).toContain('aria-label="Code mode"'); + expect(html).toContain('page.tsx'); + expect(html).toContain('index.ts'); }); }); diff --git a/apps/cockpit/src/components/prompt-drawer/prompt-drawer.spec.tsx b/apps/cockpit/src/components/prompt-drawer/prompt-drawer.spec.tsx deleted file mode 100644 index 01301d947..000000000 --- a/apps/cockpit/src/components/prompt-drawer/prompt-drawer.spec.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** @vitest-environment jsdom */ -import React from 'react'; -import { act } from 'react'; -import { createRoot } from 'react-dom/client'; -import { afterEach, describe, expect, it } from 'vitest'; -import { CockpitShell } from '../cockpit-shell'; -import { getCockpitPageModel } from '../../lib/cockpit-page'; - -describe('prompt drawer shell behavior', () => { - let container: HTMLDivElement | undefined; - let root: ReturnType | undefined; - - afterEach(() => { - act(() => { - root?.unmount(); - }); - container?.remove(); - }); - - it('opens prompt assets in a secondary slide-over and preserves the active mode when it closes', () => { - const model = getCockpitPageModel(); - container = document.createElement('div'); - document.body.append(container); - root = createRoot(container); - - act(() => { - root.render( - - ); - }); - - const codeButton = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'Code' - ); - - act(() => { - codeButton?.click(); - }); - - expect(container.textContent).toContain('Code'); - expect(container.textContent).toContain('page.tsx'); - - const openPromptButton = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'Open prompt assets' - ); - - act(() => { - openPromptButton?.click(); - }); - - expect(container.querySelector('[aria-label="Prompt drawer"]')).not.toBeNull(); - expect(container.textContent).toContain('streaming.md'); - - const closeButton = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'Close' - ); - - act(() => { - closeButton?.click(); - }); - - expect(container.querySelector('[aria-label="Prompt drawer"]')).toBeNull(); - expect(container.textContent).toContain('page.tsx'); - }); -}); diff --git a/apps/cockpit/src/components/prompt-drawer/prompt-drawer.tsx b/apps/cockpit/src/components/prompt-drawer/prompt-drawer.tsx deleted file mode 100644 index e4bc27039..000000000 --- a/apps/cockpit/src/components/prompt-drawer/prompt-drawer.tsx +++ /dev/null @@ -1,78 +0,0 @@ -'use client'; - -import React, { useEffect, useState } from 'react'; - -interface PromptDrawerProps { - isOpen: boolean; - entryTitle: string; - paths: readonly string[]; - onClose: () => void; -} - -const getPromptLabel = (path: string): string => path.split('/').pop() ?? path; - -export function PromptDrawer({ - isOpen, - entryTitle, - paths, - onClose, -}: PromptDrawerProps) { - const [activePath, setActivePath] = useState(paths[0] ?? ''); - - useEffect(() => { - if (!paths.includes(activePath)) { - setActivePath(paths[0] ?? ''); - } - }, [activePath, paths]); - - if (!isOpen) { - return null; - } - - const resolvedActivePath = paths.includes(activePath) ? activePath : (paths[0] ?? ''); - - return ( - - ); -} diff --git a/apps/cockpit/src/components/prompt-pane/prompt-pane.tsx b/apps/cockpit/src/components/prompt-pane/prompt-pane.tsx deleted file mode 100644 index 79fcf94f6..000000000 --- a/apps/cockpit/src/components/prompt-pane/prompt-pane.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; - -interface PromptPaneProps { - paths: string[]; -} - -export function PromptPane({ paths }: PromptPaneProps) { - return ( -
-

Prompts

-
    - {paths.map((path) => ( -
  • {path}
  • - ))} -
-
- ); -} diff --git a/apps/cockpit/src/components/run-mode/run-mode.spec.tsx b/apps/cockpit/src/components/run-mode/run-mode.spec.tsx index 8e4ea7680..4189a4cb7 100644 --- a/apps/cockpit/src/components/run-mode/run-mode.spec.tsx +++ b/apps/cockpit/src/components/run-mode/run-mode.spec.tsx @@ -4,22 +4,20 @@ import { describe, expect, it } from 'vitest'; import { RunMode } from './run-mode'; describe('RunMode', () => { - it('renders the live example surface and supporting implementation context', () => { + it('renders an iframe when runtimeUrl is provided', () => { const html = renderToStaticMarkup( - + ); + expect(html).toContain(' { + const html = renderToStaticMarkup( + + ); + expect(html).not.toContain(' -
-

Run

-

Interactive example

-

Open the working surface first, then move into code or docs as you need detail.

-
-

{entryTitle}

-

Live example surface ready.

-
-
+export function RunMode({ entryTitle, runtimeUrl }: RunModeProps) { + if (!runtimeUrl) { + return ( +
+

No runtime available. Start the local dev server to preview.

+
+ ); + } - + return ( +
+