diff --git a/apps/cockpit/scripts/capability-registry.ts b/apps/cockpit/scripts/capability-registry.ts index dfaa13d7a..c504b7a6d 100644 --- a/apps/cockpit/scripts/capability-registry.ts +++ b/apps/cockpit/scripts/capability-registry.ts @@ -4,7 +4,7 @@ */ export interface Capability { id: string; - product: 'langgraph' | 'deep-agents'; + product: 'langgraph' | 'deep-agents' | 'render' | 'chat'; topic: string; angularProject: string; port: number; @@ -27,6 +27,24 @@ export const capabilities: readonly Capability[] = [ { 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' }, + // Render capabilities + { id: 'spec-rendering', product: 'render', topic: 'spec-rendering', angularProject: 'cockpit-render-spec-rendering-angular', port: 4401, pythonDir: 'cockpit/render/spec-rendering/python', graphName: 'spec-rendering' }, + { id: 'element-rendering', product: 'render', topic: 'element-rendering', angularProject: 'cockpit-render-element-rendering-angular', port: 4402, pythonDir: 'cockpit/render/element-rendering/python', graphName: 'element-rendering' }, + { id: 'state-management', product: 'render', topic: 'state-management', angularProject: 'cockpit-render-state-management-angular', port: 4403, pythonDir: 'cockpit/render/state-management/python', graphName: 'state-management' }, + { id: 'r-registry', product: 'render', topic: 'registry', angularProject: 'cockpit-render-registry-angular', port: 4404, pythonDir: 'cockpit/render/registry/python', graphName: 'r-registry' }, + { id: 'repeat-loops', product: 'render', topic: 'repeat-loops', angularProject: 'cockpit-render-repeat-loops-angular', port: 4405, pythonDir: 'cockpit/render/repeat-loops/python', graphName: 'repeat-loops' }, + { id: 'computed-functions', product: 'render', topic: 'computed-functions', angularProject: 'cockpit-render-computed-functions-angular', port: 4406, pythonDir: 'cockpit/render/computed-functions/python', graphName: 'computed-functions' }, + // Chat capabilities + { id: 'c-messages', product: 'chat', topic: 'messages', angularProject: 'cockpit-chat-messages-angular', port: 4501, pythonDir: 'cockpit/chat/messages/python', graphName: 'c-messages' }, + { id: 'c-input', product: 'chat', topic: 'input', angularProject: 'cockpit-chat-input-angular', port: 4502, pythonDir: 'cockpit/chat/input/python', graphName: 'c-input' }, + { id: 'c-interrupts', product: 'chat', topic: 'interrupts', angularProject: 'cockpit-chat-interrupts-angular', port: 4503, pythonDir: 'cockpit/chat/interrupts/python', graphName: 'c-interrupts' }, + { id: 'c-tool-calls', product: 'chat', topic: 'tool-calls', angularProject: 'cockpit-chat-tool-calls-angular', port: 4504, pythonDir: 'cockpit/chat/tool-calls/python', graphName: 'c-tool-calls' }, + { id: 'c-subagents', product: 'chat', topic: 'subagents', angularProject: 'cockpit-chat-subagents-angular', port: 4505, pythonDir: 'cockpit/chat/subagents/python', graphName: 'c-subagents' }, + { id: 'c-threads', product: 'chat', topic: 'threads', angularProject: 'cockpit-chat-threads-angular', port: 4506, pythonDir: 'cockpit/chat/threads/python', graphName: 'c-threads' }, + { id: 'c-timeline', product: 'chat', topic: 'timeline', angularProject: 'cockpit-chat-timeline-angular', port: 4507, pythonDir: 'cockpit/chat/timeline/python', graphName: 'c-timeline' }, + { id: 'c-generative-ui', product: 'chat', topic: 'generative-ui', angularProject: 'cockpit-chat-generative-ui-angular', port: 4508, pythonDir: 'cockpit/chat/generative-ui/python', graphName: 'c-generative-ui' }, + { id: 'c-debug', product: 'chat', topic: 'debug', angularProject: 'cockpit-chat-debug-angular', port: 4509, pythonDir: 'cockpit/chat/debug/python', graphName: 'c-debug' }, + { id: 'c-theming', product: 'chat', topic: 'theming', angularProject: 'cockpit-chat-theming-angular', port: 4510, pythonDir: 'cockpit/chat/theming/python', graphName: 'c-theming' }, ] as const; export function findCapability(id: string): Capability | undefined { diff --git a/apps/cockpit/src/components/cockpit-shell.tsx b/apps/cockpit/src/components/cockpit-shell.tsx index d244638dc..fcb803147 100644 --- a/apps/cockpit/src/components/cockpit-shell.tsx +++ b/apps/cockpit/src/components/cockpit-shell.tsx @@ -57,11 +57,11 @@ export function CockpitShell({ return (
{/* Desktop sidebar — hidden on mobile */} -
+
)} -
+
+ } +
+
+

CSS Variables

+
    +
  • --chat-bg
  • +
  • --chat-text
  • +
  • --chat-accent
  • +
  • --chat-surface
  • +
  • --chat-border
  • +
  • --chat-text-muted
  • +
+
+ +
+ `, +}) +export class ThemingComponent { + protected readonly stream = agent({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.streamingAssistantId, + }); + + protected readonly themeNames = Object.keys(THEMES); + protected readonly activeTheme = signal('dark'); + + setTheme(name: string) { + const theme = THEMES[name]; + if (!theme) return; + this.activeTheme.set(name); + Object.entries(theme).forEach(([key, value]) => { + document.documentElement.style.setProperty(key, value); + }); + } +} diff --git a/cockpit/chat/theming/angular/src/environments/environment.development.ts b/cockpit/chat/theming/angular/src/environments/environment.development.ts new file mode 100644 index 000000000..118036d5d --- /dev/null +++ b/cockpit/chat/theming/angular/src/environments/environment.development.ts @@ -0,0 +1,5 @@ +export const environment = { + production: false, + langGraphApiUrl: 'http://localhost:4510/api', + streamingAssistantId: 'c-theming', +}; diff --git a/cockpit/chat/theming/angular/src/environments/environment.ts b/cockpit/chat/theming/angular/src/environments/environment.ts new file mode 100644 index 000000000..fca3eed5b --- /dev/null +++ b/cockpit/chat/theming/angular/src/environments/environment.ts @@ -0,0 +1,5 @@ +export const environment = { + production: true, + langGraphApiUrl: '/api', + streamingAssistantId: 'c-theming', +}; diff --git a/cockpit/chat/theming/angular/src/index.html b/cockpit/chat/theming/angular/src/index.html new file mode 100644 index 000000000..cb6f574f4 --- /dev/null +++ b/cockpit/chat/theming/angular/src/index.html @@ -0,0 +1,13 @@ + + + + + Chat Theming — Angular + + + + + + + + diff --git a/cockpit/chat/theming/angular/src/index.ts b/cockpit/chat/theming/angular/src/index.ts new file mode 100644 index 000000000..66ae2b164 --- /dev/null +++ b/cockpit/chat/theming/angular/src/index.ts @@ -0,0 +1,29 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'chat'; + section: 'core-capabilities'; + topic: 'theming'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const chatThemingAngularModule: CockpitCapabilityModule = { + id: 'chat-theming-angular', + manifestIdentity: { + product: 'chat', + section: 'core-capabilities', + topic: 'theming', + page: 'overview', + language: 'angular', + }, + title: 'Chat Theming (Angular)', + docsPath: '/docs/chat/core-capabilities/theming/overview/angular', + promptAssetPaths: ['cockpit/chat/theming/python/prompts/theming.md'], + codeAssetPaths: ['cockpit/chat/theming/angular/src/app/theming.component.ts'], +}; diff --git a/cockpit/chat/theming/angular/src/main.ts b/cockpit/chat/theming/angular/src/main.ts new file mode 100644 index 000000000..0669011b9 --- /dev/null +++ b/cockpit/chat/theming/angular/src/main.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { ThemingComponent } from './app/theming.component'; + +bootstrapApplication(ThemingComponent, appConfig).catch(console.error); diff --git a/cockpit/chat/theming/angular/src/styles.css b/cockpit/chat/theming/angular/src/styles.css new file mode 100644 index 000000000..e31c63fdd --- /dev/null +++ b/cockpit/chat/theming/angular/src/styles.css @@ -0,0 +1 @@ +/* Global styles for the theming capability demo */ diff --git a/cockpit/chat/theming/angular/tsconfig.app.json b/cockpit/chat/theming/angular/tsconfig.app.json new file mode 100644 index 000000000..72c01e364 --- /dev/null +++ b/cockpit/chat/theming/angular/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts", "src/**/*.ts"] +} diff --git a/cockpit/chat/theming/angular/tsconfig.json b/cockpit/chat/theming/angular/tsconfig.json new file mode 100644 index 000000000..3fd970371 --- /dev/null +++ b/cockpit/chat/theming/angular/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "noPropertyAccessFromIndexSignature": false, + "experimentalDecorators": true, + "module": "preserve", + "emitDeclarationOnly": false, + "composite": false, + "lib": ["es2022", "dom"], + "skipLibCheck": true, + "strict": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": false, + "strictInputAccessModifiers": false, + "strictTemplates": false + }, + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/cockpit/chat/theming/python/docs/guide.md b/cockpit/chat/theming/python/docs/guide.md new file mode 100644 index 000000000..f16dc371e --- /dev/null +++ b/cockpit/chat/theming/python/docs/guide.md @@ -0,0 +1,82 @@ +# Chat Theming with @cacheplane/chat + + +Customize chat appearance using CSS custom properties and +CHAT_THEME_STYLES. Create theme presets and build a theme picker +for runtime theme switching. + + + +Add theming to your chat interface using CSS custom properties and +`CHAT_THEME_STYLES` from `@cacheplane/chat`. Create theme presets +and a theme picker for switching themes at runtime. + + + + + +Chat components use CSS custom properties for all visual styling: + +```css +--chat-bg: #171717; +--chat-text: #e0e0e0; +--chat-accent: #3b82f6; +--chat-surface: #222; +--chat-border: #333; +--chat-text-muted: #777; +``` + + + + +Use `CHAT_THEME_STYLES` to apply a complete theme: + +```typescript +import { CHAT_THEME_STYLES } from '@cacheplane/chat'; +``` + + + + +Define theme presets as objects mapping CSS custom properties: + +```typescript +const themes = { + dark: { '--chat-bg': '#171717', '--chat-text': '#e0e0e0' }, + light: { '--chat-bg': '#ffffff', '--chat-text': '#1a1a1a' }, + ocean: { '--chat-bg': '#0c1426', '--chat-text': '#c8d6e5' }, +}; +``` + + + + +Create buttons that swap CSS variables on the host element: + +```typescript +setTheme(name: string) { + const theme = this.themes[name]; + Object.entries(theme).forEach(([key, value]) => { + document.documentElement.style.setProperty(key, value); + }); +} +``` + + + + +Override specific component styles without affecting the global theme: + +```css +chat-input { + --chat-input-bg: #1a1a2e; +} +``` + + + + + +CHAT_THEME_STYLES provides sensible defaults. Override only the +properties you need to change for your brand. + diff --git a/cockpit/chat/theming/python/langgraph.json b/cockpit/chat/theming/python/langgraph.json new file mode 100644 index 000000000..35924baa9 --- /dev/null +++ b/cockpit/chat/theming/python/langgraph.json @@ -0,0 +1,8 @@ +{ + "graphs": { + "c-theming": "./src/graph.py:graph" + }, + "dependencies": ["."], + "python_version": "3.12", + "env": ".env" +} diff --git a/cockpit/chat/theming/python/package.json b/cockpit/chat/theming/python/package.json new file mode 100644 index 000000000..0bdcd1f02 --- /dev/null +++ b/cockpit/chat/theming/python/package.json @@ -0,0 +1,6 @@ +{ + "name": "@cacheplane/cockpit-chat-theming-python", + "version": "0.0.1", + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/chat/theming/python/prompts/theming.md b/cockpit/chat/theming/python/prompts/theming.md new file mode 100644 index 000000000..88613665f --- /dev/null +++ b/cockpit/chat/theming/python/prompts/theming.md @@ -0,0 +1,13 @@ +# Chat Theming Assistant + +You are an assistant that demonstrates chat theming and CSS custom +property customization in @cacheplane/chat. + +The chat UI supports extensive theming via CSS custom properties like +`--chat-bg`, `--chat-text`, `--chat-accent`, `--chat-surface`, and more. +These can be swapped at runtime using CHAT_THEME_STYLES or by setting +CSS variables on a parent element. + +Explain the theming system when asked, and demonstrate how different +themes change the appearance of the chat interface. The sidebar contains +theme picker buttons that swap themes in real time. diff --git a/cockpit/chat/theming/python/pyproject.toml b/cockpit/chat/theming/python/pyproject.toml new file mode 100644 index 000000000..919cc8ac6 --- /dev/null +++ b/cockpit/chat/theming/python/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "cockpit-chat-theming" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "langgraph>=0.3", + "langchain-openai>=0.3", + "langsmith>=0.2", +] + +[tool.uv] +dev-dependencies = [ + "langgraph-cli[inmem]>=0.1", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] diff --git a/cockpit/chat/theming/python/src/graph.py b/cockpit/chat/theming/python/src/graph.py new file mode 100644 index 000000000..c88e8ef95 --- /dev/null +++ b/cockpit/chat/theming/python/src/graph.py @@ -0,0 +1,37 @@ +""" +Chat Theming Graph + +A simple conversational agent. The focus of this capability is on +frontend theming with CSS custom properties, not graph complexity. +""" + +from pathlib import Path +from langgraph.graph import StateGraph, MessagesState, END +from langchain_openai import ChatOpenAI +from langchain_core.messages import SystemMessage + +PROMPTS_DIR = Path(__file__).parent.parent / "prompts" + + +def build_theming_graph(): + """ + Constructs a simple conversational agent for demonstrating + chat theming and CSS custom property customization. + """ + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) + + async def generate(state: MessagesState) -> dict: + system_prompt = (PROMPTS_DIR / "theming.md").read_text() + messages = [SystemMessage(content=system_prompt)] + state["messages"] + response = await llm.ainvoke(messages) + return {"messages": [response]} + + graph = StateGraph(MessagesState) + graph.add_node("generate", generate) + graph.set_entry_point("generate") + graph.add_edge("generate", END) + + return graph.compile() + + +graph = build_theming_graph() diff --git a/cockpit/chat/theming/python/src/index.ts b/cockpit/chat/theming/python/src/index.ts new file mode 100644 index 000000000..baf29ce75 --- /dev/null +++ b/cockpit/chat/theming/python/src/index.ts @@ -0,0 +1,42 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'chat'; + section: 'core-capabilities'; + topic: 'theming'; + page: 'overview'; + language: 'python'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; + backendAssetPaths: string[]; + docsAssetPaths: string[]; + runtimeUrl?: string; + devPort?: number; +} + +export const chatThemingPythonModule: CockpitCapabilityModule = { + id: 'chat-theming-python', + manifestIdentity: { + product: 'chat', + section: 'core-capabilities', + topic: 'theming', + page: 'overview', + language: 'python', + }, + title: 'Chat Theming (Python)', + docsPath: '/docs/chat/core-capabilities/theming/overview/python', + promptAssetPaths: ['cockpit/chat/theming/python/prompts/theming.md'], + codeAssetPaths: [ + 'cockpit/chat/theming/angular/src/app/theming.component.ts', + 'cockpit/chat/theming/angular/src/app/app.config.ts', + ], + backendAssetPaths: [ + 'cockpit/chat/theming/python/src/graph.py', + ], + docsAssetPaths: ['cockpit/chat/theming/python/docs/guide.md'], + runtimeUrl: 'chat/theming', + devPort: 4510, +}; diff --git a/cockpit/chat/threads/angular/e2e/threads.spec.ts b/cockpit/chat/threads/angular/e2e/threads.spec.ts new file mode 100644 index 000000000..c9acaf482 --- /dev/null +++ b/cockpit/chat/threads/angular/e2e/threads.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Chat Threads Example', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4506'); + await page.waitForSelector('app-threads', { state: 'attached' }); + }); + + test('renders the chat interface with thread list sidebar', async ({ page }) => { + await expect(page.locator('chat')).toBeVisible(); + await expect(page.locator('aside')).toBeVisible(); + await expect(page.locator('aside h3')).toHaveText('Threads'); + }); + + test('displays the thread list component', async ({ page }) => { + await expect(page.locator('chat-thread-list')).toBeVisible(); + }); +}); diff --git a/cockpit/chat/threads/angular/package.json b/cockpit/chat/threads/angular/package.json new file mode 100644 index 000000000..655507028 --- /dev/null +++ b/cockpit/chat/threads/angular/package.json @@ -0,0 +1,11 @@ +{ + "name": "@cacheplane/cockpit-chat-threads-angular", + "version": "0.0.1", + "peerDependencies": { + "@cacheplane/chat": "^0.0.1", + "@cacheplane/angular": "^0.0.1", + "@langchain/langgraph-sdk": "^0.0.36" + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/chat/threads/angular/project.json b/cockpit/chat/threads/angular/project.json new file mode 100644 index 000000000..5dbf5e131 --- /dev/null +++ b/cockpit/chat/threads/angular/project.json @@ -0,0 +1,61 @@ +{ + "name": "cockpit-chat-threads-angular", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/chat/threads/angular/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@angular/build:application", + "outputs": ["{options.outputPath.base}"], + "options": { + "outputPath": { + "base": "dist/cockpit/chat/threads/angular", + "browser": "" + }, + "browser": "cockpit/chat/threads/angular/src/main.ts", + "tsConfig": "cockpit/chat/threads/angular/tsconfig.app.json", + "styles": ["cockpit/chat/threads/angular/src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { "type": "initial", "maximumWarning": "500kb", "maximumError": "1mb" }, + { "type": "anyComponentStyle", "maximumWarning": "4kb", "maximumError": "8kb" } + ], + "outputHashing": "none" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "cockpit/chat/threads/angular/src/environments/environment.ts", + "with": "cockpit/chat/threads/angular/src/environments/environment.development.ts" + } + ] + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "continuous": true, + "executor": "@angular/build:dev-server", + "configurations": { + "production": { "buildTarget": "cockpit-chat-threads-angular:build:production" }, + "development": { "buildTarget": "cockpit-chat-threads-angular:build:development" } + }, + "defaultConfiguration": "development", + "options": { + "proxyConfig": "cockpit/chat/threads/angular/proxy.conf.json" + } + }, + "smoke": { + "executor": "nx:run-commands", + "options": { + "cwd": "cockpit/chat/threads/angular", + "command": "npx tsx -e \"import { chatThreadsAngularModule } from './src/index.ts'; const module = chatThreadsAngularModule; if (module.id !== 'chat-threads-angular' || module.title !== 'Chat Threads (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" + } + } + } +} diff --git a/cockpit/chat/threads/angular/proxy.conf.json b/cockpit/chat/threads/angular/proxy.conf.json new file mode 100644 index 000000000..8523362d7 --- /dev/null +++ b/cockpit/chat/threads/angular/proxy.conf.json @@ -0,0 +1,9 @@ +{ + "/api": { + "target": "http://localhost:8123", + "secure": false, + "changeOrigin": true, + "pathRewrite": { "^/api": "" }, + "ws": true + } +} diff --git a/cockpit/chat/threads/angular/src/app/app.config.ts b/cockpit/chat/threads/angular/src/app/app.config.ts new file mode 100644 index 000000000..1e72bb3c9 --- /dev/null +++ b/cockpit/chat/threads/angular/src/app/app.config.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideAgent } from '@cacheplane/angular'; +import { provideChat } from '@cacheplane/chat'; +import { environment } from '../environments/environment'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideAgent({ apiUrl: environment.langGraphApiUrl }), + provideChat({}), + ], +}; diff --git a/cockpit/chat/threads/angular/src/app/threads.component.ts b/cockpit/chat/threads/angular/src/app/threads.component.ts new file mode 100644 index 000000000..12c76b7f2 --- /dev/null +++ b/cockpit/chat/threads/angular/src/app/threads.component.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component } from '@angular/core'; +import { ChatComponent, ChatThreadListComponent } from '@cacheplane/chat'; +import { agent } from '@cacheplane/angular'; +import { environment } from '../environments/environment'; + +/** + * ThreadsComponent demonstrates multi-thread conversation management + * with ChatComponent and ChatThreadListComponent in a sidebar. + */ +@Component({ + selector: 'app-threads', + standalone: true, + imports: [ChatComponent, ChatThreadListComponent], + template: ` +
+ + +
+ `, +}) +export class ThreadsComponent { + protected readonly stream = agent({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.streamingAssistantId, + }); +} diff --git a/cockpit/chat/threads/angular/src/environments/environment.development.ts b/cockpit/chat/threads/angular/src/environments/environment.development.ts new file mode 100644 index 000000000..d25c91e6a --- /dev/null +++ b/cockpit/chat/threads/angular/src/environments/environment.development.ts @@ -0,0 +1,5 @@ +export const environment = { + production: false, + langGraphApiUrl: 'http://localhost:4506/api', + streamingAssistantId: 'c-threads', +}; diff --git a/cockpit/chat/threads/angular/src/environments/environment.ts b/cockpit/chat/threads/angular/src/environments/environment.ts new file mode 100644 index 000000000..70af086a0 --- /dev/null +++ b/cockpit/chat/threads/angular/src/environments/environment.ts @@ -0,0 +1,5 @@ +export const environment = { + production: true, + langGraphApiUrl: '/api', + streamingAssistantId: 'c-threads', +}; diff --git a/cockpit/chat/threads/angular/src/index.html b/cockpit/chat/threads/angular/src/index.html new file mode 100644 index 000000000..6eb7f52a8 --- /dev/null +++ b/cockpit/chat/threads/angular/src/index.html @@ -0,0 +1,13 @@ + + + + + Chat Threads — Angular + + + + + + + + diff --git a/cockpit/chat/threads/angular/src/index.ts b/cockpit/chat/threads/angular/src/index.ts new file mode 100644 index 000000000..6a16f54b0 --- /dev/null +++ b/cockpit/chat/threads/angular/src/index.ts @@ -0,0 +1,29 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'chat'; + section: 'core-capabilities'; + topic: 'threads'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const chatThreadsAngularModule: CockpitCapabilityModule = { + id: 'chat-threads-angular', + manifestIdentity: { + product: 'chat', + section: 'core-capabilities', + topic: 'threads', + page: 'overview', + language: 'angular', + }, + title: 'Chat Threads (Angular)', + docsPath: '/docs/chat/core-capabilities/threads/overview/angular', + promptAssetPaths: ['cockpit/chat/threads/python/prompts/threads.md'], + codeAssetPaths: ['cockpit/chat/threads/angular/src/app/threads.component.ts'], +}; diff --git a/cockpit/chat/threads/angular/src/main.ts b/cockpit/chat/threads/angular/src/main.ts new file mode 100644 index 000000000..e24b4705e --- /dev/null +++ b/cockpit/chat/threads/angular/src/main.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { ThreadsComponent } from './app/threads.component'; + +bootstrapApplication(ThreadsComponent, appConfig).catch(console.error); diff --git a/cockpit/chat/threads/angular/src/styles.css b/cockpit/chat/threads/angular/src/styles.css new file mode 100644 index 000000000..7f6c26701 --- /dev/null +++ b/cockpit/chat/threads/angular/src/styles.css @@ -0,0 +1 @@ +/* Global styles for the threads capability demo */ diff --git a/cockpit/chat/threads/angular/tsconfig.app.json b/cockpit/chat/threads/angular/tsconfig.app.json new file mode 100644 index 000000000..72c01e364 --- /dev/null +++ b/cockpit/chat/threads/angular/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts", "src/**/*.ts"] +} diff --git a/cockpit/chat/threads/angular/tsconfig.json b/cockpit/chat/threads/angular/tsconfig.json new file mode 100644 index 000000000..3fd970371 --- /dev/null +++ b/cockpit/chat/threads/angular/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "noPropertyAccessFromIndexSignature": false, + "experimentalDecorators": true, + "module": "preserve", + "emitDeclarationOnly": false, + "composite": false, + "lib": ["es2022", "dom"], + "skipLibCheck": true, + "strict": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": false, + "strictInputAccessModifiers": false, + "strictTemplates": false + }, + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/cockpit/chat/threads/python/docs/guide.md b/cockpit/chat/threads/python/docs/guide.md new file mode 100644 index 000000000..9e654b849 --- /dev/null +++ b/cockpit/chat/threads/python/docs/guide.md @@ -0,0 +1,73 @@ +# Chat Threads with @cacheplane/chat + + +Manage multiple conversation threads using ChatThreadListComponent. +Each thread maintains isolated message history, enabling users to +run parallel conversations with the same agent. + + + +Add multi-thread support to your chat interface using `ChatThreadListComponent` +from `@cacheplane/chat`. Display a thread list sidebar for creating and +switching between conversations. + + + + + +Thread management is built into `streamResource()`. Each thread gets +a unique ID that persists its conversation state: + +```typescript +protected readonly stream = streamResource({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.streamingAssistantId, +}); +``` + + + + +Use `ChatThreadListComponent` in a sidebar to show all threads: + +```html + +``` + + + + +The thread list component includes a button to create new threads. +Each new thread starts with an empty conversation: + +```typescript +createThread() { + this.stream.createThread(); +} +``` + + + + +Click a thread in the list to switch to it. The chat area updates +to show that thread's message history: + +```typescript +switchThread(threadId: string) { + this.stream.switchThread(threadId); +} +``` + + + + +Threads are persisted by the LangGraph backend. Reloading the page +restores the thread list and conversation history. + + + + + +Threads are ideal for keeping separate contexts — e.g., one thread +for code review and another for brainstorming. + diff --git a/cockpit/chat/threads/python/langgraph.json b/cockpit/chat/threads/python/langgraph.json new file mode 100644 index 000000000..8bf246711 --- /dev/null +++ b/cockpit/chat/threads/python/langgraph.json @@ -0,0 +1,8 @@ +{ + "graphs": { + "c-threads": "./src/graph.py:graph" + }, + "dependencies": ["."], + "python_version": "3.12", + "env": ".env" +} diff --git a/cockpit/chat/threads/python/package.json b/cockpit/chat/threads/python/package.json new file mode 100644 index 000000000..04453c8c1 --- /dev/null +++ b/cockpit/chat/threads/python/package.json @@ -0,0 +1,6 @@ +{ + "name": "@cacheplane/cockpit-chat-threads-python", + "version": "0.0.1", + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/chat/threads/python/prompts/threads.md b/cockpit/chat/threads/python/prompts/threads.md new file mode 100644 index 000000000..678d2ad5e --- /dev/null +++ b/cockpit/chat/threads/python/prompts/threads.md @@ -0,0 +1,11 @@ +# Chat Threads Assistant + +You are an assistant that demonstrates multi-thread conversation management. + +Each conversation thread maintains its own isolated message history. +Users can create new threads, switch between existing threads, and +each thread preserves its full conversation context independently. + +When the user starts a conversation, acknowledge the current thread +and explain that they can create new threads or switch between them +using the thread list in the sidebar. diff --git a/cockpit/chat/threads/python/pyproject.toml b/cockpit/chat/threads/python/pyproject.toml new file mode 100644 index 000000000..3323111bf --- /dev/null +++ b/cockpit/chat/threads/python/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "cockpit-chat-threads" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "langgraph>=0.3", + "langchain-openai>=0.3", + "langsmith>=0.2", +] + +[tool.uv] +dev-dependencies = [ + "langgraph-cli[inmem]>=0.1", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] diff --git a/cockpit/chat/threads/python/src/graph.py b/cockpit/chat/threads/python/src/graph.py new file mode 100644 index 000000000..0d0b03c2d --- /dev/null +++ b/cockpit/chat/threads/python/src/graph.py @@ -0,0 +1,37 @@ +""" +Chat Threads Graph + +A standard conversational agent. Thread management (creating, switching, +persisting) is handled by the frontend and LangGraph SDK, not the graph itself. +""" + +from pathlib import Path +from langgraph.graph import StateGraph, MessagesState, END +from langchain_openai import ChatOpenAI +from langchain_core.messages import SystemMessage + +PROMPTS_DIR = Path(__file__).parent.parent / "prompts" + + +def build_threads_graph(): + """ + Constructs a standard conversational agent. + Threads are managed by the LangGraph SDK on the frontend side. + """ + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) + + async def generate(state: MessagesState) -> dict: + system_prompt = (PROMPTS_DIR / "threads.md").read_text() + messages = [SystemMessage(content=system_prompt)] + state["messages"] + response = await llm.ainvoke(messages) + return {"messages": [response]} + + graph = StateGraph(MessagesState) + graph.add_node("generate", generate) + graph.set_entry_point("generate") + graph.add_edge("generate", END) + + return graph.compile() + + +graph = build_threads_graph() diff --git a/cockpit/chat/threads/python/src/index.ts b/cockpit/chat/threads/python/src/index.ts new file mode 100644 index 000000000..9ef3f113a --- /dev/null +++ b/cockpit/chat/threads/python/src/index.ts @@ -0,0 +1,42 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'chat'; + section: 'core-capabilities'; + topic: 'threads'; + page: 'overview'; + language: 'python'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; + backendAssetPaths: string[]; + docsAssetPaths: string[]; + runtimeUrl?: string; + devPort?: number; +} + +export const chatThreadsPythonModule: CockpitCapabilityModule = { + id: 'chat-threads-python', + manifestIdentity: { + product: 'chat', + section: 'core-capabilities', + topic: 'threads', + page: 'overview', + language: 'python', + }, + title: 'Chat Threads (Python)', + docsPath: '/docs/chat/core-capabilities/threads/overview/python', + promptAssetPaths: ['cockpit/chat/threads/python/prompts/threads.md'], + codeAssetPaths: [ + 'cockpit/chat/threads/angular/src/app/threads.component.ts', + 'cockpit/chat/threads/angular/src/app/app.config.ts', + ], + backendAssetPaths: [ + 'cockpit/chat/threads/python/src/graph.py', + ], + docsAssetPaths: ['cockpit/chat/threads/python/docs/guide.md'], + runtimeUrl: 'chat/threads', + devPort: 4506, +}; diff --git a/cockpit/chat/timeline/angular/e2e/timeline.spec.ts b/cockpit/chat/timeline/angular/e2e/timeline.spec.ts new file mode 100644 index 000000000..2e5a2f6e8 --- /dev/null +++ b/cockpit/chat/timeline/angular/e2e/timeline.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Chat Timeline Example', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4507'); + await page.waitForSelector('app-timeline', { state: 'attached' }); + }); + + test('renders the chat interface with timeline sidebar', async ({ page }) => { + await expect(page.locator('chat')).toBeVisible(); + await expect(page.locator('aside')).toBeVisible(); + await expect(page.locator('aside h3')).toHaveText('Timeline'); + }); + + test('displays the timeline slider', async ({ page }) => { + await expect(page.locator('chat-timeline-slider')).toBeVisible(); + }); +}); diff --git a/cockpit/chat/timeline/angular/package.json b/cockpit/chat/timeline/angular/package.json new file mode 100644 index 000000000..e3ee64af2 --- /dev/null +++ b/cockpit/chat/timeline/angular/package.json @@ -0,0 +1,11 @@ +{ + "name": "@cacheplane/cockpit-chat-timeline-angular", + "version": "0.0.1", + "peerDependencies": { + "@cacheplane/chat": "^0.0.1", + "@cacheplane/angular": "^0.0.1", + "@langchain/langgraph-sdk": "^0.0.36" + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/chat/timeline/angular/project.json b/cockpit/chat/timeline/angular/project.json new file mode 100644 index 000000000..f3705700d --- /dev/null +++ b/cockpit/chat/timeline/angular/project.json @@ -0,0 +1,61 @@ +{ + "name": "cockpit-chat-timeline-angular", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/chat/timeline/angular/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@angular/build:application", + "outputs": ["{options.outputPath.base}"], + "options": { + "outputPath": { + "base": "dist/cockpit/chat/timeline/angular", + "browser": "" + }, + "browser": "cockpit/chat/timeline/angular/src/main.ts", + "tsConfig": "cockpit/chat/timeline/angular/tsconfig.app.json", + "styles": ["cockpit/chat/timeline/angular/src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { "type": "initial", "maximumWarning": "500kb", "maximumError": "1mb" }, + { "type": "anyComponentStyle", "maximumWarning": "4kb", "maximumError": "8kb" } + ], + "outputHashing": "none" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "cockpit/chat/timeline/angular/src/environments/environment.ts", + "with": "cockpit/chat/timeline/angular/src/environments/environment.development.ts" + } + ] + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "continuous": true, + "executor": "@angular/build:dev-server", + "configurations": { + "production": { "buildTarget": "cockpit-chat-timeline-angular:build:production" }, + "development": { "buildTarget": "cockpit-chat-timeline-angular:build:development" } + }, + "defaultConfiguration": "development", + "options": { + "proxyConfig": "cockpit/chat/timeline/angular/proxy.conf.json" + } + }, + "smoke": { + "executor": "nx:run-commands", + "options": { + "cwd": "cockpit/chat/timeline/angular", + "command": "npx tsx -e \"import { chatTimelineAngularModule } from './src/index.ts'; const module = chatTimelineAngularModule; if (module.id !== 'chat-timeline-angular' || module.title !== 'Chat Timeline (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" + } + } + } +} diff --git a/cockpit/chat/timeline/angular/proxy.conf.json b/cockpit/chat/timeline/angular/proxy.conf.json new file mode 100644 index 000000000..8523362d7 --- /dev/null +++ b/cockpit/chat/timeline/angular/proxy.conf.json @@ -0,0 +1,9 @@ +{ + "/api": { + "target": "http://localhost:8123", + "secure": false, + "changeOrigin": true, + "pathRewrite": { "^/api": "" }, + "ws": true + } +} diff --git a/cockpit/chat/timeline/angular/src/app/app.config.ts b/cockpit/chat/timeline/angular/src/app/app.config.ts new file mode 100644 index 000000000..1e72bb3c9 --- /dev/null +++ b/cockpit/chat/timeline/angular/src/app/app.config.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideAgent } from '@cacheplane/angular'; +import { provideChat } from '@cacheplane/chat'; +import { environment } from '../environments/environment'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideAgent({ apiUrl: environment.langGraphApiUrl }), + provideChat({}), + ], +}; diff --git a/cockpit/chat/timeline/angular/src/app/timeline.component.ts b/cockpit/chat/timeline/angular/src/app/timeline.component.ts new file mode 100644 index 000000000..354c71ffd --- /dev/null +++ b/cockpit/chat/timeline/angular/src/app/timeline.component.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component } from '@angular/core'; +import { ChatComponent, ChatTimelineSliderComponent } from '@cacheplane/chat'; +import { agent } from '@cacheplane/angular'; +import { environment } from '../environments/environment'; + +/** + * TimelineComponent demonstrates conversation timeline navigation + * with ChatComponent and ChatTimelineSliderComponent for scrubbing + * through conversation checkpoints. + */ +@Component({ + selector: 'app-timeline', + standalone: true, + imports: [ChatComponent, ChatTimelineSliderComponent], + template: ` +
+ + +
+ `, +}) +export class TimelineComponent { + protected readonly stream = agent({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.streamingAssistantId, + }); +} diff --git a/cockpit/chat/timeline/angular/src/environments/environment.development.ts b/cockpit/chat/timeline/angular/src/environments/environment.development.ts new file mode 100644 index 000000000..1ad681989 --- /dev/null +++ b/cockpit/chat/timeline/angular/src/environments/environment.development.ts @@ -0,0 +1,5 @@ +export const environment = { + production: false, + langGraphApiUrl: 'http://localhost:4507/api', + streamingAssistantId: 'c-timeline', +}; diff --git a/cockpit/chat/timeline/angular/src/environments/environment.ts b/cockpit/chat/timeline/angular/src/environments/environment.ts new file mode 100644 index 000000000..ec0bb715d --- /dev/null +++ b/cockpit/chat/timeline/angular/src/environments/environment.ts @@ -0,0 +1,5 @@ +export const environment = { + production: true, + langGraphApiUrl: '/api', + streamingAssistantId: 'c-timeline', +}; diff --git a/cockpit/chat/timeline/angular/src/index.html b/cockpit/chat/timeline/angular/src/index.html new file mode 100644 index 000000000..860eb68eb --- /dev/null +++ b/cockpit/chat/timeline/angular/src/index.html @@ -0,0 +1,13 @@ + + + + + Chat Timeline — Angular + + + + + + + + diff --git a/cockpit/chat/timeline/angular/src/index.ts b/cockpit/chat/timeline/angular/src/index.ts new file mode 100644 index 000000000..bafe0e47e --- /dev/null +++ b/cockpit/chat/timeline/angular/src/index.ts @@ -0,0 +1,29 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'chat'; + section: 'core-capabilities'; + topic: 'timeline'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const chatTimelineAngularModule: CockpitCapabilityModule = { + id: 'chat-timeline-angular', + manifestIdentity: { + product: 'chat', + section: 'core-capabilities', + topic: 'timeline', + page: 'overview', + language: 'angular', + }, + title: 'Chat Timeline (Angular)', + docsPath: '/docs/chat/core-capabilities/timeline/overview/angular', + promptAssetPaths: ['cockpit/chat/timeline/python/prompts/timeline.md'], + codeAssetPaths: ['cockpit/chat/timeline/angular/src/app/timeline.component.ts'], +}; diff --git a/cockpit/chat/timeline/angular/src/main.ts b/cockpit/chat/timeline/angular/src/main.ts new file mode 100644 index 000000000..c454999eb --- /dev/null +++ b/cockpit/chat/timeline/angular/src/main.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { TimelineComponent } from './app/timeline.component'; + +bootstrapApplication(TimelineComponent, appConfig).catch(console.error); diff --git a/cockpit/chat/timeline/angular/src/styles.css b/cockpit/chat/timeline/angular/src/styles.css new file mode 100644 index 000000000..bd63176c6 --- /dev/null +++ b/cockpit/chat/timeline/angular/src/styles.css @@ -0,0 +1 @@ +/* Global styles for the timeline capability demo */ diff --git a/cockpit/chat/timeline/angular/tsconfig.app.json b/cockpit/chat/timeline/angular/tsconfig.app.json new file mode 100644 index 000000000..72c01e364 --- /dev/null +++ b/cockpit/chat/timeline/angular/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts", "src/**/*.ts"] +} diff --git a/cockpit/chat/timeline/angular/tsconfig.json b/cockpit/chat/timeline/angular/tsconfig.json new file mode 100644 index 000000000..3fd970371 --- /dev/null +++ b/cockpit/chat/timeline/angular/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "noPropertyAccessFromIndexSignature": false, + "experimentalDecorators": true, + "module": "preserve", + "emitDeclarationOnly": false, + "composite": false, + "lib": ["es2022", "dom"], + "skipLibCheck": true, + "strict": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": false, + "strictInputAccessModifiers": false, + "strictTemplates": false + }, + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/cockpit/chat/timeline/python/docs/guide.md b/cockpit/chat/timeline/python/docs/guide.md new file mode 100644 index 000000000..40c62d4b9 --- /dev/null +++ b/cockpit/chat/timeline/python/docs/guide.md @@ -0,0 +1,67 @@ +# Chat Timeline with @cacheplane/chat + + +Navigate conversation history using ChatTimelineSliderComponent. +Each exchange creates a checkpoint that users can scrub through, +enabling time-travel debugging and conversation branching. + + + +Add timeline navigation to your chat interface using +`ChatTimelineSliderComponent` from `@cacheplane/chat`. Enable users +to navigate checkpoints and branch from previous conversation states. + + + + + +History tracking is built into `streamResource()`. Each message exchange +creates a checkpoint automatically: + +```typescript +protected readonly stream = streamResource({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.streamingAssistantId, +}); +``` + + + + +Use `ChatTimelineSliderComponent` to display a scrubber for +navigating conversation checkpoints: + +```html + +``` + + + + +The slider allows users to scrub through conversation history. +Each position corresponds to a graph checkpoint with its full state. + + + + +Position the slider below the chat or in a sidebar for easy access: + +```html + +``` + + + + +Users can branch from any checkpoint to explore alternative +conversation paths. The timeline tracks all branches. + + + + + +Timeline navigation is especially useful for debugging agent behavior +and understanding how the conversation state evolved over time. + diff --git a/cockpit/chat/timeline/python/langgraph.json b/cockpit/chat/timeline/python/langgraph.json new file mode 100644 index 000000000..330a954a2 --- /dev/null +++ b/cockpit/chat/timeline/python/langgraph.json @@ -0,0 +1,8 @@ +{ + "graphs": { + "c-timeline": "./src/graph.py:graph" + }, + "dependencies": ["."], + "python_version": "3.12", + "env": ".env" +} diff --git a/cockpit/chat/timeline/python/package.json b/cockpit/chat/timeline/python/package.json new file mode 100644 index 000000000..4a852e66b --- /dev/null +++ b/cockpit/chat/timeline/python/package.json @@ -0,0 +1,6 @@ +{ + "name": "@cacheplane/cockpit-chat-timeline-python", + "version": "0.0.1", + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/chat/timeline/python/prompts/timeline.md b/cockpit/chat/timeline/python/prompts/timeline.md new file mode 100644 index 000000000..2eaaaca16 --- /dev/null +++ b/cockpit/chat/timeline/python/prompts/timeline.md @@ -0,0 +1,11 @@ +# Chat Timeline Assistant + +You are an assistant that demonstrates conversation timeline and +checkpoint navigation using stream-resource. + +Each message exchange creates a checkpoint in the conversation timeline. +Users can navigate backward and forward through these checkpoints using +the timeline slider, and even branch from a previous checkpoint to +explore alternative conversation paths. + +Respond helpfully to demonstrate how checkpoints accumulate over time. diff --git a/cockpit/chat/timeline/python/pyproject.toml b/cockpit/chat/timeline/python/pyproject.toml new file mode 100644 index 000000000..30e1d5b63 --- /dev/null +++ b/cockpit/chat/timeline/python/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "cockpit-chat-timeline" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "langgraph>=0.3", + "langchain-openai>=0.3", + "langsmith>=0.2", +] + +[tool.uv] +dev-dependencies = [ + "langgraph-cli[inmem]>=0.1", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] diff --git a/cockpit/chat/timeline/python/src/graph.py b/cockpit/chat/timeline/python/src/graph.py new file mode 100644 index 000000000..40d552d27 --- /dev/null +++ b/cockpit/chat/timeline/python/src/graph.py @@ -0,0 +1,37 @@ +""" +Chat Timeline Graph + +A standard conversational agent. Timeline and checkpoint navigation +is managed by stream-resource on the frontend side. +""" + +from pathlib import Path +from langgraph.graph import StateGraph, MessagesState, END +from langchain_openai import ChatOpenAI +from langchain_core.messages import SystemMessage + +PROMPTS_DIR = Path(__file__).parent.parent / "prompts" + + +def build_timeline_graph(): + """ + Constructs a standard conversational agent. + Timeline/history navigation is handled by the stream-resource frontend. + """ + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) + + async def generate(state: MessagesState) -> dict: + system_prompt = (PROMPTS_DIR / "timeline.md").read_text() + messages = [SystemMessage(content=system_prompt)] + state["messages"] + response = await llm.ainvoke(messages) + return {"messages": [response]} + + graph = StateGraph(MessagesState) + graph.add_node("generate", generate) + graph.set_entry_point("generate") + graph.add_edge("generate", END) + + return graph.compile() + + +graph = build_timeline_graph() diff --git a/cockpit/chat/timeline/python/src/index.ts b/cockpit/chat/timeline/python/src/index.ts new file mode 100644 index 000000000..8c5a8825a --- /dev/null +++ b/cockpit/chat/timeline/python/src/index.ts @@ -0,0 +1,42 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'chat'; + section: 'core-capabilities'; + topic: 'timeline'; + page: 'overview'; + language: 'python'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; + backendAssetPaths: string[]; + docsAssetPaths: string[]; + runtimeUrl?: string; + devPort?: number; +} + +export const chatTimelinePythonModule: CockpitCapabilityModule = { + id: 'chat-timeline-python', + manifestIdentity: { + product: 'chat', + section: 'core-capabilities', + topic: 'timeline', + page: 'overview', + language: 'python', + }, + title: 'Chat Timeline (Python)', + docsPath: '/docs/chat/core-capabilities/timeline/overview/python', + promptAssetPaths: ['cockpit/chat/timeline/python/prompts/timeline.md'], + codeAssetPaths: [ + 'cockpit/chat/timeline/angular/src/app/timeline.component.ts', + 'cockpit/chat/timeline/angular/src/app/app.config.ts', + ], + backendAssetPaths: [ + 'cockpit/chat/timeline/python/src/graph.py', + ], + docsAssetPaths: ['cockpit/chat/timeline/python/docs/guide.md'], + runtimeUrl: 'chat/timeline', + devPort: 4507, +}; diff --git a/cockpit/chat/tool-calls/angular/e2e/tool-calls.spec.ts b/cockpit/chat/tool-calls/angular/e2e/tool-calls.spec.ts new file mode 100644 index 000000000..5f33c76f6 --- /dev/null +++ b/cockpit/chat/tool-calls/angular/e2e/tool-calls.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Chat Tool Calls Example', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4504'); + await page.waitForSelector('app-tool-calls', { state: 'attached' }); + }); + + test('renders the chat interface with tool calls sidebar', async ({ page }) => { + await expect(page.locator('chat')).toBeVisible(); + await expect(page.locator('aside')).toBeVisible(); + await expect(page.locator('aside h3')).toHaveText('Tool Calls'); + }); + + test('displays the available tools list', async ({ page }) => { + await expect(page.locator('aside')).toContainText('search'); + await expect(page.locator('aside')).toContainText('calculator'); + await expect(page.locator('aside')).toContainText('weather'); + }); +}); diff --git a/cockpit/chat/tool-calls/angular/package.json b/cockpit/chat/tool-calls/angular/package.json new file mode 100644 index 000000000..fcc75b15c --- /dev/null +++ b/cockpit/chat/tool-calls/angular/package.json @@ -0,0 +1,11 @@ +{ + "name": "@cacheplane/cockpit-chat-tool-calls-angular", + "version": "0.0.1", + "peerDependencies": { + "@cacheplane/chat": "^0.0.1", + "@cacheplane/angular": "^0.0.1", + "@langchain/langgraph-sdk": "^0.0.36" + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/chat/tool-calls/angular/project.json b/cockpit/chat/tool-calls/angular/project.json new file mode 100644 index 000000000..533b9f573 --- /dev/null +++ b/cockpit/chat/tool-calls/angular/project.json @@ -0,0 +1,61 @@ +{ + "name": "cockpit-chat-tool-calls-angular", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/chat/tool-calls/angular/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@angular/build:application", + "outputs": ["{options.outputPath.base}"], + "options": { + "outputPath": { + "base": "dist/cockpit/chat/tool-calls/angular", + "browser": "" + }, + "browser": "cockpit/chat/tool-calls/angular/src/main.ts", + "tsConfig": "cockpit/chat/tool-calls/angular/tsconfig.app.json", + "styles": ["cockpit/chat/tool-calls/angular/src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { "type": "initial", "maximumWarning": "500kb", "maximumError": "1mb" }, + { "type": "anyComponentStyle", "maximumWarning": "4kb", "maximumError": "8kb" } + ], + "outputHashing": "none" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "cockpit/chat/tool-calls/angular/src/environments/environment.ts", + "with": "cockpit/chat/tool-calls/angular/src/environments/environment.development.ts" + } + ] + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "continuous": true, + "executor": "@angular/build:dev-server", + "configurations": { + "production": { "buildTarget": "cockpit-chat-tool-calls-angular:build:production" }, + "development": { "buildTarget": "cockpit-chat-tool-calls-angular:build:development" } + }, + "defaultConfiguration": "development", + "options": { + "proxyConfig": "cockpit/chat/tool-calls/angular/proxy.conf.json" + } + }, + "smoke": { + "executor": "nx:run-commands", + "options": { + "cwd": "cockpit/chat/tool-calls/angular", + "command": "npx tsx -e \"import { chatToolCallsAngularModule } from './src/index.ts'; const module = chatToolCallsAngularModule; if (module.id !== 'chat-tool-calls-angular' || module.title !== 'Chat Tool Calls (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" + } + } + } +} diff --git a/cockpit/chat/tool-calls/angular/proxy.conf.json b/cockpit/chat/tool-calls/angular/proxy.conf.json new file mode 100644 index 000000000..8523362d7 --- /dev/null +++ b/cockpit/chat/tool-calls/angular/proxy.conf.json @@ -0,0 +1,9 @@ +{ + "/api": { + "target": "http://localhost:8123", + "secure": false, + "changeOrigin": true, + "pathRewrite": { "^/api": "" }, + "ws": true + } +} diff --git a/cockpit/chat/tool-calls/angular/src/app/app.config.ts b/cockpit/chat/tool-calls/angular/src/app/app.config.ts new file mode 100644 index 000000000..1e72bb3c9 --- /dev/null +++ b/cockpit/chat/tool-calls/angular/src/app/app.config.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideAgent } from '@cacheplane/angular'; +import { provideChat } from '@cacheplane/chat'; +import { environment } from '../environments/environment'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideAgent({ apiUrl: environment.langGraphApiUrl }), + provideChat({}), + ], +}; diff --git a/cockpit/chat/tool-calls/angular/src/app/tool-calls.component.ts b/cockpit/chat/tool-calls/angular/src/app/tool-calls.component.ts new file mode 100644 index 000000000..a7e37e448 --- /dev/null +++ b/cockpit/chat/tool-calls/angular/src/app/tool-calls.component.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component } from '@angular/core'; +import { + ChatComponent, + ChatToolCallsComponent, + ChatToolCallCardComponent, +} from '@cacheplane/chat'; +import { agent } from '@cacheplane/angular'; +import { environment } from '../environments/environment'; + +/** + * ToolCallsComponent demonstrates tool calling with ChatComponent + * and a sidebar showing ChatToolCallsComponent / ChatToolCallCardComponent. + */ +@Component({ + selector: 'app-tool-calls', + standalone: true, + imports: [ChatComponent, ChatToolCallsComponent, ChatToolCallCardComponent], + template: ` +
+ + +
+ `, +}) +export class ToolCallsComponent { + protected readonly stream = agent({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.streamingAssistantId, + }); +} diff --git a/cockpit/chat/tool-calls/angular/src/environments/environment.development.ts b/cockpit/chat/tool-calls/angular/src/environments/environment.development.ts new file mode 100644 index 000000000..7fc09b8c9 --- /dev/null +++ b/cockpit/chat/tool-calls/angular/src/environments/environment.development.ts @@ -0,0 +1,5 @@ +export const environment = { + production: false, + langGraphApiUrl: 'http://localhost:4504/api', + streamingAssistantId: 'c-tool-calls', +}; diff --git a/cockpit/chat/tool-calls/angular/src/environments/environment.ts b/cockpit/chat/tool-calls/angular/src/environments/environment.ts new file mode 100644 index 000000000..6e9080d59 --- /dev/null +++ b/cockpit/chat/tool-calls/angular/src/environments/environment.ts @@ -0,0 +1,5 @@ +export const environment = { + production: true, + langGraphApiUrl: '/api', + streamingAssistantId: 'c-tool-calls', +}; diff --git a/cockpit/chat/tool-calls/angular/src/index.html b/cockpit/chat/tool-calls/angular/src/index.html new file mode 100644 index 000000000..d35828138 --- /dev/null +++ b/cockpit/chat/tool-calls/angular/src/index.html @@ -0,0 +1,13 @@ + + + + + Chat Tool Calls — Angular + + + + + + + + diff --git a/cockpit/chat/tool-calls/angular/src/index.ts b/cockpit/chat/tool-calls/angular/src/index.ts new file mode 100644 index 000000000..4e8cf3185 --- /dev/null +++ b/cockpit/chat/tool-calls/angular/src/index.ts @@ -0,0 +1,29 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'chat'; + section: 'core-capabilities'; + topic: 'tool-calls'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const chatToolCallsAngularModule: CockpitCapabilityModule = { + id: 'chat-tool-calls-angular', + manifestIdentity: { + product: 'chat', + section: 'core-capabilities', + topic: 'tool-calls', + page: 'overview', + language: 'angular', + }, + title: 'Chat Tool Calls (Angular)', + docsPath: '/docs/chat/core-capabilities/tool-calls/overview/angular', + promptAssetPaths: ['cockpit/chat/tool-calls/python/prompts/tool-calls.md'], + codeAssetPaths: ['cockpit/chat/tool-calls/angular/src/app/tool-calls.component.ts'], +}; diff --git a/cockpit/chat/tool-calls/angular/src/main.ts b/cockpit/chat/tool-calls/angular/src/main.ts new file mode 100644 index 000000000..ecb623177 --- /dev/null +++ b/cockpit/chat/tool-calls/angular/src/main.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { ToolCallsComponent } from './app/tool-calls.component'; + +bootstrapApplication(ToolCallsComponent, appConfig).catch(console.error); diff --git a/cockpit/chat/tool-calls/angular/src/styles.css b/cockpit/chat/tool-calls/angular/src/styles.css new file mode 100644 index 000000000..79c4e28fd --- /dev/null +++ b/cockpit/chat/tool-calls/angular/src/styles.css @@ -0,0 +1 @@ +/* Global styles for the tool-calls capability demo */ diff --git a/cockpit/chat/tool-calls/angular/tsconfig.app.json b/cockpit/chat/tool-calls/angular/tsconfig.app.json new file mode 100644 index 000000000..72c01e364 --- /dev/null +++ b/cockpit/chat/tool-calls/angular/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts", "src/**/*.ts"] +} diff --git a/cockpit/chat/tool-calls/angular/tsconfig.json b/cockpit/chat/tool-calls/angular/tsconfig.json new file mode 100644 index 000000000..3fd970371 --- /dev/null +++ b/cockpit/chat/tool-calls/angular/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "noPropertyAccessFromIndexSignature": false, + "experimentalDecorators": true, + "module": "preserve", + "emitDeclarationOnly": false, + "composite": false, + "lib": ["es2022", "dom"], + "skipLibCheck": true, + "strict": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": false, + "strictInputAccessModifiers": false, + "strictTemplates": false + }, + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/cockpit/chat/tool-calls/python/docs/guide.md b/cockpit/chat/tool-calls/python/docs/guide.md new file mode 100644 index 000000000..42b1b0110 --- /dev/null +++ b/cockpit/chat/tool-calls/python/docs/guide.md @@ -0,0 +1,73 @@ +# Chat Tool Calls with @cacheplane/chat + + +Display tool call execution and results using ChatToolCallsComponent +and ChatToolCallCardComponent. The agent invokes tools and the frontend +renders each call with its arguments and response. + + + +Add tool call visualization to your chat interface using +`ChatToolCallsComponent` and `ChatToolCallCardComponent` from +`@cacheplane/chat`. Display active tool calls in a sidebar. + + + + + +Create LangChain tools using the `@tool` decorator: + +```python +from langchain_core.tools import tool + +@tool +def search(query: str) -> str: + """Search the web for information.""" + return f"Results for '{query}'" +``` + + + + +Bind the tools to the ChatOpenAI model so it can invoke them: + +```python +llm = ChatOpenAI(model="gpt-5-mini", streaming=True).bind_tools(tools) +``` + + + + +Use LangGraph's `ToolNode` to execute tool calls: + +```python +from langgraph.prebuilt import ToolNode +tool_node = ToolNode(tools) +``` + + + + +Use `ChatToolCallsComponent` to display active tool calls: + +```html + +``` + + + + +Use `ChatToolCallCardComponent` for detailed tool call views +showing the tool name, arguments, and result: + +```html + +``` + + + + + +Tool calls execute in a loop — the agent generates a tool call, the tool +node executes it, and the result feeds back into the agent for the next step. + diff --git a/cockpit/chat/tool-calls/python/langgraph.json b/cockpit/chat/tool-calls/python/langgraph.json new file mode 100644 index 000000000..f11bbfefa --- /dev/null +++ b/cockpit/chat/tool-calls/python/langgraph.json @@ -0,0 +1,8 @@ +{ + "graphs": { + "c-tool-calls": "./src/graph.py:graph" + }, + "dependencies": ["."], + "python_version": "3.12", + "env": ".env" +} diff --git a/cockpit/chat/tool-calls/python/package.json b/cockpit/chat/tool-calls/python/package.json new file mode 100644 index 000000000..fb6e6bd39 --- /dev/null +++ b/cockpit/chat/tool-calls/python/package.json @@ -0,0 +1,6 @@ +{ + "name": "@cacheplane/cockpit-chat-tool-calls-python", + "version": "0.0.1", + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/chat/tool-calls/python/prompts/tool-calls.md b/cockpit/chat/tool-calls/python/prompts/tool-calls.md new file mode 100644 index 000000000..bd84e06d7 --- /dev/null +++ b/cockpit/chat/tool-calls/python/prompts/tool-calls.md @@ -0,0 +1,13 @@ +# Chat Tool Calls Assistant + +You are an assistant with access to search, calculator, and weather tools. +Use these tools proactively to answer user questions. + +Available tools: +- **search**: Search the web for information on any topic +- **calculator**: Evaluate mathematical expressions +- **weather**: Get current weather for any city + +When the user asks a question, use the appropriate tool(s) to gather +information before responding. Combine results from multiple tools +when needed. Always explain which tools you used and why. diff --git a/cockpit/chat/tool-calls/python/pyproject.toml b/cockpit/chat/tool-calls/python/pyproject.toml new file mode 100644 index 000000000..ca0f95f2b --- /dev/null +++ b/cockpit/chat/tool-calls/python/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "cockpit-chat-tool-calls" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "langgraph>=0.3", + "langchain-openai>=0.3", + "langsmith>=0.2", +] + +[tool.uv] +dev-dependencies = [ + "langgraph-cli[inmem]>=0.1", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] diff --git a/cockpit/chat/tool-calls/python/src/graph.py b/cockpit/chat/tool-calls/python/src/graph.py new file mode 100644 index 000000000..db34de0b0 --- /dev/null +++ b/cockpit/chat/tool-calls/python/src/graph.py @@ -0,0 +1,75 @@ +""" +Chat Tool Calls Graph + +A LangGraph StateGraph that demonstrates tool calling with search, +calculator, and weather tools. The agent uses tools proactively +and the frontend renders tool call cards. +""" + +from pathlib import Path +from langgraph.graph import StateGraph, MessagesState, END +from langgraph.prebuilt import ToolNode +from langchain_openai import ChatOpenAI +from langchain_core.messages import SystemMessage +from langchain_core.tools import tool + +PROMPTS_DIR = Path(__file__).parent.parent / "prompts" + + +@tool +def search(query: str) -> str: + """Search the web for information.""" + return f"Search results for '{query}': Found 3 relevant articles about {query}." + + +@tool +def calculator(expression: str) -> str: + """Evaluate a mathematical expression.""" + try: + result = eval(expression, {"__builtins__": {}}, {}) + return f"Result: {result}" + except Exception as e: + return f"Error: {str(e)}" + + +@tool +def weather(city: str) -> str: + """Get current weather for a city.""" + return f"Weather in {city}: 72F, partly cloudy, humidity 45%." + + +tools = [search, calculator, weather] + + +def build_tool_calls_graph(): + """ + Constructs a tool-calling agent with search, calculator, and weather tools. + Uses conditional edges to route between generation and tool execution. + """ + llm = ChatOpenAI(model="gpt-5-mini", streaming=True).bind_tools(tools) + + async def generate(state: MessagesState) -> dict: + system_prompt = (PROMPTS_DIR / "tool-calls.md").read_text() + messages = [SystemMessage(content=system_prompt)] + state["messages"] + response = await llm.ainvoke(messages) + return {"messages": [response]} + + def should_continue(state: MessagesState) -> str: + last_message = state["messages"][-1] + if hasattr(last_message, "tool_calls") and last_message.tool_calls: + return "tools" + return END + + tool_node = ToolNode(tools) + + graph = StateGraph(MessagesState) + graph.add_node("generate", generate) + graph.add_node("tools", tool_node) + graph.set_entry_point("generate") + graph.add_conditional_edges("generate", should_continue) + graph.add_edge("tools", "generate") + + return graph.compile() + + +graph = build_tool_calls_graph() diff --git a/cockpit/chat/tool-calls/python/src/index.ts b/cockpit/chat/tool-calls/python/src/index.ts new file mode 100644 index 000000000..08ea6075f --- /dev/null +++ b/cockpit/chat/tool-calls/python/src/index.ts @@ -0,0 +1,42 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'chat'; + section: 'core-capabilities'; + topic: 'tool-calls'; + page: 'overview'; + language: 'python'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; + backendAssetPaths: string[]; + docsAssetPaths: string[]; + runtimeUrl?: string; + devPort?: number; +} + +export const chatToolCallsPythonModule: CockpitCapabilityModule = { + id: 'chat-tool-calls-python', + manifestIdentity: { + product: 'chat', + section: 'core-capabilities', + topic: 'tool-calls', + page: 'overview', + language: 'python', + }, + title: 'Chat Tool Calls (Python)', + docsPath: '/docs/chat/core-capabilities/tool-calls/overview/python', + promptAssetPaths: ['cockpit/chat/tool-calls/python/prompts/tool-calls.md'], + codeAssetPaths: [ + 'cockpit/chat/tool-calls/angular/src/app/tool-calls.component.ts', + 'cockpit/chat/tool-calls/angular/src/app/app.config.ts', + ], + backendAssetPaths: [ + 'cockpit/chat/tool-calls/python/src/graph.py', + ], + docsAssetPaths: ['cockpit/chat/tool-calls/python/docs/guide.md'], + runtimeUrl: 'chat/tool-calls', + devPort: 4504, +}; diff --git a/cockpit/deep-agents/memory/python/docs/guide.md b/cockpit/deep-agents/memory/python/docs/guide.md index 82340d6bb..113713336 100644 --- a/cockpit/deep-agents/memory/python/docs/guide.md +++ b/cockpit/deep-agents/memory/python/docs/guide.md @@ -144,3 +144,7 @@ Because LangGraph persists state across turns in a thread, `agent_memory` accumu The `@empty` block in `@for` renders when the memory dict is empty — a clean way to show a placeholder before the user shares any personal information. + + +- [Chat Threads](/chat/core-capabilities/threads/overview/python) — Learn how ChatThreadsComponent manages conversation threads + diff --git a/cockpit/deep-agents/planning/python/docs/guide.md b/cockpit/deep-agents/planning/python/docs/guide.md index 6a848c49d..1833de5bf 100644 --- a/cockpit/deep-agents/planning/python/docs/guide.md +++ b/cockpit/deep-agents/planning/python/docs/guide.md @@ -150,3 +150,7 @@ For real step-by-step execution, add one node per step and update the step's `st The `@empty` block in `@for` renders when the plan array is empty — a clean way to show a placeholder before the user submits their first complex question. + + +- [Chat Tool Calls](/chat/core-capabilities/tool-calls/overview/python) — Learn how ChatToolCallsComponent renders tool call activity + diff --git a/cockpit/deep-agents/subagents/python/docs/guide.md b/cockpit/deep-agents/subagents/python/docs/guide.md index 75ec364a5..8824a1bff 100644 --- a/cockpit/deep-agents/subagents/python/docs/guide.md +++ b/cockpit/deep-agents/subagents/python/docs/guide.md @@ -153,3 +153,7 @@ For real parallel subagent execution, use `asyncio.gather()` inside `run_subagen The `@empty` block in `@for` renders when no subagents have been spawned yet — a clean placeholder before the first message is submitted. + + +- [Chat Subagents](/chat/core-capabilities/subagents/overview/python) — Learn how ChatSubagentsComponent visualizes nested agent delegation + diff --git a/cockpit/langgraph/interrupts/python/docs/guide.md b/cockpit/langgraph/interrupts/python/docs/guide.md index 268540ba5..ce2237b66 100644 --- a/cockpit/langgraph/interrupts/python/docs/guide.md +++ b/cockpit/langgraph/interrupts/python/docs/guide.md @@ -147,3 +147,7 @@ The `` component handles message rendering, input, loading states, and Never expose your LangSmith API key in client-side code. Use server-side environment variables or a proxy. + + +- [Chat Interrupts](/chat/core-capabilities/interrupts/overview/python) — Learn how ChatInterruptsComponent handles human-in-the-loop approval flows + diff --git a/cockpit/langgraph/memory/python/docs/guide.md b/cockpit/langgraph/memory/python/docs/guide.md index 8271b83d2..2922777d5 100644 --- a/cockpit/langgraph/memory/python/docs/guide.md +++ b/cockpit/langgraph/memory/python/docs/guide.md @@ -162,3 +162,8 @@ every state update. There is no separate API call needed — just read `stream.v Never expose your LangSmith API key in client-side code. Use server-side environment variables or a proxy. + + +- [Chat Messages](/chat/core-capabilities/messages/overview/python) — Learn how ChatMessagesComponent renders messages +- [Chat Threads](/chat/core-capabilities/threads/overview/python) — Learn how ChatThreadsComponent manages conversation threads + diff --git a/cockpit/langgraph/persistence/python/docs/guide.md b/cockpit/langgraph/persistence/python/docs/guide.md index cbd2b5a4a..6cb3c3d48 100644 --- a/cockpit/langgraph/persistence/python/docs/guide.md +++ b/cockpit/langgraph/persistence/python/docs/guide.md @@ -150,3 +150,8 @@ The `` component handles message rendering, input, loading states, and Never expose your LangSmith API key in client-side code. Use server-side environment variables or a proxy. + + +- [Chat Threads](/chat/core-capabilities/threads/overview/python) — Learn how ChatThreadsComponent manages conversation threads +- [Chat Timeline](/chat/core-capabilities/timeline/overview/python) — Explore ChatTimelineComponent for visualizing thread history + diff --git a/cockpit/langgraph/streaming/python/docs/guide.md b/cockpit/langgraph/streaming/python/docs/guide.md index 529431cb2..41e6e771e 100644 --- a/cockpit/langgraph/streaming/python/docs/guide.md +++ b/cockpit/langgraph/streaming/python/docs/guide.md @@ -121,3 +121,8 @@ No service layer needed — `agent()` replaces wrapper services entirely. It han Never expose your LangSmith API key in client-side code. Use server-side environment variables or a proxy. + +- [Chat Messages](/chat/core-capabilities/messages/overview/python) — Learn how ChatMessagesComponent renders messages +- [Chat Input](/chat/core-capabilities/input/overview/python) — Explore ChatInputComponent for message submission + + diff --git a/cockpit/langgraph/subgraphs/python/docs/guide.md b/cockpit/langgraph/subgraphs/python/docs/guide.md index fd61a5f5e..01c902c86 100644 --- a/cockpit/langgraph/subgraphs/python/docs/guide.md +++ b/cockpit/langgraph/subgraphs/python/docs/guide.md @@ -130,3 +130,7 @@ The `` component handles message rendering, input, loading states, and Never expose your LangSmith API key in client-side code. Use server-side environment variables or a proxy. + + +- [Chat Subagents](/chat/core-capabilities/subagents/overview/python) — Learn how ChatSubagentsComponent visualizes nested agent delegation + diff --git a/cockpit/langgraph/time-travel/python/docs/guide.md b/cockpit/langgraph/time-travel/python/docs/guide.md index 63d5bace1..5fb2809a3 100644 --- a/cockpit/langgraph/time-travel/python/docs/guide.md +++ b/cockpit/langgraph/time-travel/python/docs/guide.md @@ -143,3 +143,8 @@ The list grows as the conversation progresses, giving you a full audit trail. effect on the next `stream.submit()` call. Calling `setBranch` without submitting does not modify the thread state. + + +- [Chat Timeline](/chat/core-capabilities/timeline/overview/python) — Explore ChatTimelineComponent for visualizing thread history +- [Chat Debug](/chat/core-capabilities/debug/overview/python) — Learn how ChatDebugComponent aids in debugging agent behavior + diff --git a/cockpit/render/computed-functions/angular/e2e/computed-functions.spec.ts b/cockpit/render/computed-functions/angular/e2e/computed-functions.spec.ts new file mode 100644 index 000000000..6ddf375b1 --- /dev/null +++ b/cockpit/render/computed-functions/angular/e2e/computed-functions.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Render Computed Functions Example', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4406'); + await page.waitForSelector('app-computed-functions', { state: 'attached' }); + }); + + test('renders spec picker and timeline', async ({ page }) => { + await expect(page.locator('button', { hasText: 'Text Transforms' })).toBeVisible(); + await expect(page.locator('streaming-timeline')).toBeVisible(); + }); + + test('shows streaming JSON pane', async ({ page }) => { + await expect(page.locator('pre')).toBeVisible(); + }); +}); diff --git a/cockpit/render/computed-functions/angular/package.json b/cockpit/render/computed-functions/angular/package.json new file mode 100644 index 000000000..35a715a8d --- /dev/null +++ b/cockpit/render/computed-functions/angular/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cacheplane/cockpit-render-computed-functions-angular", + "version": "0.0.1", + "peerDependencies": { + "@cacheplane/render": "^0.0.1" + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/render/computed-functions/angular/project.json b/cockpit/render/computed-functions/angular/project.json new file mode 100644 index 000000000..bf070de8e --- /dev/null +++ b/cockpit/render/computed-functions/angular/project.json @@ -0,0 +1,61 @@ +{ + "name": "cockpit-render-computed-functions-angular", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/render/computed-functions/angular/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@angular/build:application", + "outputs": ["{options.outputPath.base}"], + "options": { + "outputPath": { + "base": "dist/cockpit/render/computed-functions/angular", + "browser": "" + }, + "browser": "cockpit/render/computed-functions/angular/src/main.ts", + "tsConfig": "cockpit/render/computed-functions/angular/tsconfig.app.json", + "styles": ["cockpit/render/computed-functions/angular/src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { "type": "initial", "maximumWarning": "500kb", "maximumError": "1mb" }, + { "type": "anyComponentStyle", "maximumWarning": "4kb", "maximumError": "8kb" } + ], + "outputHashing": "none" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "cockpit/render/computed-functions/angular/src/environments/environment.ts", + "with": "cockpit/render/computed-functions/angular/src/environments/environment.development.ts" + } + ] + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "continuous": true, + "executor": "@angular/build:dev-server", + "configurations": { + "production": { "buildTarget": "cockpit-render-computed-functions-angular:build:production" }, + "development": { "buildTarget": "cockpit-render-computed-functions-angular:build:development" } + }, + "defaultConfiguration": "development", + "options": { + "proxyConfig": "cockpit/render/computed-functions/angular/proxy.conf.json" + } + }, + "smoke": { + "executor": "nx:run-commands", + "options": { + "cwd": "cockpit/render/computed-functions/angular", + "command": "npx tsx -e \"import { renderComputedFunctionsAngularModule } from './src/index.ts'; const module = renderComputedFunctionsAngularModule; if (module.id !== 'render-computed-functions-angular' || module.title !== 'Render Computed Functions (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" + } + } + } +} diff --git a/cockpit/render/computed-functions/angular/proxy.conf.json b/cockpit/render/computed-functions/angular/proxy.conf.json new file mode 100644 index 000000000..8523362d7 --- /dev/null +++ b/cockpit/render/computed-functions/angular/proxy.conf.json @@ -0,0 +1,9 @@ +{ + "/api": { + "target": "http://localhost:8123", + "secure": false, + "changeOrigin": true, + "pathRewrite": { "^/api": "" }, + "ws": true + } +} diff --git a/cockpit/render/computed-functions/angular/src/app/app.config.ts b/cockpit/render/computed-functions/angular/src/app/app.config.ts new file mode 100644 index 000000000..fa452f8d8 --- /dev/null +++ b/cockpit/render/computed-functions/angular/src/app/app.config.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideRender } from '@cacheplane/render'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRender({ + functions: { + formatDate: (args: Record) => new Date(args['value'] as string).toLocaleDateString(), + uppercase: (args: Record) => (args['value'] as string).toUpperCase(), + multiply: (args: Record) => (args['a'] as number) * (args['b'] as number), + reverse: (args: Record) => (args['value'] as string).split('').reverse().join(''), + }, + }), + ], +}; diff --git a/cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts b/cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts new file mode 100644 index 000000000..7eb458108 --- /dev/null +++ b/cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, OnDestroy, viewChild, ElementRef, effect } from '@angular/core'; +import { + RenderSpecComponent, + RenderElementComponent, + defineAngularRegistry, + signalStateStore, +} from '@cacheplane/render'; +import type { Spec } from '@json-render/core'; +import { StreamingSimulator } from '../../../../shared/streaming-simulator'; +import { StreamingTimelineComponent } from '../../../../shared/streaming-timeline.component'; +import { COMPUTED_FUNCTIONS_SPECS } from './specs'; + +// --- Inline view components --- + +@Component({ + selector: 'demo-value', + standalone: true, + template: ` + @if (label() || value()) { +
+ {{ label() }}: + {{ value() }} +
+ } @else if (loading()) { +
+
+
+
+ } + `, +}) +class DemoValueComponent { + readonly label = input(''); + readonly value = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-heading', + standalone: true, + imports: [RenderElementComponent], + template: ` + @if (content()) { +

{{ content() }}

+ } @else if (loading()) { +
+ } + @for (key of childKeys(); track key) { + + } + @if (!childKeys().length && loading()) { +
+
+
+
+ } + `, +}) +class DemoHeadingComponent { + readonly content = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-card', + standalone: true, + imports: [RenderElementComponent], + template: ` +
+ @if (title()) { +

{{ title() }}

+ } @else if (loading()) { +
+ } + @if (childKeys().length) { + @for (key of childKeys(); track key) { + + } + } @else if (loading()) { +
+
+
+
+ } +
+ `, +}) +class DemoCardComponent { + readonly title = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'app-computed-functions', + standalone: true, + imports: [RenderSpecComponent, StreamingTimelineComponent], + template: ` +
+ +
+ Spec: + @for (spec of specs; track spec.label; let i = $index) { + + } +
+ + +
+ +
+
Live Render Output
+ @if (simulator.spec(); as renderedSpec) { + + } @else { +
Press play to start streaming...
+ } +
+ + +
+ +
+
Streaming JSON
+
{{ simulator.rawJson() }}|
+
+ {{ simulator.playing() ? 'Streaming...' : simulator.position() >= simulator.total() ? 'Complete' : 'Paused' }} + {{ percent() }}% +
+
+
+
+ + + +
+ `, +}) +export class ComputedFunctionsComponent implements OnDestroy { + protected readonly specs = COMPUTED_FUNCTIONS_SPECS; + protected activeIndex = 0; + + protected readonly simulator = new StreamingSimulator(this.specs[0].json); + + private readonly jsonPane = viewChild>('jsonPane'); + + constructor() { + effect(() => { + this.simulator.rawJson(); + const el = this.jsonPane()?.nativeElement; + if (el) { + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight; + }); + } + }); + } + + protected readonly registry = defineAngularRegistry({ + Value: DemoValueComponent, + Heading: DemoHeadingComponent, + Card: DemoCardComponent, + }); + + protected readonly store = signalStateStore({}); + + protected percent(): number { + return Math.round(this.simulator.progress() * 100); + } + + protected selectSpec(index: number): void { + this.activeIndex = index; + this.simulator.setSource(this.specs[index].json); + this.simulator.play(); + } + + ngOnDestroy(): void { + this.simulator.destroy(); + } +} diff --git a/cockpit/render/computed-functions/angular/src/app/specs.ts b/cockpit/render/computed-functions/angular/src/app/specs.ts new file mode 100644 index 000000000..bb674d081 --- /dev/null +++ b/cockpit/render/computed-functions/angular/src/app/specs.ts @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { DemoSpec } from '../../../../spec-rendering/angular/src/app/specs'; + +export const COMPUTED_FUNCTIONS_SPECS: DemoSpec[] = [ + { + label: 'Text Transforms', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Text Transforms' }, + children: ['upper', 'reversed'], + }, + upper: { + type: 'Value', + props: { + label: 'Uppercase', + value: { $fn: 'uppercase', args: { value: 'hello world' } }, + }, + }, + reversed: { + type: 'Value', + props: { + label: 'Reversed', + value: { $fn: 'reverse', args: { value: 'streaming' } }, + }, + }, + }, + }, null, 2), + }, + { + label: 'Data Display', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Card', + props: { title: 'Formatted Data' }, + children: ['date', 'product'], + }, + date: { + type: 'Value', + props: { + label: 'Formatted Date', + value: { $fn: 'formatDate', args: { value: '2024-06-15T12:00:00Z' } }, + }, + }, + product: { + type: 'Value', + props: { + label: 'Multiply 7 x 6', + value: { $fn: 'multiply', args: { a: 7, b: 6 } }, + }, + }, + }, + }, null, 2), + }, + { + label: 'Mixed Functions', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Mixed Functions' }, + children: ['calc', 'transform', 'format'], + }, + calc: { + type: 'Value', + props: { + label: 'Multiply 12 x 5', + value: { $fn: 'multiply', args: { a: 12, b: 5 } }, + }, + }, + transform: { + type: 'Value', + props: { + label: 'Uppercase', + value: { $fn: 'uppercase', args: { value: 'computed functions' } }, + }, + }, + format: { + type: 'Value', + props: { + label: 'Date', + value: { $fn: 'formatDate', args: { value: '2025-01-01T00:00:00Z' } }, + }, + }, + }, + }, null, 2), + }, +]; diff --git a/cockpit/render/computed-functions/angular/src/environments/environment.development.ts b/cockpit/render/computed-functions/angular/src/environments/environment.development.ts new file mode 100644 index 000000000..8558a09ba --- /dev/null +++ b/cockpit/render/computed-functions/angular/src/environments/environment.development.ts @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export const environment = { production: false }; diff --git a/cockpit/render/computed-functions/angular/src/environments/environment.ts b/cockpit/render/computed-functions/angular/src/environments/environment.ts new file mode 100644 index 000000000..6c1ae4083 --- /dev/null +++ b/cockpit/render/computed-functions/angular/src/environments/environment.ts @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export const environment = { production: true }; diff --git a/cockpit/render/computed-functions/angular/src/index.html b/cockpit/render/computed-functions/angular/src/index.html new file mode 100644 index 000000000..a081abe3d --- /dev/null +++ b/cockpit/render/computed-functions/angular/src/index.html @@ -0,0 +1,13 @@ + + + + + Render Computed Functions — Angular + + + + + + + + diff --git a/cockpit/render/computed-functions/angular/src/index.ts b/cockpit/render/computed-functions/angular/src/index.ts new file mode 100644 index 000000000..ec12f09aa --- /dev/null +++ b/cockpit/render/computed-functions/angular/src/index.ts @@ -0,0 +1,29 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'render'; + section: 'core-capabilities'; + topic: 'computed-functions'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const renderComputedFunctionsAngularModule: CockpitCapabilityModule = { + id: 'render-computed-functions-angular', + manifestIdentity: { + product: 'render', + section: 'core-capabilities', + topic: 'computed-functions', + page: 'overview', + language: 'angular', + }, + title: 'Render Computed Functions (Angular)', + docsPath: '/docs/render/core-capabilities/computed-functions/overview/angular', + promptAssetPaths: ['cockpit/render/computed-functions/angular/prompts/computed-functions.md'], + codeAssetPaths: ['cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts'], +}; diff --git a/cockpit/render/computed-functions/angular/src/main.ts b/cockpit/render/computed-functions/angular/src/main.ts new file mode 100644 index 000000000..80c2386ae --- /dev/null +++ b/cockpit/render/computed-functions/angular/src/main.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { ComputedFunctionsComponent } from './app/computed-functions.component'; + +bootstrapApplication(ComputedFunctionsComponent, appConfig).catch(console.error); diff --git a/cockpit/render/computed-functions/angular/src/styles.css b/cockpit/render/computed-functions/angular/src/styles.css new file mode 100644 index 000000000..9d3775bff --- /dev/null +++ b/cockpit/render/computed-functions/angular/src/styles.css @@ -0,0 +1,12 @@ +/* Global styles for the computed-functions capability demo */ + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +.skeleton-shimmer { + background: linear-gradient(90deg, rgb(31 41 55 / 0) 0%, rgb(55 65 81 / 0.3) 50%, rgb(31 41 55 / 0) 100%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; +} diff --git a/cockpit/render/computed-functions/angular/tsconfig.app.json b/cockpit/render/computed-functions/angular/tsconfig.app.json new file mode 100644 index 000000000..72c01e364 --- /dev/null +++ b/cockpit/render/computed-functions/angular/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts", "src/**/*.ts"] +} diff --git a/cockpit/render/computed-functions/angular/tsconfig.json b/cockpit/render/computed-functions/angular/tsconfig.json new file mode 100644 index 000000000..3fd970371 --- /dev/null +++ b/cockpit/render/computed-functions/angular/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "noPropertyAccessFromIndexSignature": false, + "experimentalDecorators": true, + "module": "preserve", + "emitDeclarationOnly": false, + "composite": false, + "lib": ["es2022", "dom"], + "skipLibCheck": true, + "strict": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": false, + "strictInputAccessModifiers": false, + "strictTemplates": false + }, + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/cockpit/render/computed-functions/python/docs/guide.md b/cockpit/render/computed-functions/python/docs/guide.md new file mode 100644 index 000000000..e8299bd13 --- /dev/null +++ b/cockpit/render/computed-functions/python/docs/guide.md @@ -0,0 +1,89 @@ +# Computed Functions with @cacheplane/render + + +Define custom functions for prop resolution and data transformation in +render specs. Register functions with provideRender() and reference them +in spec prop expressions for dynamic computed values. + + + +Add computed functions to this Angular application using `provideRender()` +from `@cacheplane/render`. Define custom functions for data formatting, +register them in the render config, and use them in spec props. + + + + + +Create functions for data transformation: + +```typescript +const functions = { + formatDate: (value: string) => new Date(value).toLocaleDateString(), + uppercase: (value: string) => value.toUpperCase(), + multiply: (a: number, b: number) => a * b, +}; +``` + + + + +Pass functions to the provideRender configuration: + +```typescript +export const appConfig: ApplicationConfig = { + providers: [ + provideRender({ + functions, + }), + ], +}; +``` + + + + +Reference computed functions in render spec prop expressions: + +```typescript +const spec = { + type: 'text', + props: { + content: { compute: 'formatDate', args: ['2024-01-15'] }, + }, +}; +``` + + + + +Computed functions can read from the state store: + +```typescript +const spec = { + type: 'text', + props: { + content: { compute: 'uppercase', args: [{ bind: '/user/name' }] }, + }, +}; +``` + + + + +Use `streamResource()` to receive specs with computed props from the agent: + +```typescript +protected readonly stream = streamResource({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.streamingAssistantId, +}); +``` + + + + + +Keep computed functions pure and side-effect-free. They run during every +change detection cycle, so expensive operations should be memoized. + diff --git a/cockpit/render/computed-functions/python/langgraph.json b/cockpit/render/computed-functions/python/langgraph.json new file mode 100644 index 000000000..050a72f78 --- /dev/null +++ b/cockpit/render/computed-functions/python/langgraph.json @@ -0,0 +1,8 @@ +{ + "graphs": { + "computed-functions": "./src/graph.py:graph" + }, + "dependencies": ["."], + "python_version": "3.12", + "env": ".env" +} diff --git a/cockpit/render/computed-functions/python/package.json b/cockpit/render/computed-functions/python/package.json new file mode 100644 index 000000000..ebef84171 --- /dev/null +++ b/cockpit/render/computed-functions/python/package.json @@ -0,0 +1,6 @@ +{ + "name": "@cacheplane/cockpit-render-computed-functions-python", + "version": "0.0.1", + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/render/computed-functions/python/prompts/computed-functions.md b/cockpit/render/computed-functions/python/prompts/computed-functions.md new file mode 100644 index 000000000..8e0d90d1e --- /dev/null +++ b/cockpit/render/computed-functions/python/prompts/computed-functions.md @@ -0,0 +1,17 @@ +# Computed Functions Assistant + +You are an assistant that demonstrates computed functions from @cacheplane/render. + +Computed functions are custom functions registered with provideRender() that +can be used for prop resolution, expressions, and data transformation in +render specs. They allow render specs to reference dynamic computations +without hardcoding values. + +When the user asks about computed functions, explain: +- How to define custom functions that transform data for prop resolution +- How to register functions via the provideRender() configuration +- How render specs reference computed functions in prop expressions +- How computed functions combine with state store values for dynamic UIs + +Include examples of formatting dates, string transforms, math operations, +and combining computed values with state bindings. diff --git a/cockpit/render/computed-functions/python/pyproject.toml b/cockpit/render/computed-functions/python/pyproject.toml new file mode 100644 index 000000000..8d9001737 --- /dev/null +++ b/cockpit/render/computed-functions/python/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "cockpit-render-computed-functions" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "langgraph>=0.3", + "langchain-openai>=0.3", + "langsmith>=0.2", +] + +[tool.uv] +dev-dependencies = [ + "langgraph-cli[inmem]>=0.1", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] diff --git a/cockpit/render/computed-functions/python/src/graph.py b/cockpit/render/computed-functions/python/src/graph.py new file mode 100644 index 000000000..b74b7fa20 --- /dev/null +++ b/cockpit/render/computed-functions/python/src/graph.py @@ -0,0 +1,39 @@ +""" +Render Computed Functions Graph + +A LangGraph StateGraph that explains computed functions and prop resolution +for data transformation in render specs. +""" + +from pathlib import Path +from langgraph.graph import StateGraph, MessagesState, END +from langchain_openai import ChatOpenAI +from langchain_core.messages import SystemMessage + +PROMPTS_DIR = Path(__file__).parent.parent / "prompts" + + +def build_computed_functions_graph(): + """ + Constructs a graph that explains computed functions. + + The agent responds with guidance on defining custom functions for + prop resolution, expressions, and data transformation in render specs. + """ + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) + + async def generate(state: MessagesState) -> dict: + system_prompt = (PROMPTS_DIR / "computed-functions.md").read_text() + messages = [SystemMessage(content=system_prompt)] + state["messages"] + response = await llm.ainvoke(messages) + return {"messages": [response]} + + graph = StateGraph(MessagesState) + graph.add_node("generate", generate) + graph.set_entry_point("generate") + graph.add_edge("generate", END) + + return graph.compile() + + +graph = build_computed_functions_graph() diff --git a/cockpit/render/computed-functions/python/src/index.ts b/cockpit/render/computed-functions/python/src/index.ts new file mode 100644 index 000000000..79eef758c --- /dev/null +++ b/cockpit/render/computed-functions/python/src/index.ts @@ -0,0 +1,42 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'render'; + section: 'core-capabilities'; + topic: 'computed-functions'; + page: 'overview'; + language: 'python'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; + backendAssetPaths: string[]; + docsAssetPaths: string[]; + runtimeUrl?: string; + devPort?: number; +} + +export const renderComputedFunctionsPythonModule: CockpitCapabilityModule = { + id: 'render-computed-functions-python', + manifestIdentity: { + product: 'render', + section: 'core-capabilities', + topic: 'computed-functions', + page: 'overview', + language: 'python', + }, + title: 'Render Computed Functions (Python)', + docsPath: '/docs/render/core-capabilities/computed-functions/overview/python', + promptAssetPaths: ['cockpit/render/computed-functions/python/prompts/computed-functions.md'], + codeAssetPaths: [ + 'cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts', + 'cockpit/render/computed-functions/angular/src/app/app.config.ts', + ], + backendAssetPaths: [ + 'cockpit/render/computed-functions/python/src/graph.py', + ], + docsAssetPaths: ['cockpit/render/computed-functions/python/docs/guide.md'], + runtimeUrl: 'render/computed-functions', + devPort: 4406, +}; diff --git a/cockpit/render/element-rendering/angular/e2e/element-rendering.spec.ts b/cockpit/render/element-rendering/angular/e2e/element-rendering.spec.ts new file mode 100644 index 000000000..d70d964a9 --- /dev/null +++ b/cockpit/render/element-rendering/angular/e2e/element-rendering.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Render Element Rendering Example', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4402'); + await page.waitForSelector('app-element-rendering', { state: 'attached' }); + }); + + test('renders spec picker and timeline', async ({ page }) => { + await expect(page.locator('button', { hasText: 'Parent + Children' })).toBeVisible(); + await expect(page.locator('streaming-timeline')).toBeVisible(); + }); + + test('shows streaming JSON pane', async ({ page }) => { + await expect(page.locator('pre')).toBeVisible(); + }); +}); diff --git a/cockpit/render/element-rendering/angular/package.json b/cockpit/render/element-rendering/angular/package.json new file mode 100644 index 000000000..2abc83f40 --- /dev/null +++ b/cockpit/render/element-rendering/angular/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cacheplane/cockpit-render-element-rendering-angular", + "version": "0.0.1", + "peerDependencies": { + "@cacheplane/render": "^0.0.1" + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/render/element-rendering/angular/project.json b/cockpit/render/element-rendering/angular/project.json new file mode 100644 index 000000000..c92192f8a --- /dev/null +++ b/cockpit/render/element-rendering/angular/project.json @@ -0,0 +1,61 @@ +{ + "name": "cockpit-render-element-rendering-angular", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/render/element-rendering/angular/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@angular/build:application", + "outputs": ["{options.outputPath.base}"], + "options": { + "outputPath": { + "base": "dist/cockpit/render/element-rendering/angular", + "browser": "" + }, + "browser": "cockpit/render/element-rendering/angular/src/main.ts", + "tsConfig": "cockpit/render/element-rendering/angular/tsconfig.app.json", + "styles": ["cockpit/render/element-rendering/angular/src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { "type": "initial", "maximumWarning": "500kb", "maximumError": "1mb" }, + { "type": "anyComponentStyle", "maximumWarning": "4kb", "maximumError": "8kb" } + ], + "outputHashing": "none" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "cockpit/render/element-rendering/angular/src/environments/environment.ts", + "with": "cockpit/render/element-rendering/angular/src/environments/environment.development.ts" + } + ] + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "continuous": true, + "executor": "@angular/build:dev-server", + "configurations": { + "production": { "buildTarget": "cockpit-render-element-rendering-angular:build:production" }, + "development": { "buildTarget": "cockpit-render-element-rendering-angular:build:development" } + }, + "defaultConfiguration": "development", + "options": { + "proxyConfig": "cockpit/render/element-rendering/angular/proxy.conf.json" + } + }, + "smoke": { + "executor": "nx:run-commands", + "options": { + "cwd": "cockpit/render/element-rendering/angular", + "command": "npx tsx -e \"import { renderElementRenderingAngularModule } from './src/index.ts'; const module = renderElementRenderingAngularModule; if (module.id !== 'render-element-rendering-angular' || module.title !== 'Render Element Rendering (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" + } + } + } +} diff --git a/cockpit/render/element-rendering/angular/proxy.conf.json b/cockpit/render/element-rendering/angular/proxy.conf.json new file mode 100644 index 000000000..8523362d7 --- /dev/null +++ b/cockpit/render/element-rendering/angular/proxy.conf.json @@ -0,0 +1,9 @@ +{ + "/api": { + "target": "http://localhost:8123", + "secure": false, + "changeOrigin": true, + "pathRewrite": { "^/api": "" }, + "ws": true + } +} diff --git a/cockpit/render/element-rendering/angular/src/app/app.config.ts b/cockpit/render/element-rendering/angular/src/app/app.config.ts new file mode 100644 index 000000000..ee0fd6f31 --- /dev/null +++ b/cockpit/render/element-rendering/angular/src/app/app.config.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideRender } from '@cacheplane/render'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRender({}), + ], +}; diff --git a/cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts b/cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts new file mode 100644 index 000000000..153248a29 --- /dev/null +++ b/cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, OnDestroy, viewChild, ElementRef, effect, signal } from '@angular/core'; +import { + RenderSpecComponent, + RenderElementComponent, + defineAngularRegistry, + signalStateStore, +} from '@cacheplane/render'; +import type { Spec } from '@json-render/core'; +import { StreamingSimulator } from '../../../../shared/streaming-simulator'; +import { StreamingTimelineComponent } from '../../../../shared/streaming-timeline.component'; +import { ELEMENT_RENDERING_SPECS } from './specs'; + +// --- Inline view components registered in the demo registry --- + +@Component({ + selector: 'demo-text', + standalone: true, + template: ` + @if (content()) { +

{{ content() }}

+ } @else if (loading()) { +
+
+
+
+ } + `, +}) +class DemoTextComponent { + readonly content = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-heading', + standalone: true, + imports: [RenderElementComponent], + template: ` + @if (content()) { +

{{ content() }}

+ } @else if (loading()) { +
+ } + @for (key of childKeys(); track key) { + + } + @if (!childKeys().length && loading()) { +
+
+
+
+ } + `, +}) +class DemoHeadingComponent { + readonly content = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-card', + standalone: true, + imports: [RenderElementComponent], + template: ` +
+ @if (title()) { +

{{ title() }}

+ } @else if (loading()) { +
+ } + @if (childKeys().length) { + @for (key of childKeys(); track key) { + + } + } @else if (loading()) { +
+
+
+
+ } +
+ `, +}) +class DemoCardComponent { + readonly title = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'app-element-rendering', + standalone: true, + imports: [RenderSpecComponent, StreamingTimelineComponent], + template: ` +
+ +
+ Spec: + @for (spec of specs; track spec.label; let i = $index) { + + } +
+ + +
+ +
+
Live Render Output
+ @if (simulator.spec(); as renderedSpec) { + + } @else { +
Press play to start streaming...
+ } +
+ + +
+ +
+
Streaming JSON
+
{{ simulator.rawJson() }}|
+
+ {{ simulator.playing() ? 'Streaming...' : simulator.position() >= simulator.total() ? 'Complete' : 'Paused' }} + {{ percent() }}% +
+
+ + +
+
Controls
+
+ +

+ Toggles /showDetail in the state store. Elements with visible: bind react instantly. +

+
+
+
+
+ + + +
+ `, +}) +export class ElementRenderingComponent implements OnDestroy { + protected readonly specs = ELEMENT_RENDERING_SPECS; + protected activeIndex = 0; + + protected readonly simulator = new StreamingSimulator(this.specs[0].json); + + private readonly jsonPane = viewChild>('jsonPane'); + + protected readonly registry = defineAngularRegistry({ + Text: DemoTextComponent, + Heading: DemoHeadingComponent, + Card: DemoCardComponent, + }); + + protected readonly store = signalStateStore({ showDetail: true }); + + /** Angular signal tracking store value for template reactivity. */ + protected readonly showDetail = signal(true); + + constructor() { + // Sync store changes to Angular signal for change detection + this.store.subscribe(() => { + this.showDetail.set(this.store.get('/showDetail') as boolean ?? true); + }); + + // Auto-scroll JSON pane + effect(() => { + this.simulator.rawJson(); + const el = this.jsonPane()?.nativeElement; + if (el) { + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight; + }); + } + }); + } + + protected toggleShowDetail(): void { + const current = this.showDetail(); + this.store.set('/showDetail', !current); + this.showDetail.set(!current); + } + + protected percent(): number { + return Math.round(this.simulator.progress() * 100); + } + + protected selectSpec(index: number): void { + this.activeIndex = index; + this.simulator.setSource(this.specs[index].json); + this.simulator.play(); + } + + ngOnDestroy(): void { + this.simulator.destroy(); + } +} diff --git a/cockpit/render/element-rendering/angular/src/app/specs.ts b/cockpit/render/element-rendering/angular/src/app/specs.ts new file mode 100644 index 000000000..6f09ce9b9 --- /dev/null +++ b/cockpit/render/element-rendering/angular/src/app/specs.ts @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { DemoSpec } from '../../../../spec-rendering/angular/src/app/specs'; + +export const ELEMENT_RENDERING_SPECS: DemoSpec[] = [ + { + label: 'Parent + Children', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Parent Heading' }, + children: ['child1', 'child2'], + }, + child1: { + type: 'Text', + props: { content: 'First child text element rendered beneath the parent heading.' }, + }, + child2: { + type: 'Text', + props: { content: 'Second child text element demonstrating sibling rendering.' }, + }, + }, + }, null, 2), + }, + { + label: 'Deep Nesting', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Card', + props: { title: 'Outer Card' }, + children: ['inner'], + }, + inner: { + type: 'Card', + props: { title: 'Inner Card' }, + children: ['leaf'], + }, + leaf: { + type: 'Text', + props: { content: 'Deeply nested text inside two levels of card wrappers.' }, + }, + }, + }, null, 2), + }, + { + label: 'Visibility', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Visibility Demo' }, + children: ['always', 'conditional'], + }, + always: { + type: 'Text', + props: { content: 'This element is always visible.' }, + }, + conditional: { + type: 'Text', + props: { content: 'This element is conditionally visible based on /showDetail.' }, + visible: { $state: '/showDetail' }, + }, + }, + }, null, 2), + }, +]; diff --git a/cockpit/render/element-rendering/angular/src/environments/environment.development.ts b/cockpit/render/element-rendering/angular/src/environments/environment.development.ts new file mode 100644 index 000000000..8558a09ba --- /dev/null +++ b/cockpit/render/element-rendering/angular/src/environments/environment.development.ts @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export const environment = { production: false }; diff --git a/cockpit/render/element-rendering/angular/src/environments/environment.ts b/cockpit/render/element-rendering/angular/src/environments/environment.ts new file mode 100644 index 000000000..6c1ae4083 --- /dev/null +++ b/cockpit/render/element-rendering/angular/src/environments/environment.ts @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export const environment = { production: true }; diff --git a/cockpit/render/element-rendering/angular/src/index.html b/cockpit/render/element-rendering/angular/src/index.html new file mode 100644 index 000000000..381c92482 --- /dev/null +++ b/cockpit/render/element-rendering/angular/src/index.html @@ -0,0 +1,13 @@ + + + + + Render Element Rendering — Angular + + + + + + + + diff --git a/cockpit/render/element-rendering/angular/src/index.ts b/cockpit/render/element-rendering/angular/src/index.ts new file mode 100644 index 000000000..df11d4e15 --- /dev/null +++ b/cockpit/render/element-rendering/angular/src/index.ts @@ -0,0 +1,29 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'render'; + section: 'core-capabilities'; + topic: 'element-rendering'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const renderElementRenderingAngularModule: CockpitCapabilityModule = { + id: 'render-element-rendering-angular', + manifestIdentity: { + product: 'render', + section: 'core-capabilities', + topic: 'element-rendering', + page: 'overview', + language: 'angular', + }, + title: 'Render Element Rendering (Angular)', + docsPath: '/docs/render/core-capabilities/element-rendering/overview/angular', + promptAssetPaths: ['cockpit/render/element-rendering/angular/prompts/element-rendering.md'], + codeAssetPaths: ['cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts'], +}; diff --git a/cockpit/render/element-rendering/angular/src/main.ts b/cockpit/render/element-rendering/angular/src/main.ts new file mode 100644 index 000000000..4606a6ac7 --- /dev/null +++ b/cockpit/render/element-rendering/angular/src/main.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { ElementRenderingComponent } from './app/element-rendering.component'; + +bootstrapApplication(ElementRenderingComponent, appConfig).catch(console.error); diff --git a/cockpit/render/element-rendering/angular/src/styles.css b/cockpit/render/element-rendering/angular/src/styles.css new file mode 100644 index 000000000..0d333e182 --- /dev/null +++ b/cockpit/render/element-rendering/angular/src/styles.css @@ -0,0 +1,12 @@ +/* Global styles for the element-rendering capability demo */ + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +.skeleton-shimmer { + background: linear-gradient(90deg, rgb(31 41 55 / 0) 0%, rgb(55 65 81 / 0.3) 50%, rgb(31 41 55 / 0) 100%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; +} diff --git a/cockpit/render/element-rendering/angular/tsconfig.app.json b/cockpit/render/element-rendering/angular/tsconfig.app.json new file mode 100644 index 000000000..72c01e364 --- /dev/null +++ b/cockpit/render/element-rendering/angular/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts", "src/**/*.ts"] +} diff --git a/cockpit/render/element-rendering/angular/tsconfig.json b/cockpit/render/element-rendering/angular/tsconfig.json new file mode 100644 index 000000000..3fd970371 --- /dev/null +++ b/cockpit/render/element-rendering/angular/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "noPropertyAccessFromIndexSignature": false, + "experimentalDecorators": true, + "module": "preserve", + "emitDeclarationOnly": false, + "composite": false, + "lib": ["es2022", "dom"], + "skipLibCheck": true, + "strict": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": false, + "strictInputAccessModifiers": false, + "strictTemplates": false + }, + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/cockpit/render/element-rendering/python/docs/guide.md b/cockpit/render/element-rendering/python/docs/guide.md new file mode 100644 index 000000000..5cd1decd0 --- /dev/null +++ b/cockpit/render/element-rendering/python/docs/guide.md @@ -0,0 +1,86 @@ +# Element Rendering with @cacheplane/render + + +Recursively render nested element trees using RenderElementComponent. +Each element resolves its type from the registry and supports visibility +conditions bound to a reactive state store. + + + +Add recursive element rendering to this Angular component using +`RenderElementComponent` from `@cacheplane/render`. Define a nested element +spec, create a state store for visibility toggling, and render the tree. + + + + + +Create a spec with nested children forming a recursive tree: + +```typescript +const spec = { + type: 'container', + props: { class: 'space-y-2' }, + children: [ + { type: 'heading', props: { text: 'Parent Element' } }, + { + type: 'container', + props: { class: 'pl-4' }, + children: [ + { type: 'text', props: { content: 'Child element' } }, + { type: 'text', props: { content: 'Another child', visible: { bind: '/showDetail' } } }, + ], + }, + ], +}; +``` + + + + +Use `signalStateStore()` to manage visibility flags: + +```typescript +import { signalStateStore } from '@cacheplane/render'; + +const store = signalStateStore({ showDetail: true }); +``` + + + + +Pass the spec and store to the render component: + +```html + +``` + +RenderElementComponent handles the recursive rendering internally, +walking each level of the tree. + + + + +Each element in the tree is rendered by RenderElementComponent. Children +are resolved recursively, so deeply nested structures render correctly. +Visibility conditions at any level control the entire subtree below. + + + + +Use `streamResource()` to receive element specs from the agent: + +```typescript +protected readonly stream = streamResource({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.streamingAssistantId, +}); +``` + + + + + +Use JSON Pointer paths like `/showDetail` to bind visibility conditions +to values in the state store. + diff --git a/cockpit/render/element-rendering/python/langgraph.json b/cockpit/render/element-rendering/python/langgraph.json new file mode 100644 index 000000000..4d29646b2 --- /dev/null +++ b/cockpit/render/element-rendering/python/langgraph.json @@ -0,0 +1,8 @@ +{ + "graphs": { + "element-rendering": "./src/graph.py:graph" + }, + "dependencies": ["."], + "python_version": "3.12", + "env": ".env" +} diff --git a/cockpit/render/element-rendering/python/package.json b/cockpit/render/element-rendering/python/package.json new file mode 100644 index 000000000..ac18b1737 --- /dev/null +++ b/cockpit/render/element-rendering/python/package.json @@ -0,0 +1,6 @@ +{ + "name": "@cacheplane/cockpit-render-element-rendering-python", + "version": "0.0.1", + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/render/element-rendering/python/prompts/element-rendering.md b/cockpit/render/element-rendering/python/prompts/element-rendering.md new file mode 100644 index 000000000..4d7e55bd3 --- /dev/null +++ b/cockpit/render/element-rendering/python/prompts/element-rendering.md @@ -0,0 +1,15 @@ +# Element Rendering Assistant + +You are an assistant that demonstrates RenderElementComponent from @cacheplane/render. + +RenderElementComponent recursively renders nested element trees. Each element +in the tree can have children, forming a recursive structure. Visibility +conditions control whether an element and its subtree are rendered. + +When the user asks about element rendering, explain: +- How RenderElementComponent walks the element tree recursively +- How each element resolves its type from the registry +- How visibility conditions (e.g., bound to state store values) can show/hide subtrees +- How nested children inherit context from their parent elements + +Include JSON element spec examples showing nested structures with visibility bindings. diff --git a/cockpit/render/element-rendering/python/pyproject.toml b/cockpit/render/element-rendering/python/pyproject.toml new file mode 100644 index 000000000..4bd11bfb7 --- /dev/null +++ b/cockpit/render/element-rendering/python/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "cockpit-render-element-rendering" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "langgraph>=0.3", + "langchain-openai>=0.3", + "langsmith>=0.2", +] + +[tool.uv] +dev-dependencies = [ + "langgraph-cli[inmem]>=0.1", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] diff --git a/cockpit/render/element-rendering/python/src/graph.py b/cockpit/render/element-rendering/python/src/graph.py new file mode 100644 index 000000000..b8dbc9d6c --- /dev/null +++ b/cockpit/render/element-rendering/python/src/graph.py @@ -0,0 +1,40 @@ +""" +Render Element Rendering Graph + +A LangGraph StateGraph that explains recursive RenderElementComponent usage. +The Angular frontend uses RenderElementComponent to recursively render +nested element trees with visibility conditions. +""" + +from pathlib import Path +from langgraph.graph import StateGraph, MessagesState, END +from langchain_openai import ChatOpenAI +from langchain_core.messages import SystemMessage + +PROMPTS_DIR = Path(__file__).parent.parent / "prompts" + + +def build_element_rendering_graph(): + """ + Constructs a graph that explains recursive element rendering. + + The agent responds with guidance on using RenderElementComponent to + recursively render nested element trees with visibility conditions. + """ + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) + + async def generate(state: MessagesState) -> dict: + system_prompt = (PROMPTS_DIR / "element-rendering.md").read_text() + messages = [SystemMessage(content=system_prompt)] + state["messages"] + response = await llm.ainvoke(messages) + return {"messages": [response]} + + graph = StateGraph(MessagesState) + graph.add_node("generate", generate) + graph.set_entry_point("generate") + graph.add_edge("generate", END) + + return graph.compile() + + +graph = build_element_rendering_graph() diff --git a/cockpit/render/element-rendering/python/src/index.ts b/cockpit/render/element-rendering/python/src/index.ts new file mode 100644 index 000000000..0746c6cec --- /dev/null +++ b/cockpit/render/element-rendering/python/src/index.ts @@ -0,0 +1,42 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'render'; + section: 'core-capabilities'; + topic: 'element-rendering'; + page: 'overview'; + language: 'python'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; + backendAssetPaths: string[]; + docsAssetPaths: string[]; + runtimeUrl?: string; + devPort?: number; +} + +export const renderElementRenderingPythonModule: CockpitCapabilityModule = { + id: 'render-element-rendering-python', + manifestIdentity: { + product: 'render', + section: 'core-capabilities', + topic: 'element-rendering', + page: 'overview', + language: 'python', + }, + title: 'Render Element Rendering (Python)', + docsPath: '/docs/render/core-capabilities/element-rendering/overview/python', + promptAssetPaths: ['cockpit/render/element-rendering/python/prompts/element-rendering.md'], + codeAssetPaths: [ + 'cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts', + 'cockpit/render/element-rendering/angular/src/app/app.config.ts', + ], + backendAssetPaths: [ + 'cockpit/render/element-rendering/python/src/graph.py', + ], + docsAssetPaths: ['cockpit/render/element-rendering/python/docs/guide.md'], + runtimeUrl: 'render/element-rendering', + devPort: 4402, +}; diff --git a/cockpit/render/footprint.spec.ts b/cockpit/render/footprint.spec.ts new file mode 100644 index 000000000..5a7bcac97 --- /dev/null +++ b/cockpit/render/footprint.spec.ts @@ -0,0 +1,51 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const topicNames = [ + 'spec-rendering', + 'element-rendering', + 'state-management', + 'registry', + 'repeat-loops', + 'computed-functions', +] as const; + +const pageNames = ['overview', 'build', 'prompts', 'code', 'testing'] as const; + +const renderRoot = path.join(process.cwd(), 'cockpit', 'render'); +const websiteDocsRoot = path.join( + process.cwd(), 'apps', 'website', 'content', 'docs', 'render' +); + +describe('Render footprint', () => { + it('keeps the getting-started overview in place', () => { + expect( + fs.existsSync( + path.join(websiteDocsRoot, 'getting-started', 'overview', 'python', 'overview.mdx') + ) + ).toBe(true); + }); + + it('creates the approved topic modules and docs pages', () => { + for (const topic of topicNames) { + const moduleRoot = path.join(renderRoot, topic, 'python'); + const projectJsonPath = path.join(renderRoot, topic, 'angular', 'project.json'); + + expect(fs.existsSync(path.join(moduleRoot, 'src', 'index.ts'))).toBe(true); + expect(fs.existsSync(path.join(moduleRoot, 'prompts', `${topic}.md`))).toBe(true); + expect(fs.existsSync(projectJsonPath)).toBe(true); + + const projectJson = JSON.parse(fs.readFileSync(projectJsonPath, 'utf8')); + expect(projectJson.targets?.smoke?.executor).toBe('nx:run-commands'); + + for (const page of pageNames) { + expect( + fs.existsSync( + path.join(websiteDocsRoot, 'core-capabilities', topic, 'python', `${page}.mdx`) + ) + ).toBe(true); + } + } + }); +}); diff --git a/cockpit/render/matrix.spec.ts b/cockpit/render/matrix.spec.ts new file mode 100644 index 000000000..0a4e37637 --- /dev/null +++ b/cockpit/render/matrix.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { renderSpecRenderingPythonModule } from './spec-rendering/python/src/index'; +import { renderElementRenderingPythonModule } from './element-rendering/python/src/index'; +import { renderStateManagementPythonModule } from './state-management/python/src/index'; +import { renderRegistryPythonModule } from './registry/python/src/index'; +import { renderRepeatLoopsPythonModule } from './repeat-loops/python/src/index'; +import { renderComputedFunctionsPythonModule } from './computed-functions/python/src/index'; + +describe('Render matrix slice', () => { + it('exposes canonical python modules for the approved core capability topics', () => { + const modules = [ + renderSpecRenderingPythonModule, + renderElementRenderingPythonModule, + renderStateManagementPythonModule, + renderRegistryPythonModule, + renderRepeatLoopsPythonModule, + renderComputedFunctionsPythonModule, + ]; + + expect(modules).toHaveLength(6); + expect(modules.map((module) => module.manifestIdentity.topic)).toEqual([ + 'spec-rendering', + 'element-rendering', + 'state-management', + 'registry', + 'repeat-loops', + 'computed-functions', + ]); + + for (const module of modules) { + expect(module.manifestIdentity).toMatchObject({ + product: 'render', + section: 'core-capabilities', + page: 'overview', + language: 'python', + }); + expect(module.docsPath).toBe( + `/docs/render/core-capabilities/${module.manifestIdentity.topic}/overview/python` + ); + expect(module.promptAssetPaths.length).toBe(1); + expect(module.codeAssetPaths.length).toBeGreaterThanOrEqual(1); + } + }); +}); diff --git a/cockpit/render/registry/angular/e2e/registry.spec.ts b/cockpit/render/registry/angular/e2e/registry.spec.ts new file mode 100644 index 000000000..a92adf97a --- /dev/null +++ b/cockpit/render/registry/angular/e2e/registry.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Render Registry Example', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4404'); + await page.waitForSelector('app-registry', { state: 'attached' }); + }); + + test('renders spec picker and timeline', async ({ page }) => { + await expect(page.locator('button', { hasText: 'Basic Types' })).toBeVisible(); + await expect(page.locator('streaming-timeline')).toBeVisible(); + }); + + test('shows streaming JSON pane', async ({ page }) => { + await expect(page.locator('pre')).toBeVisible(); + }); +}); diff --git a/cockpit/render/registry/angular/package.json b/cockpit/render/registry/angular/package.json new file mode 100644 index 000000000..630684ffc --- /dev/null +++ b/cockpit/render/registry/angular/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cacheplane/cockpit-render-registry-angular", + "version": "0.0.1", + "peerDependencies": { + "@cacheplane/render": "^0.0.1" + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/render/registry/angular/project.json b/cockpit/render/registry/angular/project.json new file mode 100644 index 000000000..c771d1fa5 --- /dev/null +++ b/cockpit/render/registry/angular/project.json @@ -0,0 +1,61 @@ +{ + "name": "cockpit-render-registry-angular", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/render/registry/angular/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@angular/build:application", + "outputs": ["{options.outputPath.base}"], + "options": { + "outputPath": { + "base": "dist/cockpit/render/registry/angular", + "browser": "" + }, + "browser": "cockpit/render/registry/angular/src/main.ts", + "tsConfig": "cockpit/render/registry/angular/tsconfig.app.json", + "styles": ["cockpit/render/registry/angular/src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { "type": "initial", "maximumWarning": "500kb", "maximumError": "1mb" }, + { "type": "anyComponentStyle", "maximumWarning": "4kb", "maximumError": "8kb" } + ], + "outputHashing": "none" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "cockpit/render/registry/angular/src/environments/environment.ts", + "with": "cockpit/render/registry/angular/src/environments/environment.development.ts" + } + ] + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "continuous": true, + "executor": "@angular/build:dev-server", + "configurations": { + "production": { "buildTarget": "cockpit-render-registry-angular:build:production" }, + "development": { "buildTarget": "cockpit-render-registry-angular:build:development" } + }, + "defaultConfiguration": "development", + "options": { + "proxyConfig": "cockpit/render/registry/angular/proxy.conf.json" + } + }, + "smoke": { + "executor": "nx:run-commands", + "options": { + "cwd": "cockpit/render/registry/angular", + "command": "npx tsx -e \"import { renderRegistryAngularModule } from './src/index.ts'; const module = renderRegistryAngularModule; if (module.id !== 'render-registry-angular' || module.title !== 'Render Registry (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" + } + } + } +} diff --git a/cockpit/render/registry/angular/proxy.conf.json b/cockpit/render/registry/angular/proxy.conf.json new file mode 100644 index 000000000..8523362d7 --- /dev/null +++ b/cockpit/render/registry/angular/proxy.conf.json @@ -0,0 +1,9 @@ +{ + "/api": { + "target": "http://localhost:8123", + "secure": false, + "changeOrigin": true, + "pathRewrite": { "^/api": "" }, + "ws": true + } +} diff --git a/cockpit/render/registry/angular/src/app/app.config.ts b/cockpit/render/registry/angular/src/app/app.config.ts new file mode 100644 index 000000000..ee0fd6f31 --- /dev/null +++ b/cockpit/render/registry/angular/src/app/app.config.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideRender } from '@cacheplane/render'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRender({}), + ], +}; diff --git a/cockpit/render/registry/angular/src/app/registry.component.ts b/cockpit/render/registry/angular/src/app/registry.component.ts new file mode 100644 index 000000000..1d065f8c7 --- /dev/null +++ b/cockpit/render/registry/angular/src/app/registry.component.ts @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, OnDestroy, viewChild, ElementRef, effect } from '@angular/core'; +import { + RenderSpecComponent, + RenderElementComponent, + defineAngularRegistry, + signalStateStore, +} from '@cacheplane/render'; +import type { Spec } from '@json-render/core'; +import { StreamingSimulator } from '../../../../shared/streaming-simulator'; +import { StreamingTimelineComponent } from '../../../../shared/streaming-timeline.component'; +import { REGISTRY_SPECS } from './specs'; + +// --- Inline view components registered in the demo registry --- + +@Component({ + selector: 'demo-text', + standalone: true, + template: ` + @if (content()) { +

{{ content() }}

+ } @else if (loading()) { +
+
+
+
+ } + `, +}) +class DemoTextComponent { + readonly content = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-heading', + standalone: true, + imports: [RenderElementComponent], + template: ` + @if (content()) { +

{{ content() }}

+ } @else if (loading()) { +
+ } + @for (key of childKeys(); track key) { + + } + @if (!childKeys().length && loading()) { +
+
+
+
+ } + `, +}) +class DemoHeadingComponent { + readonly content = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-badge', + standalone: true, + template: ` + @if (label()) { + {{ label() }} + } @else if (loading()) { +
+ } + `, +}) +class DemoBadgeComponent { + readonly label = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-card', + standalone: true, + imports: [RenderElementComponent], + template: ` +
+ @if (title()) { +

{{ title() }}

+ } @else if (loading()) { +
+ } + @if (childKeys().length) { + @for (key of childKeys(); track key) { + + } + } @else if (loading()) { +
+
+
+
+ } +
+ `, +}) +class DemoCardComponent { + readonly title = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'app-registry', + standalone: true, + imports: [RenderSpecComponent, StreamingTimelineComponent], + template: ` +
+ +
+ Spec: + @for (spec of specs; track spec.label; let i = $index) { + + } +
+ + +
+ +
+
Live Render Output
+ @if (simulator.spec(); as renderedSpec) { + + } @else { +
Press play to start streaming...
+ } +
+ + +
+ +
+
Streaming JSON
+
{{ simulator.rawJson() }}|
+
+ {{ simulator.playing() ? 'Streaming...' : simulator.position() >= simulator.total() ? 'Complete' : 'Paused' }} + {{ percent() }}% +
+
+
+
+ + + +
+ `, +}) +export class RegistryComponent implements OnDestroy { + protected readonly specs = REGISTRY_SPECS; + protected activeIndex = 0; + + protected readonly simulator = new StreamingSimulator(this.specs[0].json); + + private readonly jsonPane = viewChild>('jsonPane'); + + constructor() { + effect(() => { + this.simulator.rawJson(); + const el = this.jsonPane()?.nativeElement; + if (el) { + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight; + }); + } + }); + } + + protected readonly registry = defineAngularRegistry({ + Text: DemoTextComponent, + Heading: DemoHeadingComponent, + Badge: DemoBadgeComponent, + Card: DemoCardComponent, + }); + + protected readonly store = signalStateStore({}); + + protected percent(): number { + return Math.round(this.simulator.progress() * 100); + } + + protected selectSpec(index: number): void { + this.activeIndex = index; + this.simulator.setSource(this.specs[index].json); + this.simulator.play(); + } + + ngOnDestroy(): void { + this.simulator.destroy(); + } +} diff --git a/cockpit/render/registry/angular/src/app/specs.ts b/cockpit/render/registry/angular/src/app/specs.ts new file mode 100644 index 000000000..d9aef552e --- /dev/null +++ b/cockpit/render/registry/angular/src/app/specs.ts @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { DemoSpec } from '../../../../spec-rendering/angular/src/app/specs'; + +export const REGISTRY_SPECS: DemoSpec[] = [ + { + label: 'Basic Types', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Basic Component Types' }, + children: ['desc', 'badge'], + }, + desc: { + type: 'Text', + props: { content: 'Each type string resolves to a registered Angular component at render time.' }, + }, + badge: { + type: 'Badge', + props: { label: 'Registered' }, + }, + }, + }, null, 2), + }, + { + label: 'Card Layout', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Card', + props: { title: 'Card Layout Demo' }, + children: ['heading', 'body', 'tag'], + }, + heading: { + type: 'Heading', + props: { content: 'Inside a Card' }, + }, + body: { + type: 'Text', + props: { content: 'Cards render their children recursively using render-element.' }, + }, + tag: { + type: 'Badge', + props: { label: 'Nested' }, + }, + }, + }, null, 2), + }, + { + label: 'Mixed Components', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'All Registered Types' }, + children: ['card1', 'card2'], + }, + card1: { + type: 'Card', + props: { title: 'First Section' }, + children: ['text1', 'badge1'], + }, + text1: { + type: 'Text', + props: { content: 'Text inside the first card section.' }, + }, + badge1: { + type: 'Badge', + props: { label: 'Section 1' }, + }, + card2: { + type: 'Card', + props: { title: 'Second Section' }, + children: ['text2', 'badge2'], + }, + text2: { + type: 'Text', + props: { content: 'Text inside the second card section.' }, + }, + badge2: { + type: 'Badge', + props: { label: 'Section 2' }, + }, + }, + }, null, 2), + }, +]; diff --git a/cockpit/render/registry/angular/src/environments/environment.development.ts b/cockpit/render/registry/angular/src/environments/environment.development.ts new file mode 100644 index 000000000..8558a09ba --- /dev/null +++ b/cockpit/render/registry/angular/src/environments/environment.development.ts @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export const environment = { production: false }; diff --git a/cockpit/render/registry/angular/src/environments/environment.ts b/cockpit/render/registry/angular/src/environments/environment.ts new file mode 100644 index 000000000..6c1ae4083 --- /dev/null +++ b/cockpit/render/registry/angular/src/environments/environment.ts @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export const environment = { production: true }; diff --git a/cockpit/render/registry/angular/src/index.html b/cockpit/render/registry/angular/src/index.html new file mode 100644 index 000000000..44fec203e --- /dev/null +++ b/cockpit/render/registry/angular/src/index.html @@ -0,0 +1,13 @@ + + + + + Render Registry — Angular + + + + + + + + diff --git a/cockpit/render/registry/angular/src/index.ts b/cockpit/render/registry/angular/src/index.ts new file mode 100644 index 000000000..a4c11b4d1 --- /dev/null +++ b/cockpit/render/registry/angular/src/index.ts @@ -0,0 +1,29 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'render'; + section: 'core-capabilities'; + topic: 'registry'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const renderRegistryAngularModule: CockpitCapabilityModule = { + id: 'render-registry-angular', + manifestIdentity: { + product: 'render', + section: 'core-capabilities', + topic: 'registry', + page: 'overview', + language: 'angular', + }, + title: 'Render Registry (Angular)', + docsPath: '/docs/render/core-capabilities/registry/overview/angular', + promptAssetPaths: ['cockpit/render/registry/angular/prompts/registry.md'], + codeAssetPaths: ['cockpit/render/registry/angular/src/app/registry.component.ts'], +}; diff --git a/cockpit/render/registry/angular/src/main.ts b/cockpit/render/registry/angular/src/main.ts new file mode 100644 index 000000000..81544a19d --- /dev/null +++ b/cockpit/render/registry/angular/src/main.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { RegistryComponent } from './app/registry.component'; + +bootstrapApplication(RegistryComponent, appConfig).catch(console.error); diff --git a/cockpit/render/registry/angular/src/styles.css b/cockpit/render/registry/angular/src/styles.css new file mode 100644 index 000000000..eda2f7b03 --- /dev/null +++ b/cockpit/render/registry/angular/src/styles.css @@ -0,0 +1,12 @@ +/* Global styles for the registry capability demo */ + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +.skeleton-shimmer { + background: linear-gradient(90deg, rgb(31 41 55 / 0) 0%, rgb(55 65 81 / 0.3) 50%, rgb(31 41 55 / 0) 100%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; +} diff --git a/cockpit/render/registry/angular/tsconfig.app.json b/cockpit/render/registry/angular/tsconfig.app.json new file mode 100644 index 000000000..72c01e364 --- /dev/null +++ b/cockpit/render/registry/angular/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts", "src/**/*.ts"] +} diff --git a/cockpit/render/registry/angular/tsconfig.json b/cockpit/render/registry/angular/tsconfig.json new file mode 100644 index 000000000..3fd970371 --- /dev/null +++ b/cockpit/render/registry/angular/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "noPropertyAccessFromIndexSignature": false, + "experimentalDecorators": true, + "module": "preserve", + "emitDeclarationOnly": false, + "composite": false, + "lib": ["es2022", "dom"], + "skipLibCheck": true, + "strict": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": false, + "strictInputAccessModifiers": false, + "strictTemplates": false + }, + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/cockpit/render/registry/python/docs/guide.md b/cockpit/render/registry/python/docs/guide.md new file mode 100644 index 000000000..3570bdf6b --- /dev/null +++ b/cockpit/render/registry/python/docs/guide.md @@ -0,0 +1,83 @@ +# Registry with @cacheplane/render + + +Map type strings to Angular component classes using defineAngularRegistry. +The registry resolves component types at render time, enabling JSON specs +to reference components by name. + + + +Add a component registry to this Angular application using +`defineAngularRegistry()` from `@cacheplane/render`. Register component +types, look them up with `registry.get()`, and list all types with +`registry.names()`. + + + + + +Create simple Angular components that will be registered: + +```typescript +@Component({ selector: 'app-card', template: '
' }) +export class CardComponent {} + +@Component({ selector: 'app-badge', template: '{{ label }}' }) +export class BadgeComponent { @Input() label = ''; } +``` + +
+ + +Map type strings to component classes: + +```typescript +import { defineAngularRegistry } from '@cacheplane/render'; + +const registry = defineAngularRegistry({ + card: CardComponent, + badge: BadgeComponent, +}); +``` + + + + +Look up a component class by its type string: + +```typescript +const CardClass = registry.get('card'); // CardComponent +const BadgeClass = registry.get('badge'); // BadgeComponent +``` + + + + +Get all registered type strings: + +```typescript +const types = registry.names(); // ['card', 'badge'] +``` + + + + +Pass the registry to provideRender in your app config: + +```typescript +export const appConfig: ApplicationConfig = { + providers: [ + provideRender({ registry }), + ], +}; +``` + +RenderSpecComponent will use this registry to resolve types in JSON specs. + + +
+ + +Keep registry entries focused — each type string should map to exactly one +component. Use descriptive names like 'data-table' or 'stat-card' for clarity. + diff --git a/cockpit/render/registry/python/langgraph.json b/cockpit/render/registry/python/langgraph.json new file mode 100644 index 000000000..0ecff1a5b --- /dev/null +++ b/cockpit/render/registry/python/langgraph.json @@ -0,0 +1,8 @@ +{ + "graphs": { + "registry": "./src/graph.py:graph" + }, + "dependencies": ["."], + "python_version": "3.12", + "env": ".env" +} diff --git a/cockpit/render/registry/python/package.json b/cockpit/render/registry/python/package.json new file mode 100644 index 000000000..ca0d813b4 --- /dev/null +++ b/cockpit/render/registry/python/package.json @@ -0,0 +1,6 @@ +{ + "name": "@cacheplane/cockpit-render-registry-python", + "version": "0.0.1", + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/render/registry/python/prompts/registry.md b/cockpit/render/registry/python/prompts/registry.md new file mode 100644 index 000000000..9af148941 --- /dev/null +++ b/cockpit/render/registry/python/prompts/registry.md @@ -0,0 +1,15 @@ +# Registry Assistant + +You are an assistant that demonstrates defineAngularRegistry from @cacheplane/render. + +defineAngularRegistry() creates a component registry that maps type strings +to Angular component classes. This registry is used by RenderSpecComponent +to resolve which component to instantiate for each element in a render spec. + +When the user asks about the registry, explain: +- How defineAngularRegistry() accepts a map of type string to component class +- How registry.get(type) returns the component class for a given type +- How registry.names() returns all registered type strings +- How the registry integrates with provideRender() configuration + +Include examples showing registry creation, lookup, and integration with the render system. diff --git a/cockpit/render/registry/python/pyproject.toml b/cockpit/render/registry/python/pyproject.toml new file mode 100644 index 000000000..16cd7ec36 --- /dev/null +++ b/cockpit/render/registry/python/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "cockpit-render-registry" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "langgraph>=0.3", + "langchain-openai>=0.3", + "langsmith>=0.2", +] + +[tool.uv] +dev-dependencies = [ + "langgraph-cli[inmem]>=0.1", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] diff --git a/cockpit/render/registry/python/src/graph.py b/cockpit/render/registry/python/src/graph.py new file mode 100644 index 000000000..fb47e1e05 --- /dev/null +++ b/cockpit/render/registry/python/src/graph.py @@ -0,0 +1,39 @@ +""" +Render Registry Graph + +A LangGraph StateGraph that explains defineAngularRegistry() for mapping +type strings to Angular component classes. +""" + +from pathlib import Path +from langgraph.graph import StateGraph, MessagesState, END +from langchain_openai import ChatOpenAI +from langchain_core.messages import SystemMessage + +PROMPTS_DIR = Path(__file__).parent.parent / "prompts" + + +def build_registry_graph(): + """ + Constructs a graph that explains defineAngularRegistry. + + The agent responds with guidance on creating and using component + registries for render spec type resolution. + """ + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) + + async def generate(state: MessagesState) -> dict: + system_prompt = (PROMPTS_DIR / "registry.md").read_text() + messages = [SystemMessage(content=system_prompt)] + state["messages"] + response = await llm.ainvoke(messages) + return {"messages": [response]} + + graph = StateGraph(MessagesState) + graph.add_node("generate", generate) + graph.set_entry_point("generate") + graph.add_edge("generate", END) + + return graph.compile() + + +graph = build_registry_graph() diff --git a/cockpit/render/registry/python/src/index.ts b/cockpit/render/registry/python/src/index.ts new file mode 100644 index 000000000..21928736c --- /dev/null +++ b/cockpit/render/registry/python/src/index.ts @@ -0,0 +1,42 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'render'; + section: 'core-capabilities'; + topic: 'registry'; + page: 'overview'; + language: 'python'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; + backendAssetPaths: string[]; + docsAssetPaths: string[]; + runtimeUrl?: string; + devPort?: number; +} + +export const renderRegistryPythonModule: CockpitCapabilityModule = { + id: 'render-registry-python', + manifestIdentity: { + product: 'render', + section: 'core-capabilities', + topic: 'registry', + page: 'overview', + language: 'python', + }, + title: 'Render Registry (Python)', + docsPath: '/docs/render/core-capabilities/registry/overview/python', + promptAssetPaths: ['cockpit/render/registry/python/prompts/registry.md'], + codeAssetPaths: [ + 'cockpit/render/registry/angular/src/app/registry.component.ts', + 'cockpit/render/registry/angular/src/app/app.config.ts', + ], + backendAssetPaths: [ + 'cockpit/render/registry/python/src/graph.py', + ], + docsAssetPaths: ['cockpit/render/registry/python/docs/guide.md'], + runtimeUrl: 'render/registry', + devPort: 4404, +}; diff --git a/cockpit/render/repeat-loops/angular/e2e/repeat-loops.spec.ts b/cockpit/render/repeat-loops/angular/e2e/repeat-loops.spec.ts new file mode 100644 index 000000000..5bf666258 --- /dev/null +++ b/cockpit/render/repeat-loops/angular/e2e/repeat-loops.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Render Repeat Loops Example', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4405'); + await page.waitForSelector('app-repeat-loops', { state: 'attached' }); + }); + + test('renders spec picker and timeline', async ({ page }) => { + await expect(page.locator('button', { hasText: 'Simple List' })).toBeVisible(); + await expect(page.locator('streaming-timeline')).toBeVisible(); + }); + + test('shows streaming JSON pane', async ({ page }) => { + await expect(page.locator('pre')).toBeVisible(); + }); +}); diff --git a/cockpit/render/repeat-loops/angular/package.json b/cockpit/render/repeat-loops/angular/package.json new file mode 100644 index 000000000..13da5861e --- /dev/null +++ b/cockpit/render/repeat-loops/angular/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cacheplane/cockpit-render-repeat-loops-angular", + "version": "0.0.1", + "peerDependencies": { + "@cacheplane/render": "^0.0.1" + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/render/repeat-loops/angular/project.json b/cockpit/render/repeat-loops/angular/project.json new file mode 100644 index 000000000..b1127da91 --- /dev/null +++ b/cockpit/render/repeat-loops/angular/project.json @@ -0,0 +1,61 @@ +{ + "name": "cockpit-render-repeat-loops-angular", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/render/repeat-loops/angular/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@angular/build:application", + "outputs": ["{options.outputPath.base}"], + "options": { + "outputPath": { + "base": "dist/cockpit/render/repeat-loops/angular", + "browser": "" + }, + "browser": "cockpit/render/repeat-loops/angular/src/main.ts", + "tsConfig": "cockpit/render/repeat-loops/angular/tsconfig.app.json", + "styles": ["cockpit/render/repeat-loops/angular/src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { "type": "initial", "maximumWarning": "500kb", "maximumError": "1mb" }, + { "type": "anyComponentStyle", "maximumWarning": "4kb", "maximumError": "8kb" } + ], + "outputHashing": "none" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "cockpit/render/repeat-loops/angular/src/environments/environment.ts", + "with": "cockpit/render/repeat-loops/angular/src/environments/environment.development.ts" + } + ] + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "continuous": true, + "executor": "@angular/build:dev-server", + "configurations": { + "production": { "buildTarget": "cockpit-render-repeat-loops-angular:build:production" }, + "development": { "buildTarget": "cockpit-render-repeat-loops-angular:build:development" } + }, + "defaultConfiguration": "development", + "options": { + "proxyConfig": "cockpit/render/repeat-loops/angular/proxy.conf.json" + } + }, + "smoke": { + "executor": "nx:run-commands", + "options": { + "cwd": "cockpit/render/repeat-loops/angular", + "command": "npx tsx -e \"import { renderRepeatLoopsAngularModule } from './src/index.ts'; const module = renderRepeatLoopsAngularModule; if (module.id !== 'render-repeat-loops-angular' || module.title !== 'Render Repeat Loops (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" + } + } + } +} diff --git a/cockpit/render/repeat-loops/angular/proxy.conf.json b/cockpit/render/repeat-loops/angular/proxy.conf.json new file mode 100644 index 000000000..8523362d7 --- /dev/null +++ b/cockpit/render/repeat-loops/angular/proxy.conf.json @@ -0,0 +1,9 @@ +{ + "/api": { + "target": "http://localhost:8123", + "secure": false, + "changeOrigin": true, + "pathRewrite": { "^/api": "" }, + "ws": true + } +} diff --git a/cockpit/render/repeat-loops/angular/src/app/app.config.ts b/cockpit/render/repeat-loops/angular/src/app/app.config.ts new file mode 100644 index 000000000..ee0fd6f31 --- /dev/null +++ b/cockpit/render/repeat-loops/angular/src/app/app.config.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideRender } from '@cacheplane/render'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRender({}), + ], +}; diff --git a/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts b/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts new file mode 100644 index 000000000..07546f93e --- /dev/null +++ b/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, OnDestroy, viewChild, ElementRef, effect } from '@angular/core'; +import { + RenderSpecComponent, + RenderElementComponent, + defineAngularRegistry, + signalStateStore, +} from '@cacheplane/render'; +import type { Spec } from '@json-render/core'; +import { StreamingSimulator } from '../../../../shared/streaming-simulator'; +import { StreamingTimelineComponent } from '../../../../shared/streaming-timeline.component'; +import { REPEAT_LOOPS_SPECS } from './specs'; + +// --- Inline view components registered in the demo registry --- + +@Component({ + selector: 'demo-text', + standalone: true, + template: ` + @if (content()) { +

{{ content() }}

+ } @else if (loading()) { +
+
+
+
+ } + `, +}) +class DemoTextComponent { + readonly content = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-heading', + standalone: true, + imports: [RenderElementComponent], + template: ` + @if (content()) { +

{{ content() }}

+ } @else if (loading()) { +
+ } + @for (key of childKeys(); track key) { + + } + @if (!childKeys().length && loading()) { +
+
+
+
+ } + `, +}) +class DemoHeadingComponent { + readonly content = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-card', + standalone: true, + imports: [RenderElementComponent], + template: ` +
+ @if (title()) { +

{{ title() }}

+ } @else if (loading()) { +
+ } + @if (childKeys().length) { + @for (key of childKeys(); track key) { + + } + } @else if (loading()) { +
+
+
+
+ } +
+ `, +}) +class DemoCardComponent { + readonly title = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'app-repeat-loops', + standalone: true, + imports: [RenderSpecComponent, StreamingTimelineComponent], + template: ` +
+ +
+ Spec: + @for (spec of specs; track spec.label; let i = $index) { + + } +
+ + +
+ +
+
Live Render Output
+ @if (simulator.spec(); as renderedSpec) { + + } @else { +
Press play to start streaming...
+ } +
+ + +
+ +
+
Streaming JSON
+
{{ simulator.rawJson() }}|
+
+ {{ simulator.playing() ? 'Streaming...' : simulator.position() >= simulator.total() ? 'Complete' : 'Paused' }} + {{ percent() }}% +
+
+ + +
+
List Controls
+
+ + @if (getItems().length) { +
+ @for (item of getItems(); track $index) { +
+ {{ item }} + +
+ } +
+ } +

+ Modify /items array in the store. +

+
+
+
+
+ + + +
+ `, +}) +export class RepeatLoopsComponent implements OnDestroy { + protected readonly specs = REPEAT_LOOPS_SPECS; + protected activeIndex = 0; + + protected readonly simulator = new StreamingSimulator(this.specs[0].json); + + private readonly jsonPane = viewChild>('jsonPane'); + + constructor() { + effect(() => { + this.simulator.rawJson(); + const el = this.jsonPane()?.nativeElement; + if (el) { + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight; + }); + } + }); + } + + protected readonly registry = defineAngularRegistry({ + Text: DemoTextComponent, + Heading: DemoHeadingComponent, + Card: DemoCardComponent, + }); + + protected readonly store = signalStateStore({ items: ['Alpha', 'Beta', 'Gamma'] }); + + private counter = 0; + + protected getItems(): string[] { + return (this.store.get('/items') as string[]) ?? []; + } + + protected addItem(): void { + this.counter++; + const items = this.getItems(); + this.store.set('/items', [...items, `Item ${this.counter}`]); + } + + protected removeItem(index: number): void { + const items = this.getItems(); + this.store.set('/items', items.filter((_: string, i: number) => i !== index)); + } + + protected percent(): number { + return Math.round(this.simulator.progress() * 100); + } + + protected selectSpec(index: number): void { + this.activeIndex = index; + this.simulator.setSource(this.specs[index].json); + this.simulator.play(); + } + + ngOnDestroy(): void { + this.simulator.destroy(); + } +} diff --git a/cockpit/render/repeat-loops/angular/src/app/specs.ts b/cockpit/render/repeat-loops/angular/src/app/specs.ts new file mode 100644 index 000000000..124344737 --- /dev/null +++ b/cockpit/render/repeat-loops/angular/src/app/specs.ts @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { DemoSpec } from '../../../../spec-rendering/angular/src/app/specs'; + +export const REPEAT_LOOPS_SPECS: DemoSpec[] = [ + { + label: 'Simple List', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Simple List' }, + children: ['item1', 'item2', 'item3'], + }, + item1: { + type: 'Text', + props: { content: 'Alpha' }, + }, + item2: { + type: 'Text', + props: { content: 'Beta' }, + }, + item3: { + type: 'Text', + props: { content: 'Gamma' }, + }, + }, + }, null, 2), + }, + { + label: 'Task List', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Card', + props: { title: 'Task List' }, + children: ['task1', 'task2', 'task3'], + }, + task1: { + type: 'Text', + props: { content: 'Review pull request' }, + }, + task2: { + type: 'Text', + props: { content: 'Update documentation' }, + }, + task3: { + type: 'Text', + props: { content: 'Deploy to staging' }, + }, + }, + }, null, 2), + }, + { + label: 'Sections', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Multiple Sections' }, + children: ['sectionA', 'sectionB'], + }, + sectionA: { + type: 'Card', + props: { title: 'Frontend Tasks' }, + children: ['feTask1', 'feTask2'], + }, + feTask1: { + type: 'Text', + props: { content: 'Build component library' }, + }, + feTask2: { + type: 'Text', + props: { content: 'Add accessibility tests' }, + }, + sectionB: { + type: 'Card', + props: { title: 'Backend Tasks' }, + children: ['beTask1', 'beTask2'], + }, + beTask1: { + type: 'Text', + props: { content: 'Optimize database queries' }, + }, + beTask2: { + type: 'Text', + props: { content: 'Add rate limiting' }, + }, + }, + }, null, 2), + }, +]; diff --git a/cockpit/render/repeat-loops/angular/src/environments/environment.development.ts b/cockpit/render/repeat-loops/angular/src/environments/environment.development.ts new file mode 100644 index 000000000..8558a09ba --- /dev/null +++ b/cockpit/render/repeat-loops/angular/src/environments/environment.development.ts @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export const environment = { production: false }; diff --git a/cockpit/render/repeat-loops/angular/src/environments/environment.ts b/cockpit/render/repeat-loops/angular/src/environments/environment.ts new file mode 100644 index 000000000..6c1ae4083 --- /dev/null +++ b/cockpit/render/repeat-loops/angular/src/environments/environment.ts @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export const environment = { production: true }; diff --git a/cockpit/render/repeat-loops/angular/src/index.html b/cockpit/render/repeat-loops/angular/src/index.html new file mode 100644 index 000000000..dbfa907c8 --- /dev/null +++ b/cockpit/render/repeat-loops/angular/src/index.html @@ -0,0 +1,13 @@ + + + + + Render Repeat Loops — Angular + + + + + + + + diff --git a/cockpit/render/repeat-loops/angular/src/index.ts b/cockpit/render/repeat-loops/angular/src/index.ts new file mode 100644 index 000000000..5e4d442c9 --- /dev/null +++ b/cockpit/render/repeat-loops/angular/src/index.ts @@ -0,0 +1,29 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'render'; + section: 'core-capabilities'; + topic: 'repeat-loops'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const renderRepeatLoopsAngularModule: CockpitCapabilityModule = { + id: 'render-repeat-loops-angular', + manifestIdentity: { + product: 'render', + section: 'core-capabilities', + topic: 'repeat-loops', + page: 'overview', + language: 'angular', + }, + title: 'Render Repeat Loops (Angular)', + docsPath: '/docs/render/core-capabilities/repeat-loops/overview/angular', + promptAssetPaths: ['cockpit/render/repeat-loops/angular/prompts/repeat-loops.md'], + codeAssetPaths: ['cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts'], +}; diff --git a/cockpit/render/repeat-loops/angular/src/main.ts b/cockpit/render/repeat-loops/angular/src/main.ts new file mode 100644 index 000000000..0d2cac5f6 --- /dev/null +++ b/cockpit/render/repeat-loops/angular/src/main.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { RepeatLoopsComponent } from './app/repeat-loops.component'; + +bootstrapApplication(RepeatLoopsComponent, appConfig).catch(console.error); diff --git a/cockpit/render/repeat-loops/angular/src/styles.css b/cockpit/render/repeat-loops/angular/src/styles.css new file mode 100644 index 000000000..61f71f9dc --- /dev/null +++ b/cockpit/render/repeat-loops/angular/src/styles.css @@ -0,0 +1,12 @@ +/* Global styles for the repeat-loops capability demo */ + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +.skeleton-shimmer { + background: linear-gradient(90deg, rgb(31 41 55 / 0) 0%, rgb(55 65 81 / 0.3) 50%, rgb(31 41 55 / 0) 100%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; +} diff --git a/cockpit/render/repeat-loops/angular/tsconfig.app.json b/cockpit/render/repeat-loops/angular/tsconfig.app.json new file mode 100644 index 000000000..72c01e364 --- /dev/null +++ b/cockpit/render/repeat-loops/angular/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts", "src/**/*.ts"] +} diff --git a/cockpit/render/repeat-loops/angular/tsconfig.json b/cockpit/render/repeat-loops/angular/tsconfig.json new file mode 100644 index 000000000..3fd970371 --- /dev/null +++ b/cockpit/render/repeat-loops/angular/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "noPropertyAccessFromIndexSignature": false, + "experimentalDecorators": true, + "module": "preserve", + "emitDeclarationOnly": false, + "composite": false, + "lib": ["es2022", "dom"], + "skipLibCheck": true, + "strict": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": false, + "strictInputAccessModifiers": false, + "strictTemplates": false + }, + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/cockpit/render/repeat-loops/python/docs/guide.md b/cockpit/render/repeat-loops/python/docs/guide.md new file mode 100644 index 000000000..ad6833554 --- /dev/null +++ b/cockpit/render/repeat-loops/python/docs/guide.md @@ -0,0 +1,95 @@ +# Repeat Loops with @cacheplane/render + + +Iterate over arrays in the state store using repeat specs. Each iteration +provides RepeatScope context with repeatItem, repeatIndex, and repeatBasePath +for per-item rendering. + + + +Add repeat rendering to this Angular component using repeat specs from +`@cacheplane/render`. Define array state, create a repeat spec template, +access RepeatScope context, and add/remove items dynamically. + + + + + +Create a state store with an array to iterate over: + +```typescript +import { signalStateStore } from '@cacheplane/render'; + +const store = signalStateStore({ + items: [ + { name: 'Item A', done: false }, + { name: 'Item B', done: true }, + { name: 'Item C', done: false }, + ], +}); +``` + + + + +Define a spec with `repeat` pointing to the array path: + +```typescript +const spec = { + type: 'list', + repeat: '/items', + children: [ + { type: 'text', props: { content: { bind: 'name' } } }, + { type: 'checkbox', props: { checked: { bind: 'done' } } }, + ], +}; +``` + + + + +Inside repeated components, inject RepeatScope for iteration context: + +```typescript +const scope = inject(RepeatScope); +const item = scope.repeatItem; // current item +const index = scope.repeatIndex; // zero-based index +const basePath = scope.repeatBasePath; // e.g. '/items/0' +``` + + + + +Modify the array in the store to add or remove items: + +```typescript +// Add an item +store.update((draft) => { + draft.items.push({ name: 'New Item', done: false }); +}); + +// Remove an item by index +store.update((draft) => { + draft.items.splice(index, 1); +}); +``` + + + + +Use `streamResource()` to receive repeat specs from the agent: + +```typescript +protected readonly stream = streamResource({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.streamingAssistantId, +}); +``` + + + + + +repeatBasePath gives you the JSON Pointer for the current item (e.g. `/items/2`), +so child bindings can use relative paths within each iteration. + diff --git a/cockpit/render/repeat-loops/python/langgraph.json b/cockpit/render/repeat-loops/python/langgraph.json new file mode 100644 index 000000000..52316e099 --- /dev/null +++ b/cockpit/render/repeat-loops/python/langgraph.json @@ -0,0 +1,8 @@ +{ + "graphs": { + "repeat-loops": "./src/graph.py:graph" + }, + "dependencies": ["."], + "python_version": "3.12", + "env": ".env" +} diff --git a/cockpit/render/repeat-loops/python/package.json b/cockpit/render/repeat-loops/python/package.json new file mode 100644 index 000000000..7d659cab4 --- /dev/null +++ b/cockpit/render/repeat-loops/python/package.json @@ -0,0 +1,6 @@ +{ + "name": "@cacheplane/cockpit-render-repeat-loops-python", + "version": "0.0.1", + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/render/repeat-loops/python/prompts/repeat-loops.md b/cockpit/render/repeat-loops/python/prompts/repeat-loops.md new file mode 100644 index 000000000..d249e1f75 --- /dev/null +++ b/cockpit/render/repeat-loops/python/prompts/repeat-loops.md @@ -0,0 +1,19 @@ +# Repeat Loops Assistant + +You are an assistant that demonstrates repeat rendering from @cacheplane/render. + +Repeat rendering allows iterating over arrays in the state store to render +a template for each item. It uses: + +- **RepeatScope**: An injection token providing context for each iteration +- **repeatItem**: The current item in the iteration +- **repeatIndex**: The zero-based index of the current item +- **repeatBasePath**: The JSON Pointer base path for the current item + +When the user asks about repeat loops, explain: +- How to define a repeat spec that iterates over an array in the state store +- How RepeatScope provides per-iteration context to child components +- How repeatBasePath enables relative path resolution within each iteration +- How to add and remove items from the array dynamically + +Include examples of repeat specs with array state and item templates. diff --git a/cockpit/render/repeat-loops/python/pyproject.toml b/cockpit/render/repeat-loops/python/pyproject.toml new file mode 100644 index 000000000..d22027246 --- /dev/null +++ b/cockpit/render/repeat-loops/python/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "cockpit-render-repeat-loops" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "langgraph>=0.3", + "langchain-openai>=0.3", + "langsmith>=0.2", +] + +[tool.uv] +dev-dependencies = [ + "langgraph-cli[inmem]>=0.1", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] diff --git a/cockpit/render/repeat-loops/python/src/graph.py b/cockpit/render/repeat-loops/python/src/graph.py new file mode 100644 index 000000000..73cbaa3ff --- /dev/null +++ b/cockpit/render/repeat-loops/python/src/graph.py @@ -0,0 +1,39 @@ +""" +Render Repeat Loops Graph + +A LangGraph StateGraph that explains repeat/loop rendering with +RepeatScope for iterating arrays in render specs. +""" + +from pathlib import Path +from langgraph.graph import StateGraph, MessagesState, END +from langchain_openai import ChatOpenAI +from langchain_core.messages import SystemMessage + +PROMPTS_DIR = Path(__file__).parent.parent / "prompts" + + +def build_repeat_loops_graph(): + """ + Constructs a graph that explains repeat rendering. + + The agent responds with guidance on using RepeatScope injection token, + repeatItem, repeatIndex, and repeatBasePath for iterating arrays. + """ + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) + + async def generate(state: MessagesState) -> dict: + system_prompt = (PROMPTS_DIR / "repeat-loops.md").read_text() + messages = [SystemMessage(content=system_prompt)] + state["messages"] + response = await llm.ainvoke(messages) + return {"messages": [response]} + + graph = StateGraph(MessagesState) + graph.add_node("generate", generate) + graph.set_entry_point("generate") + graph.add_edge("generate", END) + + return graph.compile() + + +graph = build_repeat_loops_graph() diff --git a/cockpit/render/repeat-loops/python/src/index.ts b/cockpit/render/repeat-loops/python/src/index.ts new file mode 100644 index 000000000..eb5f509be --- /dev/null +++ b/cockpit/render/repeat-loops/python/src/index.ts @@ -0,0 +1,42 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'render'; + section: 'core-capabilities'; + topic: 'repeat-loops'; + page: 'overview'; + language: 'python'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; + backendAssetPaths: string[]; + docsAssetPaths: string[]; + runtimeUrl?: string; + devPort?: number; +} + +export const renderRepeatLoopsPythonModule: CockpitCapabilityModule = { + id: 'render-repeat-loops-python', + manifestIdentity: { + product: 'render', + section: 'core-capabilities', + topic: 'repeat-loops', + page: 'overview', + language: 'python', + }, + title: 'Render Repeat Loops (Python)', + docsPath: '/docs/render/core-capabilities/repeat-loops/overview/python', + promptAssetPaths: ['cockpit/render/repeat-loops/python/prompts/repeat-loops.md'], + codeAssetPaths: [ + 'cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts', + 'cockpit/render/repeat-loops/angular/src/app/app.config.ts', + ], + backendAssetPaths: [ + 'cockpit/render/repeat-loops/python/src/graph.py', + ], + docsAssetPaths: ['cockpit/render/repeat-loops/python/docs/guide.md'], + runtimeUrl: 'render/repeat-loops', + devPort: 4405, +}; diff --git a/cockpit/render/shared/streaming-simulator.spec.ts b/cockpit/render/shared/streaming-simulator.spec.ts new file mode 100644 index 000000000..e123cf768 --- /dev/null +++ b/cockpit/render/shared/streaming-simulator.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import { StreamingSimulator } from './streaming-simulator'; + +const SIMPLE_SPEC = JSON.stringify({ + root: 'root', + elements: { + root: { type: 'Text', props: { content: 'Hello' } }, + }, +}); + +describe('StreamingSimulator', () => { + let simulator: StreamingSimulator; + + beforeEach(() => { + globalThis.requestAnimationFrame = vi.fn((cb) => setTimeout(cb, 0) as unknown as number); + globalThis.cancelAnimationFrame = vi.fn((id) => clearTimeout(id)); + simulator = new StreamingSimulator(SIMPLE_SPEC); + }); + + afterEach(() => { + simulator.destroy(); + }); + + it('initializes with position 0 and total equal to source length', () => { + expect(simulator.position()).toBe(0); + expect(simulator.total()).toBe(SIMPLE_SPEC.length); + expect(simulator.playing()).toBe(false); + expect(simulator.speed()).toBe(1); + expect(simulator.spec()).toBeNull(); + expect(simulator.rawJson()).toBe(''); + }); + + it('seek parses from 0 to the given position and materializes', () => { + simulator.seek(SIMPLE_SPEC.length); + expect(simulator.position()).toBe(SIMPLE_SPEC.length); + expect(simulator.spec()).not.toBeNull(); + expect(simulator.spec()?.root).toBe('root'); + expect(simulator.rawJson()).toBe(SIMPLE_SPEC); + }); + + it('seek to partial position produces partial raw json', () => { + simulator.seek(10); + expect(simulator.position()).toBe(10); + expect(simulator.rawJson()).toBe(SIMPLE_SPEC.slice(0, 10)); + }); + + it('seek backwards re-parses from 0', () => { + simulator.seek(SIMPLE_SPEC.length); + simulator.seek(5); + expect(simulator.position()).toBe(5); + expect(simulator.rawJson()).toBe(SIMPLE_SPEC.slice(0, 5)); + }); + + it('setSource resets to new source', () => { + const newSpec = JSON.stringify({ root: 'r', elements: {} }); + simulator.setSource(newSpec); + expect(simulator.total()).toBe(newSpec.length); + expect(simulator.position()).toBe(0); + expect(simulator.spec()).toBeNull(); + }); + + it('toggle switches playing state', () => { + expect(simulator.playing()).toBe(false); + simulator.toggle(); + expect(simulator.playing()).toBe(true); + simulator.toggle(); + expect(simulator.playing()).toBe(false); + }); + + it('setSpeed updates speed', () => { + simulator.setSpeed(4); + expect(simulator.speed()).toBe(4); + }); + + it('progress returns fraction', () => { + expect(simulator.progress()).toBe(0); + simulator.seek(SIMPLE_SPEC.length); + expect(simulator.progress()).toBe(1); + }); +}); diff --git a/cockpit/render/shared/streaming-simulator.ts b/cockpit/render/shared/streaming-simulator.ts new file mode 100644 index 000000000..90932fb89 --- /dev/null +++ b/cockpit/render/shared/streaming-simulator.ts @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { signal, computed } from '@angular/core'; +import { createPartialJsonParser, materialize } from '@cacheplane/partial-json'; +import type { PartialJsonParser, ParseEvent } from '@cacheplane/partial-json'; +import type { Spec } from '@json-render/core'; + +export class StreamingSimulator { + private source: string; + private parser: PartialJsonParser; + private animFrameId: number | null = null; + + readonly position = signal(0); + readonly total = signal(0); + readonly playing = signal(false); + readonly speed = signal(1); + readonly spec = signal(null); + readonly rawJson = signal(''); + readonly events = signal([]); + + readonly progress = computed(() => { + const t = this.total(); + return t === 0 ? 0 : this.position() / t; + }); + + constructor(source: string) { + this.source = source; + this.parser = createPartialJsonParser(); + this.total.set(source.length); + } + + play(): void { + if (this.playing()) return; + this.playing.set(true); + this.tick(); + } + + pause(): void { + this.playing.set(false); + if (this.animFrameId !== null) { + cancelAnimationFrame(this.animFrameId); + this.animFrameId = null; + } + } + + toggle(): void { + if (this.playing()) { + this.pause(); + } else { + if (this.position() >= this.total()) { + this.seek(0); + } + this.play(); + } + } + + seek(pos: number): void { + const clamped = Math.max(0, Math.min(pos, this.source.length)); + this.parser = createPartialJsonParser(); + const chunk = this.source.slice(0, clamped); + const allEvents = chunk.length > 0 ? this.parser.push(chunk) : []; + this.position.set(clamped); + this.rawJson.set(chunk); + this.events.set(allEvents); + this.spec.set( + this.parser.root ? (materialize(this.parser.root) as Spec | null) : null + ); + } + + setSpeed(multiplier: number): void { + this.speed.set(Math.max(1, Math.round(multiplier))); + } + + setSource(json: string): void { + this.pause(); + this.source = json; + this.parser = createPartialJsonParser(); + this.total.set(json.length); + this.position.set(0); + this.rawJson.set(''); + this.spec.set(null); + this.events.set([]); + } + + destroy(): void { + this.pause(); + } + + private tick(): void { + if (!this.playing()) return; + const currentPos = this.position(); + const spd = this.speed(); + const nextPos = Math.min(currentPos + spd, this.source.length); + + if (nextPos > currentPos) { + const chunk = this.source.slice(currentPos, nextPos); + const newEvents = this.parser.push(chunk); + this.position.set(nextPos); + this.rawJson.set(this.source.slice(0, nextPos)); + this.events.set([...this.events(), ...newEvents]); + this.spec.set( + this.parser.root ? (materialize(this.parser.root) as Spec | null) : null + ); + } + + if (nextPos >= this.source.length) { + this.pause(); + return; + } + + this.animFrameId = requestAnimationFrame(() => this.tick()); + } +} diff --git a/cockpit/render/shared/streaming-timeline.component.ts b/cockpit/render/shared/streaming-timeline.component.ts new file mode 100644 index 000000000..7a8b58a26 --- /dev/null +++ b/cockpit/render/shared/streaming-timeline.component.ts @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, ElementRef, viewChild } from '@angular/core'; +import { StreamingSimulator } from './streaming-simulator'; + +@Component({ + selector: 'streaming-timeline', + standalone: true, + template: ` +
+ + +
+
+
+
+
+
+ +
+ {{ simulator().position() }} + / {{ simulator().total() }} chars +
+ +
+ @for (s of speeds; track s) { + + } +
+
+ `, +}) +export class StreamingTimelineComponent { + readonly simulator = input.required(); + readonly track = viewChild>('track'); + + protected readonly speeds = [1, 2, 4]; + + protected onTrackMouseDown(event: MouseEvent): void { + event.preventDefault(); + this.seekFromEvent(event); + + const onMove = (e: MouseEvent) => this.seekFromEvent(e); + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + } + + protected onTrackTouchStart(event: TouchEvent): void { + event.preventDefault(); + this.seekFromTouch(event); + + const onMove = (e: TouchEvent) => this.seekFromTouch(e); + const onEnd = () => { + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onEnd); + }; + document.addEventListener('touchmove', onMove); + document.addEventListener('touchend', onEnd); + } + + private seekFromEvent(event: MouseEvent): void { + const el = this.track()?.nativeElement; + if (!el) return; + const rect = el.getBoundingClientRect(); + const fraction = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); + this.simulator().seek(Math.round(fraction * this.simulator().total())); + } + + private seekFromTouch(event: TouchEvent): void { + const el = this.track()?.nativeElement; + if (!el || !event.touches[0]) return; + const rect = el.getBoundingClientRect(); + const fraction = Math.max(0, Math.min(1, (event.touches[0].clientX - rect.left) / rect.width)); + this.simulator().seek(Math.round(fraction * this.simulator().total())); + } +} diff --git a/cockpit/render/spec-rendering/angular/e2e/spec-rendering.spec.ts b/cockpit/render/spec-rendering/angular/e2e/spec-rendering.spec.ts new file mode 100644 index 000000000..c83c459b0 --- /dev/null +++ b/cockpit/render/spec-rendering/angular/e2e/spec-rendering.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Render Spec Rendering Example', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4401'); + await page.waitForSelector('app-spec-rendering', { state: 'attached' }); + }); + + test('renders spec picker and timeline', async ({ page }) => { + await expect(page.locator('button', { hasText: 'Heading + Text' })).toBeVisible(); + await expect(page.locator('streaming-timeline')).toBeVisible(); + }); + + test('shows streaming JSON pane', async ({ page }) => { + await expect(page.locator('pre')).toBeVisible(); + }); +}); diff --git a/cockpit/render/spec-rendering/angular/package.json b/cockpit/render/spec-rendering/angular/package.json new file mode 100644 index 000000000..190ba5284 --- /dev/null +++ b/cockpit/render/spec-rendering/angular/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cacheplane/cockpit-render-spec-rendering-angular", + "version": "0.0.1", + "peerDependencies": { + "@cacheplane/render": "^0.0.1" + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/render/spec-rendering/angular/project.json b/cockpit/render/spec-rendering/angular/project.json new file mode 100644 index 000000000..f53637900 --- /dev/null +++ b/cockpit/render/spec-rendering/angular/project.json @@ -0,0 +1,61 @@ +{ + "name": "cockpit-render-spec-rendering-angular", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/render/spec-rendering/angular/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@angular/build:application", + "outputs": ["{options.outputPath.base}"], + "options": { + "outputPath": { + "base": "dist/cockpit/render/spec-rendering/angular", + "browser": "" + }, + "browser": "cockpit/render/spec-rendering/angular/src/main.ts", + "tsConfig": "cockpit/render/spec-rendering/angular/tsconfig.app.json", + "styles": ["cockpit/render/spec-rendering/angular/src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { "type": "initial", "maximumWarning": "500kb", "maximumError": "1mb" }, + { "type": "anyComponentStyle", "maximumWarning": "4kb", "maximumError": "8kb" } + ], + "outputHashing": "none" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "cockpit/render/spec-rendering/angular/src/environments/environment.ts", + "with": "cockpit/render/spec-rendering/angular/src/environments/environment.development.ts" + } + ] + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "continuous": true, + "executor": "@angular/build:dev-server", + "configurations": { + "production": { "buildTarget": "cockpit-render-spec-rendering-angular:build:production" }, + "development": { "buildTarget": "cockpit-render-spec-rendering-angular:build:development" } + }, + "defaultConfiguration": "development", + "options": { + "proxyConfig": "cockpit/render/spec-rendering/angular/proxy.conf.json" + } + }, + "smoke": { + "executor": "nx:run-commands", + "options": { + "cwd": "cockpit/render/spec-rendering/angular", + "command": "npx tsx -e \"import { renderSpecRenderingAngularModule } from './src/index.ts'; const module = renderSpecRenderingAngularModule; if (module.id !== 'render-spec-rendering-angular' || module.title !== 'Render Spec Rendering (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" + } + } + } +} diff --git a/cockpit/render/spec-rendering/angular/proxy.conf.json b/cockpit/render/spec-rendering/angular/proxy.conf.json new file mode 100644 index 000000000..8523362d7 --- /dev/null +++ b/cockpit/render/spec-rendering/angular/proxy.conf.json @@ -0,0 +1,9 @@ +{ + "/api": { + "target": "http://localhost:8123", + "secure": false, + "changeOrigin": true, + "pathRewrite": { "^/api": "" }, + "ws": true + } +} diff --git a/cockpit/render/spec-rendering/angular/src/app/app.config.ts b/cockpit/render/spec-rendering/angular/src/app/app.config.ts new file mode 100644 index 000000000..ee0fd6f31 --- /dev/null +++ b/cockpit/render/spec-rendering/angular/src/app/app.config.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideRender } from '@cacheplane/render'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRender({}), + ], +}; diff --git a/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts b/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts new file mode 100644 index 000000000..ab2095229 --- /dev/null +++ b/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, OnDestroy, viewChild, ElementRef, effect } from '@angular/core'; +import { + RenderSpecComponent, + RenderElementComponent, + defineAngularRegistry, + signalStateStore, +} from '@cacheplane/render'; +import type { Spec } from '@json-render/core'; +import { StreamingSimulator } from '../../../../shared/streaming-simulator'; +import { StreamingTimelineComponent } from '../../../../shared/streaming-timeline.component'; +import { SPEC_RENDERING_SPECS } from './specs'; + +// --- Inline view components registered in the demo registry --- + +@Component({ + selector: 'demo-text', + standalone: true, + template: ` + @if (content()) { +

{{ content() }}

+ } @else if (loading()) { +
+
+
+
+ } + `, +}) +class DemoTextComponent { + readonly content = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-heading', + standalone: true, + imports: [RenderElementComponent], + template: ` + @if (content()) { +

{{ content() }}

+ } @else if (loading()) { +
+ } + @for (key of childKeys(); track key) { + + } + @if (!childKeys().length && loading()) { +
+
+
+
+ } + `, +}) +class DemoHeadingComponent { + readonly content = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-badge', + standalone: true, + template: `{{ label() }}`, +}) +class DemoBadgeComponent { + readonly label = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-card', + standalone: true, + imports: [RenderElementComponent], + template: ` +
+ @if (title()) { +

{{ title() }}

+ } @else if (loading()) { +
+ } + @if (childKeys().length) { + @for (key of childKeys(); track key) { + + } + } @else if (loading()) { +
+
+
+
+ } +
+ `, +}) +class DemoCardComponent { + readonly title = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'app-spec-rendering', + standalone: true, + imports: [RenderSpecComponent, StreamingTimelineComponent], + template: ` +
+ +
+ Spec: + @for (spec of specs; track spec.label; let i = $index) { + + } +
+ + +
+ +
+
Live Render Output
+ @if (simulator.spec(); as renderedSpec) { + + } @else { +
Press play to start streaming...
+ } +
+ + +
+ +
+
Streaming JSON
+
{{ simulator.rawJson() }}|
+
+ {{ simulator.playing() ? 'Streaming...' : simulator.position() >= simulator.total() ? 'Complete' : 'Paused' }} + {{ percent() }}% +
+
+
+
+ + + +
+ `, +}) +export class SpecRenderingComponent implements OnDestroy { + protected readonly specs = SPEC_RENDERING_SPECS; + protected activeIndex = 0; + + protected readonly simulator = new StreamingSimulator(this.specs[0].json); + + private readonly jsonPane = viewChild>('jsonPane'); + + constructor() { + effect(() => { + this.simulator.rawJson(); + const el = this.jsonPane()?.nativeElement; + if (el) { + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight; + }); + } + }); + } + + protected readonly registry = defineAngularRegistry({ + Text: DemoTextComponent, + Heading: DemoHeadingComponent, + Badge: DemoBadgeComponent, + Card: DemoCardComponent, + }); + + protected readonly store = signalStateStore({}); + + protected percent(): number { + return Math.round(this.simulator.progress() * 100); + } + + protected selectSpec(index: number): void { + this.activeIndex = index; + this.simulator.setSource(this.specs[index].json); + this.simulator.play(); + } + + ngOnDestroy(): void { + this.simulator.destroy(); + } +} diff --git a/cockpit/render/spec-rendering/angular/src/app/specs.ts b/cockpit/render/spec-rendering/angular/src/app/specs.ts new file mode 100644 index 000000000..35d81c981 --- /dev/null +++ b/cockpit/render/spec-rendering/angular/src/app/specs.ts @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export interface DemoSpec { + label: string; + json: string; +} + +export const SPEC_RENDERING_SPECS: DemoSpec[] = [ + { + label: 'Heading + Text', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Welcome to Spec Rendering' }, + children: ['desc'], + }, + desc: { + type: 'Text', + props: { content: 'This UI is rendered entirely from a JSON specification. Each element maps to a registered Angular component.' }, + }, + }, + }, null, 2), + }, + { + label: 'Card + Badge', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Card', + props: { title: 'Streaming Demo' }, + children: ['badge', 'info'], + }, + badge: { + type: 'Badge', + props: { label: 'Live Preview' }, + }, + info: { + type: 'Text', + props: { content: 'Badges, headings, and text components are all resolved from the registry at runtime.' }, + }, + }, + }, null, 2), + }, + { + label: 'Nested Layout', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Multi-Level Nesting' }, + children: ['section1', 'section2'], + }, + section1: { + type: 'Card', + props: { title: 'Section One' }, + children: ['s1text'], + }, + s1text: { + type: 'Text', + props: { content: 'First section with a card wrapper and nested text content inside.' }, + }, + section2: { + type: 'Card', + props: { title: 'Section Two' }, + children: ['s2text'], + }, + s2text: { + type: 'Text', + props: { content: 'Second section demonstrating that the parser handles multiple sibling branches.' }, + }, + }, + }, null, 2), + }, +]; diff --git a/cockpit/render/spec-rendering/angular/src/environments/environment.development.ts b/cockpit/render/spec-rendering/angular/src/environments/environment.development.ts new file mode 100644 index 000000000..8558a09ba --- /dev/null +++ b/cockpit/render/spec-rendering/angular/src/environments/environment.development.ts @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export const environment = { production: false }; diff --git a/cockpit/render/spec-rendering/angular/src/environments/environment.ts b/cockpit/render/spec-rendering/angular/src/environments/environment.ts new file mode 100644 index 000000000..6c1ae4083 --- /dev/null +++ b/cockpit/render/spec-rendering/angular/src/environments/environment.ts @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export const environment = { production: true }; diff --git a/cockpit/render/spec-rendering/angular/src/index.html b/cockpit/render/spec-rendering/angular/src/index.html new file mode 100644 index 000000000..8ba4b2d89 --- /dev/null +++ b/cockpit/render/spec-rendering/angular/src/index.html @@ -0,0 +1,13 @@ + + + + + Render Spec Rendering — Angular + + + + + + + + diff --git a/cockpit/render/spec-rendering/angular/src/index.ts b/cockpit/render/spec-rendering/angular/src/index.ts new file mode 100644 index 000000000..e7c6087c6 --- /dev/null +++ b/cockpit/render/spec-rendering/angular/src/index.ts @@ -0,0 +1,29 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'render'; + section: 'core-capabilities'; + topic: 'spec-rendering'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const renderSpecRenderingAngularModule: CockpitCapabilityModule = { + id: 'render-spec-rendering-angular', + manifestIdentity: { + product: 'render', + section: 'core-capabilities', + topic: 'spec-rendering', + page: 'overview', + language: 'angular', + }, + title: 'Render Spec Rendering (Angular)', + docsPath: '/docs/render/core-capabilities/spec-rendering/overview/angular', + promptAssetPaths: ['cockpit/render/spec-rendering/angular/prompts/spec-rendering.md'], + codeAssetPaths: ['cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts'], +}; diff --git a/cockpit/render/spec-rendering/angular/src/main.ts b/cockpit/render/spec-rendering/angular/src/main.ts new file mode 100644 index 000000000..6c4c2a798 --- /dev/null +++ b/cockpit/render/spec-rendering/angular/src/main.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { SpecRenderingComponent } from './app/spec-rendering.component'; + +bootstrapApplication(SpecRenderingComponent, appConfig).catch(console.error); diff --git a/cockpit/render/spec-rendering/angular/src/styles.css b/cockpit/render/spec-rendering/angular/src/styles.css new file mode 100644 index 000000000..1d1e4d5a9 --- /dev/null +++ b/cockpit/render/spec-rendering/angular/src/styles.css @@ -0,0 +1,12 @@ +/* Global styles for the spec-rendering capability demo */ + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +.skeleton-shimmer { + background: linear-gradient(90deg, rgb(31 41 55 / 0) 0%, rgb(55 65 81 / 0.3) 50%, rgb(31 41 55 / 0) 100%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; +} diff --git a/cockpit/render/spec-rendering/angular/tsconfig.app.json b/cockpit/render/spec-rendering/angular/tsconfig.app.json new file mode 100644 index 000000000..72c01e364 --- /dev/null +++ b/cockpit/render/spec-rendering/angular/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts", "src/**/*.ts"] +} diff --git a/cockpit/render/spec-rendering/angular/tsconfig.json b/cockpit/render/spec-rendering/angular/tsconfig.json new file mode 100644 index 000000000..3fd970371 --- /dev/null +++ b/cockpit/render/spec-rendering/angular/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "noPropertyAccessFromIndexSignature": false, + "experimentalDecorators": true, + "module": "preserve", + "emitDeclarationOnly": false, + "composite": false, + "lib": ["es2022", "dom"], + "skipLibCheck": true, + "strict": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": false, + "strictInputAccessModifiers": false, + "strictTemplates": false + }, + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/cockpit/render/spec-rendering/python/docs/guide.md b/cockpit/render/spec-rendering/python/docs/guide.md new file mode 100644 index 000000000..d836966f0 --- /dev/null +++ b/cockpit/render/spec-rendering/python/docs/guide.md @@ -0,0 +1,97 @@ +# Spec Rendering with @cacheplane/render + + +Render Angular components from JSON specifications using RenderSpecComponent. +The component recursively resolves element types from a registry and renders +them with reactive prop bindings. + + + +Add JSON-driven UI rendering to this Angular component using `RenderSpecComponent` +from `@cacheplane/render`. Define a registry with `defineAngularRegistry()`, create +a state store with `signalStateStore()`, and pass a JSON spec to the template. + + + + + +Set up `provideRender()` in your app config with a component registry: + +```typescript +// app.config.ts +import { ApplicationConfig } from '@angular/core'; +import { provideRender } from '@cacheplane/render'; +import { defineAngularRegistry } from '@cacheplane/render'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRender({ + registry: defineAngularRegistry({}), + }), + ], +}; +``` + + + + +Create a spec object describing your UI layout. Each element has a `type` that +maps to a registered Angular component: + +```typescript +const spec = { + type: 'container', + props: { class: 'p-4 space-y-2' }, + children: [ + { type: 'heading', props: { text: 'Hello from a JSON spec' } }, + { type: 'text', props: { content: 'Rendered by RenderSpecComponent' } }, + ], +}; +``` + + + + +Use `` in your template to render the JSON spec: + +```html + +``` + +RenderSpecComponent recursively walks the spec tree, resolves each type +from the registry, and creates Angular components with the specified props. + + + + +Use `signalStateStore()` to create a reactive state store that your spec +can bind to: + +```typescript +import { signalStateStore } from '@cacheplane/render'; + +const store = signalStateStore({ count: 0, name: '' }); +store.set('/count', 1); +store.get('/name'); // Signal +``` + + + + +Use `streamResource()` to connect to the agent and display render specs +from the conversation: + +```typescript +protected readonly stream = streamResource({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.streamingAssistantId, +}); +``` + + + + + +RenderSpecComponent is tree-shakeable — only registered component types are included +in your bundle. + diff --git a/cockpit/render/spec-rendering/python/langgraph.json b/cockpit/render/spec-rendering/python/langgraph.json new file mode 100644 index 000000000..36e0ba15d --- /dev/null +++ b/cockpit/render/spec-rendering/python/langgraph.json @@ -0,0 +1,8 @@ +{ + "graphs": { + "spec-rendering": "./src/graph.py:graph" + }, + "dependencies": ["."], + "python_version": "3.12", + "env": ".env" +} diff --git a/cockpit/render/spec-rendering/python/package.json b/cockpit/render/spec-rendering/python/package.json new file mode 100644 index 000000000..c68529d41 --- /dev/null +++ b/cockpit/render/spec-rendering/python/package.json @@ -0,0 +1,6 @@ +{ + "name": "@cacheplane/cockpit-render-spec-rendering-python", + "version": "0.0.1", + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/render/spec-rendering/python/prompts/spec-rendering.md b/cockpit/render/spec-rendering/python/prompts/spec-rendering.md new file mode 100644 index 000000000..5edec1100 --- /dev/null +++ b/cockpit/render/spec-rendering/python/prompts/spec-rendering.md @@ -0,0 +1,11 @@ +# Spec Rendering Assistant + +You are an assistant that demonstrates the RenderSpecComponent from @cacheplane/render. + +When the user asks you to create a UI, respond with a description of the layout +and components you would use. Include JSON render spec examples when helpful. + +A render spec is a JSON object with `type`, `props`, and optional `children` fields. +For example: `{"type": "card", "props": {"title": "Hello"}, "children": []}`. + +Explain how RenderSpecComponent recursively renders these specs into Angular components. diff --git a/cockpit/render/spec-rendering/python/pyproject.toml b/cockpit/render/spec-rendering/python/pyproject.toml new file mode 100644 index 000000000..62c0dfe83 --- /dev/null +++ b/cockpit/render/spec-rendering/python/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "cockpit-render-spec-rendering" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "langgraph>=0.3", + "langchain-openai>=0.3", + "langsmith>=0.2", +] + +[tool.uv] +dev-dependencies = [ + "langgraph-cli[inmem]>=0.1", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] diff --git a/cockpit/render/spec-rendering/python/src/graph.py b/cockpit/render/spec-rendering/python/src/graph.py new file mode 100644 index 000000000..c96c540ba --- /dev/null +++ b/cockpit/render/spec-rendering/python/src/graph.py @@ -0,0 +1,40 @@ +""" +Render Spec Rendering Graph + +A LangGraph StateGraph that returns JSON render specs describing UI layouts. +The Angular frontend uses RenderSpecComponent to render these specs into +live Angular components. +""" + +from pathlib import Path +from langgraph.graph import StateGraph, MessagesState, END +from langchain_openai import ChatOpenAI +from langchain_core.messages import SystemMessage + +PROMPTS_DIR = Path(__file__).parent.parent / "prompts" + + +def build_spec_rendering_graph(): + """ + Constructs a graph that generates JSON render specs. + + The agent responds with JSON UI specifications that the Angular frontend + renders using RenderSpecComponent from @cacheplane/render. + """ + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) + + async def generate(state: MessagesState) -> dict: + system_prompt = (PROMPTS_DIR / "spec-rendering.md").read_text() + messages = [SystemMessage(content=system_prompt)] + state["messages"] + response = await llm.ainvoke(messages) + return {"messages": [response]} + + graph = StateGraph(MessagesState) + graph.add_node("generate", generate) + graph.set_entry_point("generate") + graph.add_edge("generate", END) + + return graph.compile() + + +graph = build_spec_rendering_graph() diff --git a/cockpit/render/spec-rendering/python/src/index.ts b/cockpit/render/spec-rendering/python/src/index.ts new file mode 100644 index 000000000..ccf08aa97 --- /dev/null +++ b/cockpit/render/spec-rendering/python/src/index.ts @@ -0,0 +1,42 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'render'; + section: 'core-capabilities'; + topic: 'spec-rendering'; + page: 'overview'; + language: 'python'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; + backendAssetPaths: string[]; + docsAssetPaths: string[]; + runtimeUrl?: string; + devPort?: number; +} + +export const renderSpecRenderingPythonModule: CockpitCapabilityModule = { + id: 'render-spec-rendering-python', + manifestIdentity: { + product: 'render', + section: 'core-capabilities', + topic: 'spec-rendering', + page: 'overview', + language: 'python', + }, + title: 'Render Spec Rendering (Python)', + docsPath: '/docs/render/core-capabilities/spec-rendering/overview/python', + promptAssetPaths: ['cockpit/render/spec-rendering/python/prompts/spec-rendering.md'], + codeAssetPaths: [ + 'cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts', + 'cockpit/render/spec-rendering/angular/src/app/app.config.ts', + ], + backendAssetPaths: [ + 'cockpit/render/spec-rendering/python/src/graph.py', + ], + docsAssetPaths: ['cockpit/render/spec-rendering/python/docs/guide.md'], + runtimeUrl: 'render/spec-rendering', + devPort: 4401, +}; diff --git a/cockpit/render/state-management/angular/e2e/state-management.spec.ts b/cockpit/render/state-management/angular/e2e/state-management.spec.ts new file mode 100644 index 000000000..70b7ad7af --- /dev/null +++ b/cockpit/render/state-management/angular/e2e/state-management.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Render State Management Example', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4403'); + await page.waitForSelector('app-state-management', { state: 'attached' }); + }); + + test('renders spec picker and timeline', async ({ page }) => { + await expect(page.locator('button', { hasText: 'User Profile' })).toBeVisible(); + await expect(page.locator('streaming-timeline')).toBeVisible(); + }); + + test('shows streaming JSON pane', async ({ page }) => { + await expect(page.locator('pre')).toBeVisible(); + }); +}); diff --git a/cockpit/render/state-management/angular/package.json b/cockpit/render/state-management/angular/package.json new file mode 100644 index 000000000..e95c8c521 --- /dev/null +++ b/cockpit/render/state-management/angular/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cacheplane/cockpit-render-state-management-angular", + "version": "0.0.1", + "peerDependencies": { + "@cacheplane/render": "^0.0.1" + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/render/state-management/angular/project.json b/cockpit/render/state-management/angular/project.json new file mode 100644 index 000000000..b44f95870 --- /dev/null +++ b/cockpit/render/state-management/angular/project.json @@ -0,0 +1,61 @@ +{ + "name": "cockpit-render-state-management-angular", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "cockpit/render/state-management/angular/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@angular/build:application", + "outputs": ["{options.outputPath.base}"], + "options": { + "outputPath": { + "base": "dist/cockpit/render/state-management/angular", + "browser": "" + }, + "browser": "cockpit/render/state-management/angular/src/main.ts", + "tsConfig": "cockpit/render/state-management/angular/tsconfig.app.json", + "styles": ["cockpit/render/state-management/angular/src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { "type": "initial", "maximumWarning": "500kb", "maximumError": "1mb" }, + { "type": "anyComponentStyle", "maximumWarning": "4kb", "maximumError": "8kb" } + ], + "outputHashing": "none" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "cockpit/render/state-management/angular/src/environments/environment.ts", + "with": "cockpit/render/state-management/angular/src/environments/environment.development.ts" + } + ] + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "continuous": true, + "executor": "@angular/build:dev-server", + "configurations": { + "production": { "buildTarget": "cockpit-render-state-management-angular:build:production" }, + "development": { "buildTarget": "cockpit-render-state-management-angular:build:development" } + }, + "defaultConfiguration": "development", + "options": { + "proxyConfig": "cockpit/render/state-management/angular/proxy.conf.json" + } + }, + "smoke": { + "executor": "nx:run-commands", + "options": { + "cwd": "cockpit/render/state-management/angular", + "command": "npx tsx -e \"import { renderStateManagementAngularModule } from './src/index.ts'; const module = renderStateManagementAngularModule; if (module.id !== 'render-state-management-angular' || module.title !== 'Render State Management (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" + } + } + } +} diff --git a/cockpit/render/state-management/angular/proxy.conf.json b/cockpit/render/state-management/angular/proxy.conf.json new file mode 100644 index 000000000..8523362d7 --- /dev/null +++ b/cockpit/render/state-management/angular/proxy.conf.json @@ -0,0 +1,9 @@ +{ + "/api": { + "target": "http://localhost:8123", + "secure": false, + "changeOrigin": true, + "pathRewrite": { "^/api": "" }, + "ws": true + } +} diff --git a/cockpit/render/state-management/angular/src/app/app.config.ts b/cockpit/render/state-management/angular/src/app/app.config.ts new file mode 100644 index 000000000..ee0fd6f31 --- /dev/null +++ b/cockpit/render/state-management/angular/src/app/app.config.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideRender } from '@cacheplane/render'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRender({}), + ], +}; diff --git a/cockpit/render/state-management/angular/src/app/specs.ts b/cockpit/render/state-management/angular/src/app/specs.ts new file mode 100644 index 000000000..b86deb71c --- /dev/null +++ b/cockpit/render/state-management/angular/src/app/specs.ts @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { DemoSpec } from '../../../../spec-rendering/angular/src/app/specs'; + +export const STATE_MANAGEMENT_SPECS: DemoSpec[] = [ + { + label: 'User Profile', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'User Profile' }, + children: ['name', 'age'], + }, + name: { + type: 'Text', + props: { content: { $state: '/user/name' } }, + }, + age: { + type: 'Text', + props: { content: { $state: '/user/age' } }, + }, + }, + }, null, 2), + }, + { + label: 'Nested Paths', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Nested State Paths' }, + children: ['userName', 'userAge', 'theme'], + }, + userName: { + type: 'Label', + props: { label: 'Name', value: { $state: '/user/name' } }, + }, + userAge: { + type: 'Label', + props: { label: 'Age', value: { $state: '/user/age' } }, + }, + theme: { + type: 'Label', + props: { label: 'Theme', value: { $state: '/settings/theme' } }, + }, + }, + }, null, 2), + }, + { + label: 'Form Display', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Card', + props: { title: 'State-Driven Form' }, + children: ['nameField', 'themeField'], + }, + nameField: { + type: 'Label', + props: { label: 'User', value: { $state: '/user/name' } }, + }, + themeField: { + type: 'Label', + props: { label: 'Preference', value: { $state: '/settings/theme' } }, + }, + }, + }, null, 2), + }, +]; diff --git a/cockpit/render/state-management/angular/src/app/state-management.component.ts b/cockpit/render/state-management/angular/src/app/state-management.component.ts new file mode 100644 index 000000000..d84dab3e3 --- /dev/null +++ b/cockpit/render/state-management/angular/src/app/state-management.component.ts @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, OnDestroy, viewChild, ElementRef, effect } from '@angular/core'; +import { + RenderSpecComponent, + RenderElementComponent, + defineAngularRegistry, + signalStateStore, +} from '@cacheplane/render'; +import type { Spec } from '@json-render/core'; +import { StreamingSimulator } from '../../../../shared/streaming-simulator'; +import { StreamingTimelineComponent } from '../../../../shared/streaming-timeline.component'; +import { STATE_MANAGEMENT_SPECS } from './specs'; + +// --- Inline view components --- + +@Component({ + selector: 'demo-text', + standalone: true, + template: ` + @if (content()) { +

{{ content() }}

+ } @else if (loading()) { +
+
+
+
+ } + `, +}) +class DemoTextComponent { + readonly content = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-heading', + standalone: true, + imports: [RenderElementComponent], + template: ` + @if (content()) { +

{{ content() }}

+ } @else if (loading()) { +
+ } + @for (key of childKeys(); track key) { + + } + @if (!childKeys().length && loading()) { +
+
+
+
+ } + `, +}) +class DemoHeadingComponent { + readonly content = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-label', + standalone: true, + template: ` + @if (label() || value()) { +
+ {{ label() }}: + {{ value() }} +
+ } @else if (loading()) { +
+
+
+
+ } + `, +}) +class DemoLabelComponent { + readonly label = input(''); + readonly value = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'demo-card', + standalone: true, + imports: [RenderElementComponent], + template: ` +
+ @if (title()) { +

{{ title() }}

+ } @else if (loading()) { +
+ } + @if (childKeys().length) { + @for (key of childKeys(); track key) { + + } + } @else if (loading()) { +
+
+
+
+ } +
+ `, +}) +class DemoCardComponent { + readonly title = input(''); + readonly childKeys = input([]); + readonly spec = input(null); + readonly bindings = input>({}); + readonly emit = input<(event: string) => void>(() => {}); + readonly loading = input(false); +} + +@Component({ + selector: 'app-state-management', + standalone: true, + imports: [RenderSpecComponent, StreamingTimelineComponent], + template: ` +
+ +
+ Spec: + @for (spec of specs; track spec.label; let i = $index) { + + } +
+ + +
+ +
+
Live Render Output
+ @if (simulator.spec(); as renderedSpec) { + + } @else { +
Press play to start streaming...
+ } +
+ + +
+ +
+
Streaming JSON
+
{{ simulator.rawJson() }}|
+
+ {{ simulator.playing() ? 'Streaming...' : simulator.position() >= simulator.total() ? 'Complete' : 'Paused' }} + {{ percent() }}% +
+
+ + +
+
State Controls
+
+
+ + +
+
+ + +
+
+ + +
+

+ Edit values to update the state store. Rendered elements with $state bindings react. +

+
+
+
+
+ + + +
+ `, +}) +export class StateManagementComponent implements OnDestroy { + protected readonly specs = STATE_MANAGEMENT_SPECS; + protected activeIndex = 0; + + protected readonly simulator = new StreamingSimulator(this.specs[0].json); + + private readonly jsonPane = viewChild>('jsonPane'); + + constructor() { + effect(() => { + this.simulator.rawJson(); + const el = this.jsonPane()?.nativeElement; + if (el) { + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight; + }); + } + }); + } + + protected readonly registry = defineAngularRegistry({ + Text: DemoTextComponent, + Heading: DemoHeadingComponent, + Label: DemoLabelComponent, + Card: DemoCardComponent, + }); + + protected readonly store = signalStateStore({ user: { name: 'Alice', age: 30 }, settings: { theme: 'dark' } }); + + protected getState(path: string): unknown { + return this.store.get(path); + } + + protected percent(): number { + return Math.round(this.simulator.progress() * 100); + } + + protected selectSpec(index: number): void { + this.activeIndex = index; + this.simulator.setSource(this.specs[index].json); + this.simulator.play(); + } + + ngOnDestroy(): void { + this.simulator.destroy(); + } +} diff --git a/cockpit/render/state-management/angular/src/environments/environment.development.ts b/cockpit/render/state-management/angular/src/environments/environment.development.ts new file mode 100644 index 000000000..8558a09ba --- /dev/null +++ b/cockpit/render/state-management/angular/src/environments/environment.development.ts @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export const environment = { production: false }; diff --git a/cockpit/render/state-management/angular/src/environments/environment.ts b/cockpit/render/state-management/angular/src/environments/environment.ts new file mode 100644 index 000000000..6c1ae4083 --- /dev/null +++ b/cockpit/render/state-management/angular/src/environments/environment.ts @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +export const environment = { production: true }; diff --git a/cockpit/render/state-management/angular/src/index.html b/cockpit/render/state-management/angular/src/index.html new file mode 100644 index 000000000..7066cb4c0 --- /dev/null +++ b/cockpit/render/state-management/angular/src/index.html @@ -0,0 +1,13 @@ + + + + + Render State Management — Angular + + + + + + + + diff --git a/cockpit/render/state-management/angular/src/index.ts b/cockpit/render/state-management/angular/src/index.ts new file mode 100644 index 000000000..d1f559401 --- /dev/null +++ b/cockpit/render/state-management/angular/src/index.ts @@ -0,0 +1,29 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'render'; + section: 'core-capabilities'; + topic: 'state-management'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const renderStateManagementAngularModule: CockpitCapabilityModule = { + id: 'render-state-management-angular', + manifestIdentity: { + product: 'render', + section: 'core-capabilities', + topic: 'state-management', + page: 'overview', + language: 'angular', + }, + title: 'Render State Management (Angular)', + docsPath: '/docs/render/core-capabilities/state-management/overview/angular', + promptAssetPaths: ['cockpit/render/state-management/angular/prompts/state-management.md'], + codeAssetPaths: ['cockpit/render/state-management/angular/src/app/state-management.component.ts'], +}; diff --git a/cockpit/render/state-management/angular/src/main.ts b/cockpit/render/state-management/angular/src/main.ts new file mode 100644 index 000000000..9a62a96f5 --- /dev/null +++ b/cockpit/render/state-management/angular/src/main.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { StateManagementComponent } from './app/state-management.component'; + +bootstrapApplication(StateManagementComponent, appConfig).catch(console.error); diff --git a/cockpit/render/state-management/angular/src/styles.css b/cockpit/render/state-management/angular/src/styles.css new file mode 100644 index 000000000..893982316 --- /dev/null +++ b/cockpit/render/state-management/angular/src/styles.css @@ -0,0 +1,12 @@ +/* Global styles for the state-management capability demo */ + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +.skeleton-shimmer { + background: linear-gradient(90deg, rgb(31 41 55 / 0) 0%, rgb(55 65 81 / 0.3) 50%, rgb(31 41 55 / 0) 100%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; +} diff --git a/cockpit/render/state-management/angular/tsconfig.app.json b/cockpit/render/state-management/angular/tsconfig.app.json new file mode 100644 index 000000000..72c01e364 --- /dev/null +++ b/cockpit/render/state-management/angular/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts", "src/**/*.ts"] +} diff --git a/cockpit/render/state-management/angular/tsconfig.json b/cockpit/render/state-management/angular/tsconfig.json new file mode 100644 index 000000000..3fd970371 --- /dev/null +++ b/cockpit/render/state-management/angular/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "noPropertyAccessFromIndexSignature": false, + "experimentalDecorators": true, + "module": "preserve", + "emitDeclarationOnly": false, + "composite": false, + "lib": ["es2022", "dom"], + "skipLibCheck": true, + "strict": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": false, + "strictInputAccessModifiers": false, + "strictTemplates": false + }, + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.app.json" } + ] +} diff --git a/cockpit/render/state-management/python/docs/guide.md b/cockpit/render/state-management/python/docs/guide.md new file mode 100644 index 000000000..f1811e796 --- /dev/null +++ b/cockpit/render/state-management/python/docs/guide.md @@ -0,0 +1,90 @@ +# State Management with @cacheplane/render + + +Manage reactive UI state using signalStateStore with JSON Pointer paths. +The store provides get/set/update methods backed by Angular Signals for +automatic UI propagation. + + + +Add reactive state management to this Angular component using +`signalStateStore()` from `@cacheplane/render`. Create a store with +nested state, read values with get(), write with set(), and batch +updates with update(). + + + + + +Initialize a `signalStateStore()` with your initial state shape: + +```typescript +import { signalStateStore } from '@cacheplane/render'; + +const store = signalStateStore({ + user: { name: '', age: 0 }, + settings: { theme: 'dark' }, +}); +``` + + + + +Use JSON Pointer paths to read reactive Signal values: + +```typescript +const name = store.get('/user/name'); // Signal +const theme = store.get('/settings/theme'); // Signal + +// In template: {{ name() }} +``` + + + + +Set individual values at any path: + +```typescript +store.set('/user/name', 'Alice'); +store.set('/user/age', 30); +store.set('/settings/theme', 'light'); +``` + +All Signals referencing these paths update automatically. + + + + +Apply multiple changes atomically: + +```typescript +store.update((draft) => { + draft.user.name = 'Bob'; + draft.user.age = 25; + draft.settings.theme = 'dark'; +}); +``` + + + + +Render specs can bind props to store paths: + +```typescript +const spec = { + type: 'text', + props: { content: { bind: '/user/name' } }, +}; +``` + +```html + +``` + + + + + +JSON Pointer paths follow RFC 6901. Use `/` to separate segments: +`/user/name` points to `state.user.name`. + diff --git a/cockpit/render/state-management/python/langgraph.json b/cockpit/render/state-management/python/langgraph.json new file mode 100644 index 000000000..2c4995a2a --- /dev/null +++ b/cockpit/render/state-management/python/langgraph.json @@ -0,0 +1,8 @@ +{ + "graphs": { + "state-management": "./src/graph.py:graph" + }, + "dependencies": ["."], + "python_version": "3.12", + "env": ".env" +} diff --git a/cockpit/render/state-management/python/package.json b/cockpit/render/state-management/python/package.json new file mode 100644 index 000000000..b39d0d942 --- /dev/null +++ b/cockpit/render/state-management/python/package.json @@ -0,0 +1,6 @@ +{ + "name": "@cacheplane/cockpit-render-state-management-python", + "version": "0.0.1", + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/cockpit/render/state-management/python/prompts/state-management.md b/cockpit/render/state-management/python/prompts/state-management.md new file mode 100644 index 000000000..9e1120299 --- /dev/null +++ b/cockpit/render/state-management/python/prompts/state-management.md @@ -0,0 +1,18 @@ +# State Management Assistant + +You are an assistant that demonstrates signalStateStore from @cacheplane/render. + +signalStateStore provides reactive state management using JSON Pointer paths +(RFC 6901) and Angular Signals. It supports: + +- **get(path)**: Returns a Signal for the value at the given JSON Pointer path +- **set(path, value)**: Sets the value at the given path, triggering reactive updates +- **update(fn)**: Batch updates multiple values at once + +When the user asks about state management, explain: +- How JSON Pointer paths like `/user/name` address nested state +- How get() returns Angular Signals that automatically update the UI +- How set() triggers reactive propagation to all bound components +- How update() enables atomic batch modifications + +Include examples of creating stores, reading/writing values, and binding to render specs. diff --git a/cockpit/render/state-management/python/pyproject.toml b/cockpit/render/state-management/python/pyproject.toml new file mode 100644 index 000000000..fe757312c --- /dev/null +++ b/cockpit/render/state-management/python/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "cockpit-render-state-management" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "langgraph>=0.3", + "langchain-openai>=0.3", + "langsmith>=0.2", +] + +[tool.uv] +dev-dependencies = [ + "langgraph-cli[inmem]>=0.1", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] diff --git a/cockpit/render/state-management/python/src/graph.py b/cockpit/render/state-management/python/src/graph.py new file mode 100644 index 000000000..97d07bd10 --- /dev/null +++ b/cockpit/render/state-management/python/src/graph.py @@ -0,0 +1,39 @@ +""" +Render State Management Graph + +A LangGraph StateGraph that explains signalStateStore usage with +JSON Pointer paths for reactive state management in Angular. +""" + +from pathlib import Path +from langgraph.graph import StateGraph, MessagesState, END +from langchain_openai import ChatOpenAI +from langchain_core.messages import SystemMessage + +PROMPTS_DIR = Path(__file__).parent.parent / "prompts" + + +def build_state_management_graph(): + """ + Constructs a graph that explains signalStateStore. + + The agent responds with guidance on using signalStateStore for + reactive state management with JSON Pointer paths. + """ + llm = ChatOpenAI(model="gpt-5-mini", streaming=True) + + async def generate(state: MessagesState) -> dict: + system_prompt = (PROMPTS_DIR / "state-management.md").read_text() + messages = [SystemMessage(content=system_prompt)] + state["messages"] + response = await llm.ainvoke(messages) + return {"messages": [response]} + + graph = StateGraph(MessagesState) + graph.add_node("generate", generate) + graph.set_entry_point("generate") + graph.add_edge("generate", END) + + return graph.compile() + + +graph = build_state_management_graph() diff --git a/cockpit/render/state-management/python/src/index.ts b/cockpit/render/state-management/python/src/index.ts new file mode 100644 index 000000000..210aad4ea --- /dev/null +++ b/cockpit/render/state-management/python/src/index.ts @@ -0,0 +1,42 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'render'; + section: 'core-capabilities'; + topic: 'state-management'; + page: 'overview'; + language: 'python'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; + backendAssetPaths: string[]; + docsAssetPaths: string[]; + runtimeUrl?: string; + devPort?: number; +} + +export const renderStateManagementPythonModule: CockpitCapabilityModule = { + id: 'render-state-management-python', + manifestIdentity: { + product: 'render', + section: 'core-capabilities', + topic: 'state-management', + page: 'overview', + language: 'python', + }, + title: 'Render State Management (Python)', + docsPath: '/docs/render/core-capabilities/state-management/overview/python', + promptAssetPaths: ['cockpit/render/state-management/python/prompts/state-management.md'], + codeAssetPaths: [ + 'cockpit/render/state-management/angular/src/app/state-management.component.ts', + 'cockpit/render/state-management/angular/src/app/app.config.ts', + ], + backendAssetPaths: [ + 'cockpit/render/state-management/python/src/graph.py', + ], + docsAssetPaths: ['cockpit/render/state-management/python/docs/guide.md'], + runtimeUrl: 'render/state-management', + devPort: 4403, +}; diff --git a/docs/superpowers/plans/2026-04-08-streaming-simulation.md b/docs/superpowers/plans/2026-04-08-streaming-simulation.md new file mode 100644 index 000000000..2b2cd185d --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-streaming-simulation.md @@ -0,0 +1,900 @@ +# Streaming Simulation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace static spec cycling in all 6 render examples with a streaming simulation using the real partial JSON parser, timeline scrubber, and progressive rendering. + +**Architecture:** A shared `StreamingSimulator` class wraps `createPartialJsonParser()` + `materialize()` from `@cacheplane/partial-json`. It feeds a pre-stringified JSON spec character-by-character via `requestAnimationFrame`, exposing Angular signals for the partial spec, raw JSON, position, and playback state. A shared `StreamingTimelineComponent` renders the play/pause, scrubber, and speed controls. Each of the 6 render examples uses these shared utilities with feature-specific specs. + +**Tech Stack:** Angular 19 (standalone components, signals), `@cacheplane/partial-json`, `@cacheplane/render`, `@json-render/core`, Vitest + +--- + +## Task 1: StreamingSimulator Class + +**Files:** +- Create: `cockpit/render/shared/streaming-simulator.ts` +- Create: `cockpit/render/shared/streaming-simulator.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// cockpit/render/shared/streaming-simulator.spec.ts +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { StreamingSimulator } from './streaming-simulator'; + +const SIMPLE_SPEC = JSON.stringify({ + root: 'root', + elements: { + root: { type: 'Text', props: { content: 'Hello' } }, + }, +}); + +describe('StreamingSimulator', () => { + let simulator: StreamingSimulator; + + beforeEach(() => { + simulator = new StreamingSimulator(SIMPLE_SPEC); + }); + + afterEach(() => { + simulator.destroy(); + }); + + it('initializes with position 0 and total equal to source length', () => { + expect(simulator.position()).toBe(0); + expect(simulator.total()).toBe(SIMPLE_SPEC.length); + expect(simulator.playing()).toBe(false); + expect(simulator.speed()).toBe(1); + expect(simulator.spec()).toBeNull(); + expect(simulator.rawJson()).toBe(''); + }); + + it('seek parses from 0 to the given position and materializes', () => { + simulator.seek(SIMPLE_SPEC.length); + expect(simulator.position()).toBe(SIMPLE_SPEC.length); + expect(simulator.spec()).not.toBeNull(); + expect(simulator.spec()?.root).toBe('root'); + expect(simulator.rawJson()).toBe(SIMPLE_SPEC); + }); + + it('seek to partial position produces partial spec', () => { + // Seek to just past {"root": — enough for the root key + const partialPos = 10; + simulator.seek(partialPos); + expect(simulator.position()).toBe(partialPos); + expect(simulator.rawJson()).toBe(SIMPLE_SPEC.slice(0, partialPos)); + }); + + it('seek backwards re-parses from 0', () => { + simulator.seek(SIMPLE_SPEC.length); + simulator.seek(5); + expect(simulator.position()).toBe(5); + expect(simulator.rawJson()).toBe(SIMPLE_SPEC.slice(0, 5)); + }); + + it('setSource resets to new source', () => { + const newSpec = JSON.stringify({ root: 'r', elements: {} }); + simulator.setSource(newSpec); + expect(simulator.total()).toBe(newSpec.length); + expect(simulator.position()).toBe(0); + expect(simulator.spec()).toBeNull(); + }); + + it('toggle switches playing state', () => { + expect(simulator.playing()).toBe(false); + simulator.toggle(); + expect(simulator.playing()).toBe(true); + simulator.toggle(); + expect(simulator.playing()).toBe(false); + }); + + it('setSpeed updates speed', () => { + simulator.setSpeed(4); + expect(simulator.speed()).toBe(4); + }); + + it('progress returns fraction', () => { + expect(simulator.progress()).toBe(0); + simulator.seek(SIMPLE_SPEC.length); + expect(simulator.progress()).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PATH="/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH" npx vitest run cockpit/render/shared/streaming-simulator.spec.ts` +Expected: FAIL — module not found + +- [ ] **Step 3: Write the implementation** + +```typescript +// cockpit/render/shared/streaming-simulator.ts +import { signal, computed } from '@angular/core'; +import { createPartialJsonParser, materialize } from '@cacheplane/partial-json'; +import type { PartialJsonParser, ParseEvent } from '@cacheplane/partial-json'; +import type { Spec } from '@json-render/core'; + +export class StreamingSimulator { + private source: string; + private parser: PartialJsonParser; + private animFrameId: number | null = null; + + readonly position = signal(0); + readonly total = signal(0); + readonly playing = signal(false); + readonly speed = signal(1); + readonly spec = signal(null); + readonly rawJson = signal(''); + readonly events = signal([]); + + readonly progress = computed(() => { + const t = this.total(); + return t === 0 ? 0 : this.position() / t; + }); + + constructor(source: string) { + this.source = source; + this.parser = createPartialJsonParser(); + this.total.set(source.length); + } + + play(): void { + if (this.playing()) return; + this.playing.set(true); + this.tick(); + } + + pause(): void { + this.playing.set(false); + if (this.animFrameId !== null) { + cancelAnimationFrame(this.animFrameId); + this.animFrameId = null; + } + } + + toggle(): void { + if (this.playing()) { + this.pause(); + } else { + // If at end, restart + if (this.position() >= this.total()) { + this.seek(0); + } + this.play(); + } + } + + seek(pos: number): void { + const clamped = Math.max(0, Math.min(pos, this.source.length)); + // Re-parse from scratch + this.parser = createPartialJsonParser(); + const chunk = this.source.slice(0, clamped); + const allEvents = chunk.length > 0 ? this.parser.push(chunk) : []; + this.position.set(clamped); + this.rawJson.set(chunk); + this.events.set(allEvents); + this.spec.set( + this.parser.root ? (materialize(this.parser.root) as Spec | null) : null + ); + } + + setSpeed(multiplier: number): void { + this.speed.set(multiplier); + } + + setSource(json: string): void { + this.pause(); + this.source = json; + this.parser = createPartialJsonParser(); + this.total.set(json.length); + this.position.set(0); + this.rawJson.set(''); + this.spec.set(null); + this.events.set([]); + } + + destroy(): void { + this.pause(); + } + + private tick(): void { + if (!this.playing()) return; + const currentPos = this.position(); + const spd = this.speed(); + const nextPos = Math.min(currentPos + spd, this.source.length); + + if (nextPos > currentPos) { + const chunk = this.source.slice(currentPos, nextPos); + const newEvents = this.parser.push(chunk); + this.position.set(nextPos); + this.rawJson.set(this.source.slice(0, nextPos)); + this.events.update((prev) => [...prev, ...newEvents]); + this.spec.set( + this.parser.root ? (materialize(this.parser.root) as Spec | null) : null + ); + } + + if (nextPos >= this.source.length) { + this.pause(); + return; + } + + this.animFrameId = requestAnimationFrame(() => this.tick()); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `PATH="/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH" npx vitest run cockpit/render/shared/streaming-simulator.spec.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add cockpit/render/shared/streaming-simulator.ts cockpit/render/shared/streaming-simulator.spec.ts +git commit -m "feat(cockpit): add StreamingSimulator class for render examples" +``` + +--- + +## Task 2: StreamingTimelineComponent + +**Files:** +- Create: `cockpit/render/shared/streaming-timeline.component.ts` + +- [ ] **Step 1: Create the timeline component** + +```typescript +// cockpit/render/shared/streaming-timeline.component.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, ElementRef, viewChild } from '@angular/core'; +import { StreamingSimulator } from './streaming-simulator'; + +@Component({ + selector: 'streaming-timeline', + standalone: true, + template: ` +
+ + + + +
+ +
+
+ +
+
+
+ + +
+ {{ simulator().position() }} + / {{ simulator().total() }} chars +
+ + +
+ @for (s of speeds; track s) { + + } +
+
+ `, +}) +export class StreamingTimelineComponent { + readonly simulator = input.required(); + readonly track = viewChild>('track'); + + protected readonly speeds = [1, 2, 4]; + + private dragging = false; + + protected onTrackMouseDown(event: MouseEvent): void { + event.preventDefault(); + this.dragging = true; + this.seekFromEvent(event); + + const onMove = (e: MouseEvent) => { + if (this.dragging) this.seekFromEvent(e); + }; + const onUp = () => { + this.dragging = false; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + } + + protected onTrackTouchStart(event: TouchEvent): void { + event.preventDefault(); + this.seekFromTouch(event); + + const onMove = (e: TouchEvent) => this.seekFromTouch(e); + const onEnd = () => { + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onEnd); + }; + document.addEventListener('touchmove', onMove); + document.addEventListener('touchend', onEnd); + } + + private seekFromEvent(event: MouseEvent): void { + const el = this.track()?.nativeElement; + if (!el) return; + const rect = el.getBoundingClientRect(); + const fraction = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); + this.simulator().seek(Math.round(fraction * this.simulator().total())); + } + + private seekFromTouch(event: TouchEvent): void { + const el = this.track()?.nativeElement; + if (!el || !event.touches[0]) return; + const rect = el.getBoundingClientRect(); + const fraction = Math.max(0, Math.min(1, (event.touches[0].clientX - rect.left) / rect.width)); + this.simulator().seek(Math.round(fraction * this.simulator().total())); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add cockpit/render/shared/streaming-timeline.component.ts +git commit -m "feat(cockpit): add StreamingTimelineComponent for render examples" +``` + +--- + +## Task 3: Rewrite spec-rendering Example + +**Files:** +- Create: `cockpit/render/spec-rendering/angular/src/app/specs.ts` +- Modify: `cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts` + +- [ ] **Step 1: Create specs data file** + +```typescript +// cockpit/render/spec-rendering/angular/src/app/specs.ts +export interface DemoSpec { + label: string; + json: string; +} + +export const SPEC_RENDERING_SPECS: DemoSpec[] = [ + { + label: 'Heading + Text', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Welcome to Spec Rendering' }, + children: ['desc'], + }, + desc: { + type: 'Text', + props: { content: 'This UI is rendered entirely from a JSON specification. Each element maps to a registered Angular component.' }, + }, + }, + }, null, 2), + }, + { + label: 'Card + Badge', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Card', + props: { title: 'Streaming Demo' }, + children: ['badge', 'info'], + }, + badge: { + type: 'Badge', + props: { label: 'Live Preview' }, + }, + info: { + type: 'Text', + props: { content: 'Badges, headings, and text components are all resolved from the registry at runtime.' }, + }, + }, + }, null, 2), + }, + { + label: 'Nested Layout', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Multi-Level Nesting' }, + children: ['section1', 'section2'], + }, + section1: { + type: 'Card', + props: { title: 'Section One' }, + children: ['s1text'], + }, + s1text: { + type: 'Text', + props: { content: 'First section with a card wrapper and nested text content inside.' }, + }, + section2: { + type: 'Card', + props: { title: 'Section Two' }, + children: ['s2text'], + }, + s2text: { + type: 'Text', + props: { content: 'Second section demonstrating that the parser handles multiple sibling branches.' }, + }, + }, + }, null, 2), + }, +]; +``` + +- [ ] **Step 2: Rewrite spec-rendering component** + +```typescript +// cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, OnDestroy } from '@angular/core'; +import { + RenderSpecComponent, + defineAngularRegistry, + signalStateStore, +} from '@cacheplane/render'; +import type { Spec } from '@json-render/core'; +import { StreamingSimulator } from '../../../../shared/streaming-simulator'; +import { StreamingTimelineComponent } from '../../../../shared/streaming-timeline.component'; +import { SPEC_RENDERING_SPECS } from './specs'; + +// --- Inline view components registered in the demo registry --- + +@Component({ + selector: 'demo-text', + standalone: true, + template: `

{{ content() }}

`, +}) +class DemoTextComponent { + readonly content = input(''); + readonly childKeys = input([]); + readonly spec = input(null); +} + +@Component({ + selector: 'demo-heading', + standalone: true, + template: `

{{ content() }}

`, +}) +class DemoHeadingComponent { + readonly content = input(''); + readonly childKeys = input([]); + readonly spec = input(null); +} + +@Component({ + selector: 'demo-badge', + standalone: true, + template: `{{ label() }}`, +}) +class DemoBadgeComponent { + readonly label = input(''); + readonly childKeys = input([]); + readonly spec = input(null); +} + +@Component({ + selector: 'demo-card', + standalone: true, + imports: [RenderSpecComponent], + template: ` +
+ @if (title()) { +

{{ title() }}

+ } + @if (spec() && childKeys().length) { + @for (key of childKeys(); track key) { + + } + } +
+ `, +}) +class DemoCardComponent { + readonly title = input(''); + readonly childKeys = input([]); + readonly spec = input(null); +} + +@Component({ + selector: 'app-spec-rendering', + standalone: true, + imports: [RenderSpecComponent, StreamingTimelineComponent], + template: ` +
+ +
+ Spec: + @for (spec of specs; track spec.label; let i = $index) { + + } +
+ + +
+ +
+
Live Render Output
+ @if (simulator.spec(); as renderedSpec) { + + } @else { +
Press play to start streaming...
+ } +
+ + +
+
Streaming JSON
+
{{ simulator.rawJson() }}|
+
+ {{ simulator.playing() ? 'Streaming...' : simulator.position() >= simulator.total() ? 'Complete' : 'Paused' }} + {{ simulator.progress() * 100 | number:'1.0-0' }}% parsed +
+
+
+ + + +
+ `, +}) +export class SpecRenderingComponent implements OnDestroy { + protected readonly specs = SPEC_RENDERING_SPECS; + protected activeIndex = 0; + protected readonly simulator = new StreamingSimulator(this.specs[0].json); + + protected readonly registry = defineAngularRegistry({ + Text: DemoTextComponent, + Heading: DemoHeadingComponent, + Badge: DemoBadgeComponent, + Card: DemoCardComponent, + }); + + protected readonly store = signalStateStore({}); + + selectSpec(index: number): void { + this.activeIndex = index; + this.simulator.setSource(this.specs[index].json); + this.simulator.play(); + } + + ngOnDestroy(): void { + this.simulator.destroy(); + } +} +``` + +- [ ] **Step 3: Verify it builds** + +Run: `PATH="/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH" npx nx reset && npx nx build cockpit-render-spec-rendering-angular --configuration=development 2>&1 | tail -10` +Expected: Build succeeds + +- [ ] **Step 4: Commit** + +```bash +git add cockpit/render/spec-rendering/angular/src/app/specs.ts cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts +git commit -m "feat(cockpit): rewrite spec-rendering with streaming simulation" +``` + +--- + +## Task 4: Rewrite element-rendering Example + +**Files:** +- Create: `cockpit/render/element-rendering/angular/src/app/specs.ts` +- Modify: `cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts` + +- [ ] **Step 1: Create specs data file** + +```typescript +// cockpit/render/element-rendering/angular/src/app/specs.ts +import type { DemoSpec } from '../../../../../../render/spec-rendering/angular/src/app/specs'; +export type { DemoSpec }; + +export const ELEMENT_RENDERING_SPECS: DemoSpec[] = [ + { + label: 'Parent + Children', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Parent Element' }, + children: ['child1', 'child2'], + }, + child1: { + type: 'Text', + props: { content: 'First child element — always visible' }, + }, + child2: { + type: 'Text', + props: { content: 'Second child element — rendered after first' }, + }, + }, + }, null, 2), + }, + { + label: 'Deep Nesting', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Card', + props: { title: 'Level 1' }, + children: ['level2'], + }, + level2: { + type: 'Card', + props: { title: 'Level 2' }, + children: ['level3'], + }, + level3: { + type: 'Text', + props: { content: 'Deepest level — three levels of nesting. The parser resolves each level as its type field arrives.' }, + }, + }, + }, null, 2), + }, + { + label: 'Visibility Conditions', + json: JSON.stringify({ + root: 'root', + elements: { + root: { + type: 'Heading', + props: { content: 'Conditional Rendering' }, + children: ['always', 'conditional'], + }, + always: { + type: 'Text', + props: { content: 'This element is always visible.' }, + }, + conditional: { + type: 'Text', + props: { content: 'This element has a visibility condition bound to state.' }, + visible: { bind: '/showDetail' }, + }, + }, + }, null, 2), + }, +]; +``` + +- [ ] **Step 2: Rewrite element-rendering component** + +Follow the same pattern as Task 3's spec-rendering component but: +- Import `ELEMENT_RENDERING_SPECS` from `./specs` +- Include the same inline demo components (DemoTextComponent, DemoHeadingComponent, DemoCardComponent) +- Use `signalStateStore({ showDetail: true })` for the visibility demo +- Selector: `app-element-rendering` +- Same layout: spec picker, split panes (render + JSON), timeline bar + +- [ ] **Step 3: Verify it builds** + +Run: `PATH="/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH" npx nx build cockpit-render-element-rendering-angular --configuration=development 2>&1 | tail -5` +Expected: Build succeeds + +- [ ] **Step 4: Commit** + +```bash +git add cockpit/render/element-rendering/angular/src/app/ +git commit -m "feat(cockpit): rewrite element-rendering with streaming simulation" +``` + +--- + +## Task 5: Rewrite state-management Example + +**Files:** +- Create: `cockpit/render/state-management/angular/src/app/specs.ts` +- Modify: `cockpit/render/state-management/angular/src/app/state-management.component.ts` + +- [ ] **Step 1: Create specs and rewrite component** + +Specs focus on state-bound rendering: form inputs with `/user/name`, `/user/age` paths, and a display spec that reads from state. + +Component: Same layout pattern. Registry includes DemoTextComponent, DemoHeadingComponent. Store initialized with `{ user: { name: 'Alice', age: 30 }, settings: { theme: 'dark' } }`. The sidebar shows `store.getSnapshot() | json` below the streaming JSON. + +- [ ] **Step 2: Verify and commit** + +```bash +git add cockpit/render/state-management/angular/src/app/ +git commit -m "feat(cockpit): rewrite state-management with streaming simulation" +``` + +--- + +## Task 6: Rewrite registry Example + +**Files:** +- Create: `cockpit/render/registry/angular/src/app/specs.ts` +- Modify: `cockpit/render/registry/angular/src/app/registry.component.ts` + +- [ ] **Step 1: Create specs and rewrite component** + +Specs use 3+ registered types to show registry resolution during streaming. Sidebar shows `registry.names()` list. + +- [ ] **Step 2: Verify and commit** + +```bash +git add cockpit/render/registry/angular/src/app/ +git commit -m "feat(cockpit): rewrite registry with streaming simulation" +``` + +--- + +## Task 7: Rewrite repeat-loops Example + +**Files:** +- Create: `cockpit/render/repeat-loops/angular/src/app/specs.ts` +- Modify: `cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts` + +- [ ] **Step 1: Create specs and rewrite component** + +Specs demonstrate array elements with `children` arrays of varying lengths. Items appear one by one as the parser encounters them. Store has `{ items: ['Alpha', 'Beta', 'Gamma'] }`. + +- [ ] **Step 2: Verify and commit** + +```bash +git add cockpit/render/repeat-loops/angular/src/app/ +git commit -m "feat(cockpit): rewrite repeat-loops with streaming simulation" +``` + +--- + +## Task 8: Rewrite computed-functions Example + +**Files:** +- Create: `cockpit/render/computed-functions/angular/src/app/specs.ts` +- Modify: `cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts` + +- [ ] **Step 1: Create specs and rewrite component** + +Specs demonstrate elements whose props would use computed functions. `provideRender()` in `app.config.ts` already has custom functions with correct `(args: Record) => unknown` signature. Sidebar shows function names. + +- [ ] **Step 2: Verify and commit** + +```bash +git add cockpit/render/computed-functions/angular/src/app/ +git commit -m "feat(cockpit): rewrite computed-functions with streaming simulation" +``` + +--- + +## Task 9: Update E2E Tests + +**Files:** +- Modify: `cockpit/render/spec-rendering/angular/e2e/spec-rendering.spec.ts` +- Modify: `cockpit/render/element-rendering/angular/e2e/element-rendering.spec.ts` +- Modify: `cockpit/render/state-management/angular/e2e/state-management.spec.ts` +- Modify: `cockpit/render/registry/angular/e2e/registry.spec.ts` +- Modify: `cockpit/render/repeat-loops/angular/e2e/repeat-loops.spec.ts` +- Modify: `cockpit/render/computed-functions/angular/e2e/computed-functions.spec.ts` + +- [ ] **Step 1: Update all 6 e2e tests** + +Each test should verify: +- The spec picker buttons render +- The timeline component renders (play button, scrubber) +- The streaming JSON pane is visible + +Example for spec-rendering: +```typescript +// cockpit/render/spec-rendering/angular/e2e/spec-rendering.spec.ts +import { expect, test } from '@playwright/test'; + +test.describe('Render Spec Rendering Example', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4401'); + await page.waitForSelector('app-spec-rendering', { state: 'attached' }); + }); + + test('renders spec picker and timeline', async ({ page }) => { + await expect(page.locator('button', { hasText: 'Heading + Text' })).toBeVisible(); + await expect(page.locator('streaming-timeline')).toBeVisible(); + }); + + test('shows streaming JSON pane', async ({ page }) => { + await expect(page.locator('pre')).toBeVisible(); + }); +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add cockpit/render/*/angular/e2e/ +git commit -m "test(cockpit): update render example e2e tests for streaming simulation" +``` + +--- + +## Task 10: Verify All Builds and Tests + +- [ ] **Step 1: Run all vitest tests** + +```bash +PATH="/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH" npx vitest run cockpit/render/shared/ cockpit/render/matrix.spec.ts cockpit/render/footprint.spec.ts +``` +Expected: All pass + +- [ ] **Step 2: Build all 6 render examples** + +```bash +PATH="/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH" npx nx reset +for topic in spec-rendering element-rendering state-management registry repeat-loops computed-functions; do + echo "=== $topic ===" + npx nx build cockpit-render-$topic-angular --configuration=development 2>&1 | tail -3 +done +``` +Expected: All 6 build successfully + +- [ ] **Step 3: Start spec-rendering and verify in browser** + +```bash +PATH="/Users/blove/.nvm/versions/node/v22.14.0/bin:$PATH" npx nx serve cockpit-render-spec-rendering-angular --port 4401 +``` + +Verify: +- Spec picker shows 3 buttons +- Clicking a spec starts streaming +- Timeline bar shows play/pause, scrubber, speed controls +- Left pane renders progressively +- Right pane shows JSON with cursor +- Scrubbing works in both directions + +- [ ] **Step 4: Push and verify CI** + +```bash +git push --force-with-lease +gh pr checks 68 --watch +``` diff --git a/docs/superpowers/specs/2026-04-08-streaming-simulation-design.md b/docs/superpowers/specs/2026-04-08-streaming-simulation-design.md new file mode 100644 index 000000000..2eb97105a --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-streaming-simulation-design.md @@ -0,0 +1,156 @@ +# Streaming Simulation for Render Examples + +**Date:** 2026-04-08 +**Status:** Approved + +--- + +## 1. Overview + +Replace the static spec-cycling UI in all 6 render cockpit examples with a streaming simulation that demonstrates the partial JSON parser and progressive rendering in real time. Users control playback with a play/pause button, draggable timeline scrubber, and speed controls (1x/2x/4x). + +The simulation uses the production `createPartialJsonParser()` and `materialize()` from `libs/partial-json` — no fake behavior. A pre-stringified JSON spec is fed character-by-character through the parser, producing a partial `Spec` object at each position that `RenderSpecComponent` renders progressively. + +--- + +## 2. Architecture + +### StreamingSimulator (shared utility) + +A plain TypeScript class (no Angular DI required) that encapsulates the streaming engine. + +**Input:** JSON string (the spec to stream) + +**State signals:** +- `spec: Signal` — materialized partial spec for RenderSpecComponent +- `rawJson: Signal` — JSON substring up to current position (for sidebar display) +- `position: Signal` — current character index +- `total: Signal` — total characters in source +- `playing: Signal` — play/pause state +- `speed: Signal` — playback multiplier (1, 2, or 4) +- `events: Signal` — accumulated parse events for timeline markers +- `progress: Signal` — computed 0..1 fraction (position / total) + +**Actions:** +- `play()` / `pause()` / `toggle()` — control playback +- `seek(position: number)` — scrub to character position. Creates a fresh parser, pushes substring 0..position, materializes. Instant for small specs (<1KB). +- `setSpeed(multiplier: number)` — set playback speed +- `setSource(json: string)` — load new spec, reset position to 0, create fresh parser +- `destroy()` — cancel animation frame loop + +**Internal:** A `requestAnimationFrame` loop that advances position by `speed` characters per tick (~60fps) when `playing` is true. Each tick calls `parser.push(nextChars)` and `materialize(parser.root)`. + +### StreamingTimelineComponent (shared Angular component) + +A standalone Angular component that renders the full-width timeline bar. + +**Inputs:** +- `simulator: StreamingSimulator` — the simulator instance to control + +**Renders:** +- Play/pause button (circle with play/pause icon) +- Draggable scrubber track with progress fill and handle +- Character counter (`position / total chars`) +- Speed buttons (1x / 2x / 4x) + +**Handles:** +- Click play/pause → `simulator.toggle()` +- Drag scrubber → `simulator.seek(position)` based on drag X position +- Click speed → `simulator.setSpeed(n)` + +--- + +## 3. Layout + +All 6 render examples share the same layout: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Spec: [Spec 1] [Spec 2] [Spec 3] │ +├──────────────────────────┬──────────────────────────────┤ +│ │ │ +│ LIVE RENDER OUTPUT │ STREAMING JSON │ +│ │ │ +│ Components render │ Syntax-highlighted JSON │ +│ progressively as │ with cursor at parse │ +│ JSON tokens arrive. │ position. Auto-scrolls. │ +│ Skeleton placeholders │ │ +│ for pending elements. │ │ +│ │ │ +├──────────────────────────┴──────────────────────────────┤ +│ [▶] ━━━━━━━━●━━━━━━━━━━━━━━━━━━━ 142/412 chars [1x] │ +└─────────────────────────────────────────────────────────┘ +``` + +- Left pane: `` +- Right pane: `
` with `simulator.rawJson()`, cursor indicator at end
+- Bottom: ``
+- Top: spec picker buttons that call `simulator.setSource(json)` and auto-play
+
+---
+
+## 4. Render Behavior During Streaming
+
+- Elements appear as soon as their `type` field is parsed by the partial JSON parser
+- Props fill in progressively (text content grows character by character)
+- Pending children show skeleton placeholders with dashed borders
+- The `loading` input on RenderSpecComponent propagates to child components
+- On completion: timeline pauses, play button shows replay icon
+
+---
+
+## 5. Per-Example Spec Content
+
+Each example has 2-3 pre-defined specs as JSON strings in a `specs.ts` file:
+
+| Example | Spec 1 | Spec 2 | Spec 3 |
+|---------|--------|--------|--------|
+| spec-rendering | Heading + text | Card + badge + children | Multi-level nested |
+| element-rendering | Parent + 2 children (visibility) | 3-level nested tree | Conditional visibility |
+| state-management | Form with bound inputs | Nested paths (/user/name) | Computed display |
+| registry | 3 registered types | Mixed types | Custom component |
+| repeat-loops | List of 3 items | Nested repeat | Dynamic list |
+| computed-functions | Date + uppercase | Math operations | String reversal |
+
+Each spec is a valid `@json-render/core` `Spec` with `root` and `elements` fields.
+
+---
+
+## 6. File Structure
+
+```
+cockpit/render/
+├── shared/
+│   ├── streaming-simulator.ts          — StreamingSimulator class
+│   ├── streaming-timeline.component.ts — Timeline bar component
+│   └── json-highlight.pipe.ts          — Syntax highlighting for JSON sidebar
+├── spec-rendering/angular/src/app/
+│   ├── spec-rendering.component.ts     — Rewritten with simulator
+│   └── specs.ts                        — Pre-defined spec JSON strings
+├── element-rendering/angular/src/app/
+│   ├── element-rendering.component.ts
+│   └── specs.ts
+├── state-management/angular/src/app/
+│   ├── state-management.component.ts
+│   └── specs.ts
+├── registry/angular/src/app/
+│   ├── registry.component.ts
+│   └── specs.ts
+├── repeat-loops/angular/src/app/
+│   ├── repeat-loops.component.ts
+│   └── specs.ts
+└── computed-functions/angular/src/app/
+    ├── computed-functions.component.ts
+    └── specs.ts
+```
+
+Shared directory is imported via relative path (e.g., `../../../../shared/streaming-simulator`).
+
+---
+
+## 7. Testing
+
+- Unit test `StreamingSimulator`: verify play advances position, seek re-parses correctly, setSource resets state
+- Unit test `StreamingTimelineComponent`: verify play/pause toggle, speed change, scrubber position
+- E2e tests per example: verify timeline renders, play button exists, spec picker works
+- Smoke tests unchanged (verify module shape)
diff --git a/libs/cockpit-docs/src/lib/docs-bundle.spec.ts b/libs/cockpit-docs/src/lib/docs-bundle.spec.ts
index be2d9579b..2551b3d6f 100644
--- a/libs/cockpit-docs/src/lib/docs-bundle.spec.ts
+++ b/libs/cockpit-docs/src/lib/docs-bundle.spec.ts
@@ -84,6 +84,24 @@ describe('docs bundle matrix coverage', () => {
       ['langgraph', 'core-capabilities', 'subgraphs'],
       ['langgraph', 'core-capabilities', 'time-travel'],
       ['langgraph', 'core-capabilities', 'deployment-runtime'],
+      ['render', 'getting-started', 'overview'],
+      ['render', 'core-capabilities', 'spec-rendering'],
+      ['render', 'core-capabilities', 'element-rendering'],
+      ['render', 'core-capabilities', 'state-management'],
+      ['render', 'core-capabilities', 'registry'],
+      ['render', 'core-capabilities', 'repeat-loops'],
+      ['render', 'core-capabilities', 'computed-functions'],
+      ['chat', 'getting-started', 'overview'],
+      ['chat', 'core-capabilities', 'messages'],
+      ['chat', 'core-capabilities', 'input'],
+      ['chat', 'core-capabilities', 'interrupts'],
+      ['chat', 'core-capabilities', 'tool-calls'],
+      ['chat', 'core-capabilities', 'subagents'],
+      ['chat', 'core-capabilities', 'threads'],
+      ['chat', 'core-capabilities', 'timeline'],
+      ['chat', 'core-capabilities', 'generative-ui'],
+      ['chat', 'core-capabilities', 'debug'],
+      ['chat', 'core-capabilities', 'theming'],
     ] as const;
     const bundles = getDocsBundles();
     const pageCounts = new Map();
diff --git a/libs/cockpit-registry/src/lib/manifest.spec.ts b/libs/cockpit-registry/src/lib/manifest.spec.ts
index af290ad61..b97602bc7 100644
--- a/libs/cockpit-registry/src/lib/manifest.spec.ts
+++ b/libs/cockpit-registry/src/lib/manifest.spec.ts
@@ -24,6 +24,28 @@ const expectedTopics = {
     ['core-capabilities', 'time-travel'],
     ['core-capabilities', 'deployment-runtime'],
   ],
+  render: [
+    ['getting-started', 'overview'],
+    ['core-capabilities', 'spec-rendering'],
+    ['core-capabilities', 'element-rendering'],
+    ['core-capabilities', 'state-management'],
+    ['core-capabilities', 'registry'],
+    ['core-capabilities', 'repeat-loops'],
+    ['core-capabilities', 'computed-functions'],
+  ],
+  chat: [
+    ['getting-started', 'overview'],
+    ['core-capabilities', 'messages'],
+    ['core-capabilities', 'input'],
+    ['core-capabilities', 'interrupts'],
+    ['core-capabilities', 'tool-calls'],
+    ['core-capabilities', 'subagents'],
+    ['core-capabilities', 'threads'],
+    ['core-capabilities', 'timeline'],
+    ['core-capabilities', 'generative-ui'],
+    ['core-capabilities', 'debug'],
+    ['core-capabilities', 'theming'],
+  ],
 } as const;
 
 describe('cockpitManifest', () => {
@@ -63,6 +85,8 @@ describe('cockpitManifest', () => {
     expect(docsOnlyEntries).toEqual([
       'deep-agents/getting-started/overview',
       'langgraph/getting-started/overview',
+      'render/getting-started/overview',
+      'chat/getting-started/overview',
     ]);
   });
 
@@ -71,7 +95,7 @@ describe('cockpitManifest', () => {
       (entry) => entry.entryKind === 'capability'
     );
 
-    expect(capabilityEntries).toHaveLength(14);
+    expect(capabilityEntries).toHaveLength(30);
 
     for (const entry of capabilityEntries) {
       expect(entry.supportedLanguages).toEqual(['python']);
diff --git a/libs/cockpit-registry/src/lib/manifest.ts b/libs/cockpit-registry/src/lib/manifest.ts
index 85356d884..d75b2c1be 100644
--- a/libs/cockpit-registry/src/lib/manifest.ts
+++ b/libs/cockpit-registry/src/lib/manifest.ts
@@ -30,6 +30,32 @@ const APPROVED_TOPICS = {
       'deployment-runtime',
     ],
   },
+  render: {
+    'getting-started': ['overview'],
+    'core-capabilities': [
+      'spec-rendering',
+      'element-rendering',
+      'state-management',
+      'registry',
+      'repeat-loops',
+      'computed-functions',
+    ],
+  },
+  chat: {
+    'getting-started': ['overview'],
+    'core-capabilities': [
+      'messages',
+      'input',
+      'interrupts',
+      'tool-calls',
+      'subagents',
+      'threads',
+      'timeline',
+      'generative-ui',
+      'debug',
+      'theming',
+    ],
+  },
 } as const;
 
 const toTitle = (value: string): string =>
@@ -38,8 +64,18 @@ const toTitle = (value: string): string =>
     .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
     .join(' ');
 
-const getProductTitle = (product: CockpitProduct): string =>
-  product === 'deep-agents' ? 'Deep Agents' : 'LangGraph';
+const getProductTitle = (product: CockpitProduct): string => {
+  switch (product) {
+    case 'deep-agents':
+      return 'Deep Agents';
+    case 'langgraph':
+      return 'LangGraph';
+    case 'render':
+      return 'Render';
+    case 'chat':
+      return 'Chat';
+  }
+};
 
 const getOverviewIdentity = (product: CockpitProduct): CockpitManifestIdentity => ({
   product,
diff --git a/libs/cockpit-registry/src/lib/manifest.types.ts b/libs/cockpit-registry/src/lib/manifest.types.ts
index af744ba43..235b78afc 100644
--- a/libs/cockpit-registry/src/lib/manifest.types.ts
+++ b/libs/cockpit-registry/src/lib/manifest.types.ts
@@ -1,4 +1,4 @@
-export type CockpitProduct = 'deep-agents' | 'langgraph';
+export type CockpitProduct = 'deep-agents' | 'langgraph' | 'render' | 'chat';
 
 export type CockpitSection = 'getting-started' | 'core-capabilities';
 
diff --git a/vite.config.mts b/vite.config.mts
new file mode 100644
index 000000000..7ec986114
--- /dev/null
+++ b/vite.config.mts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'vite';
+import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
+
+export default defineConfig({
+  plugins: [nxViteTsPaths()],
+  test: {
+    environment: 'node',
+    globals: true,
+    passWithNoTests: true,
+  },
+});