diff --git a/cockpit/deep-agents/filesystem/angular/package.json b/cockpit/deep-agents/filesystem/angular/package.json index 2cb339bf6..cd7907aeb 100644 --- a/cockpit/deep-agents/filesystem/angular/package.json +++ b/cockpit/deep-agents/filesystem/angular/package.json @@ -1,11 +1,11 @@ { - "name": "cockpit-deep-agents-filesystem-angular", + "name": "@cacheplane/cockpit-deep-agents-filesystem-angular", "version": "0.0.1", - "private": true, - "dependencies": { - "@cacheplane/stream-resource": "^0.0.1", + "peerDependencies": { "@cacheplane/chat": "^0.0.1", - "@langchain/core": "^0.3.0", + "@cacheplane/stream-resource": "^0.0.1", "@langchain/langgraph-sdk": "^0.0.36" - } + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false } diff --git a/cockpit/deep-agents/filesystem/angular/project.json b/cockpit/deep-agents/filesystem/angular/project.json index a6d1833b4..a60898d46 100644 --- a/cockpit/deep-agents/filesystem/angular/project.json +++ b/cockpit/deep-agents/filesystem/angular/project.json @@ -2,36 +2,21 @@ "name": "cockpit-deep-agents-filesystem-angular", "$schema": "../../../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "cockpit/deep-agents/filesystem/angular/src", - "projectType": "application", - "tags": ["scope:cockpit", "type:example"], + "projectType": "library", "targets": { "build": { - "executor": "@angular/build:application", + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/cockpit/deep-agents/filesystem/angular"], "options": { "outputPath": "dist/cockpit/deep-agents/filesystem/angular", - "index": "cockpit/deep-agents/filesystem/angular/src/index.html", - "browser": "cockpit/deep-agents/filesystem/angular/src/main.ts", - "tsConfig": "cockpit/deep-agents/filesystem/angular/tsconfig.app.json", - "styles": ["cockpit/deep-agents/filesystem/angular/src/styles.css"] - }, - "configurations": { - "development": { - "fileReplacements": [ - { - "replace": "cockpit/deep-agents/filesystem/angular/src/environments/environment.ts", - "with": "cockpit/deep-agents/filesystem/angular/src/environments/environment.development.ts" - } - ] - } - }, - "defaultConfiguration": "development" + "main": "cockpit/deep-agents/filesystem/angular/src/index.ts", + "tsConfig": "cockpit/deep-agents/filesystem/angular/tsconfig.json" + } }, - "serve": { - "executor": "@angular/build:dev-server", + "smoke": { + "executor": "nx:run-commands", "options": { - "buildTarget": "cockpit-deep-agents-filesystem-angular:build:development", - "port": 4311, - "proxyConfig": "cockpit/deep-agents/filesystem/angular/proxy.conf.json" + "command": "npx tsx -e \"import { deepAgentsFilesystemAngularModule } from './cockpit/deep-agents/filesystem/angular/src/index.ts'; const mod = deepAgentsFilesystemAngularModule; if (mod.id !== 'deep-agents-filesystem-angular') throw new Error('Unexpected id: ' + mod.id); if (mod.title !== 'Deep Agents Filesystem (Angular)') throw new Error('Unexpected title: ' + mod.title); console.log(JSON.stringify({ id: mod.id, title: mod.title }));\"" } } } diff --git a/cockpit/deep-agents/filesystem/angular/prompts/filesystem.md b/cockpit/deep-agents/filesystem/angular/prompts/filesystem.md new file mode 100644 index 000000000..05c68164c --- /dev/null +++ b/cockpit/deep-agents/filesystem/angular/prompts/filesystem.md @@ -0,0 +1,5 @@ +# Deep Agents Filesystem (Angular) + +This capability demonstrates a deep agent that reads, writes, and navigates a sandboxed filesystem using the `@cacheplane/chat` Angular component library. The `` component surfaces every filesystem tool call — including path, arguments, and result — so developers can follow the agent's file operations step by step. + +Key components used: ``. Each tool invocation (read_file, write_file, list_dir, etc.) appears as a collapsible trace node, giving full visibility into how the agent interacts with the filesystem without cluttering the end-user chat view. diff --git a/cockpit/deep-agents/filesystem/angular/src/app.component.ts b/cockpit/deep-agents/filesystem/angular/src/app.component.ts new file mode 100644 index 000000000..1c9ea78ac --- /dev/null +++ b/cockpit/deep-agents/filesystem/angular/src/app.component.ts @@ -0,0 +1,38 @@ +import { Component, inject, Injector, OnInit } from '@angular/core'; +import { runInInjectionContext } from '@angular/core'; +import { ChatDebugComponent } from '@cacheplane/chat'; +import { streamResource, StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-filesystem', + standalone: true, + imports: [ChatDebugComponent], + template: ` +
+ + +
+ `, +}) +export class FilesystemAppComponent implements OnInit { + private readonly injector = inject(Injector); + chat!: StreamResourceRef; + + ngOnInit(): void { + runInInjectionContext(this.injector, () => { + this.chat = streamResource({ assistantId: 'filesystem_agent' }); + }); + } +} diff --git a/cockpit/deep-agents/filesystem/angular/src/app.config.ts b/cockpit/deep-agents/filesystem/angular/src/app.config.ts new file mode 100644 index 000000000..d64820e1e --- /dev/null +++ b/cockpit/deep-agents/filesystem/angular/src/app.config.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideStreamResource } from '@cacheplane/stream-resource'; +import { provideChat } from '@cacheplane/chat'; +import { provideRender } from '@cacheplane/render'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStreamResource({ + apiUrl: 'http://localhost:2024', + }), + provideChat({}), + provideRender({}), + ], +}; diff --git a/cockpit/deep-agents/filesystem/angular/src/index.html b/cockpit/deep-agents/filesystem/angular/src/index.html index a5762c8bd..20be6aea4 100644 --- a/cockpit/deep-agents/filesystem/angular/src/index.html +++ b/cockpit/deep-agents/filesystem/angular/src/index.html @@ -2,10 +2,11 @@ - Deep Agents Filesystem + Filesystem - Deep Agents Angular Example + - + diff --git a/cockpit/deep-agents/filesystem/angular/src/index.ts b/cockpit/deep-agents/filesystem/angular/src/index.ts new file mode 100644 index 000000000..16ef2972c --- /dev/null +++ b/cockpit/deep-agents/filesystem/angular/src/index.ts @@ -0,0 +1,33 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'deep-agents'; + section: 'core-capabilities'; + topic: 'filesystem'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const deepAgentsFilesystemAngularModule: CockpitCapabilityModule = { + id: 'deep-agents-filesystem-angular', + manifestIdentity: { + product: 'deep-agents', + section: 'core-capabilities', + topic: 'filesystem', + page: 'overview', + language: 'angular', + }, + title: 'Deep Agents Filesystem (Angular)', + docsPath: '/docs/deep-agents/core-capabilities/filesystem/overview/angular', + promptAssetPaths: [ + 'cockpit/deep-agents/filesystem/angular/prompts/filesystem.md', + ], + codeAssetPaths: [ + 'cockpit/deep-agents/filesystem/angular/src/app.component.ts', + ], +}; diff --git a/cockpit/deep-agents/filesystem/angular/src/main.ts b/cockpit/deep-agents/filesystem/angular/src/main.ts index a6094a039..af525927a 100644 --- a/cockpit/deep-agents/filesystem/angular/src/main.ts +++ b/cockpit/deep-agents/filesystem/angular/src/main.ts @@ -1,7 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { bootstrapApplication } from '@angular/platform-browser'; -import { FilesystemComponent } from './app/filesystem.component'; -import { appConfig } from './app/app.config'; +import { appConfig } from './app.config'; +import { FilesystemAppComponent } from './app.component'; -bootstrapApplication(FilesystemComponent, appConfig).catch((err) => - console.error(err) -); +bootstrapApplication(FilesystemAppComponent, appConfig).catch(console.error); diff --git a/cockpit/deep-agents/filesystem/angular/tsconfig.json b/cockpit/deep-agents/filesystem/angular/tsconfig.json index 8deb44c70..90497de60 100644 --- a/cockpit/deep-agents/filesystem/angular/tsconfig.json +++ b/cockpit/deep-agents/filesystem/angular/tsconfig.json @@ -1,16 +1,7 @@ { - "extends": "../../../../tsconfig.base.json", + "extends": "../../../../../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "experimentalDecorators": true, - "emitDeclarationOnly": false, - "noEmit": true, - "lib": [ - "ES2022", - "dom" - ], - "strict": true - } + "module": "preserve" + }, + "include": ["src/**/*.ts"] } diff --git a/cockpit/deep-agents/memory/angular/package.json b/cockpit/deep-agents/memory/angular/package.json index a9f97bb42..f494653dc 100644 --- a/cockpit/deep-agents/memory/angular/package.json +++ b/cockpit/deep-agents/memory/angular/package.json @@ -1,11 +1,11 @@ { - "name": "cockpit-deep-agents-memory-angular", + "name": "@cacheplane/cockpit-deep-agents-memory-angular", "version": "0.0.1", - "private": true, - "dependencies": { - "@cacheplane/stream-resource": "^0.0.1", + "peerDependencies": { "@cacheplane/chat": "^0.0.1", - "@langchain/core": "^0.3.0", + "@cacheplane/stream-resource": "^0.0.1", "@langchain/langgraph-sdk": "^0.0.36" - } + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false } diff --git a/cockpit/deep-agents/memory/angular/project.json b/cockpit/deep-agents/memory/angular/project.json index d9a5e1139..6d8960f96 100644 --- a/cockpit/deep-agents/memory/angular/project.json +++ b/cockpit/deep-agents/memory/angular/project.json @@ -2,36 +2,21 @@ "name": "cockpit-deep-agents-memory-angular", "$schema": "../../../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "cockpit/deep-agents/memory/angular/src", - "projectType": "application", - "tags": ["scope:cockpit", "type:example"], + "projectType": "library", "targets": { "build": { - "executor": "@angular/build:application", + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/cockpit/deep-agents/memory/angular"], "options": { "outputPath": "dist/cockpit/deep-agents/memory/angular", - "index": "cockpit/deep-agents/memory/angular/src/index.html", - "browser": "cockpit/deep-agents/memory/angular/src/main.ts", - "tsConfig": "cockpit/deep-agents/memory/angular/tsconfig.app.json", - "styles": ["cockpit/deep-agents/memory/angular/src/styles.css"] - }, - "configurations": { - "development": { - "fileReplacements": [ - { - "replace": "cockpit/deep-agents/memory/angular/src/environments/environment.ts", - "with": "cockpit/deep-agents/memory/angular/src/environments/environment.development.ts" - } - ] - } - }, - "defaultConfiguration": "development" + "main": "cockpit/deep-agents/memory/angular/src/index.ts", + "tsConfig": "cockpit/deep-agents/memory/angular/tsconfig.json" + } }, - "serve": { - "executor": "@angular/build:dev-server", + "smoke": { + "executor": "nx:run-commands", "options": { - "buildTarget": "cockpit-deep-agents-memory-angular:build:development", - "port": 4313, - "proxyConfig": "cockpit/deep-agents/memory/angular/proxy.conf.json" + "command": "npx tsx -e \"import { deepAgentsMemoryAngularModule } from './cockpit/deep-agents/memory/angular/src/index.ts'; const mod = deepAgentsMemoryAngularModule; if (mod.id !== 'deep-agents-memory-angular') throw new Error('Unexpected id: ' + mod.id); if (mod.title !== 'Deep Agents Memory (Angular)') throw new Error('Unexpected title: ' + mod.title); console.log(JSON.stringify({ id: mod.id, title: mod.title }));\"" } } } diff --git a/cockpit/deep-agents/memory/angular/prompts/memory.md b/cockpit/deep-agents/memory/angular/prompts/memory.md new file mode 100644 index 000000000..df0e2a256 --- /dev/null +++ b/cockpit/deep-agents/memory/angular/prompts/memory.md @@ -0,0 +1,5 @@ +# Deep Agents Memory (Angular) + +This capability demonstrates how a deep agent stores, retrieves, and updates long-term memories across sessions using the `@cacheplane/chat` Angular component library. The `` component reveals every memory read and write operation — including the memory key, value, and retrieval score — so developers can verify that the agent is building and using its knowledge store correctly. + +Key components used: ``. Memory tool calls (store_memory, retrieve_memories, delete_memory) appear as collapsible trace nodes, giving full visibility into how the agent's persistent knowledge base evolves over the course of a session and across session boundaries. diff --git a/cockpit/deep-agents/memory/angular/src/app.component.ts b/cockpit/deep-agents/memory/angular/src/app.component.ts new file mode 100644 index 000000000..d30c0fc8d --- /dev/null +++ b/cockpit/deep-agents/memory/angular/src/app.component.ts @@ -0,0 +1,38 @@ +import { Component, inject, Injector, OnInit } from '@angular/core'; +import { runInInjectionContext } from '@angular/core'; +import { ChatDebugComponent } from '@cacheplane/chat'; +import { streamResource, StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-memory', + standalone: true, + imports: [ChatDebugComponent], + template: ` +
+ + +
+ `, +}) +export class MemoryAppComponent implements OnInit { + private readonly injector = inject(Injector); + chat!: StreamResourceRef; + + ngOnInit(): void { + runInInjectionContext(this.injector, () => { + this.chat = streamResource({ assistantId: 'memory_agent' }); + }); + } +} diff --git a/cockpit/deep-agents/memory/angular/src/app.config.ts b/cockpit/deep-agents/memory/angular/src/app.config.ts new file mode 100644 index 000000000..d64820e1e --- /dev/null +++ b/cockpit/deep-agents/memory/angular/src/app.config.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideStreamResource } from '@cacheplane/stream-resource'; +import { provideChat } from '@cacheplane/chat'; +import { provideRender } from '@cacheplane/render'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStreamResource({ + apiUrl: 'http://localhost:2024', + }), + provideChat({}), + provideRender({}), + ], +}; diff --git a/cockpit/deep-agents/memory/angular/src/index.html b/cockpit/deep-agents/memory/angular/src/index.html index a5bd9dc8c..b9a5a9c10 100644 --- a/cockpit/deep-agents/memory/angular/src/index.html +++ b/cockpit/deep-agents/memory/angular/src/index.html @@ -2,10 +2,11 @@ - Deep Agents Memory + Memory - Deep Agents Angular Example + - - + + diff --git a/cockpit/deep-agents/memory/angular/src/index.ts b/cockpit/deep-agents/memory/angular/src/index.ts new file mode 100644 index 000000000..59afd9c9a --- /dev/null +++ b/cockpit/deep-agents/memory/angular/src/index.ts @@ -0,0 +1,33 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'deep-agents'; + section: 'core-capabilities'; + topic: 'memory'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const deepAgentsMemoryAngularModule: CockpitCapabilityModule = { + id: 'deep-agents-memory-angular', + manifestIdentity: { + product: 'deep-agents', + section: 'core-capabilities', + topic: 'memory', + page: 'overview', + language: 'angular', + }, + title: 'Deep Agents Memory (Angular)', + docsPath: '/docs/deep-agents/core-capabilities/memory/overview/angular', + promptAssetPaths: [ + 'cockpit/deep-agents/memory/angular/prompts/memory.md', + ], + codeAssetPaths: [ + 'cockpit/deep-agents/memory/angular/src/app.component.ts', + ], +}; diff --git a/cockpit/deep-agents/memory/angular/src/main.ts b/cockpit/deep-agents/memory/angular/src/main.ts index 4cf24534e..180df289a 100644 --- a/cockpit/deep-agents/memory/angular/src/main.ts +++ b/cockpit/deep-agents/memory/angular/src/main.ts @@ -1,7 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { bootstrapApplication } from '@angular/platform-browser'; -import { MemoryComponent } from './app/memory.component'; -import { appConfig } from './app/app.config'; +import { appConfig } from './app.config'; +import { MemoryAppComponent } from './app.component'; -bootstrapApplication(MemoryComponent, appConfig).catch((err) => - console.error(err) -); +bootstrapApplication(MemoryAppComponent, appConfig).catch(console.error); diff --git a/cockpit/deep-agents/memory/angular/tsconfig.json b/cockpit/deep-agents/memory/angular/tsconfig.json index 8deb44c70..90497de60 100644 --- a/cockpit/deep-agents/memory/angular/tsconfig.json +++ b/cockpit/deep-agents/memory/angular/tsconfig.json @@ -1,16 +1,7 @@ { - "extends": "../../../../tsconfig.base.json", + "extends": "../../../../../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "experimentalDecorators": true, - "emitDeclarationOnly": false, - "noEmit": true, - "lib": [ - "ES2022", - "dom" - ], - "strict": true - } + "module": "preserve" + }, + "include": ["src/**/*.ts"] } diff --git a/cockpit/deep-agents/planning/angular/package.json b/cockpit/deep-agents/planning/angular/package.json index 8df3d9498..8d2057bb3 100644 --- a/cockpit/deep-agents/planning/angular/package.json +++ b/cockpit/deep-agents/planning/angular/package.json @@ -1,11 +1,11 @@ { - "name": "cockpit-deep-agents-planning-angular", + "name": "@cacheplane/cockpit-deep-agents-planning-angular", "version": "0.0.1", - "private": true, - "dependencies": { - "@cacheplane/stream-resource": "^0.0.1", + "peerDependencies": { "@cacheplane/chat": "^0.0.1", - "@langchain/core": "^0.3.0", + "@cacheplane/stream-resource": "^0.0.1", "@langchain/langgraph-sdk": "^0.0.36" - } + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false } diff --git a/cockpit/deep-agents/planning/angular/project.json b/cockpit/deep-agents/planning/angular/project.json index 3b37cd1e5..6410ab68e 100644 --- a/cockpit/deep-agents/planning/angular/project.json +++ b/cockpit/deep-agents/planning/angular/project.json @@ -2,36 +2,21 @@ "name": "cockpit-deep-agents-planning-angular", "$schema": "../../../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "cockpit/deep-agents/planning/angular/src", - "projectType": "application", - "tags": ["scope:cockpit", "type:example"], + "projectType": "library", "targets": { "build": { - "executor": "@angular/build:application", + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/cockpit/deep-agents/planning/angular"], "options": { "outputPath": "dist/cockpit/deep-agents/planning/angular", - "index": "cockpit/deep-agents/planning/angular/src/index.html", - "browser": "cockpit/deep-agents/planning/angular/src/main.ts", - "tsConfig": "cockpit/deep-agents/planning/angular/tsconfig.app.json", - "styles": ["cockpit/deep-agents/planning/angular/src/styles.css"] - }, - "configurations": { - "development": { - "fileReplacements": [ - { - "replace": "cockpit/deep-agents/planning/angular/src/environments/environment.ts", - "with": "cockpit/deep-agents/planning/angular/src/environments/environment.development.ts" - } - ] - } - }, - "defaultConfiguration": "development" + "main": "cockpit/deep-agents/planning/angular/src/index.ts", + "tsConfig": "cockpit/deep-agents/planning/angular/tsconfig.json" + } }, - "serve": { - "executor": "@angular/build:dev-server", + "smoke": { + "executor": "nx:run-commands", "options": { - "buildTarget": "cockpit-deep-agents-planning-angular:build:development", - "port": 4310, - "proxyConfig": "cockpit/deep-agents/planning/angular/proxy.conf.json" + "command": "npx tsx -e \"import { deepAgentsPlanningAngularModule } from './cockpit/deep-agents/planning/angular/src/index.ts'; const mod = deepAgentsPlanningAngularModule; if (mod.id !== 'deep-agents-planning-angular') throw new Error('Unexpected id: ' + mod.id); if (mod.title !== 'Deep Agents Planning (Angular)') throw new Error('Unexpected title: ' + mod.title); console.log(JSON.stringify({ id: mod.id, title: mod.title }));\"" } } } diff --git a/cockpit/deep-agents/planning/angular/prompts/planning.md b/cockpit/deep-agents/planning/angular/prompts/planning.md new file mode 100644 index 000000000..82544d36e --- /dev/null +++ b/cockpit/deep-agents/planning/angular/prompts/planning.md @@ -0,0 +1,5 @@ +# Deep Agents Planning (Angular) + +This capability demonstrates how a deep agent decomposes complex tasks into structured plans using the `@cacheplane/chat` Angular component library. The `` component exposes the agent's internal reasoning trace — goal decomposition, sub-task generation, and dependency resolution — so developers can inspect planning decisions in real time. + +Key components used: ``. The debug panel renders the full agent thought trace alongside the final response, making it easy to understand how the planning agent broke down the user's request and in which order it intends to tackle each sub-task. diff --git a/cockpit/deep-agents/planning/angular/src/app.component.ts b/cockpit/deep-agents/planning/angular/src/app.component.ts new file mode 100644 index 000000000..69585febe --- /dev/null +++ b/cockpit/deep-agents/planning/angular/src/app.component.ts @@ -0,0 +1,35 @@ +import { Component, inject, Injector, OnInit } from '@angular/core'; +import { runInInjectionContext } from '@angular/core'; +import { ChatDebugComponent } from '@cacheplane/chat'; +import { streamResource, StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-planning', + standalone: true, + imports: [ChatDebugComponent], + template: ` +
+ + +
+ `, +}) +export class PlanningAppComponent implements OnInit { + private readonly injector = inject(Injector); + chat!: StreamResourceRef; + + ngOnInit(): void { + runInInjectionContext(this.injector, () => { + this.chat = streamResource({ assistantId: 'planning_agent' }); + }); + } +} diff --git a/cockpit/deep-agents/planning/angular/src/app.config.ts b/cockpit/deep-agents/planning/angular/src/app.config.ts new file mode 100644 index 000000000..d64820e1e --- /dev/null +++ b/cockpit/deep-agents/planning/angular/src/app.config.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideStreamResource } from '@cacheplane/stream-resource'; +import { provideChat } from '@cacheplane/chat'; +import { provideRender } from '@cacheplane/render'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStreamResource({ + apiUrl: 'http://localhost:2024', + }), + provideChat({}), + provideRender({}), + ], +}; diff --git a/cockpit/deep-agents/planning/angular/src/index.html b/cockpit/deep-agents/planning/angular/src/index.html index fbd123206..cc6217adc 100644 --- a/cockpit/deep-agents/planning/angular/src/index.html +++ b/cockpit/deep-agents/planning/angular/src/index.html @@ -2,10 +2,11 @@ - Deep Agents Planning + Planning - Deep Agents Angular Example + - + diff --git a/cockpit/deep-agents/planning/angular/src/index.ts b/cockpit/deep-agents/planning/angular/src/index.ts new file mode 100644 index 000000000..c7e9c1a16 --- /dev/null +++ b/cockpit/deep-agents/planning/angular/src/index.ts @@ -0,0 +1,33 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'deep-agents'; + section: 'core-capabilities'; + topic: 'planning'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const deepAgentsPlanningAngularModule: CockpitCapabilityModule = { + id: 'deep-agents-planning-angular', + manifestIdentity: { + product: 'deep-agents', + section: 'core-capabilities', + topic: 'planning', + page: 'overview', + language: 'angular', + }, + title: 'Deep Agents Planning (Angular)', + docsPath: '/docs/deep-agents/core-capabilities/planning/overview/angular', + promptAssetPaths: [ + 'cockpit/deep-agents/planning/angular/prompts/planning.md', + ], + codeAssetPaths: [ + 'cockpit/deep-agents/planning/angular/src/app.component.ts', + ], +}; diff --git a/cockpit/deep-agents/planning/angular/src/main.ts b/cockpit/deep-agents/planning/angular/src/main.ts index 304c54df2..cdeac74d0 100644 --- a/cockpit/deep-agents/planning/angular/src/main.ts +++ b/cockpit/deep-agents/planning/angular/src/main.ts @@ -1,7 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { bootstrapApplication } from '@angular/platform-browser'; -import { PlanningComponent } from './app/planning.component'; -import { appConfig } from './app/app.config'; +import { appConfig } from './app.config'; +import { PlanningAppComponent } from './app.component'; -bootstrapApplication(PlanningComponent, appConfig).catch((err) => - console.error(err) -); +bootstrapApplication(PlanningAppComponent, appConfig).catch(console.error); diff --git a/cockpit/deep-agents/planning/angular/tsconfig.json b/cockpit/deep-agents/planning/angular/tsconfig.json index 8deb44c70..90497de60 100644 --- a/cockpit/deep-agents/planning/angular/tsconfig.json +++ b/cockpit/deep-agents/planning/angular/tsconfig.json @@ -1,16 +1,7 @@ { - "extends": "../../../../tsconfig.base.json", + "extends": "../../../../../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "experimentalDecorators": true, - "emitDeclarationOnly": false, - "noEmit": true, - "lib": [ - "ES2022", - "dom" - ], - "strict": true - } + "module": "preserve" + }, + "include": ["src/**/*.ts"] } diff --git a/cockpit/deep-agents/sandboxes/angular/package.json b/cockpit/deep-agents/sandboxes/angular/package.json index d4a6a0eea..3c0d67a53 100644 --- a/cockpit/deep-agents/sandboxes/angular/package.json +++ b/cockpit/deep-agents/sandboxes/angular/package.json @@ -1,11 +1,11 @@ { - "name": "cockpit-deep-agents-sandboxes-angular", + "name": "@cacheplane/cockpit-deep-agents-sandboxes-angular", "version": "0.0.1", - "private": true, - "dependencies": { - "@cacheplane/stream-resource": "^0.0.1", + "peerDependencies": { "@cacheplane/chat": "^0.0.1", - "@langchain/core": "^0.3.0", + "@cacheplane/stream-resource": "^0.0.1", "@langchain/langgraph-sdk": "^0.0.36" - } + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false } diff --git a/cockpit/deep-agents/sandboxes/angular/project.json b/cockpit/deep-agents/sandboxes/angular/project.json index 7da6ebf46..e36edfe37 100644 --- a/cockpit/deep-agents/sandboxes/angular/project.json +++ b/cockpit/deep-agents/sandboxes/angular/project.json @@ -2,36 +2,21 @@ "name": "cockpit-deep-agents-sandboxes-angular", "$schema": "../../../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "cockpit/deep-agents/sandboxes/angular/src", - "projectType": "application", - "tags": ["scope:cockpit", "type:example"], + "projectType": "library", "targets": { "build": { - "executor": "@angular/build:application", + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/cockpit/deep-agents/sandboxes/angular"], "options": { "outputPath": "dist/cockpit/deep-agents/sandboxes/angular", - "index": "cockpit/deep-agents/sandboxes/angular/src/index.html", - "browser": "cockpit/deep-agents/sandboxes/angular/src/main.ts", - "tsConfig": "cockpit/deep-agents/sandboxes/angular/tsconfig.app.json", - "styles": ["cockpit/deep-agents/sandboxes/angular/src/styles.css"] - }, - "configurations": { - "development": { - "fileReplacements": [ - { - "replace": "cockpit/deep-agents/sandboxes/angular/src/environments/environment.ts", - "with": "cockpit/deep-agents/sandboxes/angular/src/environments/environment.development.ts" - } - ] - } - }, - "defaultConfiguration": "development" + "main": "cockpit/deep-agents/sandboxes/angular/src/index.ts", + "tsConfig": "cockpit/deep-agents/sandboxes/angular/tsconfig.json" + } }, - "serve": { - "executor": "@angular/build:dev-server", + "smoke": { + "executor": "nx:run-commands", "options": { - "buildTarget": "cockpit-deep-agents-sandboxes-angular:build:development", - "port": 4315, - "proxyConfig": "cockpit/deep-agents/sandboxes/angular/proxy.conf.json" + "command": "npx tsx -e \"import { deepAgentsSandboxesAngularModule } from './cockpit/deep-agents/sandboxes/angular/src/index.ts'; const mod = deepAgentsSandboxesAngularModule; if (mod.id !== 'deep-agents-sandboxes-angular') throw new Error('Unexpected id: ' + mod.id); if (mod.title !== 'Deep Agents Sandboxes (Angular)') throw new Error('Unexpected title: ' + mod.title); console.log(JSON.stringify({ id: mod.id, title: mod.title }));\"" } } } diff --git a/cockpit/deep-agents/sandboxes/angular/prompts/sandboxes.md b/cockpit/deep-agents/sandboxes/angular/prompts/sandboxes.md new file mode 100644 index 000000000..0896e67e1 --- /dev/null +++ b/cockpit/deep-agents/sandboxes/angular/prompts/sandboxes.md @@ -0,0 +1,5 @@ +# Deep Agents Sandboxes (Angular) + +This capability demonstrates a deep agent executing code and shell commands inside an isolated sandbox environment using the `@cacheplane/chat` Angular component library. The `` component surfaces every sandbox invocation — the code submitted, the execution environment, stdout/stderr, and exit codes — giving developers complete visibility into agent-driven code execution. + +Key components used: ``. Sandbox execution events appear as trace nodes labelled with the runtime (Python, Node.js, shell, etc.), with expandable panels showing the exact code submitted and the full execution output, making it straightforward to reproduce and debug agent-generated code. diff --git a/cockpit/deep-agents/sandboxes/angular/src/app.component.ts b/cockpit/deep-agents/sandboxes/angular/src/app.component.ts new file mode 100644 index 000000000..408dd027a --- /dev/null +++ b/cockpit/deep-agents/sandboxes/angular/src/app.component.ts @@ -0,0 +1,39 @@ +import { Component, inject, Injector, OnInit } from '@angular/core'; +import { runInInjectionContext } from '@angular/core'; +import { ChatDebugComponent } from '@cacheplane/chat'; +import { streamResource, StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-sandboxes', + standalone: true, + imports: [ChatDebugComponent], + template: ` +
+ + +
+ `, +}) +export class SandboxesAppComponent implements OnInit { + private readonly injector = inject(Injector); + chat!: StreamResourceRef; + + ngOnInit(): void { + runInInjectionContext(this.injector, () => { + this.chat = streamResource({ assistantId: 'sandbox_agent' }); + }); + } +} diff --git a/cockpit/deep-agents/sandboxes/angular/src/app.config.ts b/cockpit/deep-agents/sandboxes/angular/src/app.config.ts new file mode 100644 index 000000000..d64820e1e --- /dev/null +++ b/cockpit/deep-agents/sandboxes/angular/src/app.config.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideStreamResource } from '@cacheplane/stream-resource'; +import { provideChat } from '@cacheplane/chat'; +import { provideRender } from '@cacheplane/render'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStreamResource({ + apiUrl: 'http://localhost:2024', + }), + provideChat({}), + provideRender({}), + ], +}; diff --git a/cockpit/deep-agents/sandboxes/angular/src/index.html b/cockpit/deep-agents/sandboxes/angular/src/index.html index 7e6d4d222..36bcecc82 100644 --- a/cockpit/deep-agents/sandboxes/angular/src/index.html +++ b/cockpit/deep-agents/sandboxes/angular/src/index.html @@ -2,10 +2,11 @@ - Deep Agents Sandboxes + Sandboxes - Deep Agents Angular Example + - + diff --git a/cockpit/deep-agents/sandboxes/angular/src/index.ts b/cockpit/deep-agents/sandboxes/angular/src/index.ts new file mode 100644 index 000000000..db2c0f26f --- /dev/null +++ b/cockpit/deep-agents/sandboxes/angular/src/index.ts @@ -0,0 +1,33 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'deep-agents'; + section: 'core-capabilities'; + topic: 'sandboxes'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const deepAgentsSandboxesAngularModule: CockpitCapabilityModule = { + id: 'deep-agents-sandboxes-angular', + manifestIdentity: { + product: 'deep-agents', + section: 'core-capabilities', + topic: 'sandboxes', + page: 'overview', + language: 'angular', + }, + title: 'Deep Agents Sandboxes (Angular)', + docsPath: '/docs/deep-agents/core-capabilities/sandboxes/overview/angular', + promptAssetPaths: [ + 'cockpit/deep-agents/sandboxes/angular/prompts/sandboxes.md', + ], + codeAssetPaths: [ + 'cockpit/deep-agents/sandboxes/angular/src/app.component.ts', + ], +}; diff --git a/cockpit/deep-agents/sandboxes/angular/src/main.ts b/cockpit/deep-agents/sandboxes/angular/src/main.ts index a7ffd45d1..6ea997dcb 100644 --- a/cockpit/deep-agents/sandboxes/angular/src/main.ts +++ b/cockpit/deep-agents/sandboxes/angular/src/main.ts @@ -1,7 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { bootstrapApplication } from '@angular/platform-browser'; -import { SandboxesComponent } from './app/sandboxes.component'; -import { appConfig } from './app/app.config'; +import { appConfig } from './app.config'; +import { SandboxesAppComponent } from './app.component'; -bootstrapApplication(SandboxesComponent, appConfig).catch((err) => - console.error(err) -); +bootstrapApplication(SandboxesAppComponent, appConfig).catch(console.error); diff --git a/cockpit/deep-agents/sandboxes/angular/tsconfig.json b/cockpit/deep-agents/sandboxes/angular/tsconfig.json index 8deb44c70..90497de60 100644 --- a/cockpit/deep-agents/sandboxes/angular/tsconfig.json +++ b/cockpit/deep-agents/sandboxes/angular/tsconfig.json @@ -1,16 +1,7 @@ { - "extends": "../../../../tsconfig.base.json", + "extends": "../../../../../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "experimentalDecorators": true, - "emitDeclarationOnly": false, - "noEmit": true, - "lib": [ - "ES2022", - "dom" - ], - "strict": true - } + "module": "preserve" + }, + "include": ["src/**/*.ts"] } diff --git a/cockpit/deep-agents/skills/angular/package.json b/cockpit/deep-agents/skills/angular/package.json index 7e60efcc8..6a35f6cae 100644 --- a/cockpit/deep-agents/skills/angular/package.json +++ b/cockpit/deep-agents/skills/angular/package.json @@ -1,11 +1,11 @@ { - "name": "cockpit-deep-agents-skills-angular", + "name": "@cacheplane/cockpit-deep-agents-skills-angular", "version": "0.0.1", - "private": true, - "dependencies": { - "@cacheplane/stream-resource": "^0.0.1", + "peerDependencies": { "@cacheplane/chat": "^0.0.1", - "@langchain/core": "^0.3.0", + "@cacheplane/stream-resource": "^0.0.1", "@langchain/langgraph-sdk": "^0.0.36" - } + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false } diff --git a/cockpit/deep-agents/skills/angular/project.json b/cockpit/deep-agents/skills/angular/project.json index 3d0f0f99c..4f85e59a5 100644 --- a/cockpit/deep-agents/skills/angular/project.json +++ b/cockpit/deep-agents/skills/angular/project.json @@ -2,36 +2,21 @@ "name": "cockpit-deep-agents-skills-angular", "$schema": "../../../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "cockpit/deep-agents/skills/angular/src", - "projectType": "application", - "tags": ["scope:cockpit", "type:example"], + "projectType": "library", "targets": { "build": { - "executor": "@angular/build:application", + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/cockpit/deep-agents/skills/angular"], "options": { "outputPath": "dist/cockpit/deep-agents/skills/angular", - "index": "cockpit/deep-agents/skills/angular/src/index.html", - "browser": "cockpit/deep-agents/skills/angular/src/main.ts", - "tsConfig": "cockpit/deep-agents/skills/angular/tsconfig.app.json", - "styles": ["cockpit/deep-agents/skills/angular/src/styles.css"] - }, - "configurations": { - "development": { - "fileReplacements": [ - { - "replace": "cockpit/deep-agents/skills/angular/src/environments/environment.ts", - "with": "cockpit/deep-agents/skills/angular/src/environments/environment.development.ts" - } - ] - } - }, - "defaultConfiguration": "development" + "main": "cockpit/deep-agents/skills/angular/src/index.ts", + "tsConfig": "cockpit/deep-agents/skills/angular/tsconfig.json" + } }, - "serve": { - "executor": "@angular/build:dev-server", + "smoke": { + "executor": "nx:run-commands", "options": { - "buildTarget": "cockpit-deep-agents-skills-angular:build:development", - "port": 4314, - "proxyConfig": "cockpit/deep-agents/skills/angular/proxy.conf.json" + "command": "npx tsx -e \"import { deepAgentsSkillsAngularModule } from './cockpit/deep-agents/skills/angular/src/index.ts'; const mod = deepAgentsSkillsAngularModule; if (mod.id !== 'deep-agents-skills-angular') throw new Error('Unexpected id: ' + mod.id); if (mod.title !== 'Deep Agents Skills (Angular)') throw new Error('Unexpected title: ' + mod.title); console.log(JSON.stringify({ id: mod.id, title: mod.title }));\"" } } } diff --git a/cockpit/deep-agents/skills/angular/prompts/skills.md b/cockpit/deep-agents/skills/angular/prompts/skills.md new file mode 100644 index 000000000..e1af2858a --- /dev/null +++ b/cockpit/deep-agents/skills/angular/prompts/skills.md @@ -0,0 +1,5 @@ +# Deep Agents Skills (Angular) + +This capability demonstrates a deep agent that selects and executes reusable skill modules — pre-packaged sequences of tool calls — using the `@cacheplane/chat` Angular component library. The `` component shows which skill was invoked, the parameters it received, and the intermediate steps it performed, giving developers full traceability into skill dispatch and execution. + +Key components used: ``. Skill invocations appear as named trace nodes with expandable step-by-step sub-traces, making it easy to audit skill selection logic, identify skill failures, and verify that parameter binding between the agent and each skill is correct. diff --git a/cockpit/deep-agents/skills/angular/src/app.component.ts b/cockpit/deep-agents/skills/angular/src/app.component.ts new file mode 100644 index 000000000..249563587 --- /dev/null +++ b/cockpit/deep-agents/skills/angular/src/app.component.ts @@ -0,0 +1,36 @@ +import { Component, inject, Injector, OnInit } from '@angular/core'; +import { runInInjectionContext } from '@angular/core'; +import { ChatDebugComponent } from '@cacheplane/chat'; +import { streamResource, StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-skills', + standalone: true, + imports: [ChatDebugComponent], + template: ` +
+ + +
+ `, +}) +export class SkillsAppComponent implements OnInit { + private readonly injector = inject(Injector); + chat!: StreamResourceRef; + + ngOnInit(): void { + runInInjectionContext(this.injector, () => { + this.chat = streamResource({ assistantId: 'skills_agent' }); + }); + } +} diff --git a/cockpit/deep-agents/skills/angular/src/app.config.ts b/cockpit/deep-agents/skills/angular/src/app.config.ts new file mode 100644 index 000000000..d64820e1e --- /dev/null +++ b/cockpit/deep-agents/skills/angular/src/app.config.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideStreamResource } from '@cacheplane/stream-resource'; +import { provideChat } from '@cacheplane/chat'; +import { provideRender } from '@cacheplane/render'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStreamResource({ + apiUrl: 'http://localhost:2024', + }), + provideChat({}), + provideRender({}), + ], +}; diff --git a/cockpit/deep-agents/skills/angular/src/index.html b/cockpit/deep-agents/skills/angular/src/index.html index 47cdef7d4..bcd6ec7f5 100644 --- a/cockpit/deep-agents/skills/angular/src/index.html +++ b/cockpit/deep-agents/skills/angular/src/index.html @@ -2,10 +2,11 @@ - Deep Agents Skills + Skills - Deep Agents Angular Example + - + diff --git a/cockpit/deep-agents/skills/angular/src/index.ts b/cockpit/deep-agents/skills/angular/src/index.ts new file mode 100644 index 000000000..3ae5e5319 --- /dev/null +++ b/cockpit/deep-agents/skills/angular/src/index.ts @@ -0,0 +1,33 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'deep-agents'; + section: 'core-capabilities'; + topic: 'skills'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const deepAgentsSkillsAngularModule: CockpitCapabilityModule = { + id: 'deep-agents-skills-angular', + manifestIdentity: { + product: 'deep-agents', + section: 'core-capabilities', + topic: 'skills', + page: 'overview', + language: 'angular', + }, + title: 'Deep Agents Skills (Angular)', + docsPath: '/docs/deep-agents/core-capabilities/skills/overview/angular', + promptAssetPaths: [ + 'cockpit/deep-agents/skills/angular/prompts/skills.md', + ], + codeAssetPaths: [ + 'cockpit/deep-agents/skills/angular/src/app.component.ts', + ], +}; diff --git a/cockpit/deep-agents/skills/angular/src/main.ts b/cockpit/deep-agents/skills/angular/src/main.ts index 1247d5963..eb3fab282 100644 --- a/cockpit/deep-agents/skills/angular/src/main.ts +++ b/cockpit/deep-agents/skills/angular/src/main.ts @@ -1,7 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { bootstrapApplication } from '@angular/platform-browser'; -import { SkillsComponent } from './app/skills.component'; -import { appConfig } from './app/app.config'; +import { appConfig } from './app.config'; +import { SkillsAppComponent } from './app.component'; -bootstrapApplication(SkillsComponent, appConfig).catch((err) => - console.error(err) -); +bootstrapApplication(SkillsAppComponent, appConfig).catch(console.error); diff --git a/cockpit/deep-agents/skills/angular/tsconfig.json b/cockpit/deep-agents/skills/angular/tsconfig.json index 8deb44c70..90497de60 100644 --- a/cockpit/deep-agents/skills/angular/tsconfig.json +++ b/cockpit/deep-agents/skills/angular/tsconfig.json @@ -1,16 +1,7 @@ { - "extends": "../../../../tsconfig.base.json", + "extends": "../../../../../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "experimentalDecorators": true, - "emitDeclarationOnly": false, - "noEmit": true, - "lib": [ - "ES2022", - "dom" - ], - "strict": true - } + "module": "preserve" + }, + "include": ["src/**/*.ts"] } diff --git a/cockpit/deep-agents/subagents/angular/package.json b/cockpit/deep-agents/subagents/angular/package.json index c08d27119..c5a6b89b5 100644 --- a/cockpit/deep-agents/subagents/angular/package.json +++ b/cockpit/deep-agents/subagents/angular/package.json @@ -1,11 +1,11 @@ { - "name": "cockpit-deep-agents-subagents-angular", + "name": "@cacheplane/cockpit-deep-agents-subagents-angular", "version": "0.0.1", - "private": true, - "dependencies": { - "@cacheplane/stream-resource": "^0.0.1", + "peerDependencies": { "@cacheplane/chat": "^0.0.1", - "@langchain/core": "^0.3.0", + "@cacheplane/stream-resource": "^0.0.1", "@langchain/langgraph-sdk": "^0.0.36" - } + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false } diff --git a/cockpit/deep-agents/subagents/angular/project.json b/cockpit/deep-agents/subagents/angular/project.json index 490288e76..255d66722 100644 --- a/cockpit/deep-agents/subagents/angular/project.json +++ b/cockpit/deep-agents/subagents/angular/project.json @@ -2,36 +2,21 @@ "name": "cockpit-deep-agents-subagents-angular", "$schema": "../../../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "cockpit/deep-agents/subagents/angular/src", - "projectType": "application", - "tags": ["scope:cockpit", "type:example"], + "projectType": "library", "targets": { "build": { - "executor": "@angular/build:application", + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/cockpit/deep-agents/subagents/angular"], "options": { "outputPath": "dist/cockpit/deep-agents/subagents/angular", - "index": "cockpit/deep-agents/subagents/angular/src/index.html", - "browser": "cockpit/deep-agents/subagents/angular/src/main.ts", - "tsConfig": "cockpit/deep-agents/subagents/angular/tsconfig.app.json", - "styles": ["cockpit/deep-agents/subagents/angular/src/styles.css"] - }, - "configurations": { - "development": { - "fileReplacements": [ - { - "replace": "cockpit/deep-agents/subagents/angular/src/environments/environment.ts", - "with": "cockpit/deep-agents/subagents/angular/src/environments/environment.development.ts" - } - ] - } - }, - "defaultConfiguration": "development" + "main": "cockpit/deep-agents/subagents/angular/src/index.ts", + "tsConfig": "cockpit/deep-agents/subagents/angular/tsconfig.json" + } }, - "serve": { - "executor": "@angular/build:dev-server", + "smoke": { + "executor": "nx:run-commands", "options": { - "buildTarget": "cockpit-deep-agents-subagents-angular:build:development", - "port": 4312, - "proxyConfig": "cockpit/deep-agents/subagents/angular/proxy.conf.json" + "command": "npx tsx -e \"import { deepAgentsSubagentsAngularModule } from './cockpit/deep-agents/subagents/angular/src/index.ts'; const mod = deepAgentsSubagentsAngularModule; if (mod.id !== 'deep-agents-subagents-angular') throw new Error('Unexpected id: ' + mod.id); if (mod.title !== 'Deep Agents Subagents (Angular)') throw new Error('Unexpected title: ' + mod.title); console.log(JSON.stringify({ id: mod.id, title: mod.title }));\"" } } } diff --git a/cockpit/deep-agents/subagents/angular/prompts/subagents.md b/cockpit/deep-agents/subagents/angular/prompts/subagents.md new file mode 100644 index 000000000..0282ea0b0 --- /dev/null +++ b/cockpit/deep-agents/subagents/angular/prompts/subagents.md @@ -0,0 +1,5 @@ +# Deep Agents Subagents (Angular) + +This capability demonstrates an orchestrator agent that delegates work to specialised subagents using the `@cacheplane/chat` Angular component library. The `` component shows the full delegation trace — which subagent was called, with what instructions, and what it returned — giving developers complete observability into multi-agent coordination. + +Key components used: ``. Each subagent invocation appears as a collapsible trace node labelled with the subagent's identity, making it straightforward to audit delegation chains, spot redundant calls, and verify that each subagent received the correct context. diff --git a/cockpit/deep-agents/subagents/angular/src/app.component.ts b/cockpit/deep-agents/subagents/angular/src/app.component.ts new file mode 100644 index 000000000..bd285ad7e --- /dev/null +++ b/cockpit/deep-agents/subagents/angular/src/app.component.ts @@ -0,0 +1,38 @@ +import { Component, inject, Injector, OnInit } from '@angular/core'; +import { runInInjectionContext } from '@angular/core'; +import { ChatDebugComponent } from '@cacheplane/chat'; +import { streamResource, StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-subagents', + standalone: true, + imports: [ChatDebugComponent], + template: ` +
+ + +
+ `, +}) +export class SubagentsAppComponent implements OnInit { + private readonly injector = inject(Injector); + chat!: StreamResourceRef; + + ngOnInit(): void { + runInInjectionContext(this.injector, () => { + this.chat = streamResource({ assistantId: 'orchestrator_agent' }); + }); + } +} diff --git a/cockpit/deep-agents/subagents/angular/src/app.config.ts b/cockpit/deep-agents/subagents/angular/src/app.config.ts new file mode 100644 index 000000000..d64820e1e --- /dev/null +++ b/cockpit/deep-agents/subagents/angular/src/app.config.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideStreamResource } from '@cacheplane/stream-resource'; +import { provideChat } from '@cacheplane/chat'; +import { provideRender } from '@cacheplane/render'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStreamResource({ + apiUrl: 'http://localhost:2024', + }), + provideChat({}), + provideRender({}), + ], +}; diff --git a/cockpit/deep-agents/subagents/angular/src/index.html b/cockpit/deep-agents/subagents/angular/src/index.html index af0416e7e..caa1129f2 100644 --- a/cockpit/deep-agents/subagents/angular/src/index.html +++ b/cockpit/deep-agents/subagents/angular/src/index.html @@ -2,10 +2,11 @@ - Deep Agents Subagents + Subagents - Deep Agents Angular Example + - + diff --git a/cockpit/deep-agents/subagents/angular/src/index.ts b/cockpit/deep-agents/subagents/angular/src/index.ts new file mode 100644 index 000000000..01a53f57c --- /dev/null +++ b/cockpit/deep-agents/subagents/angular/src/index.ts @@ -0,0 +1,33 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'deep-agents'; + section: 'core-capabilities'; + topic: 'subagents'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const deepAgentsSubagentsAngularModule: CockpitCapabilityModule = { + id: 'deep-agents-subagents-angular', + manifestIdentity: { + product: 'deep-agents', + section: 'core-capabilities', + topic: 'subagents', + page: 'overview', + language: 'angular', + }, + title: 'Deep Agents Subagents (Angular)', + docsPath: '/docs/deep-agents/core-capabilities/subagents/overview/angular', + promptAssetPaths: [ + 'cockpit/deep-agents/subagents/angular/prompts/subagents.md', + ], + codeAssetPaths: [ + 'cockpit/deep-agents/subagents/angular/src/app.component.ts', + ], +}; diff --git a/cockpit/deep-agents/subagents/angular/src/main.ts b/cockpit/deep-agents/subagents/angular/src/main.ts index e7cf28167..e51976092 100644 --- a/cockpit/deep-agents/subagents/angular/src/main.ts +++ b/cockpit/deep-agents/subagents/angular/src/main.ts @@ -1,7 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { bootstrapApplication } from '@angular/platform-browser'; -import { SubagentsComponent } from './app/subagents.component'; -import { appConfig } from './app/app.config'; +import { appConfig } from './app.config'; +import { SubagentsAppComponent } from './app.component'; -bootstrapApplication(SubagentsComponent, appConfig).catch((err) => - console.error(err) -); +bootstrapApplication(SubagentsAppComponent, appConfig).catch(console.error); diff --git a/cockpit/deep-agents/subagents/angular/tsconfig.json b/cockpit/deep-agents/subagents/angular/tsconfig.json index 8deb44c70..90497de60 100644 --- a/cockpit/deep-agents/subagents/angular/tsconfig.json +++ b/cockpit/deep-agents/subagents/angular/tsconfig.json @@ -1,16 +1,7 @@ { - "extends": "../../../../tsconfig.base.json", + "extends": "../../../../../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "experimentalDecorators": true, - "emitDeclarationOnly": false, - "noEmit": true, - "lib": [ - "ES2022", - "dom" - ], - "strict": true - } + "module": "preserve" + }, + "include": ["src/**/*.ts"] } diff --git a/cockpit/langgraph/deployment-runtime/angular/package.json b/cockpit/langgraph/deployment-runtime/angular/package.json index a9efb3264..47596b10d 100644 --- a/cockpit/langgraph/deployment-runtime/angular/package.json +++ b/cockpit/langgraph/deployment-runtime/angular/package.json @@ -1,11 +1,11 @@ { - "name": "cockpit-langgraph-deployment-runtime-angular", + "name": "@cacheplane/cockpit-langgraph-deployment-runtime-angular", "version": "0.0.1", - "private": true, - "dependencies": { - "@cacheplane/stream-resource": "^0.0.1", + "peerDependencies": { "@cacheplane/chat": "^0.0.1", - "@langchain/core": "^0.3.0", + "@cacheplane/stream-resource": "^0.0.1", "@langchain/langgraph-sdk": "^0.0.36" - } + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false } diff --git a/cockpit/langgraph/deployment-runtime/angular/project.json b/cockpit/langgraph/deployment-runtime/angular/project.json index c5034a5dc..7eb4b9fac 100644 --- a/cockpit/langgraph/deployment-runtime/angular/project.json +++ b/cockpit/langgraph/deployment-runtime/angular/project.json @@ -2,36 +2,22 @@ "name": "cockpit-langgraph-deployment-runtime-angular", "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "cockpit/langgraph/deployment-runtime/angular/src", - "projectType": "application", - "tags": ["scope:cockpit", "type:example"], + "projectType": "library", "targets": { "build": { - "executor": "@angular/build:application", + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/cockpit/langgraph/deployment-runtime/angular"], "options": { "outputPath": "dist/cockpit/langgraph/deployment-runtime/angular", - "index": "cockpit/langgraph/deployment-runtime/angular/src/index.html", - "browser": "cockpit/langgraph/deployment-runtime/angular/src/main.ts", - "tsConfig": "cockpit/langgraph/deployment-runtime/angular/tsconfig.app.json", - "styles": ["cockpit/langgraph/deployment-runtime/angular/src/styles.css"] - }, - "configurations": { - "development": { - "fileReplacements": [ - { - "replace": "cockpit/langgraph/deployment-runtime/angular/src/environments/environment.ts", - "with": "cockpit/langgraph/deployment-runtime/angular/src/environments/environment.development.ts" - } - ] - } - }, - "defaultConfiguration": "development" + "main": "cockpit/langgraph/deployment-runtime/angular/src/index.ts", + "tsConfig": "cockpit/langgraph/deployment-runtime/angular/tsconfig.json" + } }, - "serve": { - "executor": "@angular/build:dev-server", + "smoke": { + "executor": "nx:run-commands", "options": { - "buildTarget": "cockpit-langgraph-deployment-runtime-angular:build:development", - "port": 4307, - "proxyConfig": "cockpit/langgraph/deployment-runtime/angular/proxy.conf.json" + "cwd": "cockpit/langgraph/deployment-runtime/angular", + "command": "npx tsx -e \"import { langgraphDeploymentRuntimeAngularModule } from './src/index.ts'; const module = langgraphDeploymentRuntimeAngularModule; if (module.id !== 'langgraph-deployment-runtime-angular' || module.title !== 'LangGraph Deployment & Runtime (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" } } } diff --git a/cockpit/langgraph/deployment-runtime/angular/prompts/deployment-runtime.md b/cockpit/langgraph/deployment-runtime/angular/prompts/deployment-runtime.md new file mode 100644 index 000000000..d75d372c4 --- /dev/null +++ b/cockpit/langgraph/deployment-runtime/angular/prompts/deployment-runtime.md @@ -0,0 +1,5 @@ +# LangGraph Deployment & Runtime (Angular) + +This capability demonstrates production deployment patterns for LangGraph agents — including environment-specific base URLs, authentication headers, and assistant resolution — using the `@cacheplane/chat` Angular component library. The `` component is configured through Angular's dependency injection system, making it straightforward to swap between local development, staging, and production LangGraph Cloud deployments. + +Key components used: ``. Configuration is provided via an Angular environment token injected into `streamResource`, keeping all deployment concerns out of the component template and enabling zero-code environment switches at build time. diff --git a/cockpit/langgraph/deployment-runtime/angular/src/app.component.ts b/cockpit/langgraph/deployment-runtime/angular/src/app.component.ts new file mode 100644 index 000000000..a043b8cc5 --- /dev/null +++ b/cockpit/langgraph/deployment-runtime/angular/src/app.component.ts @@ -0,0 +1,46 @@ +import { Component, inject, Injector, InjectionToken, OnInit } from '@angular/core'; +import { runInInjectionContext } from '@angular/core'; +import { ChatComponent } from '@cacheplane/chat'; +import { streamResource, StreamResourceRef } from '@cacheplane/stream-resource'; + +/** Provide via environment.ts / environment.prod.ts for zero-code env switching. */ +export const LANGGRAPH_CONFIG = new InjectionToken<{ + apiUrl: string; + assistantId: string; +}>('LANGGRAPH_CONFIG', { + factory: () => ({ + apiUrl: 'http://localhost:2024', + assistantId: 'chat_agent', + }), +}); + +@Component({ + selector: 'app-deployment-runtime', + standalone: true, + imports: [ChatComponent], + template: ` +
+ + +
+ `, +}) +export class DeploymentRuntimeAppComponent implements OnInit { + private readonly injector = inject(Injector); + private readonly config = inject(LANGGRAPH_CONFIG); + chat!: StreamResourceRef; + + ngOnInit(): void { + runInInjectionContext(this.injector, () => { + this.chat = streamResource({ + apiUrl: this.config.apiUrl, + assistantId: this.config.assistantId, + }); + }); + } +} diff --git a/cockpit/langgraph/deployment-runtime/angular/src/app.config.ts b/cockpit/langgraph/deployment-runtime/angular/src/app.config.ts new file mode 100644 index 000000000..6ac3b924c --- /dev/null +++ b/cockpit/langgraph/deployment-runtime/angular/src/app.config.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideStreamResource } from '@cacheplane/stream-resource'; +import { provideChat } from '@cacheplane/chat'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStreamResource({ + apiUrl: 'http://localhost:2024', + }), + provideChat({}), + ], +}; diff --git a/cockpit/langgraph/deployment-runtime/angular/src/index.html b/cockpit/langgraph/deployment-runtime/angular/src/index.html index f0c819111..fde252ce3 100644 --- a/cockpit/langgraph/deployment-runtime/angular/src/index.html +++ b/cockpit/langgraph/deployment-runtime/angular/src/index.html @@ -2,10 +2,11 @@ - LangGraph Deployment Runtime + Deployment Runtime - LangGraph Angular Example + - + diff --git a/cockpit/langgraph/deployment-runtime/angular/src/index.ts b/cockpit/langgraph/deployment-runtime/angular/src/index.ts new file mode 100644 index 000000000..3cf2993bd --- /dev/null +++ b/cockpit/langgraph/deployment-runtime/angular/src/index.ts @@ -0,0 +1,33 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'langgraph'; + section: 'core-capabilities'; + topic: 'deployment-runtime'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const langgraphDeploymentRuntimeAngularModule: CockpitCapabilityModule = { + id: 'langgraph-deployment-runtime-angular', + manifestIdentity: { + product: 'langgraph', + section: 'core-capabilities', + topic: 'deployment-runtime', + page: 'overview', + language: 'angular', + }, + title: 'LangGraph Deployment & Runtime (Angular)', + docsPath: '/docs/langgraph/core-capabilities/deployment-runtime/overview/angular', + promptAssetPaths: [ + 'cockpit/langgraph/deployment-runtime/angular/prompts/deployment-runtime.md', + ], + codeAssetPaths: [ + 'cockpit/langgraph/deployment-runtime/angular/src/app.component.ts', + ], +}; diff --git a/cockpit/langgraph/deployment-runtime/angular/src/main.ts b/cockpit/langgraph/deployment-runtime/angular/src/main.ts index 099112bad..57d8f1c7f 100644 --- a/cockpit/langgraph/deployment-runtime/angular/src/main.ts +++ b/cockpit/langgraph/deployment-runtime/angular/src/main.ts @@ -1,7 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { bootstrapApplication } from '@angular/platform-browser'; -import { DeploymentRuntimeComponent } from './app/deployment-runtime.component'; -import { appConfig } from './app/app.config'; +import { appConfig } from './app.config'; +import { DeploymentRuntimeAppComponent } from './app.component'; -bootstrapApplication(DeploymentRuntimeComponent, appConfig).catch((err) => - console.error(err) -); +bootstrapApplication(DeploymentRuntimeAppComponent, appConfig).catch(console.error); diff --git a/cockpit/langgraph/deployment-runtime/angular/tsconfig.json b/cockpit/langgraph/deployment-runtime/angular/tsconfig.json index 8deb44c70..d9e29392d 100644 --- a/cockpit/langgraph/deployment-runtime/angular/tsconfig.json +++ b/cockpit/langgraph/deployment-runtime/angular/tsconfig.json @@ -1,16 +1,7 @@ { "extends": "../../../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "experimentalDecorators": true, - "emitDeclarationOnly": false, - "noEmit": true, - "lib": [ - "ES2022", - "dom" - ], - "strict": true - } + "module": "preserve" + }, + "include": ["src/**/*.ts"] } diff --git a/cockpit/langgraph/durable-execution/angular/package.json b/cockpit/langgraph/durable-execution/angular/package.json index 1ee41aabd..e3e2ebb1c 100644 --- a/cockpit/langgraph/durable-execution/angular/package.json +++ b/cockpit/langgraph/durable-execution/angular/package.json @@ -1,11 +1,11 @@ { - "name": "cockpit-langgraph-durable-execution-angular", + "name": "@cacheplane/cockpit-langgraph-durable-execution-angular", "version": "0.0.1", - "private": true, - "dependencies": { - "@cacheplane/stream-resource": "^0.0.1", + "peerDependencies": { "@cacheplane/chat": "^0.0.1", - "@langchain/core": "^0.3.0", + "@cacheplane/stream-resource": "^0.0.1", "@langchain/langgraph-sdk": "^0.0.36" - } + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false } diff --git a/cockpit/langgraph/durable-execution/angular/project.json b/cockpit/langgraph/durable-execution/angular/project.json index a3a848572..dbc9cc575 100644 --- a/cockpit/langgraph/durable-execution/angular/project.json +++ b/cockpit/langgraph/durable-execution/angular/project.json @@ -2,36 +2,22 @@ "name": "cockpit-langgraph-durable-execution-angular", "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "cockpit/langgraph/durable-execution/angular/src", - "projectType": "application", - "tags": ["scope:cockpit", "type:example"], + "projectType": "library", "targets": { "build": { - "executor": "@angular/build:application", + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/cockpit/langgraph/durable-execution/angular"], "options": { "outputPath": "dist/cockpit/langgraph/durable-execution/angular", - "index": "cockpit/langgraph/durable-execution/angular/src/index.html", - "browser": "cockpit/langgraph/durable-execution/angular/src/main.ts", - "tsConfig": "cockpit/langgraph/durable-execution/angular/tsconfig.app.json", - "styles": ["cockpit/langgraph/durable-execution/angular/src/styles.css"] - }, - "configurations": { - "development": { - "fileReplacements": [ - { - "replace": "cockpit/langgraph/durable-execution/angular/src/environments/environment.ts", - "with": "cockpit/langgraph/durable-execution/angular/src/environments/environment.development.ts" - } - ] - } - }, - "defaultConfiguration": "development" + "main": "cockpit/langgraph/durable-execution/angular/src/index.ts", + "tsConfig": "cockpit/langgraph/durable-execution/angular/tsconfig.json" + } }, - "serve": { - "executor": "@angular/build:dev-server", + "smoke": { + "executor": "nx:run-commands", "options": { - "buildTarget": "cockpit-langgraph-durable-execution-angular:build:development", - "port": 4304, - "proxyConfig": "cockpit/langgraph/durable-execution/angular/proxy.conf.json" + "cwd": "cockpit/langgraph/durable-execution/angular", + "command": "npx tsx -e \"import { langgraphDurableExecutionAngularModule } from './src/index.ts'; const module = langgraphDurableExecutionAngularModule; if (module.id !== 'langgraph-durable-execution-angular' || module.title !== 'LangGraph Durable Execution (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" } } } diff --git a/cockpit/langgraph/durable-execution/angular/prompts/durable-execution.md b/cockpit/langgraph/durable-execution/angular/prompts/durable-execution.md new file mode 100644 index 000000000..20dd481b5 --- /dev/null +++ b/cockpit/langgraph/durable-execution/angular/prompts/durable-execution.md @@ -0,0 +1,5 @@ +# LangGraph Durable Execution (Angular) + +This capability demonstrates LangGraph's durable execution guarantees — automatic retry, reconnection, and resumption after network interruptions — using the `@cacheplane/chat` Angular component library. The `` component surfaces transient failures with a one-click reconnect affordance, while `streamResource` handles exponential back-off and checkpoint-based resumption transparently. + +Key components used: ``, ``. When the SSE stream is interrupted, `` replaces the typing indicator with an error banner; once the user reconnects (or `streamResource` auto-retries), the component dismisses automatically and the stream resumes from the last persisted checkpoint. diff --git a/cockpit/langgraph/durable-execution/angular/src/app.component.ts b/cockpit/langgraph/durable-execution/angular/src/app.component.ts new file mode 100644 index 000000000..af24a6ac6 --- /dev/null +++ b/cockpit/langgraph/durable-execution/angular/src/app.component.ts @@ -0,0 +1,39 @@ +import { Component, inject, Injector, OnInit } from '@angular/core'; +import { runInInjectionContext } from '@angular/core'; +import { ChatComponent, ChatErrorComponent } from '@cacheplane/chat'; +import { streamResource, StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-durable-execution', + standalone: true, + imports: [ChatComponent, ChatErrorComponent], + template: ` +
+ + + + +
+ `, +}) +export class DurableExecutionAppComponent implements OnInit { + private readonly injector = inject(Injector); + chat!: StreamResourceRef; + + ngOnInit(): void { + runInInjectionContext(this.injector, () => { + this.chat = streamResource({ + assistantId: 'chat_agent', + retry: { maxAttempts: 5, baseDelayMs: 500 }, + }); + }); + } +} diff --git a/cockpit/langgraph/durable-execution/angular/src/app.config.ts b/cockpit/langgraph/durable-execution/angular/src/app.config.ts new file mode 100644 index 000000000..6ac3b924c --- /dev/null +++ b/cockpit/langgraph/durable-execution/angular/src/app.config.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideStreamResource } from '@cacheplane/stream-resource'; +import { provideChat } from '@cacheplane/chat'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStreamResource({ + apiUrl: 'http://localhost:2024', + }), + provideChat({}), + ], +}; diff --git a/cockpit/langgraph/durable-execution/angular/src/index.html b/cockpit/langgraph/durable-execution/angular/src/index.html index e3dc0c076..35a132130 100644 --- a/cockpit/langgraph/durable-execution/angular/src/index.html +++ b/cockpit/langgraph/durable-execution/angular/src/index.html @@ -2,10 +2,11 @@ - LangGraph Durable Execution + Durable Execution - LangGraph Angular Example + - + diff --git a/cockpit/langgraph/durable-execution/angular/src/index.ts b/cockpit/langgraph/durable-execution/angular/src/index.ts new file mode 100644 index 000000000..a4f3422ce --- /dev/null +++ b/cockpit/langgraph/durable-execution/angular/src/index.ts @@ -0,0 +1,33 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'langgraph'; + section: 'core-capabilities'; + topic: 'durable-execution'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const langgraphDurableExecutionAngularModule: CockpitCapabilityModule = { + id: 'langgraph-durable-execution-angular', + manifestIdentity: { + product: 'langgraph', + section: 'core-capabilities', + topic: 'durable-execution', + page: 'overview', + language: 'angular', + }, + title: 'LangGraph Durable Execution (Angular)', + docsPath: '/docs/langgraph/core-capabilities/durable-execution/overview/angular', + promptAssetPaths: [ + 'cockpit/langgraph/durable-execution/angular/prompts/durable-execution.md', + ], + codeAssetPaths: [ + 'cockpit/langgraph/durable-execution/angular/src/app.component.ts', + ], +}; diff --git a/cockpit/langgraph/durable-execution/angular/src/main.ts b/cockpit/langgraph/durable-execution/angular/src/main.ts index 613e8f490..b4e7384ec 100644 --- a/cockpit/langgraph/durable-execution/angular/src/main.ts +++ b/cockpit/langgraph/durable-execution/angular/src/main.ts @@ -1,7 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { bootstrapApplication } from '@angular/platform-browser'; -import { DurableExecutionComponent } from './app/durable-execution.component'; -import { appConfig } from './app/app.config'; +import { appConfig } from './app.config'; +import { DurableExecutionAppComponent } from './app.component'; -bootstrapApplication(DurableExecutionComponent, appConfig).catch((err) => - console.error(err) -); +bootstrapApplication(DurableExecutionAppComponent, appConfig).catch(console.error); diff --git a/cockpit/langgraph/durable-execution/angular/tsconfig.json b/cockpit/langgraph/durable-execution/angular/tsconfig.json index 8deb44c70..d9e29392d 100644 --- a/cockpit/langgraph/durable-execution/angular/tsconfig.json +++ b/cockpit/langgraph/durable-execution/angular/tsconfig.json @@ -1,16 +1,7 @@ { "extends": "../../../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "experimentalDecorators": true, - "emitDeclarationOnly": false, - "noEmit": true, - "lib": [ - "ES2022", - "dom" - ], - "strict": true - } + "module": "preserve" + }, + "include": ["src/**/*.ts"] } diff --git a/cockpit/langgraph/interrupts/angular/package.json b/cockpit/langgraph/interrupts/angular/package.json index d41820dd0..9cf2cf3e5 100644 --- a/cockpit/langgraph/interrupts/angular/package.json +++ b/cockpit/langgraph/interrupts/angular/package.json @@ -1,11 +1,11 @@ { - "name": "cockpit-langgraph-interrupts-angular", + "name": "@cacheplane/cockpit-langgraph-interrupts-angular", "version": "0.0.1", - "private": true, - "dependencies": { - "@cacheplane/stream-resource": "^0.0.1", + "peerDependencies": { "@cacheplane/chat": "^0.0.1", - "@langchain/core": "^0.3.0", + "@cacheplane/stream-resource": "^0.0.1", "@langchain/langgraph-sdk": "^0.0.36" - } + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false } diff --git a/cockpit/langgraph/interrupts/angular/project.json b/cockpit/langgraph/interrupts/angular/project.json index 70426b548..c34cc71a0 100644 --- a/cockpit/langgraph/interrupts/angular/project.json +++ b/cockpit/langgraph/interrupts/angular/project.json @@ -2,36 +2,22 @@ "name": "cockpit-langgraph-interrupts-angular", "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "cockpit/langgraph/interrupts/angular/src", - "projectType": "application", - "tags": ["scope:cockpit", "type:example"], + "projectType": "library", "targets": { "build": { - "executor": "@angular/build:application", + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/cockpit/langgraph/interrupts/angular"], "options": { "outputPath": "dist/cockpit/langgraph/interrupts/angular", - "index": "cockpit/langgraph/interrupts/angular/src/index.html", - "browser": "cockpit/langgraph/interrupts/angular/src/main.ts", - "tsConfig": "cockpit/langgraph/interrupts/angular/tsconfig.app.json", - "styles": ["cockpit/langgraph/interrupts/angular/src/styles.css"] - }, - "configurations": { - "development": { - "fileReplacements": [ - { - "replace": "cockpit/langgraph/interrupts/angular/src/environments/environment.ts", - "with": "cockpit/langgraph/interrupts/angular/src/environments/environment.development.ts" - } - ] - } - }, - "defaultConfiguration": "development" + "main": "cockpit/langgraph/interrupts/angular/src/index.ts", + "tsConfig": "cockpit/langgraph/interrupts/angular/tsconfig.json" + } }, - "serve": { - "executor": "@angular/build:dev-server", + "smoke": { + "executor": "nx:run-commands", "options": { - "buildTarget": "cockpit-langgraph-interrupts-angular:build:development", - "port": 4302, - "proxyConfig": "cockpit/langgraph/interrupts/angular/proxy.conf.json" + "cwd": "cockpit/langgraph/interrupts/angular", + "command": "npx tsx -e \"import { langgraphInterruptsAngularModule } from './src/index.ts'; const module = langgraphInterruptsAngularModule; if (module.id !== 'langgraph-interrupts-angular' || module.title !== 'LangGraph Interrupts (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" } } } diff --git a/cockpit/langgraph/interrupts/angular/prompts/interrupts.md b/cockpit/langgraph/interrupts/angular/prompts/interrupts.md new file mode 100644 index 000000000..153bdf979 --- /dev/null +++ b/cockpit/langgraph/interrupts/angular/prompts/interrupts.md @@ -0,0 +1,5 @@ +# LangGraph Interrupts (Angular) + +This capability demonstrates human-in-the-loop interrupt handling using LangGraph's `interrupt()` primitive and the `@cacheplane/chat` Angular component library. When the graph pauses at an interrupt node, the `` surfaces the pending decision to the user; their response is submitted back to the graph via `streamResource`'s `resume` helper. + +Key components used: ``, ``. The interrupt panel renders inside the chat host and becomes visible automatically whenever the underlying stream resource detects a pending interrupt in the thread state. diff --git a/cockpit/langgraph/interrupts/angular/src/app.component.ts b/cockpit/langgraph/interrupts/angular/src/app.component.ts new file mode 100644 index 000000000..9e55364ff --- /dev/null +++ b/cockpit/langgraph/interrupts/angular/src/app.component.ts @@ -0,0 +1,34 @@ +import { Component, inject, Injector, OnInit } from '@angular/core'; +import { runInInjectionContext } from '@angular/core'; +import { + ChatComponent, + ChatInterruptPanelComponent, +} from '@cacheplane/chat'; +import { streamResource, StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-interrupts', + standalone: true, + imports: [ChatComponent, ChatInterruptPanelComponent], + template: ` +
+ + + + +
+ `, +}) +export class InterruptsAppComponent implements OnInit { + private readonly injector = inject(Injector); + chat!: StreamResourceRef; + + ngOnInit(): void { + runInInjectionContext(this.injector, () => { + this.chat = streamResource({ assistantId: 'interrupt_agent' }); + }); + } +} diff --git a/cockpit/langgraph/interrupts/angular/src/app.config.ts b/cockpit/langgraph/interrupts/angular/src/app.config.ts new file mode 100644 index 000000000..6ac3b924c --- /dev/null +++ b/cockpit/langgraph/interrupts/angular/src/app.config.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideStreamResource } from '@cacheplane/stream-resource'; +import { provideChat } from '@cacheplane/chat'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStreamResource({ + apiUrl: 'http://localhost:2024', + }), + provideChat({}), + ], +}; diff --git a/cockpit/langgraph/interrupts/angular/src/index.html b/cockpit/langgraph/interrupts/angular/src/index.html index 110e229b8..850914943 100644 --- a/cockpit/langgraph/interrupts/angular/src/index.html +++ b/cockpit/langgraph/interrupts/angular/src/index.html @@ -2,10 +2,11 @@ - LangGraph Interrupts + Interrupts - LangGraph Angular Example + - + diff --git a/cockpit/langgraph/interrupts/angular/src/index.ts b/cockpit/langgraph/interrupts/angular/src/index.ts new file mode 100644 index 000000000..10066e69d --- /dev/null +++ b/cockpit/langgraph/interrupts/angular/src/index.ts @@ -0,0 +1,33 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'langgraph'; + section: 'core-capabilities'; + topic: 'interrupts'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const langgraphInterruptsAngularModule: CockpitCapabilityModule = { + id: 'langgraph-interrupts-angular', + manifestIdentity: { + product: 'langgraph', + section: 'core-capabilities', + topic: 'interrupts', + page: 'overview', + language: 'angular', + }, + title: 'LangGraph Interrupts (Angular)', + docsPath: '/docs/langgraph/core-capabilities/interrupts/overview/angular', + promptAssetPaths: [ + 'cockpit/langgraph/interrupts/angular/prompts/interrupts.md', + ], + codeAssetPaths: [ + 'cockpit/langgraph/interrupts/angular/src/app.component.ts', + ], +}; diff --git a/cockpit/langgraph/interrupts/angular/src/main.ts b/cockpit/langgraph/interrupts/angular/src/main.ts index a8ad0c699..aad2c72e0 100644 --- a/cockpit/langgraph/interrupts/angular/src/main.ts +++ b/cockpit/langgraph/interrupts/angular/src/main.ts @@ -1,7 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { bootstrapApplication } from '@angular/platform-browser'; -import { InterruptsComponent } from './app/interrupts.component'; -import { appConfig } from './app/app.config'; +import { appConfig } from './app.config'; +import { InterruptsAppComponent } from './app.component'; -bootstrapApplication(InterruptsComponent, appConfig).catch((err) => - console.error(err) -); +bootstrapApplication(InterruptsAppComponent, appConfig).catch(console.error); diff --git a/cockpit/langgraph/interrupts/angular/tsconfig.json b/cockpit/langgraph/interrupts/angular/tsconfig.json index 8deb44c70..d9e29392d 100644 --- a/cockpit/langgraph/interrupts/angular/tsconfig.json +++ b/cockpit/langgraph/interrupts/angular/tsconfig.json @@ -1,16 +1,7 @@ { "extends": "../../../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "experimentalDecorators": true, - "emitDeclarationOnly": false, - "noEmit": true, - "lib": [ - "ES2022", - "dom" - ], - "strict": true - } + "module": "preserve" + }, + "include": ["src/**/*.ts"] } diff --git a/cockpit/langgraph/memory/angular/package.json b/cockpit/langgraph/memory/angular/package.json index a9e0bd8a1..9e29cbbf0 100644 --- a/cockpit/langgraph/memory/angular/package.json +++ b/cockpit/langgraph/memory/angular/package.json @@ -1,11 +1,11 @@ { - "name": "cockpit-langgraph-memory-angular", + "name": "@cacheplane/cockpit-langgraph-memory-angular", "version": "0.0.1", - "private": true, - "dependencies": { - "@cacheplane/stream-resource": "^0.0.1", + "peerDependencies": { "@cacheplane/chat": "^0.0.1", - "@langchain/core": "^0.3.0", + "@cacheplane/stream-resource": "^0.0.1", "@langchain/langgraph-sdk": "^0.0.36" - } + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false } diff --git a/cockpit/langgraph/memory/angular/project.json b/cockpit/langgraph/memory/angular/project.json index 093bdc325..bf746a477 100644 --- a/cockpit/langgraph/memory/angular/project.json +++ b/cockpit/langgraph/memory/angular/project.json @@ -2,36 +2,22 @@ "name": "cockpit-langgraph-memory-angular", "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "cockpit/langgraph/memory/angular/src", - "projectType": "application", - "tags": ["scope:cockpit", "type:example"], + "projectType": "library", "targets": { "build": { - "executor": "@angular/build:application", + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/cockpit/langgraph/memory/angular"], "options": { "outputPath": "dist/cockpit/langgraph/memory/angular", - "index": "cockpit/langgraph/memory/angular/src/index.html", - "browser": "cockpit/langgraph/memory/angular/src/main.ts", - "tsConfig": "cockpit/langgraph/memory/angular/tsconfig.app.json", - "styles": ["cockpit/langgraph/memory/angular/src/styles.css"] - }, - "configurations": { - "development": { - "fileReplacements": [ - { - "replace": "cockpit/langgraph/memory/angular/src/environments/environment.ts", - "with": "cockpit/langgraph/memory/angular/src/environments/environment.development.ts" - } - ] - } - }, - "defaultConfiguration": "development" + "main": "cockpit/langgraph/memory/angular/src/index.ts", + "tsConfig": "cockpit/langgraph/memory/angular/tsconfig.json" + } }, - "serve": { - "executor": "@angular/build:dev-server", + "smoke": { + "executor": "nx:run-commands", "options": { - "buildTarget": "cockpit-langgraph-memory-angular:build:development", - "port": 4303, - "proxyConfig": "cockpit/langgraph/memory/angular/proxy.conf.json" + "cwd": "cockpit/langgraph/memory/angular", + "command": "npx tsx -e \"import { langgraphMemoryAngularModule } from './src/index.ts'; const module = langgraphMemoryAngularModule; if (module.id !== 'langgraph-memory-angular' || module.title !== 'LangGraph Memory (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" } } } diff --git a/cockpit/langgraph/memory/angular/prompts/memory.md b/cockpit/langgraph/memory/angular/prompts/memory.md new file mode 100644 index 000000000..773eec12c --- /dev/null +++ b/cockpit/langgraph/memory/angular/prompts/memory.md @@ -0,0 +1,5 @@ +# LangGraph Memory (Angular) + +This capability demonstrates cross-thread memory using LangGraph's persistent memory store and the `@cacheplane/chat` Angular component library. Facts and preferences written by the agent in one thread are automatically recalled in subsequent threads, giving the user a coherent long-term experience across sessions. + +Key components used: `` with a thread list sidebar. Each new `streamResource` ref carries the same `userId` namespace so the LangGraph memory store can surface relevant memories regardless of which thread is active. diff --git a/cockpit/langgraph/memory/angular/src/app.component.ts b/cockpit/langgraph/memory/angular/src/app.component.ts new file mode 100644 index 000000000..b61309b77 --- /dev/null +++ b/cockpit/langgraph/memory/angular/src/app.component.ts @@ -0,0 +1,90 @@ +import { Component, inject, Injector, OnInit, signal } from '@angular/core'; +import { runInInjectionContext } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ChatComponent } from '@cacheplane/chat'; +import { streamResource, StreamResourceRef } from '@cacheplane/stream-resource'; + +interface Thread { + id: string; + label: string; +} + +@Component({ + selector: 'app-memory', + standalone: true, + imports: [CommonModule, ChatComponent], + template: ` +
+ + + + +
+ +
+
+ `, +}) +export class MemoryAppComponent implements OnInit { + private readonly injector = inject(Injector); + /** Stable user identity so the memory store scopes memories correctly. */ + private readonly userId = 'demo-user'; + + chat!: StreamResourceRef; + threads = signal([ + { id: 'thread-1', label: 'Session 1' }, + { id: 'thread-2', label: 'Session 2' }, + ]); + activeThreadId = signal('thread-1'); + + ngOnInit(): void { + this.initChat(this.activeThreadId()); + } + + selectThread(threadId: string): void { + this.activeThreadId.set(threadId); + this.initChat(threadId); + } + + newThread(): void { + const id = `thread-${Date.now()}`; + this.threads.update((ts) => [ + ...ts, + { id, label: `Session ${ts.length + 1}` }, + ]); + this.selectThread(id); + } + + private initChat(threadId: string): void { + runInInjectionContext(this.injector, () => { + this.chat = streamResource({ + assistantId: 'memory_agent', + threadId, + metadata: { userId: this.userId }, + }); + }); + } +} diff --git a/cockpit/langgraph/memory/angular/src/app.config.ts b/cockpit/langgraph/memory/angular/src/app.config.ts new file mode 100644 index 000000000..6ac3b924c --- /dev/null +++ b/cockpit/langgraph/memory/angular/src/app.config.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideStreamResource } from '@cacheplane/stream-resource'; +import { provideChat } from '@cacheplane/chat'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStreamResource({ + apiUrl: 'http://localhost:2024', + }), + provideChat({}), + ], +}; diff --git a/cockpit/langgraph/memory/angular/src/index.html b/cockpit/langgraph/memory/angular/src/index.html index 4e7b36fae..f78ad2ca0 100644 --- a/cockpit/langgraph/memory/angular/src/index.html +++ b/cockpit/langgraph/memory/angular/src/index.html @@ -2,10 +2,11 @@ - LangGraph Memory + Memory - LangGraph Angular Example + - + diff --git a/cockpit/langgraph/memory/angular/src/index.ts b/cockpit/langgraph/memory/angular/src/index.ts new file mode 100644 index 000000000..059bce335 --- /dev/null +++ b/cockpit/langgraph/memory/angular/src/index.ts @@ -0,0 +1,33 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'langgraph'; + section: 'core-capabilities'; + topic: 'memory'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const langgraphMemoryAngularModule: CockpitCapabilityModule = { + id: 'langgraph-memory-angular', + manifestIdentity: { + product: 'langgraph', + section: 'core-capabilities', + topic: 'memory', + page: 'overview', + language: 'angular', + }, + title: 'LangGraph Memory (Angular)', + docsPath: '/docs/langgraph/core-capabilities/memory/overview/angular', + promptAssetPaths: [ + 'cockpit/langgraph/memory/angular/prompts/memory.md', + ], + codeAssetPaths: [ + 'cockpit/langgraph/memory/angular/src/app.component.ts', + ], +}; diff --git a/cockpit/langgraph/memory/angular/src/main.ts b/cockpit/langgraph/memory/angular/src/main.ts index 4cf24534e..180df289a 100644 --- a/cockpit/langgraph/memory/angular/src/main.ts +++ b/cockpit/langgraph/memory/angular/src/main.ts @@ -1,7 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { bootstrapApplication } from '@angular/platform-browser'; -import { MemoryComponent } from './app/memory.component'; -import { appConfig } from './app/app.config'; +import { appConfig } from './app.config'; +import { MemoryAppComponent } from './app.component'; -bootstrapApplication(MemoryComponent, appConfig).catch((err) => - console.error(err) -); +bootstrapApplication(MemoryAppComponent, appConfig).catch(console.error); diff --git a/cockpit/langgraph/memory/angular/tsconfig.json b/cockpit/langgraph/memory/angular/tsconfig.json index 8deb44c70..d9e29392d 100644 --- a/cockpit/langgraph/memory/angular/tsconfig.json +++ b/cockpit/langgraph/memory/angular/tsconfig.json @@ -1,16 +1,7 @@ { "extends": "../../../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "experimentalDecorators": true, - "emitDeclarationOnly": false, - "noEmit": true, - "lib": [ - "ES2022", - "dom" - ], - "strict": true - } + "module": "preserve" + }, + "include": ["src/**/*.ts"] } diff --git a/cockpit/langgraph/persistence/angular/package.json b/cockpit/langgraph/persistence/angular/package.json index 67308c5cf..5e7cb32bd 100644 --- a/cockpit/langgraph/persistence/angular/package.json +++ b/cockpit/langgraph/persistence/angular/package.json @@ -1,11 +1,11 @@ { - "name": "cockpit-langgraph-persistence-angular", + "name": "@cacheplane/cockpit-langgraph-persistence-angular", "version": "0.0.1", - "private": true, - "dependencies": { - "@cacheplane/stream-resource": "^0.0.1", + "peerDependencies": { "@cacheplane/chat": "^0.0.1", - "@langchain/core": "^0.3.0", + "@cacheplane/stream-resource": "^0.0.1", "@langchain/langgraph-sdk": "^0.0.36" - } + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false } diff --git a/cockpit/langgraph/persistence/angular/project.json b/cockpit/langgraph/persistence/angular/project.json index f4f9a4d78..0c363e462 100644 --- a/cockpit/langgraph/persistence/angular/project.json +++ b/cockpit/langgraph/persistence/angular/project.json @@ -2,36 +2,22 @@ "name": "cockpit-langgraph-persistence-angular", "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "cockpit/langgraph/persistence/angular/src", - "projectType": "application", - "tags": ["scope:cockpit", "type:example"], + "projectType": "library", "targets": { "build": { - "executor": "@angular/build:application", + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/cockpit/langgraph/persistence/angular"], "options": { "outputPath": "dist/cockpit/langgraph/persistence/angular", - "index": "cockpit/langgraph/persistence/angular/src/index.html", - "browser": "cockpit/langgraph/persistence/angular/src/main.ts", - "tsConfig": "cockpit/langgraph/persistence/angular/tsconfig.app.json", - "styles": ["cockpit/langgraph/persistence/angular/src/styles.css"] - }, - "configurations": { - "development": { - "fileReplacements": [ - { - "replace": "cockpit/langgraph/persistence/angular/src/environments/environment.ts", - "with": "cockpit/langgraph/persistence/angular/src/environments/environment.development.ts" - } - ] - } - }, - "defaultConfiguration": "development" + "main": "cockpit/langgraph/persistence/angular/src/index.ts", + "tsConfig": "cockpit/langgraph/persistence/angular/tsconfig.json" + } }, - "serve": { - "executor": "@angular/build:dev-server", + "smoke": { + "executor": "nx:run-commands", "options": { - "buildTarget": "cockpit-langgraph-persistence-angular:build:development", - "port": 4301, - "proxyConfig": "cockpit/langgraph/persistence/angular/proxy.conf.json" + "cwd": "cockpit/langgraph/persistence/angular", + "command": "npx tsx -e \"import { langgraphPersistenceAngularModule } from './src/index.ts'; const module = langgraphPersistenceAngularModule; if (module.id !== 'langgraph-persistence-angular' || module.title !== 'LangGraph Persistence (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" } } } diff --git a/cockpit/langgraph/persistence/angular/prompts/persistence.md b/cockpit/langgraph/persistence/angular/prompts/persistence.md new file mode 100644 index 000000000..a9b69b93a --- /dev/null +++ b/cockpit/langgraph/persistence/angular/prompts/persistence.md @@ -0,0 +1,5 @@ +# LangGraph Persistence (Angular) + +This capability demonstrates persisted conversation threads using LangGraph checkpointers and the `@cacheplane/chat` Angular component library. The `` component is paired with a thread list so users can switch between saved conversations — each backed by a LangGraph thread ID — without losing history. + +Key components used: `` with a thread list sidebar driven by the LangGraph Threads API. The `streamResource` ref is re-initialised with a new `threadId` whenever the user selects a different thread, and the chat view replays the persisted checkpoint automatically. diff --git a/cockpit/langgraph/persistence/angular/src/app.component.ts b/cockpit/langgraph/persistence/angular/src/app.component.ts new file mode 100644 index 000000000..7d22076cb --- /dev/null +++ b/cockpit/langgraph/persistence/angular/src/app.component.ts @@ -0,0 +1,84 @@ +import { Component, inject, Injector, OnInit, signal } from '@angular/core'; +import { runInInjectionContext } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ChatComponent } from '@cacheplane/chat'; +import { streamResource, StreamResourceRef } from '@cacheplane/stream-resource'; + +interface Thread { + id: string; + label: string; +} + +@Component({ + selector: 'app-persistence', + standalone: true, + imports: [CommonModule, ChatComponent], + template: ` +
+ + + + +
+ +
+
+ `, +}) +export class PersistenceAppComponent implements OnInit { + private readonly injector = inject(Injector); + + chat!: StreamResourceRef; + threads = signal([ + { id: 'thread-1', label: 'Conversation 1' }, + { id: 'thread-2', label: 'Conversation 2' }, + ]); + activeThreadId = signal('thread-1'); + + ngOnInit(): void { + this.initChat(this.activeThreadId()); + } + + selectThread(threadId: string): void { + this.activeThreadId.set(threadId); + this.initChat(threadId); + } + + newThread(): void { + const id = `thread-${Date.now()}`; + this.threads.update((ts) => [ + ...ts, + { id, label: `Conversation ${ts.length + 1}` }, + ]); + this.selectThread(id); + } + + private initChat(threadId: string): void { + runInInjectionContext(this.injector, () => { + this.chat = streamResource({ assistantId: 'chat_agent', threadId }); + }); + } +} diff --git a/cockpit/langgraph/persistence/angular/src/app.config.ts b/cockpit/langgraph/persistence/angular/src/app.config.ts new file mode 100644 index 000000000..6ac3b924c --- /dev/null +++ b/cockpit/langgraph/persistence/angular/src/app.config.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideStreamResource } from '@cacheplane/stream-resource'; +import { provideChat } from '@cacheplane/chat'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStreamResource({ + apiUrl: 'http://localhost:2024', + }), + provideChat({}), + ], +}; diff --git a/cockpit/langgraph/persistence/angular/src/index.html b/cockpit/langgraph/persistence/angular/src/index.html index 5223e0d0f..fe09aaa9e 100644 --- a/cockpit/langgraph/persistence/angular/src/index.html +++ b/cockpit/langgraph/persistence/angular/src/index.html @@ -2,10 +2,11 @@ - LangGraph Persistence + Persistence - LangGraph Angular Example + - + diff --git a/cockpit/langgraph/persistence/angular/src/index.ts b/cockpit/langgraph/persistence/angular/src/index.ts new file mode 100644 index 000000000..14af3a1a4 --- /dev/null +++ b/cockpit/langgraph/persistence/angular/src/index.ts @@ -0,0 +1,33 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'langgraph'; + section: 'core-capabilities'; + topic: 'persistence'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const langgraphPersistenceAngularModule: CockpitCapabilityModule = { + id: 'langgraph-persistence-angular', + manifestIdentity: { + product: 'langgraph', + section: 'core-capabilities', + topic: 'persistence', + page: 'overview', + language: 'angular', + }, + title: 'LangGraph Persistence (Angular)', + docsPath: '/docs/langgraph/core-capabilities/persistence/overview/angular', + promptAssetPaths: [ + 'cockpit/langgraph/persistence/angular/prompts/persistence.md', + ], + codeAssetPaths: [ + 'cockpit/langgraph/persistence/angular/src/app.component.ts', + ], +}; diff --git a/cockpit/langgraph/persistence/angular/src/main.ts b/cockpit/langgraph/persistence/angular/src/main.ts index 2ba53f607..8ac088e84 100644 --- a/cockpit/langgraph/persistence/angular/src/main.ts +++ b/cockpit/langgraph/persistence/angular/src/main.ts @@ -1,7 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { bootstrapApplication } from '@angular/platform-browser'; -import { PersistenceComponent } from './app/persistence.component'; -import { appConfig } from './app/app.config'; +import { appConfig } from './app.config'; +import { PersistenceAppComponent } from './app.component'; -bootstrapApplication(PersistenceComponent, appConfig).catch((err) => - console.error(err) -); +bootstrapApplication(PersistenceAppComponent, appConfig).catch(console.error); diff --git a/cockpit/langgraph/persistence/angular/tsconfig.json b/cockpit/langgraph/persistence/angular/tsconfig.json index 8deb44c70..d9e29392d 100644 --- a/cockpit/langgraph/persistence/angular/tsconfig.json +++ b/cockpit/langgraph/persistence/angular/tsconfig.json @@ -1,16 +1,7 @@ { "extends": "../../../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "experimentalDecorators": true, - "emitDeclarationOnly": false, - "noEmit": true, - "lib": [ - "ES2022", - "dom" - ], - "strict": true - } + "module": "preserve" + }, + "include": ["src/**/*.ts"] } diff --git a/cockpit/langgraph/streaming/angular/package.json b/cockpit/langgraph/streaming/angular/package.json index 541ca7b60..02699697e 100644 --- a/cockpit/langgraph/streaming/angular/package.json +++ b/cockpit/langgraph/streaming/angular/package.json @@ -1,11 +1,11 @@ { - "name": "cockpit-langgraph-streaming-angular", + "name": "@cacheplane/cockpit-langgraph-streaming-angular", "version": "0.0.1", - "private": true, - "dependencies": { + "peerDependencies": { "@cacheplane/chat": "^0.0.1", "@cacheplane/stream-resource": "^0.0.1", - "@langchain/core": "^0.3.0", "@langchain/langgraph-sdk": "^0.0.36" - } + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false } diff --git a/cockpit/langgraph/streaming/angular/project.json b/cockpit/langgraph/streaming/angular/project.json index 21233e1ba..c72120c6e 100644 --- a/cockpit/langgraph/streaming/angular/project.json +++ b/cockpit/langgraph/streaming/angular/project.json @@ -2,36 +2,22 @@ "name": "cockpit-langgraph-streaming-angular", "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "cockpit/langgraph/streaming/angular/src", - "projectType": "application", - "tags": ["scope:cockpit", "type:example"], + "projectType": "library", "targets": { "build": { - "executor": "@angular/build:application", + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/cockpit/langgraph/streaming/angular"], "options": { "outputPath": "dist/cockpit/langgraph/streaming/angular", - "index": "cockpit/langgraph/streaming/angular/src/index.html", - "browser": "cockpit/langgraph/streaming/angular/src/main.ts", - "tsConfig": "cockpit/langgraph/streaming/angular/tsconfig.app.json", - "styles": ["cockpit/langgraph/streaming/angular/src/styles.css"] - }, - "configurations": { - "development": { - "fileReplacements": [ - { - "replace": "cockpit/langgraph/streaming/angular/src/environments/environment.ts", - "with": "cockpit/langgraph/streaming/angular/src/environments/environment.development.ts" - } - ] - } - }, - "defaultConfiguration": "development" + "main": "cockpit/langgraph/streaming/angular/src/index.ts", + "tsConfig": "cockpit/langgraph/streaming/angular/tsconfig.json" + } }, - "serve": { - "executor": "@angular/build:dev-server", + "smoke": { + "executor": "nx:run-commands", "options": { - "buildTarget": "cockpit-langgraph-streaming-angular:build:development", - "port": 4300, - "proxyConfig": "cockpit/langgraph/streaming/angular/proxy.conf.json" + "cwd": "cockpit/langgraph/streaming/angular", + "command": "npx tsx -e \"import { langgraphStreamingAngularModule } from './src/index.ts'; const module = langgraphStreamingAngularModule; if (module.id !== 'langgraph-streaming-angular' || module.title !== 'LangGraph Streaming (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" } } } diff --git a/cockpit/langgraph/streaming/angular/prompts/streaming.md b/cockpit/langgraph/streaming/angular/prompts/streaming.md new file mode 100644 index 000000000..dc055e2c6 --- /dev/null +++ b/cockpit/langgraph/streaming/angular/prompts/streaming.md @@ -0,0 +1,5 @@ +# LangGraph Streaming (Angular) + +This capability demonstrates real-time token streaming from a LangGraph agent using the `@cacheplane/chat` Angular component library. The example shows how to wire a `streamResource` ref into the `` host component and compose ``, ``, and `` to deliver a responsive, streaming chat experience. + +Key components used: ``, ``, ``, ``. The `streamResource` signal handles SSE fan-out from the LangGraph streaming endpoint, and the chat components subscribe reactively without any manual subscription management. diff --git a/cockpit/langgraph/streaming/angular/src/app.component.ts b/cockpit/langgraph/streaming/angular/src/app.component.ts new file mode 100644 index 000000000..51879bf97 --- /dev/null +++ b/cockpit/langgraph/streaming/angular/src/app.component.ts @@ -0,0 +1,42 @@ +import { Component, inject, Injector, OnInit } from '@angular/core'; +import { runInInjectionContext } from '@angular/core'; +import { + ChatComponent, + ChatMessagesComponent, + ChatInputComponent, + ChatTypingIndicatorComponent, +} from '@cacheplane/chat'; +import { streamResource, StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-streaming', + standalone: true, + imports: [ + ChatComponent, + ChatMessagesComponent, + ChatInputComponent, + ChatTypingIndicatorComponent, + ], + template: ` +
+ + + + + +
+ `, +}) +export class StreamingAppComponent implements OnInit { + private readonly injector = inject(Injector); + chat!: StreamResourceRef; + + ngOnInit(): void { + runInInjectionContext(this.injector, () => { + this.chat = streamResource({ assistantId: 'chat_agent' }); + }); + } +} diff --git a/cockpit/langgraph/streaming/angular/src/app.config.ts b/cockpit/langgraph/streaming/angular/src/app.config.ts new file mode 100644 index 000000000..6ac3b924c --- /dev/null +++ b/cockpit/langgraph/streaming/angular/src/app.config.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideStreamResource } from '@cacheplane/stream-resource'; +import { provideChat } from '@cacheplane/chat'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStreamResource({ + apiUrl: 'http://localhost:2024', + }), + provideChat({}), + ], +}; diff --git a/cockpit/langgraph/streaming/angular/src/index.html b/cockpit/langgraph/streaming/angular/src/index.html index 3837f4217..0d8fdfa2c 100644 --- a/cockpit/langgraph/streaming/angular/src/index.html +++ b/cockpit/langgraph/streaming/angular/src/index.html @@ -2,10 +2,11 @@ - LangGraph Streaming + Streaming - LangGraph Angular Example + - + diff --git a/cockpit/langgraph/streaming/angular/src/index.ts b/cockpit/langgraph/streaming/angular/src/index.ts new file mode 100644 index 000000000..090891981 --- /dev/null +++ b/cockpit/langgraph/streaming/angular/src/index.ts @@ -0,0 +1,33 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'langgraph'; + section: 'core-capabilities'; + topic: 'streaming'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const langgraphStreamingAngularModule: CockpitCapabilityModule = { + id: 'langgraph-streaming-angular', + manifestIdentity: { + product: 'langgraph', + section: 'core-capabilities', + topic: 'streaming', + page: 'overview', + language: 'angular', + }, + title: 'LangGraph Streaming (Angular)', + docsPath: '/docs/langgraph/core-capabilities/streaming/overview/angular', + promptAssetPaths: [ + 'cockpit/langgraph/streaming/angular/prompts/streaming.md', + ], + codeAssetPaths: [ + 'cockpit/langgraph/streaming/angular/src/app.component.ts', + ], +}; diff --git a/cockpit/langgraph/streaming/angular/src/main.ts b/cockpit/langgraph/streaming/angular/src/main.ts index 3485f8400..1bc6a1a3d 100644 --- a/cockpit/langgraph/streaming/angular/src/main.ts +++ b/cockpit/langgraph/streaming/angular/src/main.ts @@ -1,7 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { bootstrapApplication } from '@angular/platform-browser'; -import { StreamingComponent } from './app/streaming.component'; -import { appConfig } from './app/app.config'; +import { appConfig } from './app.config'; +import { StreamingAppComponent } from './app.component'; -bootstrapApplication(StreamingComponent, appConfig).catch((err) => - console.error(err) -); +bootstrapApplication(StreamingAppComponent, appConfig).catch(console.error); diff --git a/cockpit/langgraph/streaming/angular/tsconfig.json b/cockpit/langgraph/streaming/angular/tsconfig.json index 8deb44c70..d9e29392d 100644 --- a/cockpit/langgraph/streaming/angular/tsconfig.json +++ b/cockpit/langgraph/streaming/angular/tsconfig.json @@ -1,16 +1,7 @@ { "extends": "../../../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "experimentalDecorators": true, - "emitDeclarationOnly": false, - "noEmit": true, - "lib": [ - "ES2022", - "dom" - ], - "strict": true - } + "module": "preserve" + }, + "include": ["src/**/*.ts"] } diff --git a/cockpit/langgraph/subgraphs/angular/package.json b/cockpit/langgraph/subgraphs/angular/package.json index 090a0b829..2be9769e1 100644 --- a/cockpit/langgraph/subgraphs/angular/package.json +++ b/cockpit/langgraph/subgraphs/angular/package.json @@ -1,11 +1,11 @@ { - "name": "cockpit-langgraph-subgraphs-angular", + "name": "@cacheplane/cockpit-langgraph-subgraphs-angular", "version": "0.0.1", - "private": true, - "dependencies": { - "@cacheplane/stream-resource": "^0.0.1", + "peerDependencies": { "@cacheplane/chat": "^0.0.1", - "@langchain/core": "^0.3.0", + "@cacheplane/stream-resource": "^0.0.1", "@langchain/langgraph-sdk": "^0.0.36" - } + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false } diff --git a/cockpit/langgraph/subgraphs/angular/project.json b/cockpit/langgraph/subgraphs/angular/project.json index 09f95c5b0..dec341cfa 100644 --- a/cockpit/langgraph/subgraphs/angular/project.json +++ b/cockpit/langgraph/subgraphs/angular/project.json @@ -2,36 +2,22 @@ "name": "cockpit-langgraph-subgraphs-angular", "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "cockpit/langgraph/subgraphs/angular/src", - "projectType": "application", - "tags": ["scope:cockpit", "type:example"], + "projectType": "library", "targets": { "build": { - "executor": "@angular/build:application", + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/cockpit/langgraph/subgraphs/angular"], "options": { "outputPath": "dist/cockpit/langgraph/subgraphs/angular", - "index": "cockpit/langgraph/subgraphs/angular/src/index.html", - "browser": "cockpit/langgraph/subgraphs/angular/src/main.ts", - "tsConfig": "cockpit/langgraph/subgraphs/angular/tsconfig.app.json", - "styles": ["cockpit/langgraph/subgraphs/angular/src/styles.css"] - }, - "configurations": { - "development": { - "fileReplacements": [ - { - "replace": "cockpit/langgraph/subgraphs/angular/src/environments/environment.ts", - "with": "cockpit/langgraph/subgraphs/angular/src/environments/environment.development.ts" - } - ] - } - }, - "defaultConfiguration": "development" + "main": "cockpit/langgraph/subgraphs/angular/src/index.ts", + "tsConfig": "cockpit/langgraph/subgraphs/angular/tsconfig.json" + } }, - "serve": { - "executor": "@angular/build:dev-server", + "smoke": { + "executor": "nx:run-commands", "options": { - "buildTarget": "cockpit-langgraph-subgraphs-angular:build:development", - "port": 4305, - "proxyConfig": "cockpit/langgraph/subgraphs/angular/proxy.conf.json" + "cwd": "cockpit/langgraph/subgraphs/angular", + "command": "npx tsx -e \"import { langgraphSubgraphsAngularModule } from './src/index.ts'; const module = langgraphSubgraphsAngularModule; if (module.id !== 'langgraph-subgraphs-angular' || module.title !== 'LangGraph Subgraphs (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" } } } diff --git a/cockpit/langgraph/subgraphs/angular/prompts/subgraphs.md b/cockpit/langgraph/subgraphs/angular/prompts/subgraphs.md new file mode 100644 index 000000000..49b9ae8c7 --- /dev/null +++ b/cockpit/langgraph/subgraphs/angular/prompts/subgraphs.md @@ -0,0 +1,5 @@ +# LangGraph Subgraphs (Angular) + +This capability demonstrates composing LangGraph subgraphs — independent graphs invoked as nodes inside a parent graph — using the `@cacheplane/chat` Angular component library. The `` renders a live status card for each active subgraph invocation, letting the user see which specialised agent is running and what it has produced. + +Key components used: ``, ``. Cards appear in the message feed as the parent graph delegates work to subgraphs; each card shows the subgraph name, its streamed output, and a completion badge when the subgraph finishes. diff --git a/cockpit/langgraph/subgraphs/angular/src/app.component.ts b/cockpit/langgraph/subgraphs/angular/src/app.component.ts new file mode 100644 index 000000000..9582d1972 --- /dev/null +++ b/cockpit/langgraph/subgraphs/angular/src/app.component.ts @@ -0,0 +1,37 @@ +import { Component, inject, Injector, OnInit } from '@angular/core'; +import { runInInjectionContext } from '@angular/core'; +import { + ChatComponent, + ChatSubagentCardComponent, +} from '@cacheplane/chat'; +import { streamResource, StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-subgraphs', + standalone: true, + imports: [ChatComponent, ChatSubagentCardComponent], + template: ` +
+ + + + +
+ `, +}) +export class SubgraphsAppComponent implements OnInit { + private readonly injector = inject(Injector); + chat!: StreamResourceRef; + + ngOnInit(): void { + runInInjectionContext(this.injector, () => { + this.chat = streamResource({ assistantId: 'orchestrator_agent' }); + }); + } +} diff --git a/cockpit/langgraph/subgraphs/angular/src/app.config.ts b/cockpit/langgraph/subgraphs/angular/src/app.config.ts new file mode 100644 index 000000000..6ac3b924c --- /dev/null +++ b/cockpit/langgraph/subgraphs/angular/src/app.config.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideStreamResource } from '@cacheplane/stream-resource'; +import { provideChat } from '@cacheplane/chat'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStreamResource({ + apiUrl: 'http://localhost:2024', + }), + provideChat({}), + ], +}; diff --git a/cockpit/langgraph/subgraphs/angular/src/index.html b/cockpit/langgraph/subgraphs/angular/src/index.html index 75c41c991..bec6836d7 100644 --- a/cockpit/langgraph/subgraphs/angular/src/index.html +++ b/cockpit/langgraph/subgraphs/angular/src/index.html @@ -2,10 +2,11 @@ - LangGraph Subgraphs + Subgraphs - LangGraph Angular Example + - + diff --git a/cockpit/langgraph/subgraphs/angular/src/index.ts b/cockpit/langgraph/subgraphs/angular/src/index.ts new file mode 100644 index 000000000..21f2dc24a --- /dev/null +++ b/cockpit/langgraph/subgraphs/angular/src/index.ts @@ -0,0 +1,33 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'langgraph'; + section: 'core-capabilities'; + topic: 'subgraphs'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const langgraphSubgraphsAngularModule: CockpitCapabilityModule = { + id: 'langgraph-subgraphs-angular', + manifestIdentity: { + product: 'langgraph', + section: 'core-capabilities', + topic: 'subgraphs', + page: 'overview', + language: 'angular', + }, + title: 'LangGraph Subgraphs (Angular)', + docsPath: '/docs/langgraph/core-capabilities/subgraphs/overview/angular', + promptAssetPaths: [ + 'cockpit/langgraph/subgraphs/angular/prompts/subgraphs.md', + ], + codeAssetPaths: [ + 'cockpit/langgraph/subgraphs/angular/src/app.component.ts', + ], +}; diff --git a/cockpit/langgraph/subgraphs/angular/src/main.ts b/cockpit/langgraph/subgraphs/angular/src/main.ts index 810378440..a6de3d6d2 100644 --- a/cockpit/langgraph/subgraphs/angular/src/main.ts +++ b/cockpit/langgraph/subgraphs/angular/src/main.ts @@ -1,7 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { bootstrapApplication } from '@angular/platform-browser'; -import { SubgraphsComponent } from './app/subgraphs.component'; -import { appConfig } from './app/app.config'; +import { appConfig } from './app.config'; +import { SubgraphsAppComponent } from './app.component'; -bootstrapApplication(SubgraphsComponent, appConfig).catch((err) => - console.error(err) -); +bootstrapApplication(SubgraphsAppComponent, appConfig).catch(console.error); diff --git a/cockpit/langgraph/subgraphs/angular/tsconfig.json b/cockpit/langgraph/subgraphs/angular/tsconfig.json index 8deb44c70..d9e29392d 100644 --- a/cockpit/langgraph/subgraphs/angular/tsconfig.json +++ b/cockpit/langgraph/subgraphs/angular/tsconfig.json @@ -1,16 +1,7 @@ { "extends": "../../../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "experimentalDecorators": true, - "emitDeclarationOnly": false, - "noEmit": true, - "lib": [ - "ES2022", - "dom" - ], - "strict": true - } + "module": "preserve" + }, + "include": ["src/**/*.ts"] } diff --git a/cockpit/langgraph/time-travel/angular/package.json b/cockpit/langgraph/time-travel/angular/package.json index d410eb867..adbd93331 100644 --- a/cockpit/langgraph/time-travel/angular/package.json +++ b/cockpit/langgraph/time-travel/angular/package.json @@ -1,11 +1,11 @@ { - "name": "cockpit-langgraph-time-travel-angular", + "name": "@cacheplane/cockpit-langgraph-time-travel-angular", "version": "0.0.1", - "private": true, - "dependencies": { - "@cacheplane/stream-resource": "^0.0.1", + "peerDependencies": { "@cacheplane/chat": "^0.0.1", - "@langchain/core": "^0.3.0", + "@cacheplane/stream-resource": "^0.0.1", "@langchain/langgraph-sdk": "^0.0.36" - } + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false } diff --git a/cockpit/langgraph/time-travel/angular/project.json b/cockpit/langgraph/time-travel/angular/project.json index c396214a5..63c2675be 100644 --- a/cockpit/langgraph/time-travel/angular/project.json +++ b/cockpit/langgraph/time-travel/angular/project.json @@ -2,36 +2,22 @@ "name": "cockpit-langgraph-time-travel-angular", "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "cockpit/langgraph/time-travel/angular/src", - "projectType": "application", - "tags": ["scope:cockpit", "type:example"], + "projectType": "library", "targets": { "build": { - "executor": "@angular/build:application", + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/cockpit/langgraph/time-travel/angular"], "options": { "outputPath": "dist/cockpit/langgraph/time-travel/angular", - "index": "cockpit/langgraph/time-travel/angular/src/index.html", - "browser": "cockpit/langgraph/time-travel/angular/src/main.ts", - "tsConfig": "cockpit/langgraph/time-travel/angular/tsconfig.app.json", - "styles": ["cockpit/langgraph/time-travel/angular/src/styles.css"] - }, - "configurations": { - "development": { - "fileReplacements": [ - { - "replace": "cockpit/langgraph/time-travel/angular/src/environments/environment.ts", - "with": "cockpit/langgraph/time-travel/angular/src/environments/environment.development.ts" - } - ] - } - }, - "defaultConfiguration": "development" + "main": "cockpit/langgraph/time-travel/angular/src/index.ts", + "tsConfig": "cockpit/langgraph/time-travel/angular/tsconfig.json" + } }, - "serve": { - "executor": "@angular/build:dev-server", + "smoke": { + "executor": "nx:run-commands", "options": { - "buildTarget": "cockpit-langgraph-time-travel-angular:build:development", - "port": 4306, - "proxyConfig": "cockpit/langgraph/time-travel/angular/proxy.conf.json" + "cwd": "cockpit/langgraph/time-travel/angular", + "command": "npx tsx -e \"import { langgraphTimeTravelAngularModule } from './src/index.ts'; const module = langgraphTimeTravelAngularModule; if (module.id !== 'langgraph-time-travel-angular' || module.title !== 'LangGraph Time Travel (Angular)') { throw new Error('Unexpected module shape for ' + module.id); }\"" } } } diff --git a/cockpit/langgraph/time-travel/angular/prompts/time-travel.md b/cockpit/langgraph/time-travel/angular/prompts/time-travel.md new file mode 100644 index 000000000..0bb54152f --- /dev/null +++ b/cockpit/langgraph/time-travel/angular/prompts/time-travel.md @@ -0,0 +1,5 @@ +# LangGraph Time Travel (Angular) + +This capability demonstrates LangGraph's time-travel feature — replaying or branching from any past checkpoint — using the `@cacheplane/chat` Angular component library. The `` lets the user scrub through the full checkpoint history of a thread and fork execution from any historical state. + +Key components used: ``, ``. The slider reads checkpoint metadata from the thread state exposed by `streamResource` and emits a `checkpointId` that is passed back to the graph to resume from that point in history. diff --git a/cockpit/langgraph/time-travel/angular/src/app.component.ts b/cockpit/langgraph/time-travel/angular/src/app.component.ts new file mode 100644 index 000000000..0236d2361 --- /dev/null +++ b/cockpit/langgraph/time-travel/angular/src/app.component.ts @@ -0,0 +1,37 @@ +import { Component, inject, Injector, OnInit } from '@angular/core'; +import { runInInjectionContext } from '@angular/core'; +import { + ChatComponent, + ChatTimelineSliderComponent, +} from '@cacheplane/chat'; +import { streamResource, StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-time-travel', + standalone: true, + imports: [ChatComponent, ChatTimelineSliderComponent], + template: ` +
+ + + + +
+ `, +}) +export class TimeTravelAppComponent implements OnInit { + private readonly injector = inject(Injector); + chat!: StreamResourceRef; + + ngOnInit(): void { + runInInjectionContext(this.injector, () => { + this.chat = streamResource({ assistantId: 'chat_agent' }); + }); + } +} diff --git a/cockpit/langgraph/time-travel/angular/src/app.config.ts b/cockpit/langgraph/time-travel/angular/src/app.config.ts new file mode 100644 index 000000000..6ac3b924c --- /dev/null +++ b/cockpit/langgraph/time-travel/angular/src/app.config.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideStreamResource } from '@cacheplane/stream-resource'; +import { provideChat } from '@cacheplane/chat'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStreamResource({ + apiUrl: 'http://localhost:2024', + }), + provideChat({}), + ], +}; diff --git a/cockpit/langgraph/time-travel/angular/src/index.html b/cockpit/langgraph/time-travel/angular/src/index.html index 033a36fa3..870877931 100644 --- a/cockpit/langgraph/time-travel/angular/src/index.html +++ b/cockpit/langgraph/time-travel/angular/src/index.html @@ -2,10 +2,11 @@ - LangGraph Time Travel + Time Travel - LangGraph Angular Example + - + diff --git a/cockpit/langgraph/time-travel/angular/src/index.ts b/cockpit/langgraph/time-travel/angular/src/index.ts new file mode 100644 index 000000000..a9f5ee5af --- /dev/null +++ b/cockpit/langgraph/time-travel/angular/src/index.ts @@ -0,0 +1,33 @@ +export interface CockpitCapabilityModule { + id: string; + manifestIdentity: { + product: 'langgraph'; + section: 'core-capabilities'; + topic: 'time-travel'; + page: 'overview'; + language: 'angular'; + }; + title: string; + docsPath: string; + promptAssetPaths: string[]; + codeAssetPaths: string[]; +} + +export const langgraphTimeTravelAngularModule: CockpitCapabilityModule = { + id: 'langgraph-time-travel-angular', + manifestIdentity: { + product: 'langgraph', + section: 'core-capabilities', + topic: 'time-travel', + page: 'overview', + language: 'angular', + }, + title: 'LangGraph Time Travel (Angular)', + docsPath: '/docs/langgraph/core-capabilities/time-travel/overview/angular', + promptAssetPaths: [ + 'cockpit/langgraph/time-travel/angular/prompts/time-travel.md', + ], + codeAssetPaths: [ + 'cockpit/langgraph/time-travel/angular/src/app.component.ts', + ], +}; diff --git a/cockpit/langgraph/time-travel/angular/src/main.ts b/cockpit/langgraph/time-travel/angular/src/main.ts index b6e2f8fcf..266196d27 100644 --- a/cockpit/langgraph/time-travel/angular/src/main.ts +++ b/cockpit/langgraph/time-travel/angular/src/main.ts @@ -1,7 +1,6 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 import { bootstrapApplication } from '@angular/platform-browser'; -import { TimeTravelComponent } from './app/time-travel.component'; -import { appConfig } from './app/app.config'; +import { appConfig } from './app.config'; +import { TimeTravelAppComponent } from './app.component'; -bootstrapApplication(TimeTravelComponent, appConfig).catch((err) => - console.error(err) -); +bootstrapApplication(TimeTravelAppComponent, appConfig).catch(console.error); diff --git a/cockpit/langgraph/time-travel/angular/tsconfig.json b/cockpit/langgraph/time-travel/angular/tsconfig.json index 8deb44c70..d9e29392d 100644 --- a/cockpit/langgraph/time-travel/angular/tsconfig.json +++ b/cockpit/langgraph/time-travel/angular/tsconfig.json @@ -1,16 +1,7 @@ { "extends": "../../../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "experimentalDecorators": true, - "emitDeclarationOnly": false, - "noEmit": true, - "lib": [ - "ES2022", - "dom" - ], - "strict": true - } + "module": "preserve" + }, + "include": ["src/**/*.ts"] } diff --git a/docs/superpowers/plans/2026-04-04-cacheplane-chat.md b/docs/superpowers/plans/2026-04-04-cacheplane-chat.md new file mode 100644 index 000000000..18f5634bb --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-cacheplane-chat.md @@ -0,0 +1,2193 @@ +# @cacheplane/chat 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:** Build an Angular chat component library with headless primitives and prebuilt Tailwind compositions for LangGraph, LangChain, and Deep Agent UIs. + +**Architecture:** Two-layer design — headless primitives (unstyled, logic-only, composable via `ng-template`) and prebuilt compositions (Tailwind + shadcn model). All components accept a `StreamResourceRef` from `@cacheplane/stream-resource`. Generative UI hosted via `@cacheplane/render`. Debug component provides agent execution inspection. + +**Tech Stack:** Angular 21+, `@cacheplane/stream-resource`, `@cacheplane/render`, Tailwind CSS, Nx 22, ng-packagr, Vitest + +**Spec:** `docs/superpowers/specs/2026-04-04-chat-component-library-design.md` — Deliverable 2 + +**Depends on:** `@cacheplane/render` must be built first (Plan: `2026-04-04-cacheplane-render.md`) + +--- + +## File Structure + +``` +libs/chat/ +├── src/ +│ ├── lib/ +│ │ ├── chat.types.ts # Shared types (MessageContext, ChatConfig) +│ │ ├── provide-chat.ts # provideChat() DI provider +│ │ ├── provide-chat.spec.ts +│ │ ├── primitives/ +│ │ │ ├── chat-messages/ +│ │ │ │ ├── chat-messages.component.ts +│ │ │ │ ├── chat-messages.component.spec.ts +│ │ │ │ └── message-template.directive.ts +│ │ │ ├── chat-input/ +│ │ │ │ ├── chat-input.component.ts +│ │ │ │ └── chat-input.component.spec.ts +│ │ │ ├── chat-interrupt/ +│ │ │ │ ├── chat-interrupt.component.ts +│ │ │ │ └── chat-interrupt.component.spec.ts +│ │ │ ├── chat-tool-calls/ +│ │ │ │ ├── chat-tool-calls.component.ts +│ │ │ │ └── chat-tool-calls.component.spec.ts +│ │ │ ├── chat-subagents/ +│ │ │ │ ├── chat-subagents.component.ts +│ │ │ │ └── chat-subagents.component.spec.ts +│ │ │ ├── chat-timeline/ +│ │ │ │ ├── chat-timeline.component.ts +│ │ │ │ └── chat-timeline.component.spec.ts +│ │ │ ├── chat-generative-ui/ +│ │ │ │ ├── chat-generative-ui.component.ts +│ │ │ │ └── chat-generative-ui.component.spec.ts +│ │ │ ├── chat-typing-indicator/ +│ │ │ │ ├── chat-typing-indicator.component.ts +│ │ │ │ └── chat-typing-indicator.component.spec.ts +│ │ │ └── chat-error/ +│ │ │ ├── chat-error.component.ts +│ │ │ └── chat-error.component.spec.ts +│ │ ├── compositions/ +│ │ │ ├── chat/ +│ │ │ │ ├── chat.component.ts +│ │ │ │ └── chat.component.spec.ts +│ │ │ ├── chat-debug/ +│ │ │ │ ├── chat-debug.component.ts +│ │ │ │ ├── chat-debug.component.spec.ts +│ │ │ │ ├── debug-timeline.component.ts +│ │ │ │ ├── debug-checkpoint-card.component.ts +│ │ │ │ ├── debug-detail.component.ts +│ │ │ │ ├── debug-state-inspector.component.ts +│ │ │ │ ├── debug-state-diff.component.ts +│ │ │ │ ├── debug-tool-call-detail.component.ts +│ │ │ │ ├── debug-latency-bar.component.ts +│ │ │ │ ├── debug-controls.component.ts +│ │ │ │ └── debug-summary.component.ts +│ │ │ ├── chat-interrupt-panel/ +│ │ │ │ ├── chat-interrupt-panel.component.ts +│ │ │ │ └── chat-interrupt-panel.component.spec.ts +│ │ │ ├── chat-tool-call-card/ +│ │ │ │ ├── chat-tool-call-card.component.ts +│ │ │ │ └── chat-tool-call-card.component.spec.ts +│ │ │ ├── chat-subagent-card/ +│ │ │ │ ├── chat-subagent-card.component.ts +│ │ │ │ └── chat-subagent-card.component.spec.ts +│ │ │ └── chat-timeline-slider/ +│ │ │ ├── chat-timeline-slider.component.ts +│ │ │ └── chat-timeline-slider.component.spec.ts +│ │ └── testing/ +│ │ └── mock-stream-resource-ref.ts # Test utility for creating mock refs +│ ├── public-api.ts +│ └── test-setup.ts +├── project.json +├── package.json +├── ng-package.json +├── tsconfig.json +├── tsconfig.lib.json +├── tsconfig.lib.prod.json +├── vite.config.mts +├── eslint.config.mjs +└── tailwind.config.ts +``` + +--- + +### Task 1: Scaffold the Nx Library + +**Files:** +- Create: `libs/chat/project.json` +- Create: `libs/chat/package.json` +- Create: All config files (same pattern as render) +- Modify: `tsconfig.base.json` (add path alias) + +- [ ] **Step 1: Generate the library with Nx** + +Run: +```bash +npx nx generate @nx/angular:library chat --directory=libs/chat --publishable --importPath=@cacheplane/chat --prefix=chat --standalone --skipModule --no-interactive +``` + +- [ ] **Step 2: Update `libs/chat/package.json`** + +```json +{ + "name": "@cacheplane/chat", + "version": "0.0.1", + "peerDependencies": { + "@angular/core": "^20.0.0 || ^21.0.0", + "@angular/common": "^20.0.0 || ^21.0.0", + "@cacheplane/render": "^0.0.1", + "@cacheplane/stream-resource": "^0.0.1", + "@langchain/core": "^1.1.33" + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} +``` + +- [ ] **Step 3: Update project.json with Vitest** + +Same pattern as render library — `@nx/vite:test` executor. + +- [ ] **Step 4: Create vite.config.mts, test-setup.ts, eslint.config.mjs** + +Same pattern as render library. + +- [ ] **Step 5: Create tailwind.config.ts** + +```typescript +import type { Config } from 'tailwindcss'; + +export default { + content: ['./src/**/*.{ts,html}'], + darkMode: 'class', + theme: { + extend: { + colors: { + chat: { + primary: 'var(--chat-primary, #6366f1)', + surface: 'var(--chat-surface, #ffffff)', + 'surface-alt': 'var(--chat-surface-alt, #f9fafb)', + border: 'var(--chat-border, #e5e7eb)', + text: 'var(--chat-text, #111827)', + 'text-muted': 'var(--chat-text-muted, #6b7280)', + accent: 'var(--chat-accent, #8b5cf6)', + error: 'var(--chat-error, #ef4444)', + warning: 'var(--chat-warning, #f59e0b)', + success: 'var(--chat-success, #10b981)', + }, + }, + }, + }, + plugins: [], +} satisfies Config; +``` + +- [ ] **Step 6: Verify path alias in tsconfig.base.json** + +```json +"@cacheplane/chat": ["libs/chat/src/public-api.ts"] +``` + +- [ ] **Step 7: Verify build and test** + +Run: +```bash +npx nx build chat && npx nx test chat +``` + +- [ ] **Step 8: Commit** + +```bash +git add libs/chat/ tsconfig.base.json +git commit -m "chore: scaffold @cacheplane/chat library" +``` + +--- + +### Task 2: Shared Types and Test Utilities + +**Files:** +- Create: `libs/chat/src/lib/chat.types.ts` +- Create: `libs/chat/src/lib/testing/mock-stream-resource-ref.ts` + +- [ ] **Step 1: Create chat types** + +Create `libs/chat/src/lib/chat.types.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { Signal } from '@angular/core'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; +import type { AngularRegistry } from '@cacheplane/render'; +import type { BaseMessage } from '@langchain/core/messages'; + +/** Configuration for provideChat(). */ +export interface ChatConfig { + /** Default registry for generative UI rendering */ + registry?: AngularRegistry; +} + +/** Context available in message templates via let- bindings */ +export interface MessageContext { + /** The message object */ + message: BaseMessage; + /** Index in the messages array */ + index: number; + /** Whether this is the last message */ + isLast: boolean; +} + +/** Supported message template types */ +export type MessageTemplateType = 'human' | 'ai' | 'tool' | 'system' | 'function'; +``` + +- [ ] **Step 2: Create mock StreamResourceRef test utility** + +Create `libs/chat/src/lib/testing/mock-stream-resource-ref.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { signal, computed } from '@angular/core'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; +import { ResourceStatus } from '@cacheplane/stream-resource'; +import type { BaseMessage } from '@langchain/core/messages'; +import { HumanMessage, AIMessage } from '@langchain/core/messages'; + +/** + * Create a mock StreamResourceRef for testing chat components. + * All signals are writable for easy test setup. + */ +export function createMockStreamResourceRef( + overrides: Partial<{ + messages: BaseMessage[]; + status: ResourceStatus; + error: unknown; + interrupt: unknown; + isLoading: boolean; + }> = {}, +): StreamResourceRef { + const messages = signal(overrides.messages ?? []); + const status = signal(overrides.status ?? ResourceStatus.Idle); + const error = signal(overrides.error ?? undefined); + const interrupt = signal(overrides.interrupt ?? undefined); + const interrupts = signal([]); + const toolProgress = signal([]); + const toolCalls = signal([]); + const branch = signal(''); + const history = signal([]); + const isThreadLoading = signal(false); + const subagents = signal(new Map()); + const value = signal({}); + const hasValue = signal(messages().length > 0); + const isLoading = computed(() => overrides.isLoading ?? status() === ResourceStatus.Loading); + const activeSubagents = computed(() => []); + + return { + value: value.asReadonly(), + messages: messages.asReadonly(), + status: status.asReadonly(), + isLoading, + error: error.asReadonly(), + hasValue: hasValue.asReadonly(), + interrupt: interrupt.asReadonly(), + interrupts: interrupts.asReadonly(), + toolProgress: toolProgress.asReadonly(), + toolCalls: toolCalls.asReadonly(), + branch: branch.asReadonly(), + history: history.asReadonly(), + isThreadLoading: isThreadLoading.asReadonly(), + subagents: subagents.asReadonly(), + activeSubagents, + submit: async () => {}, + stop: async () => {}, + switchThread: () => {}, + joinStream: async () => {}, + reload: () => {}, + setBranch: () => {}, + getMessagesMetadata: () => undefined, + getToolCalls: () => [], + } as unknown as StreamResourceRef; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/ +git commit -m "feat(chat): add shared types and mock test utilities" +``` + +--- + +### Task 3: MessageTemplate Directive + ChatMessages Primitive + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-messages/message-template.directive.ts` +- Create: `libs/chat/src/lib/primitives/chat-messages/chat-messages.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-messages/chat-messages.component.spec.ts` + +- [ ] **Step 1: Write failing test** + +Create `libs/chat/src/lib/primitives/chat-messages/chat-messages.component.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import { HumanMessage, AIMessage } from '@langchain/core/messages'; +import { ChatMessagesComponent } from './chat-messages.component'; +import { MessageTemplateDirective } from './message-template.directive'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; + +@Component({ + standalone: true, + imports: [ChatMessagesComponent, MessageTemplateDirective], + template: ` + + +
{{ message.content }}
+
+ +
{{ message.content }}
+
+
+ `, +}) +class TestHostComponent { + chatRef = createMockStreamResourceRef({ + messages: [ + new HumanMessage('Hello'), + new AIMessage('Hi there!'), + ], + }); +} + +describe('ChatMessagesComponent', () => { + it('should render messages using matching templates', () => { + TestBed.configureTestingModule({ imports: [TestHostComponent] }); + const fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Hello'); + expect(fixture.nativeElement.textContent).toContain('Hi there!'); + expect(fixture.nativeElement.querySelector('.human')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('.ai')).toBeTruthy(); + }); + + it('should render empty when no messages', () => { + TestBed.configureTestingModule({ imports: [TestHostComponent] }); + const fixture = TestBed.createComponent(TestHostComponent); + fixture.componentInstance.chatRef = createMockStreamResourceRef({ messages: [] }); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent.trim()).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test chat` + +Expected: FAIL. + +- [ ] **Step 3: Implement MessageTemplateDirective** + +Create `libs/chat/src/lib/primitives/chat-messages/message-template.directive.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Directive, input, TemplateRef, inject } from '@angular/core'; +import type { MessageTemplateType } from '../../chat.types'; + +@Directive({ + selector: 'ng-template[messageTemplate]', + standalone: true, +}) +export class MessageTemplateDirective { + readonly messageTemplate = input.required(); + readonly templateRef = inject(TemplateRef); +} +``` + +- [ ] **Step 4: Implement ChatMessagesComponent** + +Create `libs/chat/src/lib/primitives/chat-messages/chat-messages.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + contentChildren, + input, + ChangeDetectionStrategy, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; +import type { BaseMessage } from '@langchain/core/messages'; +import { MessageTemplateDirective } from './message-template.directive'; +import type { MessageTemplateType } from '../../chat.types'; + +@Component({ + selector: 'chat-messages', + standalone: true, + imports: [NgTemplateOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (message of messages(); track $index) { + @if (getTemplate(message); as tpl) { + + } + } + `, +}) +export class ChatMessagesComponent { + readonly ref = input.required>(); + + private readonly templates = contentChildren(MessageTemplateDirective); + + protected readonly messages = computed(() => this.ref().messages()); + + protected getTemplate(message: BaseMessage) { + const type = this.getMessageType(message); + const match = this.templates().find(t => t.messageTemplate() === type); + return match?.templateRef ?? null; + } + + private getMessageType(message: BaseMessage): MessageTemplateType { + const msgType = message._getType(); + if (msgType === 'human') return 'human'; + if (msgType === 'ai') return 'ai'; + if (msgType === 'tool') return 'tool'; + if (msgType === 'system') return 'system'; + return 'function'; + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx nx test chat` + +Expected: All tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/ +git commit -m "feat(chat): add ChatMessages primitive with messageTemplate directive" +``` + +--- + +### Task 4: ChatInput Primitive + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-input/chat-input.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts` + +- [ ] **Step 1: Write failing test** + +Create `libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { ChatInputComponent } from './chat-input.component'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; + +describe('ChatInputComponent', () => { + it('should render a text input and submit button', () => { + TestBed.configureTestingModule({ imports: [ChatInputComponent] }); + const fixture = TestBed.createComponent(ChatInputComponent); + fixture.componentRef.setInput('ref', createMockStreamResourceRef()); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('textarea, input')).toBeTruthy(); + }); + + it('should call ref.submit when form is submitted', async () => { + const ref = createMockStreamResourceRef(); + const submitSpy = vi.spyOn(ref, 'submit').mockResolvedValue(undefined); + + TestBed.configureTestingModule({ imports: [ChatInputComponent] }); + const fixture = TestBed.createComponent(ChatInputComponent); + fixture.componentRef.setInput('ref', ref); + fixture.detectChanges(); + + const component = fixture.componentInstance; + (component as any).message.set('Hello world'); + (component as any).onSubmit(); + fixture.detectChanges(); + + expect(submitSpy).toHaveBeenCalled(); + }); + + it('should not submit empty messages', () => { + const ref = createMockStreamResourceRef(); + const submitSpy = vi.spyOn(ref, 'submit'); + + TestBed.configureTestingModule({ imports: [ChatInputComponent] }); + const fixture = TestBed.createComponent(ChatInputComponent); + fixture.componentRef.setInput('ref', ref); + fixture.detectChanges(); + + (fixture.componentInstance as any).onSubmit(); + expect(submitSpy).not.toHaveBeenCalled(); + }); + + it('should disable input when loading', () => { + TestBed.configureTestingModule({ imports: [ChatInputComponent] }); + const fixture = TestBed.createComponent(ChatInputComponent); + fixture.componentRef.setInput('ref', createMockStreamResourceRef({ isLoading: true })); + fixture.detectChanges(); + + const textarea = fixture.nativeElement.querySelector('textarea, input'); + expect(textarea.disabled).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test chat` + +- [ ] **Step 3: Implement ChatInputComponent** + +Create `libs/chat/src/lib/primitives/chat-input/chat-input.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + input, + output, + signal, + ChangeDetectionStrategy, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; +import { HumanMessage } from '@langchain/core/messages'; + +@Component({ + selector: 'chat-input', + standalone: true, + imports: [FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + +
+ `, +}) +export class ChatInputComponent { + readonly ref = input.required>(); + readonly submitOnEnter = input(true); + readonly placeholder = input('Type a message...'); + + /** Emitted after successful submit with the message text */ + readonly submitted = output(); + + protected readonly messageText = signal(''); + + protected readonly isDisabled = computed(() => this.ref().isLoading()); + + protected readonly message = this.messageText; + + protected onSubmit(): void { + const text = this.messageText().trim(); + if (!text) return; + + this.ref().submit({ + messages: [new HumanMessage(text)], + }); + + this.messageText.set(''); + this.submitted.emit(text); + } + + protected onKeydown(event: KeyboardEvent): void { + if (this.submitOnEnter() && !event.shiftKey) { + event.preventDefault(); + this.onSubmit(); + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test chat` + +Expected: All tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/ +git commit -m "feat(chat): add ChatInput primitive" +``` + +--- + +### Task 5: ChatTypingIndicator and ChatError Primitives + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.spec.ts` +- Create: `libs/chat/src/lib/primitives/chat-error/chat-error.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-error/chat-error.component.spec.ts` + +- [ ] **Step 1: Write failing tests for both** + +Create `libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { ChatTypingIndicatorComponent } from './chat-typing-indicator.component'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; +import { ResourceStatus } from '@cacheplane/stream-resource'; + +describe('ChatTypingIndicatorComponent', () => { + it('should render when loading', () => { + TestBed.configureTestingModule({ imports: [ChatTypingIndicatorComponent] }); + const fixture = TestBed.createComponent(ChatTypingIndicatorComponent); + fixture.componentRef.setInput('ref', createMockStreamResourceRef({ isLoading: true })); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent.trim()).not.toBe(''); + }); + + it('should be empty when not loading', () => { + TestBed.configureTestingModule({ imports: [ChatTypingIndicatorComponent] }); + const fixture = TestBed.createComponent(ChatTypingIndicatorComponent); + fixture.componentRef.setInput('ref', createMockStreamResourceRef({ isLoading: false })); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent.trim()).toBe(''); + }); +}); +``` + +Create `libs/chat/src/lib/primitives/chat-error/chat-error.component.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { ChatErrorComponent } from './chat-error.component'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; + +describe('ChatErrorComponent', () => { + it('should render error message when error exists', () => { + TestBed.configureTestingModule({ imports: [ChatErrorComponent] }); + const fixture = TestBed.createComponent(ChatErrorComponent); + fixture.componentRef.setInput('ref', createMockStreamResourceRef({ error: new Error('Connection failed') })); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Connection failed'); + }); + + it('should be empty when no error', () => { + TestBed.configureTestingModule({ imports: [ChatErrorComponent] }); + const fixture = TestBed.createComponent(ChatErrorComponent); + fixture.componentRef.setInput('ref', createMockStreamResourceRef()); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent.trim()).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx nx test chat` + +- [ ] **Step 3: Implement both components** + +Create `libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, computed, input, ChangeDetectionStrategy } from '@angular/core'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'chat-typing-indicator', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (ref().isLoading()) { + +
+ ... +
+
+ } + `, +}) +export class ChatTypingIndicatorComponent { + readonly ref = input.required>(); +} +``` + +Create `libs/chat/src/lib/primitives/chat-error/chat-error.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, computed, input, ChangeDetectionStrategy } from '@angular/core'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'chat-error', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (errorMessage(); as msg) { + +
{{ msg }}
+
+ } + `, +}) +export class ChatErrorComponent { + readonly ref = input.required>(); + + protected readonly errorMessage = computed(() => { + const err = this.ref().error(); + if (!err) return null; + if (err instanceof Error) return err.message; + return String(err); + }); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test chat` + +Expected: All tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/ +git commit -m "feat(chat): add ChatTypingIndicator and ChatError primitives" +``` + +--- + +### Task 6: ChatInterrupt Primitive + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.spec.ts` + +- [ ] **Step 1: Write failing test** + +Create `libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { ChatInterruptComponent } from './chat-interrupt.component'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; + +@Component({ + standalone: true, + imports: [ChatInterruptComponent], + template: ` + + +
{{ interrupt | json }}
+
+
+ `, +}) +class TestHostComponent { + chatRef = createMockStreamResourceRef({ + interrupt: { kind: 'approval', message: 'Approve this action?' }, + }); +} + +describe('ChatInterruptComponent', () => { + it('should render template when interrupt is active', () => { + TestBed.configureTestingModule({ imports: [TestHostComponent] }); + const fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.interrupt-content')).toBeTruthy(); + }); + + it('should not render when no interrupt', () => { + TestBed.configureTestingModule({ imports: [TestHostComponent] }); + const fixture = TestBed.createComponent(TestHostComponent); + fixture.componentInstance.chatRef = createMockStreamResourceRef(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.interrupt-content')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test chat` + +- [ ] **Step 3: Implement ChatInterruptComponent** + +Create `libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + contentChild, + input, + TemplateRef, + ChangeDetectionStrategy, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'chat-interrupt', + standalone: true, + imports: [NgTemplateOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (interrupt(); as int) { + @if (template(); as tpl) { + + } + } + `, +}) +export class ChatInterruptComponent { + readonly ref = input.required>(); + + protected readonly template = contentChild(TemplateRef); + protected readonly interrupt = computed(() => this.ref().interrupt()); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test chat` + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/ +git commit -m "feat(chat): add ChatInterrupt primitive" +``` + +--- + +### Task 7: ChatToolCalls and ChatSubagents Primitives + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts` +- Create: `libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.spec.ts` + +- [ ] **Step 1: Write failing tests** + +Create `libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, TemplateRef } from '@angular/core'; +import { ChatToolCallsComponent } from './chat-tool-calls.component'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; + +@Component({ + standalone: true, + imports: [ChatToolCallsComponent], + template: ` + + +
{{ toolCall.name }}
+
+
+ `, +}) +class TestHostComponent { + chatRef = createMockStreamResourceRef(); +} + +describe('ChatToolCallsComponent', () => { + it('should render', () => { + TestBed.configureTestingModule({ imports: [TestHostComponent] }); + const fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + expect(fixture).toBeTruthy(); + }); +}); +``` + +Create `libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { ChatSubagentsComponent } from './chat-subagents.component'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; + +describe('ChatSubagentsComponent', () => { + it('should render', () => { + TestBed.configureTestingModule({ imports: [ChatSubagentsComponent] }); + const fixture = TestBed.createComponent(ChatSubagentsComponent); + fixture.componentRef.setInput('ref', createMockStreamResourceRef()); + fixture.detectChanges(); + expect(fixture).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx nx test chat` + +- [ ] **Step 3: Implement ChatToolCallsComponent** + +Create `libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + contentChild, + input, + TemplateRef, + ChangeDetectionStrategy, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; +import type { BaseMessage } from '@langchain/core/messages'; + +@Component({ + selector: 'chat-tool-calls', + standalone: true, + imports: [NgTemplateOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (toolCall of toolCalls(); track toolCall.id ?? $index) { + @if (template(); as tpl) { + + } + } + `, +}) +export class ChatToolCallsComponent { + readonly ref = input.required>(); + + protected readonly template = contentChild(TemplateRef); + /** Optional: filter tool calls to a specific message */ + readonly message = input(); + + protected readonly toolCalls = computed(() => { + const msg = this.message(); + if (msg && 'tool_calls' in msg && Array.isArray((msg as any).tool_calls)) { + return (msg as any).tool_calls; + } + return this.ref().toolCalls(); + }); +} +``` + +- [ ] **Step 4: Implement ChatSubagentsComponent** + +Create `libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + contentChild, + input, + TemplateRef, + ChangeDetectionStrategy, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'chat-subagents', + standalone: true, + imports: [NgTemplateOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (subagent of activeSubagents(); track subagent.toolCallId) { + @if (template(); as tpl) { + + } + } + `, +}) +export class ChatSubagentsComponent { + readonly ref = input.required>(); + + protected readonly template = contentChild(TemplateRef); + protected readonly activeSubagents = computed(() => this.ref().activeSubagents()); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx nx test chat` + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/ +git commit -m "feat(chat): add ChatToolCalls and ChatSubagents primitives" +``` + +--- + +### Task 8: ChatThreadList Primitive + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts` + +- [ ] **Step 1: Write failing test** + +Create `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import { ChatThreadListComponent } from './chat-thread-list.component'; + +@Component({ + standalone: true, + imports: [ChatThreadListComponent], + template: ` + + +
{{ thread.id }}
+
+
+ `, +}) +class TestHostComponent { + threads = signal([ + { id: 'thread-1', metadata: {} }, + { id: 'thread-2', metadata: {} }, + ]); + activeId = signal('thread-1'); +} + +describe('ChatThreadListComponent', () => { + it('should render thread items using template', () => { + TestBed.configureTestingModule({ imports: [TestHostComponent] }); + const fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + + const items = fixture.nativeElement.querySelectorAll('.thread-item'); + expect(items.length).toBe(2); + expect(items[0].textContent).toContain('thread-1'); + }); + + it('should render empty when no threads', () => { + TestBed.configureTestingModule({ imports: [TestHostComponent] }); + const fixture = TestBed.createComponent(TestHostComponent); + fixture.componentInstance.threads.set([]); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelectorAll('.thread-item').length).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test chat` + +- [ ] **Step 3: Implement ChatThreadListComponent** + +Create `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + contentChild, + input, + output, + TemplateRef, + ChangeDetectionStrategy, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; + +@Component({ + selector: 'chat-thread-list', + standalone: true, + imports: [NgTemplateOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (thread of threads(); track thread.id) { + @if (template(); as tpl) { + + } + } + `, +}) +export class ChatThreadListComponent { + readonly threads = input.required>(); + readonly activeThreadId = input(); + readonly threadSelected = output(); + + protected readonly template = contentChild(TemplateRef); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test chat` + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/ +git commit -m "feat(chat): add ChatThreadList primitive" +``` + +--- + +### Task 9: ChatTimeline and ChatGenerativeUi Primitives + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-timeline/chat-timeline.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-timeline/chat-timeline.component.spec.ts` +- Create: `libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.spec.ts` + +- [ ] **Step 1: Write failing tests** + +Create `libs/chat/src/lib/primitives/chat-timeline/chat-timeline.component.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { ChatTimelineComponent } from './chat-timeline.component'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; + +describe('ChatTimelineComponent', () => { + it('should render', () => { + TestBed.configureTestingModule({ imports: [ChatTimelineComponent] }); + const fixture = TestBed.createComponent(ChatTimelineComponent); + fixture.componentRef.setInput('ref', createMockStreamResourceRef()); + fixture.detectChanges(); + expect(fixture).toBeTruthy(); + }); +}); +``` + +Create `libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { ChatGenerativeUiComponent } from './chat-generative-ui.component'; + +describe('ChatGenerativeUiComponent', () => { + it('should render empty when no spec', () => { + TestBed.configureTestingModule({ imports: [ChatGenerativeUiComponent] }); + const fixture = TestBed.createComponent(ChatGenerativeUiComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx nx test chat` + +- [ ] **Step 3: Implement ChatTimelineComponent** + +Create `libs/chat/src/lib/primitives/chat-timeline/chat-timeline.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + contentChild, + input, + output, + TemplateRef, + ChangeDetectionStrategy, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'chat-timeline', + standalone: true, + imports: [NgTemplateOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (state of history(); track $index) { + @if (template(); as tpl) { + + } + } + `, +}) +export class ChatTimelineComponent { + readonly ref = input.required>(); + + readonly checkpointSelected = output<{ checkpointId: string; index: number }>(); + + protected readonly template = contentChild(TemplateRef); + protected readonly history = computed(() => this.ref().history()); + protected readonly activeIndex = computed(() => this.history().length - 1); +} +``` + +- [ ] **Step 4: Implement ChatGenerativeUiComponent** + +Create `libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, ChangeDetectionStrategy } from '@angular/core'; +import { RenderSpecComponent } from '@cacheplane/render'; +import type { AngularRegistry } from '@cacheplane/render'; +import type { Spec, StateStore } from '@json-render/core'; + +@Component({ + selector: 'chat-generative-ui', + standalone: true, + imports: [RenderSpecComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (spec()) { + + } + `, +}) +export class ChatGenerativeUiComponent { + readonly spec = input(null); + readonly registry = input(); + readonly store = input(); + readonly loading = input(false); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx nx test chat` + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/ +git commit -m "feat(chat): add ChatTimeline and ChatGenerativeUi primitives" +``` + +--- + +### Task 9: provideChat DI Provider + +**Files:** +- Create: `libs/chat/src/lib/provide-chat.ts` +- Create: `libs/chat/src/lib/provide-chat.spec.ts` + +- [ ] **Step 1: Write failing test** + +Create `libs/chat/src/lib/provide-chat.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { provideChat, CHAT_CONFIG } from './provide-chat'; + +describe('provideChat', () => { + it('should provide ChatConfig via injection token', () => { + TestBed.configureTestingModule({ + providers: [provideChat({})], + }); + + const config = TestBed.inject(CHAT_CONFIG); + expect(config).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test chat` + +- [ ] **Step 3: Implement provideChat** + +Create `libs/chat/src/lib/provide-chat.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { InjectionToken, makeEnvironmentProviders } from '@angular/core'; +import type { ChatConfig } from './chat.types'; + +export const CHAT_CONFIG = new InjectionToken('CHAT_CONFIG'); + +export function provideChat(config: ChatConfig) { + return makeEnvironmentProviders([ + { provide: CHAT_CONFIG, useValue: config }, + ]); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test chat` + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/ +git commit -m "feat(chat): add provideChat DI provider" +``` + +--- + +### Task 10: `` Composition (Main Prebuilt Layout) + +**Files:** +- Create: `libs/chat/src/lib/compositions/chat/chat.component.ts` +- Create: `libs/chat/src/lib/compositions/chat/chat.component.spec.ts` + +- [ ] **Step 1: Write failing test** + +Create `libs/chat/src/lib/compositions/chat/chat.component.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { HumanMessage, AIMessage } from '@langchain/core/messages'; +import { ChatComponent } from './chat.component'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; + +describe('ChatComponent', () => { + it('should render messages, input, and typing indicator', () => { + const ref = createMockStreamResourceRef({ + messages: [new HumanMessage('Hello'), new AIMessage('Hi!')], + }); + + TestBed.configureTestingModule({ imports: [ChatComponent] }); + const fixture = TestBed.createComponent(ChatComponent); + fixture.componentRef.setInput('ref', ref); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Hello'); + expect(fixture.nativeElement.textContent).toContain('Hi!'); + }); + + it('should render error when present', () => { + const ref = createMockStreamResourceRef({ + error: new Error('Stream failed'), + }); + + TestBed.configureTestingModule({ imports: [ChatComponent] }); + const fixture = TestBed.createComponent(ChatComponent); + fixture.componentRef.setInput('ref', ref); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Stream failed'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test chat` + +- [ ] **Step 3: Implement ChatComponent** + +Create `libs/chat/src/lib/compositions/chat/chat.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, ChangeDetectionStrategy } from '@angular/core'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; +import { ChatMessagesComponent } from '../../primitives/chat-messages/chat-messages.component'; +import { MessageTemplateDirective } from '../../primitives/chat-messages/message-template.directive'; +import { ChatInputComponent } from '../../primitives/chat-input/chat-input.component'; +import { ChatTypingIndicatorComponent } from '../../primitives/chat-typing-indicator/chat-typing-indicator.component'; +import { ChatErrorComponent } from '../../primitives/chat-error/chat-error.component'; +import { ChatInterruptComponent } from '../../primitives/chat-interrupt/chat-interrupt.component'; + +@Component({ + selector: 'chat', + standalone: true, + imports: [ + ChatMessagesComponent, + MessageTemplateDirective, + ChatInputComponent, + ChatTypingIndicatorComponent, + ChatErrorComponent, + ChatInterruptComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+ + +
+
+ {{ message.content }} +
+
+
+ +
+
+ {{ message.content }} +
+
+
+
+ + +
+
+ Thinking... +
+
+
+
+ + +
+ {{ ref().error() }} +
+
+ +
+ +
+
+ `, +}) +export class ChatComponent { + readonly ref = input.required>(); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test chat` + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/ +git commit -m "feat(chat): add prebuilt composition" +``` + +--- + +### Task 11: ChatInterruptPanel, ChatToolCallCard, ChatSubagentCard Compositions + +**Files:** +- Create: `libs/chat/src/lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component.ts` +- Create: `libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts` +- Create: `libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts` +- Create: spec files for each + +These are standalone styled compositions. Each follows the same TDD pattern. + +- [ ] **Step 1: Implement ChatInterruptPanel** + +Create `libs/chat/src/lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, output, ChangeDetectionStrategy } from '@angular/core'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; + +export type InterruptAction = 'accept' | 'edit' | 'respond' | 'ignore'; + +@Component({ + selector: 'chat-interrupt-panel', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (ref().interrupt(); as interrupt) { +
+
+ + Human input required +
+
+ {{ interrupt | json }} +
+
+ + + + +
+
+ } + `, +}) +export class ChatInterruptPanelComponent { + readonly ref = input.required>(); + readonly action = output(); +} +``` + +- [ ] **Step 2: Implement ChatToolCallCard** + +Create `libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, signal, ChangeDetectionStrategy } from '@angular/core'; +import { JsonPipe } from '@angular/common'; + +@Component({ + selector: 'chat-tool-call-card', + standalone: true, + imports: [JsonPipe], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + @if (expanded()) { +
+
+ Input: +
{{ toolCall().args | json }}
+
+ @if (toolCall().result !== undefined) { +
+ Output: +
{{ toolCall().result | json }}
+
+ } +
+ } +
+ `, +}) +export class ChatToolCallCardComponent { + readonly toolCall = input.required<{ name: string; args: unknown; result?: unknown; id?: string }>(); + protected readonly expanded = signal(false); +} +``` + +- [ ] **Step 3: Implement ChatSubagentCard** + +Create `libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, computed, input, signal, ChangeDetectionStrategy } from '@angular/core'; +import type { SubagentStreamRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'chat-subagent-card', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + @if (expanded()) { +
+ @for (msg of subagent().messages(); track $index) { +
{{ msg.content }}
+ } +
+ } +
+ `, +}) +export class ChatSubagentCardComponent { + readonly subagent = input.required(); + protected readonly expanded = signal(false); + + protected readonly statusColor = computed(() => { + const status = this.subagent().status(); + switch (status) { + case 'running': return 'bg-chat-warning animate-pulse'; + case 'complete': return 'bg-chat-success'; + case 'error': return 'bg-chat-error'; + default: return 'bg-chat-text-muted'; + } + }); +} +``` + +- [ ] **Step 4: Write basic spec files for each** + +Each spec file follows the same minimal pattern: import the component, render it with test inputs, assert it exists. + +- [ ] **Step 5: Run all tests** + +Run: `npx nx test chat` + +Expected: All tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/ +git commit -m "feat(chat): add InterruptPanel, ToolCallCard, SubagentCard compositions" +``` + +--- + +### Task 12: ChatDebug Composition (Tier 1 MVP) + +**Files:** +- Create: All files in `libs/chat/src/lib/compositions/chat-debug/` + +This is the largest single task. Implements Tier 1 features only: timeline, state inspector, state diff, tool call detail, collapsible panel. + +- [ ] **Step 1: Create DebugCheckpointCard** + +Create `libs/chat/src/lib/compositions/chat-debug/debug-checkpoint-card.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, output, ChangeDetectionStrategy } from '@angular/core'; + +@Component({ + selector: 'debug-checkpoint-card', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, +}) +export class DebugCheckpointCardComponent { + readonly checkpoint = input.required<{ + node?: string; + duration?: number; + tokenCount?: number; + checkpointId?: string; + }>(); + readonly isSelected = input(false); + readonly selected = output(); +} +``` + +- [ ] **Step 2: Create DebugStateInspector** + +Create `libs/chat/src/lib/compositions/chat-debug/debug-state-inspector.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, ChangeDetectionStrategy } from '@angular/core'; +import { JsonPipe } from '@angular/common'; + +@Component({ + selector: 'debug-state-inspector', + standalone: true, + imports: [JsonPipe], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
State
+
{{ state() | json }}
+
+ `, +}) +export class DebugStateInspectorComponent { + readonly state = input.required>(); +} +``` + +- [ ] **Step 3: Create DebugStateDiff** + +Create `libs/chat/src/lib/compositions/chat-debug/debug-state-diff.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, computed, input, ChangeDetectionStrategy } from '@angular/core'; + +interface DiffEntry { + path: string; + type: 'added' | 'removed' | 'changed'; + oldValue?: unknown; + newValue?: unknown; +} + +@Component({ + selector: 'debug-state-diff', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
Diff
+ @if (diff().length === 0) { +
No changes
+ } @else { +
+ @for (entry of diff(); track entry.path) { +
+ {{ entryPrefix(entry) }} {{ entry.path }}: {{ formatValue(entry) }} +
+ } +
+ } +
+ `, +}) +export class DebugStateDiffComponent { + readonly before = input.required>(); + readonly after = input.required>(); + + protected readonly diff = computed(() => { + return this.computeDiff(this.before(), this.after()); + }); + + protected entryColor(entry: DiffEntry): string { + switch (entry.type) { + case 'added': return 'text-chat-success'; + case 'removed': return 'text-chat-error'; + case 'changed': return 'text-chat-warning'; + } + } + + protected entryPrefix(entry: DiffEntry): string { + switch (entry.type) { + case 'added': return '+'; + case 'removed': return '-'; + case 'changed': return '~'; + } + } + + protected formatValue(entry: DiffEntry): string { + if (entry.type === 'removed') return JSON.stringify(entry.oldValue); + return JSON.stringify(entry.newValue); + } + + private computeDiff(before: Record, after: Record, prefix = ''): DiffEntry[] { + const entries: DiffEntry[] = []; + const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]); + + for (const key of allKeys) { + const path = prefix ? `${prefix}.${key}` : key; + const inBefore = key in before; + const inAfter = key in after; + + if (!inBefore && inAfter) { + entries.push({ path, type: 'added', newValue: after[key] }); + } else if (inBefore && !inAfter) { + entries.push({ path, type: 'removed', oldValue: before[key] }); + } else if (before[key] !== after[key]) { + if (typeof before[key] === 'object' && typeof after[key] === 'object' && before[key] && after[key]) { + entries.push(...this.computeDiff( + before[key] as Record, + after[key] as Record, + path, + )); + } else { + entries.push({ path, type: 'changed', oldValue: before[key], newValue: after[key] }); + } + } + } + return entries; + } +} +``` + +- [ ] **Step 4: Create DebugTimeline** + +Create `libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, output, signal, ChangeDetectionStrategy } from '@angular/core'; +import { DebugCheckpointCardComponent } from './debug-checkpoint-card.component'; + +@Component({ + selector: 'debug-timeline', + standalone: true, + imports: [DebugCheckpointCardComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
Checkpoints
+ @for (cp of checkpoints(); track $index) { +
+
+ +
+ } +
+ `, +}) +export class DebugTimelineComponent { + readonly checkpoints = input.required[]>(); + readonly selectedIndex = input(-1); + readonly checkpointSelected = output(); + + protected onSelect(index: number): void { + this.checkpointSelected.emit(index); + } +} +``` + +- [ ] **Step 5: Create DebugDetail** + +Create `libs/chat/src/lib/compositions/chat-debug/debug-detail.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, input, ChangeDetectionStrategy } from '@angular/core'; +import { DebugStateInspectorComponent } from './debug-state-inspector.component'; +import { DebugStateDiffComponent } from './debug-state-diff.component'; + +@Component({ + selector: 'debug-detail', + standalone: true, + imports: [DebugStateInspectorComponent, DebugStateDiffComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ @if (previousState() && currentState()) { + + } + @if (currentState()) { + + } +
+ `, +}) +export class DebugDetailComponent { + readonly currentState = input>(); + readonly previousState = input>(); +} +``` + +- [ ] **Step 6: Create ChatDebug top-level composition** + +Create `libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, computed, input, signal, ChangeDetectionStrategy } from '@angular/core'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; +import { ChatMessagesComponent } from '../../primitives/chat-messages/chat-messages.component'; +import { MessageTemplateDirective } from '../../primitives/chat-messages/message-template.directive'; +import { ChatInputComponent } from '../../primitives/chat-input/chat-input.component'; +import { ChatTypingIndicatorComponent } from '../../primitives/chat-typing-indicator/chat-typing-indicator.component'; +import { ChatErrorComponent } from '../../primitives/chat-error/chat-error.component'; +import { DebugTimelineComponent } from './debug-timeline.component'; +import { DebugDetailComponent } from './debug-detail.component'; + +@Component({ + selector: 'chat-debug', + standalone: true, + imports: [ + ChatMessagesComponent, + MessageTemplateDirective, + ChatInputComponent, + ChatTypingIndicatorComponent, + ChatErrorComponent, + DebugTimelineComponent, + DebugDetailComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+
+ + +
+
+ {{ message.content }} +
+
+
+ +
+
+ {{ message.content }} +
+
+
+
+ +
+ +
+ +
+
+ + + @if (debugOpen()) { +
+
+ Debug + +
+
+ + +
+
+ } @else { + + } +
+ `, + styles: [`:host { display: block; position: relative; height: 100%; }`], +}) +export class ChatDebugComponent { + readonly ref = input.required>(); + + protected readonly debugOpen = signal(true); + protected readonly selectedCheckpointIndex = signal(-1); + + protected readonly checkpoints = computed(() => { + return this.ref().history() as Record[]; + }); + + protected readonly selectedState = computed(() => { + const idx = this.selectedCheckpointIndex(); + const cps = this.checkpoints(); + if (idx < 0 || idx >= cps.length) return undefined; + return (cps[idx] as any)?.values ?? cps[idx]; + }); + + protected readonly previousState = computed(() => { + const idx = this.selectedCheckpointIndex(); + const cps = this.checkpoints(); + if (idx <= 0 || idx >= cps.length) return undefined; + return (cps[idx - 1] as any)?.values ?? cps[idx - 1]; + }); +} +``` + +- [ ] **Step 7: Write test for ChatDebug** + +Create `libs/chat/src/lib/compositions/chat-debug/chat-debug.component.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { ChatDebugComponent } from './chat-debug.component'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; +import { HumanMessage, AIMessage } from '@langchain/core/messages'; + +describe('ChatDebugComponent', () => { + it('should render chat area and debug panel', () => { + const ref = createMockStreamResourceRef({ + messages: [new HumanMessage('test')], + }); + + TestBed.configureTestingModule({ imports: [ChatDebugComponent] }); + const fixture = TestBed.createComponent(ChatDebugComponent); + fixture.componentRef.setInput('ref', ref); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('test'); + expect(fixture.nativeElement.textContent).toContain('Debug'); + }); +}); +``` + +- [ ] **Step 8: Run all tests** + +Run: `npx nx test chat` + +Expected: All tests PASS. + +- [ ] **Step 9: Commit** + +```bash +git add libs/chat/src/ +git commit -m "feat(chat): add ChatDebug composition with timeline, state inspector, and diff" +``` + +--- + +### Task 13: Public API and Final Build Verification + +**Files:** +- Modify: `libs/chat/src/public-api.ts` + +- [ ] **Step 1: Finalize public-api.ts** + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +// Types +export type { ChatConfig, MessageContext, MessageTemplateType } from './lib/chat.types'; + +// Provider +export { provideChat, CHAT_CONFIG } from './lib/provide-chat'; + +// Primitives +export { ChatMessagesComponent } from './lib/primitives/chat-messages/chat-messages.component'; +export { MessageTemplateDirective } from './lib/primitives/chat-messages/message-template.directive'; +export { ChatInputComponent } from './lib/primitives/chat-input/chat-input.component'; +export { ChatInterruptComponent } from './lib/primitives/chat-interrupt/chat-interrupt.component'; +export { ChatToolCallsComponent } from './lib/primitives/chat-tool-calls/chat-tool-calls.component'; +export { ChatSubagentsComponent } from './lib/primitives/chat-subagents/chat-subagents.component'; +export { ChatThreadListComponent } from './lib/primitives/chat-thread-list/chat-thread-list.component'; +export { ChatTimelineComponent } from './lib/primitives/chat-timeline/chat-timeline.component'; +export { ChatGenerativeUiComponent } from './lib/primitives/chat-generative-ui/chat-generative-ui.component'; +export { ChatTypingIndicatorComponent } from './lib/primitives/chat-typing-indicator/chat-typing-indicator.component'; +export { ChatErrorComponent } from './lib/primitives/chat-error/chat-error.component'; + +// Compositions +export { ChatComponent } from './lib/compositions/chat/chat.component'; +export { ChatDebugComponent } from './lib/compositions/chat-debug/chat-debug.component'; +export { ChatInterruptPanelComponent } from './lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component'; +export { ChatToolCallCardComponent } from './lib/compositions/chat-tool-call-card/chat-tool-call-card.component'; +export { ChatSubagentCardComponent } from './lib/compositions/chat-subagent-card/chat-subagent-card.component'; + +// Debug sub-components (for custom debug layouts) +export { DebugTimelineComponent } from './lib/compositions/chat-debug/debug-timeline.component'; +export { DebugCheckpointCardComponent } from './lib/compositions/chat-debug/debug-checkpoint-card.component'; +export { DebugDetailComponent } from './lib/compositions/chat-debug/debug-detail.component'; +export { DebugStateInspectorComponent } from './lib/compositions/chat-debug/debug-state-inspector.component'; +export { DebugStateDiffComponent } from './lib/compositions/chat-debug/debug-state-diff.component'; + +// Debug Tier 2 (deferred — implement after MVP): +// DebugToolCallDetail, DebugLatencyBar, DebugControls, DebugSummary + +// Test utilities +export { createMockStreamResourceRef } from './lib/testing/mock-stream-resource-ref'; +``` + +- [ ] **Step 2: Run all tests** + +Run: `npx nx test chat` + +- [ ] **Step 3: Run lint** + +Run: `npx nx lint chat` + +- [ ] **Step 4: Run build** + +Run: `npx nx build chat` + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/ +git commit -m "feat(chat): finalize public API and verify build" +``` + +--- + +## Summary + +| Task | Description | Components | +|------|-------------|------------| +| 1 | Scaffold Nx library | Config files | +| 2 | Types + test utilities | chat.types, mock ref | +| 3 | ChatMessages + messageTemplate | 2 primitives | +| 4 | ChatInput | 1 primitive | +| 5 | ChatTypingIndicator + ChatError | 2 primitives | +| 6 | ChatInterrupt | 1 primitive | +| 7 | ChatToolCalls + ChatSubagents | 2 primitives | +| 8 | ChatThreadList | 1 primitive | +| 9 | ChatTimeline + ChatGenerativeUi | 2 primitives | +| 10 | provideChat | 1 provider | +| 11 | `` composition | 1 composition | +| 12 | InterruptPanel + ToolCallCard + SubagentCard | 3 compositions | +| 13 | `` (Tier 1 MVP) | 6 debug components | +| 14 | Public API + build | Final verification | diff --git a/docs/superpowers/plans/2026-04-04-cacheplane-render.md b/docs/superpowers/plans/2026-04-04-cacheplane-render.md new file mode 100644 index 000000000..fe8280b88 --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-cacheplane-render.md @@ -0,0 +1,1933 @@ +# @cacheplane/render 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:** Build an Angular rendering layer for `@json-render/core` specs, providing the same capability as `@json-render/react` but using Angular standalone components, signals, and `ngTemplateOutlet` recursion. + +**Architecture:** Peer dependency on `@json-render/core` for types, prop resolution, visibility evaluation, and streaming. The Angular layer provides: `RenderSpecComponent` (top-level entry), `RenderElementComponent` (recursive renderer), `signalStateStore()` (Signal-based StateStore), `defineAngularRegistry()` (component mapping), and `provideRender()` (DI configuration). + +**Tech Stack:** Angular 21+, `@json-render/core`, Nx 22, ng-packagr, Vitest, TypeScript 5.9 + +**Spec:** `docs/superpowers/specs/2026-04-04-chat-component-library-design.md` — Deliverable 1 + +--- + +## File Structure + +``` +libs/render/ +├── src/ +│ ├── lib/ +│ │ ├── render.types.ts # Angular-specific types (AngularComponentRenderer, AngularRegistry) +│ │ ├── define-angular-registry.ts # defineAngularRegistry() factory +│ │ ├── define-angular-registry.spec.ts +│ │ ├── signal-state-store.ts # signalStateStore() — Signal-backed StateStore +│ │ ├── signal-state-store.spec.ts +│ │ ├── provide-render.ts # provideRender() DI provider +│ │ ├── provide-render.spec.ts +│ │ ├── render-spec.component.ts # top-level component +│ │ ├── render-spec.component.spec.ts +│ │ ├── render-element.component.ts # recursive renderer +│ │ ├── render-element.component.spec.ts +│ │ ├── contexts/ +│ │ │ ├── repeat-scope.ts # RepeatScope injection token + context +│ │ │ └── render-context.ts # RenderContext injection token (registry, store, functions) +│ │ └── internals/ +│ │ ├── prop-signal.ts # Reactive prop resolution via computed signals +│ │ └── prop-signal.spec.ts +│ ├── public-api.ts +│ └── test-setup.ts +├── project.json +├── package.json +├── ng-package.json +├── tsconfig.json +├── tsconfig.lib.json +├── tsconfig.lib.prod.json +├── vite.config.mts +├── eslint.config.mjs +└── README.md +``` + +--- + +### Task 1: Scaffold the Nx Library + +**Files:** +- Create: `libs/render/project.json` +- Create: `libs/render/package.json` +- Create: `libs/render/ng-package.json` +- Create: `libs/render/tsconfig.json` +- Create: `libs/render/tsconfig.lib.json` +- Create: `libs/render/tsconfig.lib.prod.json` +- Create: `libs/render/vite.config.mts` +- Create: `libs/render/eslint.config.mjs` +- Create: `libs/render/src/public-api.ts` +- Create: `libs/render/src/test-setup.ts` +- Modify: `tsconfig.base.json` (add path alias) + +- [ ] **Step 1: Generate the library with Nx** + +Run: +```bash +npx nx generate @nx/angular:library render --directory=libs/render --publishable --importPath=@cacheplane/render --prefix=render --standalone --skipModule --no-interactive +``` + +Expected: Nx scaffolds `libs/render/` with Angular library boilerplate. + +- [ ] **Step 2: Update `libs/render/package.json` with peer deps and license** + +Replace the generated package.json content: + +```json +{ + "name": "@cacheplane/render", + "version": "0.0.1", + "peerDependencies": { + "@angular/core": "^20.0.0 || ^21.0.0", + "@angular/common": "^20.0.0 || ^21.0.0", + "@json-render/core": "^0.1.0" + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} +``` + +- [ ] **Step 3: Install `@json-render/core` as a devDependency in the root** + +Run: +```bash +npm install --save-dev @json-render/core +``` + +Expected: Package added to root `package.json` devDependencies. + +- [ ] **Step 4: Update `libs/render/project.json` to use Vitest** + +Ensure the test target uses `@nx/vite:test`: + +```json +{ + "name": "render", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/render/src", + "projectType": "library", + "prefix": "render", + "targets": { + "build": { + "executor": "@nx/angular:package", + "outputs": ["{workspaceRoot}/dist/{projectRoot}"], + "options": { + "project": "libs/render/ng-package.json", + "tsConfig": "libs/render/tsconfig.lib.json" + }, + "configurations": { + "production": { + "tsConfig": "libs/render/tsconfig.lib.prod.json" + }, + "development": {} + }, + "defaultConfiguration": "production" + }, + "test": { + "executor": "@nx/vite:test", + "options": { + "configFile": "libs/render/vite.config.mts" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}" + } + } + }, + "release": { + "version": { + "generatorOptions": { + "packageRoot": "libs/{projectName}", + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + } + } +} +``` + +- [ ] **Step 5: Create `libs/render/vite.config.mts`** + +```typescript +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + test: { + globals: true, + environment: 'jsdom', + include: ['src/**/*.spec.ts'], + setupFiles: ['src/test-setup.ts'], + }, +}); +``` + +- [ ] **Step 6: Create `libs/render/src/test-setup.ts`** + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +``` + +- [ ] **Step 7: Create initial `libs/render/src/public-api.ts`** + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +// Public API — populated as components are built +``` + +- [ ] **Step 8: Verify path alias in `tsconfig.base.json`** + +Ensure the paths section includes: +```json +"@cacheplane/render": ["libs/render/src/public-api.ts"] +``` + +- [ ] **Step 9: Verify the library builds** + +Run: +```bash +npx nx build render +``` + +Expected: Build succeeds with empty library. + +- [ ] **Step 10: Verify tests run** + +Run: +```bash +npx nx test render +``` + +Expected: Test suite runs (0 tests, no failures). + +- [ ] **Step 11: Commit** + +```bash +git add libs/render/ tsconfig.base.json package.json package-lock.json +git commit -m "chore: scaffold @cacheplane/render library" +``` + +--- + +### Task 2: Types and Angular Registry + +**Files:** +- Create: `libs/render/src/lib/render.types.ts` +- Create: `libs/render/src/lib/define-angular-registry.ts` +- Create: `libs/render/src/lib/define-angular-registry.spec.ts` +- Modify: `libs/render/src/public-api.ts` + +- [ ] **Step 1: Write the failing test for defineAngularRegistry** + +Create `libs/render/src/lib/define-angular-registry.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { Component } from '@angular/core'; +import { defineAngularRegistry } from './define-angular-registry'; + +@Component({ selector: 'test-card', standalone: true, template: '
card
' }) +class TestCardComponent {} + +@Component({ selector: 'test-button', standalone: true, template: '' }) +class TestButtonComponent {} + +describe('defineAngularRegistry', () => { + it('should create a registry mapping component names to Angular components', () => { + const registry = defineAngularRegistry({ + Card: TestCardComponent, + Button: TestButtonComponent, + }); + + expect(registry.get('Card')).toBe(TestCardComponent); + expect(registry.get('Button')).toBe(TestButtonComponent); + }); + + it('should return undefined for unregistered component names', () => { + const registry = defineAngularRegistry({ + Card: TestCardComponent, + }); + + expect(registry.get('Unknown')).toBeUndefined(); + }); + + it('should return all registered component names', () => { + const registry = defineAngularRegistry({ + Card: TestCardComponent, + Button: TestButtonComponent, + }); + + expect(registry.names()).toEqual(['Card', 'Button']); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test render` + +Expected: FAIL — `defineAngularRegistry` not found. + +- [ ] **Step 3: Create types file** + +Create `libs/render/src/lib/render.types.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Type } from '@angular/core'; +import type { Spec, StateStore, UIElement, ComputedFunction } from '@json-render/core'; + +/** + * Props passed to every Angular component rendered by the engine. + * Mirrors @json-render/react's ComponentRenderProps. + */ +export interface AngularComponentInputs { + /** Resolved props from the UIElement (dynamic expressions already evaluated) */ + props: Record; + /** Two-way binding paths: prop name → absolute state path */ + bindings?: Record; + /** Emit a named event (resolved to action bindings from the element's `on` field) */ + emit: (event: string) => void; + /** Whether the spec is currently streaming/loading */ + loading?: boolean; +} + +/** + * An Angular standalone component class that can be rendered by the engine. + */ +export type AngularComponentRenderer = Type; + +/** + * Registry mapping json-render catalog component names to Angular component classes. + */ +export interface AngularRegistry { + /** Look up an Angular component by catalog name */ + get(name: string): AngularComponentRenderer | undefined; + /** List all registered component names */ + names(): string[]; +} + +/** + * Configuration for provideRender(). + */ +export interface RenderConfig { + /** Default registry for all instances */ + registry?: AngularRegistry; + /** Default state store */ + store?: StateStore; + /** Named functions for $computed expressions */ + functions?: Record; + /** Action handlers for event bindings */ + handlers?: Record) => unknown | Promise>; +} +``` + +- [ ] **Step 4: Implement defineAngularRegistry** + +Create `libs/render/src/lib/define-angular-registry.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { AngularComponentRenderer, AngularRegistry } from './render.types'; + +/** + * Create a registry mapping json-render catalog component names + * to Angular standalone component classes. + * + * @example + * ```typescript + * const registry = defineAngularRegistry({ + * Card: CardComponent, + * Button: ButtonComponent, + * }); + * ``` + */ +export function defineAngularRegistry( + componentMap: Record, +): AngularRegistry { + const map = new Map(Object.entries(componentMap)); + + return { + get: (name: string) => map.get(name), + names: () => [...map.keys()], + }; +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx nx test render` + +Expected: 3 tests PASS. + +- [ ] **Step 6: Export from public-api.ts** + +Update `libs/render/src/public-api.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +// Types +export type { + AngularComponentInputs, + AngularComponentRenderer, + AngularRegistry, + RenderConfig, +} from './lib/render.types'; + +// Registry +export { defineAngularRegistry } from './lib/define-angular-registry'; +``` + +- [ ] **Step 7: Commit** + +```bash +git add libs/render/src/ +git commit -m "feat(render): add types and defineAngularRegistry" +``` + +--- + +### Task 3: Signal State Store + +**Files:** +- Create: `libs/render/src/lib/signal-state-store.ts` +- Create: `libs/render/src/lib/signal-state-store.spec.ts` +- Modify: `libs/render/src/public-api.ts` + +- [ ] **Step 1: Write failing tests** + +Create `libs/render/src/lib/signal-state-store.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { signalStateStore } from './signal-state-store'; + +describe('signalStateStore', () => { + it('should implement StateStore interface with get/set', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ name: 'test', count: 0 }); + + expect(store.get('/name')).toBe('test'); + expect(store.get('/count')).toBe(0); + + store.set('/count', 5); + expect(store.get('/count')).toBe(5); + }); + }); + + it('should return full state snapshot via getSnapshot', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ a: 1, b: 2 }); + expect(store.getSnapshot()).toEqual({ a: 1, b: 2 }); + }); + }); + + it('should batch updates via update()', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ x: 0, y: 0 }); + store.update({ '/x': 10, '/y': 20 }); + expect(store.get('/x')).toBe(10); + expect(store.get('/y')).toBe(20); + }); + }); + + it('should notify subscribers on state change', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ val: 'a' }); + const listener = vi.fn(); + + const unsub = store.subscribe(listener); + store.set('/val', 'b'); + + expect(listener).toHaveBeenCalled(); + unsub(); + }); + }); + + it('should handle nested paths', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ user: { name: 'Alice', age: 30 } }); + + expect(store.get('/user/name')).toBe('Alice'); + store.set('/user/name', 'Bob'); + expect(store.get('/user/name')).toBe('Bob'); + expect(store.getSnapshot()).toEqual({ user: { name: 'Bob', age: 30 } }); + }); + }); + + it('should handle array paths', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ items: ['a', 'b', 'c'] }); + + expect(store.get('/items/0')).toBe('a'); + store.set('/items/1', 'B'); + expect(store.get('/items/1')).toBe('B'); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx nx test render` + +Expected: FAIL — `signalStateStore` not found. + +- [ ] **Step 3: Implement signalStateStore** + +Create `libs/render/src/lib/signal-state-store.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { signal } from '@angular/core'; +import type { StateStore, StateModel } from '@json-render/core'; + +/** + * Parse a JSON Pointer path (RFC 6901) into segments. + * "/user/name" → ["user", "name"] + */ +function parsePointer(path: string): string[] { + if (!path || path === '/') return []; + return path + .split('/') + .filter((_, i) => i > 0) + .map(s => s.replace(/~1/g, '/').replace(/~0/g, '~')); +} + +/** + * Read a value from a nested object by path segments. + */ +function getByPath(obj: unknown, segments: string[]): unknown { + let current: unknown = obj; + for (const seg of segments) { + if (current == null || typeof current !== 'object') return undefined; + current = (current as Record)[seg]; + } + return current; +} + +/** + * Immutably set a value in a nested object by path segments. + * Returns a new root object with the updated value. + */ +function setByPath(obj: Record, segments: string[], value: unknown): Record { + if (segments.length === 0) return obj; + + const [head, ...rest] = segments; + const current = obj[head]; + + if (rest.length === 0) { + return { ...obj, [head]: value }; + } + + const child = (current != null && typeof current === 'object') + ? (Array.isArray(current) ? [...current] : { ...current as Record }) + : {}; + + return { ...obj, [head]: setByPath(child as Record, rest, value) }; +} + +/** + * Create an Angular Signal-backed StateStore compatible with @json-render/core. + * + * Uses JSON Pointer paths (RFC 6901) for all state access. + * Immutable updates — every set/update creates a new state object. + * + * Must be called in an Angular injection context. + */ +export function signalStateStore(initialState: StateModel = {}): StateStore { + const state = signal(initialState); + const listeners = new Set<() => void>(); + + function notify(): void { + for (const listener of listeners) { + listener(); + } + } + + return { + get(path: string): unknown { + const segments = parsePointer(path); + return getByPath(state(), segments); + }, + + set(path: string, value: unknown): void { + const segments = parsePointer(path); + const current = getByPath(state(), segments); + if (current === value) return; + + state.set(setByPath(state(), segments, value)); + notify(); + }, + + update(updates: Record): void { + let current = state(); + let changed = false; + + for (const [path, value] of Object.entries(updates)) { + const segments = parsePointer(path); + const existing = getByPath(current, segments); + if (existing !== value) { + current = setByPath(current, segments, value); + changed = true; + } + } + + if (changed) { + state.set(current); + notify(); + } + }, + + getSnapshot(): StateModel { + return state(); + }, + + subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test render` + +Expected: All tests PASS. + +- [ ] **Step 5: Add to public-api.ts** + +Add to `libs/render/src/public-api.ts`: + +```typescript +// State +export { signalStateStore } from './lib/signal-state-store'; +``` + +- [ ] **Step 6: Commit** + +```bash +git add libs/render/src/ +git commit -m "feat(render): add signalStateStore backed by Angular signals" +``` + +--- + +### Task 4: DI Context Tokens + +**Files:** +- Create: `libs/render/src/lib/contexts/render-context.ts` +- Create: `libs/render/src/lib/contexts/repeat-scope.ts` + +- [ ] **Step 1: Create RenderContext injection token** + +Create `libs/render/src/lib/contexts/render-context.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { InjectionToken } from '@angular/core'; +import type { StateStore, ComputedFunction } from '@json-render/core'; +import type { AngularRegistry } from '../render.types'; + +/** + * Contextual data provided to all render-element instances via DI. + * Set by RenderSpecComponent at the top level. + */ +export interface RenderContext { + registry: AngularRegistry; + store: StateStore; + functions?: Record; + handlers?: Record) => unknown | Promise>; + loading?: boolean; +} + +export const RENDER_CONTEXT = new InjectionToken('RENDER_CONTEXT'); +``` + +- [ ] **Step 2: Create RepeatScope injection token** + +Create `libs/render/src/lib/contexts/repeat-scope.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { InjectionToken } from '@angular/core'; + +/** + * Repeat scope context provided when rendering inside a repeat element. + * Each iteration gets its own RepeatScope via DI. + */ +export interface RepeatScope { + /** The current array item */ + item: unknown; + /** The current array index */ + index: number; + /** Absolute state path to the current item (e.g. "/todos/0") */ + basePath: string; +} + +export const REPEAT_SCOPE = new InjectionToken('REPEAT_SCOPE'); +``` + +- [ ] **Step 3: Commit** + +```bash +git add libs/render/src/lib/contexts/ +git commit -m "feat(render): add DI tokens for RenderContext and RepeatScope" +``` + +--- + +### Task 5: Reactive Prop Resolution + +**Files:** +- Create: `libs/render/src/lib/internals/prop-signal.ts` +- Create: `libs/render/src/lib/internals/prop-signal.spec.ts` + +- [ ] **Step 1: Write failing tests** + +Create `libs/render/src/lib/internals/prop-signal.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { computed } from '@angular/core'; +import { createStateStore } from '@json-render/core'; +import { buildPropResolutionContext } from './prop-signal'; + +describe('buildPropResolutionContext', () => { + it('should build context from store snapshot', () => { + TestBed.runInInjectionContext(() => { + const store = createStateStore({ name: 'test' }); + const ctx = buildPropResolutionContext(store); + + expect(ctx.stateModel).toEqual({ name: 'test' }); + }); + }); + + it('should include repeat scope when provided', () => { + TestBed.runInInjectionContext(() => { + const store = createStateStore({ items: ['a', 'b'] }); + const repeatScope = { item: 'a', index: 0, basePath: '/items/0' }; + const ctx = buildPropResolutionContext(store, repeatScope); + + expect(ctx.repeatItem).toBe('a'); + expect(ctx.repeatIndex).toBe(0); + expect(ctx.repeatBasePath).toBe('/items/0'); + }); + }); + + it('should include functions when provided', () => { + TestBed.runInInjectionContext(() => { + const store = createStateStore({}); + const fns = { upper: (args: Record) => String(args['text']).toUpperCase() }; + const ctx = buildPropResolutionContext(store, undefined, fns); + + expect(ctx.functions).toBe(fns); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx nx test render` + +Expected: FAIL — `buildPropResolutionContext` not found. + +- [ ] **Step 3: Implement buildPropResolutionContext** + +Create `libs/render/src/lib/internals/prop-signal.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { StateStore, ComputedFunction } from '@json-render/core'; +import type { PropResolutionContext } from '@json-render/core'; +import type { RepeatScope } from '../contexts/repeat-scope'; + +/** + * Build a PropResolutionContext from the current store state and optional repeat scope. + * This context is passed to resolveElementProps() and resolveBindings(). + */ +export function buildPropResolutionContext( + store: StateStore, + repeatScope?: RepeatScope, + functions?: Record, +): PropResolutionContext { + const ctx: PropResolutionContext = { + stateModel: store.getSnapshot(), + }; + + if (repeatScope) { + ctx.repeatItem = repeatScope.item; + ctx.repeatIndex = repeatScope.index; + ctx.repeatBasePath = repeatScope.basePath; + } + + if (functions) { + ctx.functions = functions; + } + + return ctx; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test render` + +Expected: All tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/render/src/lib/internals/ +git commit -m "feat(render): add prop resolution context builder" +``` + +--- + +### Task 6: provideRender DI Provider + +**Files:** +- Create: `libs/render/src/lib/provide-render.ts` +- Create: `libs/render/src/lib/provide-render.spec.ts` +- Modify: `libs/render/src/public-api.ts` + +- [ ] **Step 1: Write failing tests** + +Create `libs/render/src/lib/provide-render.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { provideRender, RENDER_CONFIG } from './provide-render'; +import { defineAngularRegistry } from './define-angular-registry'; +import type { RenderConfig } from './render.types'; + +@Component({ selector: 'test-card', standalone: true, template: '
card
' }) +class TestCardComponent {} + +describe('provideRender', () => { + it('should provide RenderConfig via injection token', () => { + const registry = defineAngularRegistry({ Card: TestCardComponent }); + const config: RenderConfig = { registry }; + + TestBed.configureTestingModule({ + providers: [provideRender(config)], + }); + + const injectedConfig = TestBed.inject(RENDER_CONFIG); + expect(injectedConfig.registry).toBe(registry); + }); + + it('should allow injection without provider (returns undefined)', () => { + TestBed.configureTestingModule({}); + + const injectedConfig = TestBed.inject(RENDER_CONFIG, null); + expect(injectedConfig).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx nx test render` + +Expected: FAIL — `provideRender` not found. + +- [ ] **Step 3: Implement provideRender** + +Create `libs/render/src/lib/provide-render.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { InjectionToken, makeEnvironmentProviders } from '@angular/core'; +import type { RenderConfig } from './render.types'; + +/** + * Injection token for global render configuration. + * Optional — components also accept inputs directly. + */ +export const RENDER_CONFIG = new InjectionToken('RENDER_CONFIG'); + +/** + * Provide default render configuration for all instances. + * + * @example + * ```typescript + * bootstrapApplication(AppComponent, { + * providers: [ + * provideRender({ + * registry: defineAngularRegistry({ Card: CardComponent }), + * }), + * ], + * }); + * ``` + */ +export function provideRender(config: RenderConfig) { + return makeEnvironmentProviders([ + { provide: RENDER_CONFIG, useValue: config }, + ]); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test render` + +Expected: All tests PASS. + +- [ ] **Step 5: Add to public-api.ts** + +Add to `libs/render/src/public-api.ts`: + +```typescript +// Provider +export { provideRender, RENDER_CONFIG } from './lib/provide-render'; +``` + +- [ ] **Step 6: Commit** + +```bash +git add libs/render/src/ +git commit -m "feat(render): add provideRender DI provider" +``` + +--- + +### Task 7: RenderElementComponent (Recursive Renderer) + +**Files:** +- Create: `libs/render/src/lib/render-element.component.ts` +- Create: `libs/render/src/lib/render-element.component.spec.ts` +- Modify: `libs/render/src/public-api.ts` + +- [ ] **Step 1: Write failing tests** + +Create `libs/render/src/lib/render-element.component.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, input } from '@angular/core'; +import { createStateStore } from '@json-render/core'; +import type { Spec } from '@json-render/core'; +import { RenderElementComponent } from './render-element.component'; +import { RENDER_CONTEXT, type RenderContext } from './contexts/render-context'; +import { defineAngularRegistry } from './define-angular-registry'; + +@Component({ + selector: 'test-text', + standalone: true, + template: '{{ props().label }}', +}) +class TestTextComponent { + props = input.required>(); +} + +@Component({ + selector: 'test-container', + standalone: true, + template: '
', +}) +class TestContainerComponent { + props = input.required>(); +} + +function createContext(overrides?: Partial): RenderContext { + return { + registry: defineAngularRegistry({ Text: TestTextComponent, Container: TestContainerComponent }), + store: createStateStore({}), + ...overrides, + }; +} + +describe('RenderElementComponent', () => { + it('should render a simple element', async () => { + const spec: Spec = { + root: 'heading', + elements: { + heading: { type: 'Text', props: { label: 'Hello' } }, + }, + }; + + TestBed.configureTestingModule({ + imports: [RenderElementComponent], + providers: [{ provide: RENDER_CONTEXT, useValue: createContext() }], + }); + + const fixture = TestBed.createComponent(RenderElementComponent); + fixture.componentRef.setInput('elementKey', 'heading'); + fixture.componentRef.setInput('spec', spec); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Hello'); + }); + + it('should not render when element type is not in registry', async () => { + const spec: Spec = { + root: 'unknown', + elements: { + unknown: { type: 'NonExistent', props: {} }, + }, + }; + + TestBed.configureTestingModule({ + imports: [RenderElementComponent], + providers: [{ provide: RENDER_CONTEXT, useValue: createContext() }], + }); + + const fixture = TestBed.createComponent(RenderElementComponent); + fixture.componentRef.setInput('elementKey', 'unknown'); + fixture.componentRef.setInput('spec', spec); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent.trim()).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx nx test render` + +Expected: FAIL — `RenderElementComponent` not found. + +- [ ] **Step 3: Implement RenderElementComponent** + +Create `libs/render/src/lib/render-element.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + inject, + input, + ChangeDetectionStrategy, + Injector, + EnvironmentInjector, +} from '@angular/core'; +import { NgComponentOutlet, NgTemplateOutlet } from '@angular/common'; +import { resolveElementProps, resolveBindings, evaluateVisibility } from '@json-render/core'; +import type { Spec, UIElement } from '@json-render/core'; +import { RENDER_CONTEXT } from './contexts/render-context'; +import { REPEAT_SCOPE, type RepeatScope } from './contexts/repeat-scope'; +import { buildPropResolutionContext } from './internals/prop-signal'; + +@Component({ + selector: 'render-element', + standalone: true, + imports: [NgComponentOutlet, NgTemplateOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (isVisible()) { + @if (componentClass()) { + @if (repeat()) { + @for (item of repeatItems(); track $index) { + + + } + } @else { + + + } + } + } + `, +}) +export class RenderElementComponent { + readonly elementKey = input.required(); + readonly spec = input.required(); + + private readonly ctx = inject(RENDER_CONTEXT); + private readonly repeatScope = inject(REPEAT_SCOPE, { optional: true }); + private readonly injector = inject(Injector); + private readonly envInjector = inject(EnvironmentInjector); + + protected readonly element = computed(() => { + return this.spec().elements[this.elementKey()]; + }); + + protected readonly componentClass = computed(() => { + const el = this.element(); + if (!el) return undefined; + return this.ctx.registry.get(el.type); + }); + + protected readonly repeat = computed(() => { + return this.element()?.repeat; + }); + + protected readonly repeatItems = computed(() => { + const rep = this.repeat(); + if (!rep) return []; + const items = this.ctx.store.get(rep.statePath); + return Array.isArray(items) ? items : []; + }); + + protected readonly isVisible = computed(() => { + const el = this.element(); + if (!el || el.visible === undefined) return true; + return evaluateVisibility(el.visible, { + stateModel: this.ctx.store.getSnapshot(), + repeatItem: this.repeatScope?.item, + repeatIndex: this.repeatScope?.index, + }); + }); + + protected readonly resolvedInputs = computed(() => { + const el = this.element(); + if (!el) return {}; + const propCtx = buildPropResolutionContext( + this.ctx.store, + this.repeatScope ?? undefined, + this.ctx.functions, + ); + const resolvedProps = resolveElementProps(el.props, propCtx); + const bindings = resolveBindings(el.props, propCtx); + + return { + props: resolvedProps, + bindings: bindings ?? undefined, + emit: this.createEmitFn(el), + loading: this.ctx.loading ?? false, + }; + }); + + protected resolvedInputsForRepeatItem(item: unknown, index: number) { + const el = this.element(); + if (!el) return {}; + const rep = this.repeat()!; + const scope: RepeatScope = { + item, + index, + basePath: `${rep.statePath}/${index}`, + }; + const propCtx = buildPropResolutionContext(this.ctx.store, scope, this.ctx.functions); + const resolvedProps = resolveElementProps(el.props, propCtx); + const bindings = resolveBindings(el.props, propCtx); + + return { + props: resolvedProps, + bindings: bindings ?? undefined, + emit: this.createEmitFn(el), + loading: this.ctx.loading ?? false, + }; + } + + protected repeatInjector(item: unknown, index: number): Injector { + const rep = this.repeat()!; + return Injector.create({ + providers: [ + { + provide: REPEAT_SCOPE, + useValue: { item, index, basePath: `${rep.statePath}/${index}` } satisfies RepeatScope, + }, + ], + parent: this.injector, + }); + } + + private createEmitFn(el: UIElement): (event: string) => void { + return (event: string) => { + const bindings = el.on?.[event]; + if (!bindings) return; + + const bindingArray = Array.isArray(bindings) ? bindings : [bindings]; + for (const binding of bindingArray) { + const handler = this.ctx.handlers?.[binding.action]; + if (handler) { + handler(binding.params ?? {}); + } + } + }; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test render` + +Expected: All tests PASS. + +- [ ] **Step 5: Add to public-api.ts** + +Add to `libs/render/src/public-api.ts`: + +```typescript +// Components +export { RenderElementComponent } from './lib/render-element.component'; +``` + +- [ ] **Step 6: Commit** + +```bash +git add libs/render/src/ +git commit -m "feat(render): add RenderElementComponent with recursive rendering" +``` + +--- + +### Task 8: RenderSpecComponent (Top-Level Entry) + +**Files:** +- Create: `libs/render/src/lib/render-spec.component.ts` +- Create: `libs/render/src/lib/render-spec.component.spec.ts` +- Modify: `libs/render/src/public-api.ts` + +- [ ] **Step 1: Write failing tests** + +Create `libs/render/src/lib/render-spec.component.spec.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, input } from '@angular/core'; +import type { Spec } from '@json-render/core'; +import { RenderSpecComponent } from './render-spec.component'; +import { defineAngularRegistry } from './define-angular-registry'; + +@Component({ + selector: 'test-heading', + standalone: true, + template: '

{{ props().text }}

', +}) +class TestHeadingComponent { + props = input.required>(); +} + +@Component({ + selector: 'test-paragraph', + standalone: true, + template: '

{{ props().text }}

', +}) +class TestParagraphComponent { + props = input.required>(); +} + +describe('RenderSpecComponent', () => { + const registry = defineAngularRegistry({ + Heading: TestHeadingComponent, + Paragraph: TestParagraphComponent, + }); + + it('should render a spec with a single root element', () => { + const spec: Spec = { + root: 'h1', + elements: { + h1: { type: 'Heading', props: { text: 'Hello World' } }, + }, + }; + + TestBed.configureTestingModule({ + imports: [RenderSpecComponent], + }); + + const fixture = TestBed.createComponent(RenderSpecComponent); + fixture.componentRef.setInput('spec', spec); + fixture.componentRef.setInput('registry', registry); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Hello World'); + }); + + it('should render null spec as empty', () => { + TestBed.configureTestingModule({ + imports: [RenderSpecComponent], + }); + + const fixture = TestBed.createComponent(RenderSpecComponent); + fixture.componentRef.setInput('spec', null); + fixture.componentRef.setInput('registry', registry); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent.trim()).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx nx test render` + +Expected: FAIL — `RenderSpecComponent` not found. + +- [ ] **Step 3: Implement RenderSpecComponent** + +Create `libs/render/src/lib/render-spec.component.ts`: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + effect, + inject, + input, + ChangeDetectionStrategy, + Injector, +} from '@angular/core'; +import { createStateStore } from '@json-render/core'; +import type { Spec, StateStore, ComputedFunction } from '@json-render/core'; +import { RenderElementComponent } from './render-element.component'; +import { RENDER_CONTEXT, type RenderContext } from './contexts/render-context'; +import { RENDER_CONFIG } from './provide-render'; +import { signalStateStore } from './signal-state-store'; +import type { AngularRegistry } from './render.types'; + +@Component({ + selector: 'render-spec', + standalone: true, + imports: [RenderElementComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [], + template: ` + @if (spec() && spec()!.root) { + + } + `, +}) +export class RenderSpecComponent { + /** The json-render spec to render. Accepts static or signal-updated specs for streaming. */ + readonly spec = input(null); + + /** Component registry mapping spec type names to Angular components. */ + readonly registry = input(); + + /** External state store. If not provided, creates an internal signalStateStore from spec.state. */ + readonly store = input(); + + /** Named functions for $computed prop expressions. */ + readonly functions = input>(); + + /** Action handlers for event bindings. */ + readonly handlers = input) => unknown | Promise>>(); + + /** Whether the spec is currently streaming/loading. */ + readonly loading = input(false); + + private readonly config = inject(RENDER_CONFIG, { optional: true }); + private readonly injector = inject(Injector); + + private internalStore: StateStore | undefined; + + protected readonly renderContext = computed(() => { + const registry = this.registry() ?? this.config?.registry; + if (!registry) return undefined; + + const store = this.store() ?? this.config?.store ?? this.getOrCreateStore(); + + return { + registry, + store, + functions: this.functions() ?? this.config?.functions, + handlers: this.handlers() ?? this.config?.handlers, + loading: this.loading(), + }; + }); + + private getOrCreateStore(): StateStore { + const specState = this.spec()?.state; + if (!this.internalStore) { + this.internalStore = createStateStore(specState ?? {}); + } + return this.internalStore; + } + + /** + * We provide RENDER_CONTEXT dynamically via viewProviders so that + * child RenderElementComponents can inject it. + */ + static ngComponentDef: unknown; +} +``` + +**Note:** The above needs adjustment — we need to provide RENDER_CONTEXT to children. Update the component to use `viewProviders`: + +Replace the template and add viewProviders logic. Actually, the cleaner approach is to wrap the child in an injector: + +Update `libs/render/src/lib/render-spec.component.ts` — replace the template: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + inject, + input, + ChangeDetectionStrategy, + Injector, + EnvironmentInjector, + createEnvironmentInjector, +} from '@angular/core'; +import { createStateStore } from '@json-render/core'; +import type { Spec, StateStore, ComputedFunction } from '@json-render/core'; +import { RenderElementComponent } from './render-element.component'; +import { RENDER_CONTEXT, type RenderContext } from './contexts/render-context'; +import { RENDER_CONFIG } from './provide-render'; +import type { AngularRegistry } from './render.types'; + +@Component({ + selector: 'render-spec', + standalone: true, + imports: [RenderElementComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (currentSpec(); as s) { + + } + `, +}) +export class RenderSpecComponent { + readonly spec = input(null); + readonly registry = input(); + readonly store = input(); + readonly functions = input>(); + readonly handlers = input) => unknown | Promise>>(); + readonly loading = input(false); + + private readonly config = inject(RENDER_CONFIG, { optional: true }); + private internalStore: StateStore | undefined; + + protected readonly currentSpec = computed(() => this.spec()); + + /** + * Provide RENDER_CONTEXT so all descendant RenderElementComponents + * can inject it. Uses a factory so it stays reactive. + */ + static { + // Context is provided via the component's providers array below + } + + private getOrCreateStore(): StateStore { + if (!this.internalStore) { + this.internalStore = createStateStore(this.spec()?.state ?? {}); + } + return this.internalStore; + } + + /** @internal — used by the providers factory */ + _buildContext(): RenderContext { + const registry = this.registry() ?? this.config?.registry; + if (!registry) { + throw new Error( + 'RenderSpecComponent: No registry provided. Pass [registry] input or use provideRender().', + ); + } + return { + registry, + store: this.store() ?? this.config?.store ?? this.getOrCreateStore(), + functions: this.functions() ?? this.config?.functions, + handlers: this.handlers() ?? this.config?.handlers, + loading: this.loading(), + }; + } +} + +// We provide RENDER_CONTEXT at the component level with a factory +// that reads from the component instance. +RenderSpecComponent = Component({ + selector: 'render-spec', + standalone: true, + imports: [RenderElementComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + viewProviders: [ + { + provide: RENDER_CONTEXT, + useFactory: () => { + // This will be resolved per-instance via the component + // We handle this via an alternative pattern in the actual implementation + }, + }, + ], + template: ` + @if (currentSpec(); as s) { + + } + `, +})(RenderSpecComponent) as any; +``` + +**Actually**, the cleanest Angular 20+ pattern is to provide the context via the component's `providers` or `viewProviders` using `useExisting` with the component itself acting as the context. Let me simplify: + +Create `libs/render/src/lib/render-spec.component.ts` (final version): + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + inject, + input, + ChangeDetectionStrategy, +} from '@angular/core'; +import { createStateStore } from '@json-render/core'; +import type { Spec, StateStore, ComputedFunction } from '@json-render/core'; +import { RenderElementComponent } from './render-element.component'; +import { RENDER_CONTEXT, type RenderContext } from './contexts/render-context'; +import { RENDER_CONFIG } from './provide-render'; +import type { AngularRegistry } from './render.types'; + +@Component({ + selector: 'render-spec', + standalone: true, + imports: [RenderElementComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + viewProviders: [ + { + provide: RENDER_CONTEXT, + useFactory: () => inject(RenderSpecComponent)._context(), + }, + ], + template: ` + @if (currentSpec(); as s) { + + } + `, +}) +export class RenderSpecComponent { + readonly spec = input(null); + readonly registry = input(); + readonly store = input(); + readonly functions = input>(); + readonly handlers = input) => unknown | Promise>>(); + readonly loading = input(false); + + private readonly config = inject(RENDER_CONFIG, { optional: true }); + private internalStore: StateStore | undefined; + + protected readonly currentSpec = computed(() => this.spec()); + + /** @internal */ + readonly _context = computed(() => { + const registry = this.registry() ?? this.config?.registry; + if (!registry) { + throw new Error('RenderSpecComponent: No registry provided. Pass [registry] input or use provideRender().'); + } + return { + registry, + store: this.store() ?? this.config?.store ?? this.getOrCreateStore(), + functions: this.functions() ?? this.config?.functions, + handlers: this.handlers() ?? this.config?.handlers, + loading: this.loading(), + }; + }); + + private getOrCreateStore(): StateStore { + if (!this.internalStore) { + this.internalStore = createStateStore(this.spec()?.state ?? {}); + } + return this.internalStore; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx nx test render` + +Expected: All tests PASS. + +- [ ] **Step 5: Add to public-api.ts** + +Add to `libs/render/src/public-api.ts`: + +```typescript +export { RenderSpecComponent } from './lib/render-spec.component'; +``` + +- [ ] **Step 6: Verify the library builds** + +Run: `npx nx build render` + +Expected: Build succeeds. + +- [ ] **Step 7: Commit** + +```bash +git add libs/render/src/ +git commit -m "feat(render): add RenderSpecComponent top-level entry point" +``` + +--- + +### Task 9: Children Rendering (Recursive ngTemplateOutlet) + +**Files:** +- Modify: `libs/render/src/lib/render-element.component.ts` +- Modify: `libs/render/src/lib/render-element.component.spec.ts` + +This task adds recursive child rendering — the core pattern from hashbrown. + +- [ ] **Step 1: Add failing test for children rendering** + +Add to `libs/render/src/lib/render-element.component.spec.ts`: + +```typescript +it('should recursively render children', () => { + const spec: Spec = { + root: 'wrapper', + elements: { + wrapper: { type: 'Container', props: {}, children: ['child1', 'child2'] }, + child1: { type: 'Text', props: { label: 'First' } }, + child2: { type: 'Text', props: { label: 'Second' } }, + }, + }; + + TestBed.configureTestingModule({ + imports: [RenderElementComponent], + providers: [{ provide: RENDER_CONTEXT, useValue: createContext() }], + }); + + const fixture = TestBed.createComponent(RenderElementComponent); + fixture.componentRef.setInput('elementKey', 'wrapper'); + fixture.componentRef.setInput('spec', spec); + fixture.detectChanges(); + + const text = fixture.nativeElement.textContent; + expect(text).toContain('First'); + expect(text).toContain('Second'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test render` + +Expected: FAIL — children not rendered (Container's `` receives nothing). + +- [ ] **Step 3: Update RenderElementComponent to render children recursively** + +Update the template in `libs/render/src/lib/render-element.component.ts` to use `ngTemplateOutlet` recursion for children: + +The component needs to project child `render-element` instances into the parent component. Update the template to: + +```typescript +template: ` + + @if (resolveElement(key, spec); as resolved) { + @if (resolved.visible) { + + + } + } + + + @if (isVisible()) { + @if (componentClass(); as comp) { + @if (childKeys().length === 0) { + + } @else { + + } + } + } +`, +``` + +**Note:** Angular's `NgComponentOutlet` content projection with dynamic children is complex. The recommended pattern for recursive rendering is to have child `` instances rendered as siblings, and the parent component receives them via content projection. + +A simpler, more robust approach: render children as sibling `` instances after the parent, and let the parent component use `` to slot them in. Update the template: + +```typescript +template: ` + @if (isVisible() && componentClass(); as comp) { + + @for (childKey of childKeys(); track childKey) { + + } + + } +`, +``` + +**Note:** `NgComponentOutlet` doesn't support content projection via child elements in its body. The correct pattern is to use `ngProjectAs` or to have the rendered component use inputs instead of ``. + +For the MVP, the simplest correct approach: **children are rendered as a flat list after the parent, and components that need children accept a `children` input (an array of rendered content)**. This matches json-render's flat structure. Update the approach: + +Instead of content projection, rendered components receive their children as an input signal containing the child element keys. The component itself is responsible for rendering its children (or ignoring them). This is simpler and more correct for the json-render model. + +Update the inputs passed to rendered components to include `childKeys`: + +```typescript +protected readonly resolvedInputs = computed(() => { + const el = this.element(); + if (!el) return {}; + // ... existing prop resolution ... + return { + props: resolvedProps, + bindings: bindings ?? undefined, + emit: this.createEmitFn(el), + loading: this.ctx.loading ?? false, + childKeys: el.children ?? [], + spec: this.spec(), + }; +}); +``` + +And add `childKeys` to the `AngularComponentInputs` interface. Then each registered component can use `` to render its children: + +```typescript +@Component({ + selector: 'my-card', + standalone: true, + imports: [RenderElementComponent], + template: ` +
+ @for (key of childKeys(); track key) { + + } +
+ `, +}) +class CardComponent { + props = input.required>(); + childKeys = input([]); + spec = input.required(); +} +``` + +This is the cleanest pattern. Update the implementation accordingly. + +- [ ] **Step 4: Update render.types.ts to include childKeys and spec in inputs** + +Add to `AngularComponentInputs` in `libs/render/src/lib/render.types.ts`: + +```typescript +export interface AngularComponentInputs { + props: Record; + bindings?: Record; + emit: (event: string) => void; + loading?: boolean; + /** Child element keys for recursive rendering */ + childKeys: string[]; + /** The full spec (needed by children to resolve their elements) */ + spec: Spec; +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx nx test render` + +Expected: All tests PASS (test components updated to accept new inputs). + +- [ ] **Step 6: Commit** + +```bash +git add libs/render/src/ +git commit -m "feat(render): add recursive children rendering via childKeys input" +``` + +--- + +### Task 10: Visibility and State Integration Tests + +**Files:** +- Modify: `libs/render/src/lib/render-spec.component.spec.ts` + +- [ ] **Step 1: Add integration tests for visibility conditions** + +Add to `libs/render/src/lib/render-spec.component.spec.ts`: + +```typescript +it('should hide elements when visible condition is false', () => { + const spec: Spec = { + root: 'heading', + elements: { + heading: { + type: 'Heading', + props: { text: 'Hidden' }, + visible: { $state: '/show', eq: true }, + }, + }, + state: { show: false }, + }; + + TestBed.configureTestingModule({ imports: [RenderSpecComponent] }); + const fixture = TestBed.createComponent(RenderSpecComponent); + fixture.componentRef.setInput('spec', spec); + fixture.componentRef.setInput('registry', registry); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent.trim()).toBe(''); +}); + +it('should show elements when visible condition is true', () => { + const spec: Spec = { + root: 'heading', + elements: { + heading: { + type: 'Heading', + props: { text: 'Visible' }, + visible: { $state: '/show', eq: true }, + }, + }, + state: { show: true }, + }; + + TestBed.configureTestingModule({ imports: [RenderSpecComponent] }); + const fixture = TestBed.createComponent(RenderSpecComponent); + fixture.componentRef.setInput('spec', spec); + fixture.componentRef.setInput('registry', registry); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Visible'); +}); + +it('should resolve $state prop expressions', () => { + const store = createStateStore({ title: 'Dynamic Title' }); + const spec: Spec = { + root: 'heading', + elements: { + heading: { type: 'Heading', props: { text: { $state: '/title' } } }, + }, + }; + + TestBed.configureTestingModule({ imports: [RenderSpecComponent] }); + const fixture = TestBed.createComponent(RenderSpecComponent); + fixture.componentRef.setInput('spec', spec); + fixture.componentRef.setInput('registry', registry); + fixture.componentRef.setInput('store', store); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Dynamic Title'); +}); +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `npx nx test render` + +Expected: All tests PASS. + +- [ ] **Step 3: Commit** + +```bash +git add libs/render/src/ +git commit -m "test(render): add integration tests for visibility and state resolution" +``` + +--- + +### Task 11: Full Build Verification and Final Export + +**Files:** +- Modify: `libs/render/src/public-api.ts` + +- [ ] **Step 1: Finalize public-api.ts** + +Ensure `libs/render/src/public-api.ts` contains all exports: + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +// Types +export type { + AngularComponentInputs, + AngularComponentRenderer, + AngularRegistry, + RenderConfig, +} from './lib/render.types'; + +// Registry +export { defineAngularRegistry } from './lib/define-angular-registry'; + +// State +export { signalStateStore } from './lib/signal-state-store'; + +// Provider +export { provideRender, RENDER_CONFIG } from './lib/provide-render'; + +// Components +export { RenderSpecComponent } from './lib/render-spec.component'; +export { RenderElementComponent } from './lib/render-element.component'; + +// Contexts (for advanced use / custom renderers) +export { RENDER_CONTEXT } from './lib/contexts/render-context'; +export type { RenderContext } from './lib/contexts/render-context'; +export { REPEAT_SCOPE } from './lib/contexts/repeat-scope'; +export type { RepeatScope } from './lib/contexts/repeat-scope'; +``` + +- [ ] **Step 2: Run all tests** + +Run: `npx nx test render` + +Expected: All tests PASS. + +- [ ] **Step 3: Run lint** + +Run: `npx nx lint render` + +Expected: No errors. + +- [ ] **Step 4: Run build** + +Run: `npx nx build render` + +Expected: Build succeeds, output in `dist/libs/render/`. + +- [ ] **Step 5: Commit** + +```bash +git add libs/render/ +git commit -m "feat(render): finalize public API and verify build" +``` + +--- + +## Summary + +| Task | Description | Tests | +|------|-------------|-------| +| 1 | Scaffold Nx library | Build + test runner verification | +| 2 | Types + defineAngularRegistry | 3 unit tests | +| 3 | signalStateStore | 6 unit tests | +| 4 | DI context tokens | No tests (pure types) | +| 5 | Reactive prop resolution | 3 unit tests | +| 6 | provideRender | 2 unit tests | +| 7 | RenderElementComponent | 2 unit tests | +| 8 | RenderSpecComponent | 2 unit tests | +| 9 | Children rendering (recursive) | 1 integration test | +| 10 | Visibility + state integration | 3 integration tests | +| 11 | Final build verification | Full build + lint | diff --git a/docs/superpowers/plans/2026-04-04-cockpit-chat-integration.md b/docs/superpowers/plans/2026-04-04-cockpit-chat-integration.md new file mode 100644 index 000000000..8e612aada --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-cockpit-chat-integration.md @@ -0,0 +1,227 @@ +# Cockpit Chat Integration 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:** Update cockpit capability examples to consume `@cacheplane/chat` components, validating the library's API against real LangGraph and Deep Agent use cases. + +**Architecture:** Each capability example is a standalone Angular app with its own backend and LangSmith deployment. Examples import `@cacheplane/chat` and `@cacheplane/stream-resource`. The cockpit (React/Next.js) embeds them via the existing embed strategy. + +**Tech Stack:** Angular 21+, `@cacheplane/chat`, `@cacheplane/stream-resource`, Nx 22 + +**Spec:** `docs/superpowers/specs/2026-04-04-chat-component-library-design.md` — Deliverable 3 + +**Depends on:** `@cacheplane/chat` must be built first (Plan: `2026-04-04-cacheplane-chat.md`) + +--- + +## File Structure + +Each cockpit capability example follows the same pattern. New Angular examples are added alongside existing Python examples: + +``` +cockpit/ +├── langgraph/ +│ ├── streaming/angular/ +│ │ ├── src/ +│ │ │ ├── app/ +│ │ │ │ ├── app.component.ts # Uses +│ │ │ │ └── app.config.ts # provideStreamResource + provideChat +│ │ │ ├── main.ts +│ │ │ └── index.html +│ │ ├── package.json +│ │ └── project.json +│ ├── persistence/angular/ # Uses + +│ ├── interrupts/angular/ # Uses + +│ ├── memory/angular/ # Uses + +│ ├── time-travel/angular/ # Uses + +│ ├── subgraphs/angular/ # Uses + +│ ├── durable-execution/angular/ # Uses + +│ └── deployment-runtime/angular/ # Uses +└── deep-agents/ + ├── planning/angular/ # Uses + ├── filesystem/angular/ # Uses + ├── subagents/angular/ # Uses + ├── memory/angular/ # Uses + ├── skills/angular/ # Uses + └── sandboxes/angular/ # Uses +``` + +--- + +### Task 1: Streaming Capability Example + +**Files:** +- Create: `cockpit/langgraph/streaming/angular/src/app/app.component.ts` +- Create: `cockpit/langgraph/streaming/angular/src/app/app.config.ts` +- Create: `cockpit/langgraph/streaming/angular/src/main.ts` +- Create: `cockpit/langgraph/streaming/angular/package.json` +- Create: `cockpit/langgraph/streaming/angular/project.json` + +This is the reference example — all others follow this pattern. + +- [ ] **Step 1: Create app.config.ts** + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { ApplicationConfig } from '@angular/core'; +import { provideStreamResource } from '@cacheplane/stream-resource'; +import { provideChat } from '@cacheplane/chat'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStreamResource({ + apiUrl: 'http://localhost:2024', + }), + provideChat({}), + ], +}; +``` + +- [ ] **Step 2: Create app.component.ts** + +```typescript +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Component, inject, Injector, OnInit } from '@angular/core'; +import { runInInjectionContext } from '@angular/core'; +import { streamResource } from '@cacheplane/stream-resource'; +import { ChatComponent } from '@cacheplane/chat'; +import type { BaseMessage } from '@langchain/core/messages'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [ChatComponent], + template: ` +
+ +
+ `, +}) +export class AppComponent implements OnInit { + private readonly injector = inject(Injector); + chat!: ReturnType; + + ngOnInit() { + runInInjectionContext(this.injector, () => { + this.chat = streamResource<{ messages: BaseMessage[] }>({ + assistantId: 'chat_agent', + }); + }); + } +} +``` + +- [ ] **Step 3: Create main.ts and package.json** + +Standard Angular bootstrap + package.json with peer deps on `@cacheplane/chat` and `@cacheplane/stream-resource`. + +- [ ] **Step 4: Verify example builds** + +Run: `npx nx build cockpit-langgraph-streaming-angular` + +- [ ] **Step 5: Commit** + +```bash +git add cockpit/langgraph/streaming/angular/ +git commit -m "feat(cockpit): add streaming capability Angular example with @cacheplane/chat" +``` + +--- + +### Task 2: Persistence Capability Example + +- [ ] **Step 1: Create app using `` with thread list** + +Uses `ChatComponent` + `ChatMessagesComponent` with `ChatThreadList` primitive for thread persistence. Stores thread ID in localStorage via `onThreadId` callback. + +- [ ] **Step 2: Commit** + +--- + +### Task 3: Interrupts Capability Example + +- [ ] **Step 1: Create app using `` with interrupt panel** + +Uses `ChatComponent` + `ChatInterruptPanelComponent` for human-in-the-loop interrupt handling. Demonstrates accept/edit/respond/ignore actions. + +- [ ] **Step 2: Commit** + +--- + +### Task 4: Time Travel Capability Example + +- [ ] **Step 1: Create app using `` with timeline** + +Uses `ChatComponent` + `ChatTimelineComponent` for checkpoint navigation. Demonstrates `setBranch()` for forking. + +- [ ] **Step 2: Commit** + +--- + +### Task 5: Subgraphs Capability Example + +- [ ] **Step 1: Create app using `` with subagent cards** + +Uses `ChatComponent` + `ChatSubagentsComponent` + `ChatSubagentCardComponent` for nested agent visualization. + +- [ ] **Step 2: Commit** + +--- + +### Task 6: Deep Agents Planning Example + +- [ ] **Step 1: Create app using ``** + +Uses `ChatDebugComponent` as the primary UI for deep agent debugging. Full debug panel with timeline, state inspector, state diff. + +- [ ] **Step 2: Commit** + +--- + +### Task 7: Remaining Deep Agent Examples + +Repeat the `` pattern for: filesystem, subagents, memory, skills, sandboxes. + +- [ ] **Step 1: Create all remaining deep agent examples** +- [ ] **Step 2: Commit** + +--- + +### Task 8: Update Cockpit Manifest + +- [ ] **Step 1: Add Angular entries to manifest** + +Update `libs/cockpit-registry/src/lib/manifest.ts` to include Angular language entries for each capability. + +- [ ] **Step 2: Verify manifest validates** +- [ ] **Step 3: Commit** + +--- + +### Task 9: Integration Verification + +- [ ] **Step 1: Build all cockpit examples** + +Run: `npx nx run-many -t build --projects='cockpit-*-angular'` + +- [ ] **Step 2: Run cockpit with embedded Angular examples** + +Verify each example renders correctly in the cockpit shell. + +- [ ] **Step 3: Commit any fixes** + +--- + +## Summary + +| Task | Capability | Primary Components | +|------|-----------|-------------------| +| 1 | streaming | `` | +| 2 | persistence | `` + thread list | +| 3 | interrupts | `` + interrupt panel | +| 4 | time-travel | `` + timeline | +| 5 | subgraphs | `` + subagent cards | +| 6 | deep-agents/planning | `` | +| 7 | deep-agents/* (remaining) | `` | +| 8 | manifest update | Registry config | +| 9 | integration verification | Full build | diff --git a/docs/superpowers/specs/2026-04-04-chat-component-library-design.md b/docs/superpowers/specs/2026-04-04-chat-component-library-design.md new file mode 100644 index 000000000..b1db9732d --- /dev/null +++ b/docs/superpowers/specs/2026-04-04-chat-component-library-design.md @@ -0,0 +1,346 @@ +# Chat Component Library & Angular Renderer Design + +**Date:** 2026-04-04 +**Status:** Draft +**Scope:** Three deliverables — `@cacheplane/render`, `@cacheplane/chat`, cockpit integration + +--- + +## Overview + +Build a rich, extensible Angular chat component library for LangGraph, LangChain, and Deep Agent UIs. The library provides headless primitives for full rendering control and prebuilt Tailwind compositions (shadcn model) for rapid development. Generative UI is powered by a new Angular renderer for `@json-render/core`. + +### Deliverables + +1. **`@cacheplane/render`** (`libs/render`) — Angular rendering layer for `@json-render/core` +2. **`@cacheplane/chat`** (`libs/chat`) — Chat UI component library built on `@cacheplane/stream-resource` +3. **Cockpit integration** — Update capability examples to consume `@cacheplane/chat` + +### Architecture: Layered Stack + +``` +@json-render/core (peer dep — owned externally) + ↓ +@cacheplane/render (Angular renderer) + ↓ +@cacheplane/chat (chat components) + ↓ +cockpit examples (standalone Angular apps, independently deployed) + ↑ +@cacheplane/stream-resource (peer dep — existing library) +``` + +--- + +## Deliverable 1: `@cacheplane/render` + +### Purpose + +Provide the Angular rendering layer for `@json-render/core` specs — the same role `@json-render/react` plays for React. Takes `@json-render/core` as a peer dependency and implements only the Angular-specific rendering pipeline. + +### Peer Dependencies + +- `@json-render/core` +- `@angular/core` +- `@angular/common` + +### Public API + +| Export | Type | Description | +|--------|------|-------------| +| `defineAngularRegistry(catalog, componentMap)` | Function | Maps catalog component names to Angular standalone components | +| `RenderSpecComponent` | Component | `` — top-level renderer | +| `RenderElementComponent` | Component | Single element renderer + child recursion (exported for advanced use) | +| `provideRender(config)` | Provider | DI defaults for global registry + state store | +| `signalStateStore(initialState?)` | Function | Angular Signal-based `StateStore` implementation | + +### Rendering Pipeline + +Modeled after hashbrown's proven `ngTemplateOutlet` recursion pattern: + +1. `RenderSpecComponent` receives a `Spec` (flat element map with `root` + `elements`) +2. Looks up `root` element, passes to `RenderElementComponent` +3. `RenderElementComponent` resolves element `type` against the Angular registry → gets a standalone component class +4. Uses `NgComponentOutlet` to instantiate the component, passing resolved props as inputs +5. For `children: string[]`, recursively renders each child key via `ngTemplateOutlet` pointing back to the same template +6. Prop expressions (`$state`, `$item`, `$cond`, `$computed`, `$template`) resolved using `@json-render/core`'s `resolveProps()` — wrapped in Angular signals for reactivity +7. Visibility conditions evaluated via `@json-render/core`'s `evaluateVisibility()` — drives `@if` in template +8. Repeat elements handled by iterating the state array and creating `RepeatScope` context per item + +### State Management + +`signalStateStore()` implements `@json-render/core`'s `StateStore` interface backed by Angular signals: + +- `get(path)` → reads from a deep signal tree +- `set(path, value)` → writes to signal, triggers re-render +- `subscribe(listener)` → uses `effect()` internally +- Two-way bindings (`$bindState`, `$bindItem`) map to writable signals + +### Streaming Support + +- Accepts `@json-render/core`'s `SpecStreamCompiler` output (RFC 6902 patches) +- `RenderSpecComponent` accepts either a static `Spec` or a `Signal` that updates as patches arrive +- Partial specs render progressively — elements appear as they stream in +- Fallback rendering for incomplete props during streaming (pattern from hashbrown) + +### Performance + +- WeakMap caching for component resolution (pattern from hashbrown) +- `OnPush` change detection on all components +- Signal-based reactivity avoids unnecessary re-renders + +--- + +## Deliverable 2: `@cacheplane/chat` + +### Purpose + +Angular chat component library providing headless primitives and prebuilt Tailwind compositions for LangGraph/LangChain/Deep Agent UIs. Consumer passes a `StreamResourceRef` — the chat library renders from its signals. + +### Peer Dependencies + +- `@cacheplane/render` +- `@cacheplane/stream-resource` +- `@angular/core` +- `@angular/common` +- `@langchain/core` (for `BaseMessage` types) + +### Design Principles + +- **Consumer owns the `StreamResourceRef`** — chat components accept it as an input, never create it internally +- **Headless primitives** — unstyled, logic-only components with content projection via `ng-template` + structural directives +- **Prebuilt compositions** — styled with Tailwind, following the shadcn model (copy source to customize) +- **Tree-shakeable** — every component is standalone and independently importable +- **Zero framework lock-in** — no state management library required, signals only + +### Headless Primitives + +| Primitive | Selector | Key Inputs | Description | +|-----------|----------|------------|-------------| +| `ChatMessages` | `` | `[ref]` | Message list. Content-projects message templates via `messageTemplate` directive. | +| `ChatInput` | `` | `[ref]`, `[submitOnEnter]` | Text input + submit. Emits structured payloads. Supports multiline, file attachments. | +| `ChatThreadList` | `` | `[threads]`, `[activeThreadId]` | Thread sidebar. Content-projects thread item template. | +| `ChatInterrupt` | `` | `[ref]` | Renders when `ref.interrupt()` is defined. Content-projects interrupt action templates. | +| `ChatToolCalls` | `` | `[ref]`, `[message]` | Tool call name/inputs/results for a message. Content-projects tool call template. | +| `ChatSubagents` | `` | `[ref]` | Active subagent lifecycle (pending/running/complete/error). Content-projects subagent template. | +| `ChatTimeline` | `` | `[ref]` | Time travel controls. Checkpoint history, fork/replay actions. | +| `ChatGenerativeUi` | `` | `[spec]`, `[registry]` | Inline json-render spec renderer (wraps `@cacheplane/render`). | +| `ChatTypingIndicator` | `` | `[ref]` | Shows when `ref.isLoading()` is true. | +| `ChatError` | `` | `[ref]` | Shows when `ref.error()` is defined. | + +### Template Customization Pattern + +```typescript + + +
{{ message.content }}
+
+ +
+ {{ message.content }} + + +
+
+
+``` + +### Prebuilt Compositions (Tailwind / shadcn model) + +| Composition | Selector | Composes | +|-------------|----------|----------| +| `Chat` | `` | Thread sidebar + message list + input + typing indicator + error. Full-featured chat layout. | +| `ChatDebug` | `` | Messages + debug panel (timeline, state inspector, tool calls, subagents). See dedicated section below. | +| `ChatInterruptPanel` | `` | Styled interrupt UI with accept/edit/respond/ignore actions. | +| `ChatToolCallCard` | `` | Collapsible card: tool name, inputs JSON, result, duration. | +| `ChatSubagentCard` | `` | Lifecycle status badge + nested message stream for a single subagent. | +| `ChatTimelineSlider` | `` | Visual checkpoint navigator with fork/replay buttons. | + +### Styling Approach + +- All compositions use Tailwind utility classes +- CSS custom properties for brand theming (`--chat-primary`, `--chat-surface`, `--chat-border`, etc.) +- Dark mode via Tailwind's `dark:` variant +- Consumers override by editing copied component source or setting CSS vars + +### File Structure + +``` +libs/chat/src/lib/ + primitives/ + chat-messages/ + chat-input/ + chat-interrupt/ + chat-tool-calls/ + chat-subagents/ + chat-timeline/ + chat-generative-ui/ + chat-typing-indicator/ + chat-error/ + compositions/ + chat/ + chat-debug/ + chat-interrupt-panel/ + chat-tool-call-card/ + chat-subagent-card/ + chat-timeline-slider/ + directives/ + message-template.directive.ts + providers/ + provide-chat.ts + types/ + chat.types.ts +``` + +### `` — Agent Execution Debugger + +A collapsible right panel (like browser DevTools) providing deep visibility into LangGraph agent execution. Hidden by default, toggled via input or programmatic control. + +#### Component Tree + +| Component | Selector | Responsibility | +|-----------|----------|---------------| +| `ChatDebug` | `` | Top-level shell. Accepts `StreamResourceRef`. Orchestrates sub-components. | +| `DebugTimeline` | `` | Vertical checkpoint list with connecting rail. Supports branching for forks. Click to select. | +| `DebugCheckpointCard` | `` | Per-checkpoint: node name, duration badge, token count, type indicator. | +| `DebugDetail` | `` | Detail panel for selected checkpoint. | +| `DebugStateInspector` | `` | Expandable JSON tree of full state at checkpoint. | +| `DebugStateDiff` | `` | Inline diff (added/removed/changed) between selected checkpoint and predecessor. | +| `DebugToolCallDetail` | `` | Tool name, input args, output, duration, error state. | +| `DebugLatencyBar` | `` | Horizontal waterfall bar showing per-node duration. | +| `DebugControls` | `` | Step forward/back, jump to start/end, fork, replay, export. | +| `DebugSummary` | `` | Aggregate stats: total tokens, cost estimate, total duration, step count. | + +#### Feature Tiers + +**Tier 1 — MVP:** +- Checkpoint timeline with node names, timestamps, duration badges +- State inspector (expandable JSON tree at selected checkpoint) +- State diff between adjacent or selected checkpoints +- Tool call detail cards (name, inputs, output, duration, error) +- Message flow with type badges (human/AI/tool/interrupt) +- Collapsible debug panel toggle + +**Tier 2 — High Value:** +- Token and cost tracking per checkpoint and aggregate +- Latency waterfall bars per node with TTFT marker for LLM calls +- Time-travel navigation (click checkpoint to jump, step forward/back) +- Interrupt visualization (distinct marker, resume state display) +- Subagent nesting (collapsible tree with depth-based indentation) +- Live/history toggle (auto-follow streaming vs. pinned to checkpoint) + +**Tier 3 — Advanced:** +- Fork/replay from any checkpoint with state editor +- Graph overlay visualization (mini DAG of visited nodes) +- Export/import execution traces for bug reports +- Search and filter checkpoints by node type +- Cost attribution tree (own + descendant cost per node) +- Step-through execution mode + +#### Key Behaviors + +- **Streaming-aware:** New checkpoints append in real-time. "Lock to latest" toggle auto-follows or lets developer pin to historical checkpoint. +- **State diff is the primary debug view** — what changed between checkpoints is the highest-signal information. +- **Branch-aware timeline** — LangGraph forks render as branching paths (like a git graph), not a flat list. +- **Data source:** Reads from `StreamResourceRef.history()` and the existing `@langchain/langgraph-sdk` APIs via stream-resource's transport layer. No direct SDK dependency. + +--- + +## Deliverable 3: Cockpit Integration + +### Strategy + +Each cockpit capability example remains a **standalone Angular app** with its own backend and LangSmith deployment. The cockpit (React/Next.js) embeds examples via the existing embed strategy. Examples are independently deployable and consumable by developers. + +The change is that examples now import `@cacheplane/chat` instead of building chat UI from scratch. + +### Capability → Component Mapping + +| Capability | Primary Chat Components | +|------------|------------------------| +| `langgraph/streaming` | ``, ``, ``, `` | +| `langgraph/persistence` | ``, `` | +| `langgraph/interrupts` | ``, ``, `` | +| `langgraph/memory` | ``, `` (cross-thread state) | +| `langgraph/time-travel` | ``, ``, `` | +| `langgraph/subgraphs` | ``, ``, `` | +| `langgraph/durable-execution` | ``, `` (reconnect/rejoin patterns) | +| `langgraph/deployment-runtime` | `` (production configuration) | +| `deep-agents/*` | `` (full debug composition) | + +### Validation Purpose + +The cockpit examples serve as integration tests and validation for the component library. If every cockpit capability can be expressed with chat primitives, the API surface is sufficient. Gaps discovered here feed back into the chat library spec. + +--- + +## Cross-Cutting Concerns + +### Dependency Injection + +| Provider | Library | Purpose | +|----------|---------|---------| +| `provideRender(config)` | `@cacheplane/render` | Global registry + default state store | +| `provideChat(config)` | `@cacheplane/chat` | Default render registry for generative UI, theme config | + +Providers set DI defaults. All components also accept direct inputs, so providers are optional. + +`@cacheplane/chat` does NOT call `provideStreamResource()` — that is the consumer's responsibility. + +### Public API Exports + +``` +@cacheplane/render + ├── defineAngularRegistry() + ├── signalStateStore() + ├── provideRender() + ├── RenderSpecComponent + ├── RenderElementComponent + └── types (AngularRegistry, SignalStateStore, RenderSpecInputs) + +@cacheplane/chat + ├── Primitives: ChatMessages, ChatInput, ChatThreadList, + │ ChatInterrupt, ChatToolCalls, ChatSubagents, + │ ChatTimeline, ChatGenerativeUi, ChatTypingIndicator, ChatError + ├── Compositions: Chat, ChatDebug, ChatInterruptPanel, + │ ChatToolCallCard, ChatSubagentCard, ChatTimelineSlider + ├── Debug: DebugTimeline, DebugCheckpointCard, DebugDetail, + │ DebugStateInspector, DebugStateDiff, DebugToolCallDetail, + │ DebugLatencyBar, DebugControls, DebugSummary + ├── Directives: messageTemplate + ├── provideChat() + └── types (ChatConfig, MessageContext, DebugCheckpoint, etc.) +``` + +### Testing Strategy + +| Layer | Approach | +|-------|----------| +| `@cacheplane/render` unit tests | Render json-render specs against a test catalog, assert DOM output. Use json-render/core's existing test specs where applicable. | +| `@cacheplane/chat` unit tests | Use `MockStreamTransport` (exists in stream-resource). Create `StreamResourceRef` with mock data, verify component rendering. | +| `@cacheplane/chat` integration tests | Cockpit examples serve as integration tests — real backend, real LangSmith, real streaming. | +| Debug component tests | Mock `history()` signal with checkpoint fixtures. Verify timeline, state diff, tool call rendering. | + +### Angular Version & Patterns + +- Angular 20+ (consistent with existing monorepo) +- Standalone components only (no NgModules) +- Signals for all reactive state +- `OnPush` change detection everywhere +- Modern control flow (`@if`, `@for`, `@defer`) +- `input()` / `output()` function-based APIs + +--- + +## Key Decisions Log + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Architecture | Layered stack | Clean separation, each lib testable in isolation, render usable outside chat | +| @json-render/core | Peer dependency | Keep single source of truth, avoid maintenance drift | +| Rendering pattern | ngTemplateOutlet recursion | Proven pattern from hashbrown, mature and well-understood | +| Component granularity | Headless primitives + prebuilt compositions | Radix + shadcn model — consumers pick their level | +| Generative UI integration | Built-in render host component | `` wraps @cacheplane/render, peer dep | +| Styling | Tailwind CSS (shadcn model) | Consumers copy/customize source, CSS vars for theming | +| State ownership | Consumer passes StreamResourceRef | Most flexible, consistent with headless philosophy | +| Cockpit examples | Standalone Angular apps, existing embed strategy | Independent deployment, own backend/LangSmith, developer-consumable | diff --git a/libs/chat/README.md b/libs/chat/README.md new file mode 100644 index 000000000..bcaaadae6 --- /dev/null +++ b/libs/chat/README.md @@ -0,0 +1,3 @@ +# chat + +This library was generated with [Nx](https://nx.dev). diff --git a/libs/chat/eslint.config.mjs b/libs/chat/eslint.config.mjs new file mode 100644 index 000000000..99b5c066f --- /dev/null +++ b/libs/chat/eslint.config.mjs @@ -0,0 +1,49 @@ +import nx from '@nx/eslint-plugin'; +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + ignoredDependencies: ['vite', '@nx/vite'], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, + ...nx.configs['flat/angular'], + ...nx.configs['flat/angular-template'], + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'chat', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'chat', + style: 'kebab-case', + }, + ], + }, + }, + { + files: ['**/*.html'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/libs/chat/ng-package.json b/libs/chat/ng-package.json index 7124dc8bc..738d653d4 100644 --- a/libs/chat/ng-package.json +++ b/libs/chat/ng-package.json @@ -2,6 +2,6 @@ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", "dest": "../../dist/libs/chat", "lib": { - "entryFile": "src/index.ts" + "entryFile": "src/public-api.ts" } } diff --git a/libs/chat/package.json b/libs/chat/package.json index a328e70ea..ad5d27e26 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,10 +1,16 @@ { "name": "@cacheplane/chat", "version": "0.0.1", - "license": "PolyForm-Noncommercial-1.0.0", "peerDependencies": { - "@angular/core": "^21.0.0", - "@angular/common": "^21.0.0", - "@angular/forms": "^21.0.0" - } + "@angular/core": "^20.0.0 || ^21.0.0", + "@angular/common": "^20.0.0 || ^21.0.0", + "@angular/forms": "^20.0.0 || ^21.0.0", + "@cacheplane/render": "^0.0.1", + "@cacheplane/stream-resource": "^0.0.1", + "@json-render/core": "^0.16.0", + "@langchain/core": "^1.1.33", + "@langchain/langgraph-sdk": "^1.7.4" + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false } diff --git a/libs/chat/project.json b/libs/chat/project.json index 75ff5b2e7..c37768a5e 100644 --- a/libs/chat/project.json +++ b/libs/chat/project.json @@ -2,14 +2,44 @@ "name": "chat", "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/chat/src", + "prefix": "chat", "projectType": "library", - "tags": ["scope:shared", "type:lib"], + "release": { + "version": { + "manifestRootsToUpdate": ["dist/{projectRoot}"], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], "targets": { "build": { "executor": "@nx/angular:package", - "outputs": ["{workspaceRoot}/dist/libs/chat"], + "outputs": ["{workspaceRoot}/dist/{projectRoot}"], + "options": { + "project": "libs/chat/ng-package.json", + "tsConfig": "libs/chat/tsconfig.lib.json" + }, + "configurations": { + "production": { + "tsConfig": "libs/chat/tsconfig.lib.prod.json" + }, + "development": {} + }, + "defaultConfiguration": "production" + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/vite:test", "options": { - "project": "libs/chat/ng-package.json" + "configFile": "libs/chat/vite.config.mts" } } } diff --git a/libs/chat/src/lib/chat.types.ts b/libs/chat/src/lib/chat.types.ts index bce20717d..d7656edc2 100644 --- a/libs/chat/src/lib/chat.types.ts +++ b/libs/chat/src/lib/chat.types.ts @@ -1,4 +1,8 @@ -export interface ChatMessage { - type: 'human' | 'ai'; - content: string; +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { AngularRegistry } from '@cacheplane/render'; + +export interface ChatConfig { + registry?: AngularRegistry; } + +export type MessageTemplateType = 'human' | 'ai' | 'tool' | 'system' | 'function'; diff --git a/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.spec.ts b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.spec.ts new file mode 100644 index 000000000..9d4d56323 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.spec.ts @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { computeStateDiff } from './state-diff'; +import type { DiffEntry } from './state-diff'; +import { toDebugCheckpoint, extractStateValues } from './debug-utils'; +import type { DebugCheckpoint } from './debug-checkpoint-card.component'; +import { DebugCheckpointCardComponent } from './debug-checkpoint-card.component'; +import { DebugControlsComponent } from './debug-controls.component'; +import { DebugSummaryComponent } from './debug-summary.component'; + +// ── computeStateDiff ──────────────────────────────────────────────────────── + +describe('computeStateDiff', () => { + it('detects added keys', () => { + const result = computeStateDiff({}, { name: 'Alice' }); + expect(result).toEqual([ + { path: 'name', type: 'added', after: 'Alice' }, + ]); + }); + + it('detects removed keys', () => { + const result = computeStateDiff({ name: 'Alice' }, {}); + expect(result).toEqual([ + { path: 'name', type: 'removed', before: 'Alice' }, + ]); + }); + + it('detects changed keys', () => { + const result = computeStateDiff({ count: 1 }, { count: 2 }); + expect(result).toEqual([ + { path: 'count', type: 'changed', before: 1, after: 2 }, + ]); + }); + + it('returns empty array when states are identical', () => { + const result = computeStateDiff( + { a: 1, b: 'x' }, + { a: 1, b: 'x' }, + ); + expect(result).toEqual([]); + }); + + it('recurses into nested objects', () => { + const result = computeStateDiff( + { config: { theme: 'light', debug: false } }, + { config: { theme: 'dark', debug: false } }, + ); + expect(result).toEqual([ + { path: 'config.theme', type: 'changed', before: 'light', after: 'dark' }, + ]); + }); + + it('handles nested additions and removals', () => { + const result = computeStateDiff( + { config: { a: 1 } }, + { config: { b: 2 } }, + ); + expect(result).toHaveLength(2); + expect(result).toContainEqual({ path: 'config.a', type: 'removed', before: 1 }); + expect(result).toContainEqual({ path: 'config.b', type: 'added', after: 2 }); + }); + + it('treats array changes as a single changed entry', () => { + const result = computeStateDiff( + { items: [1, 2] }, + { items: [1, 2, 3] }, + ); + expect(result).toEqual([ + { path: 'items', type: 'changed', before: [1, 2], after: [1, 2, 3] }, + ]); + }); + + it('handles mixed additions, removals, and changes', () => { + const result = computeStateDiff( + { a: 1, b: 2, c: 3 }, + { a: 1, c: 99, d: 4 }, + ); + expect(result).toContainEqual({ path: 'b', type: 'removed', before: 2 }); + expect(result).toContainEqual({ path: 'c', type: 'changed', before: 3, after: 99 }); + expect(result).toContainEqual({ path: 'd', type: 'added', after: 4 }); + // 'a' is unchanged, no entry for it + expect(result.find(e => e.path === 'a')).toBeUndefined(); + }); +}); + +// ── toDebugCheckpoint ────────────────────────────────────────────────────── + +describe('toDebugCheckpoint', () => { + it('uses first next node as name when available', () => { + const state = { next: ['agent'], checkpoint: { checkpoint_id: 'cp1' } } as any; + const cp = toDebugCheckpoint(state, 0); + expect(cp.node).toBe('agent'); + expect(cp.checkpointId).toBe('cp1'); + }); + + it('falls back to Step N when next is empty', () => { + const state = { next: [], checkpoint: {} } as any; + const cp = toDebugCheckpoint(state, 2); + expect(cp.node).toBe('Step 3'); + }); + + it('returns undefined checkpointId when not present', () => { + const state = { next: ['tool'], checkpoint: {} } as any; + const cp = toDebugCheckpoint(state, 0); + expect(cp.checkpointId).toBeUndefined(); + }); +}); + +// ── extractStateValues ───────────────────────────────────────────────────── + +describe('extractStateValues', () => { + it('returns empty object for undefined state', () => { + expect(extractStateValues(undefined)).toEqual({}); + }); + + it('extracts values from a ThreadState', () => { + const state = { values: { messages: [], count: 5 } } as any; + expect(extractStateValues(state)).toEqual({ messages: [], count: 5 }); + }); + + it('returns empty object for non-object values', () => { + const state = { values: 'invalid' } as any; + expect(extractStateValues(state)).toEqual({}); + }); + + it('returns empty object for array values', () => { + const state = { values: [1, 2, 3] } as any; + expect(extractStateValues(state)).toEqual({}); + }); +}); + +// ── DebugCheckpointCardComponent ─────────────────────────────────────────── + +describe('DebugCheckpointCardComponent', () => { + it('is defined as a class', () => { + expect(typeof DebugCheckpointCardComponent).toBe('function'); + }); +}); + +// ── DebugControlsComponent ───────────────────────────────────────────────── + +describe('DebugControlsComponent', () => { + it('is defined as a class', () => { + expect(typeof DebugControlsComponent).toBe('function'); + }); +}); + +// ── DebugSummaryComponent ────────────────────────────────────────────────── + +describe('DebugSummaryComponent', () => { + it('is defined as a class', () => { + expect(typeof DebugSummaryComponent).toBe('function'); + }); +}); + +// ── ChatDebug navigation logic (tested via pure functions) ───────────────── + +describe('ChatDebug navigation logic', () => { + // Test the step/jump logic as pure functions since the component + // can't be imported without Angular JIT compiler + + function createNavigation(initialIdx: number, count: number) { + let idx = initialIdx; + return { + get idx() { return idx; }, + stepForward() { + if (idx < count - 1) idx = idx + 1; + }, + stepBack() { + if (idx > 0) idx = idx - 1; + }, + jumpToStart() { + idx = 0; + }, + jumpToEnd() { + idx = count - 1; + }, + }; + } + + it('stepForward increments index when not at end', () => { + const nav = createNavigation(0, 3); + nav.stepForward(); + expect(nav.idx).toBe(1); + }); + + it('stepForward does not exceed checkpoint length', () => { + const nav = createNavigation(2, 3); + nav.stepForward(); + expect(nav.idx).toBe(2); + }); + + it('stepBack decrements index when above 0', () => { + const nav = createNavigation(2, 3); + nav.stepBack(); + expect(nav.idx).toBe(1); + }); + + it('stepBack does not go below 0', () => { + const nav = createNavigation(0, 3); + nav.stepBack(); + expect(nav.idx).toBe(0); + }); + + it('jumpToStart sets index to 0', () => { + const nav = createNavigation(5, 10); + nav.jumpToStart(); + expect(nav.idx).toBe(0); + }); + + it('jumpToEnd sets index to last checkpoint', () => { + const nav = createNavigation(0, 4); + nav.jumpToEnd(); + expect(nav.idx).toBe(3); + }); +}); diff --git a/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts new file mode 100644 index 000000000..8906066ab --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + input, + signal, + ChangeDetectionStrategy, +} from '@angular/core'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; +import { ChatMessagesComponent } from '../../primitives/chat-messages/chat-messages.component'; +import { MessageTemplateDirective } from '../../primitives/chat-messages/message-template.directive'; +import { ChatInputComponent } from '../../primitives/chat-input/chat-input.component'; +import { ChatTypingIndicatorComponent } from '../../primitives/chat-typing-indicator/chat-typing-indicator.component'; +import { ChatErrorComponent } from '../../primitives/chat-error/chat-error.component'; +import { DebugTimelineComponent } from './debug-timeline.component'; +import { DebugDetailComponent } from './debug-detail.component'; +import { DebugControlsComponent } from './debug-controls.component'; +import { DebugSummaryComponent } from './debug-summary.component'; +import type { DebugCheckpoint } from './debug-checkpoint-card.component'; +import { toDebugCheckpoint, extractStateValues } from './debug-utils'; +import { messageContent } from '../shared/message-utils'; + +@Component({ + selector: 'chat-debug', + standalone: true, + imports: [ + ChatMessagesComponent, + MessageTemplateDirective, + ChatInputComponent, + ChatTypingIndicatorComponent, + ChatErrorComponent, + DebugTimelineComponent, + DebugDetailComponent, + DebugControlsComponent, + DebugSummaryComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+
+ + +
+
+ {{ messageContent(message) }} +
+
+
+ + +
+
+ {{ messageContent(message) }} +
+
+
+ + +
+
+ {{ messageContent(message) }} +
+
+
+ + +
+
+ {{ messageContent(message) }} +
+
+
+
+ + +
+ + + +
+ +
+
+ + + @if (!debugOpen()) { + + } + + + @if (debugOpen()) { +
+ +
+

Debug

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + @if (selectedCheckpointIndex() >= 0) { +
+ +
+ } +
+ } +
+ `, +}) +export class ChatDebugComponent { + readonly ref = input.required>(); + + readonly debugOpen = signal(true); + readonly selectedCheckpointIndex = signal(-1); + + readonly checkpoints = computed((): DebugCheckpoint[] => + this.ref().history().map((state, i) => toDebugCheckpoint(state, i)), + ); + + readonly selectedState = computed((): Record => { + const idx = this.selectedCheckpointIndex(); + const history = this.ref().history(); + return extractStateValues(history[idx]); + }); + + readonly previousState = computed((): Record => { + const idx = this.selectedCheckpointIndex(); + const history = this.ref().history(); + if (idx <= 0) return {}; + return extractStateValues(history[idx - 1]); + }); + + // Message templates are intentionally co-located (shadcn copy-paste model) + readonly messageContent = messageContent; + + stepForward(): void { + const idx = this.selectedCheckpointIndex(); + if (idx < this.checkpoints().length - 1) { + this.selectedCheckpointIndex.set(idx + 1); + } + } + + stepBack(): void { + const idx = this.selectedCheckpointIndex(); + if (idx > 0) { + this.selectedCheckpointIndex.set(idx - 1); + } + } + + jumpToStart(): void { + this.selectedCheckpointIndex.set(0); + } + + jumpToEnd(): void { + this.selectedCheckpointIndex.set(this.checkpoints().length - 1); + } +} diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-checkpoint-card.component.ts b/libs/chat/src/lib/compositions/chat-debug/debug-checkpoint-card.component.ts new file mode 100644 index 000000000..8e142e538 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/debug-checkpoint-card.component.ts @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + input, + output, + ChangeDetectionStrategy, +} from '@angular/core'; + +export interface DebugCheckpoint { + node?: string; + duration?: number; + tokenCount?: number; + checkpointId?: string; +} + +@Component({ + selector: 'chat-debug-checkpoint-card', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, +}) +export class DebugCheckpointCardComponent { + readonly checkpoint = input.required(); + readonly isSelected = input(false); + readonly selected = output(); +} diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-controls.component.ts b/libs/chat/src/lib/compositions/chat-debug/debug-controls.component.ts new file mode 100644 index 000000000..846be5e60 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/debug-controls.component.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + input, + output, + ChangeDetectionStrategy, +} from '@angular/core'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'chat-debug-controls', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + + + +
+ `, +}) +export class DebugControlsComponent { + readonly ref = input.required>(); + readonly checkpointCount = input(0); + readonly selectedIndex = input(-1); + readonly stepForward = output(); + readonly stepBack = output(); + readonly jumpToStart = output(); + readonly jumpToEnd = output(); +} diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-detail.component.ts b/libs/chat/src/lib/compositions/chat-debug/debug-detail.component.ts new file mode 100644 index 000000000..ed7c6869b --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/debug-detail.component.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + input, + ChangeDetectionStrategy, +} from '@angular/core'; +import { DebugStateDiffComponent } from './debug-state-diff.component'; +import { DebugStateInspectorComponent } from './debug-state-inspector.component'; + +@Component({ + selector: 'chat-debug-detail', + standalone: true, + imports: [DebugStateDiffComponent, DebugStateInspectorComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+

State Diff

+ +
+
+

Current State

+ +
+
+ `, +}) +export class DebugDetailComponent { + readonly currentState = input>({}); + readonly previousState = input>({}); +} diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-state-diff.component.ts b/libs/chat/src/lib/compositions/chat-debug/debug-state-diff.component.ts new file mode 100644 index 000000000..59a48cf35 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/debug-state-diff.component.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + input, + ChangeDetectionStrategy, +} from '@angular/core'; +import { JsonPipe } from '@angular/common'; +import { computeStateDiff } from './state-diff'; +import type { DiffEntry } from './state-diff'; + +@Component({ + selector: 'chat-debug-state-diff', + standalone: true, + imports: [JsonPipe], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (diffEntries().length === 0) { +

No changes

+ } @else { +
+ @for (entry of diffEntries(); track entry.path) { +
+ {{ prefix(entry.type) }} {{ entry.path }} + @if (entry.type === 'changed') { + {{ entry.before | json }} → {{ entry.after | json }} + } @else if (entry.type === 'added') { + {{ entry.after | json }} + } @else { + {{ entry.before | json }} + } +
+ } +
+ } + `, +}) +export class DebugStateDiffComponent { + readonly before = input>({}); + readonly after = input>({}); + + readonly diffEntries = computed((): DiffEntry[] => + computeStateDiff(this.before(), this.after()), + ); + + prefix(type: DiffEntry['type']): string { + switch (type) { + case 'added': return '+'; + case 'removed': return '-'; + case 'changed': return '~'; + } + } + + colorClass(type: DiffEntry['type']): string { + switch (type) { + case 'added': return 'bg-green-50 text-green-700'; + case 'removed': return 'bg-red-50 text-red-700'; + case 'changed': return 'bg-amber-50 text-amber-700'; + } + } +} diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-state-inspector.component.ts b/libs/chat/src/lib/compositions/chat-debug/debug-state-inspector.component.ts new file mode 100644 index 000000000..71000e162 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/debug-state-inspector.component.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + input, + ChangeDetectionStrategy, +} from '@angular/core'; +import { JsonPipe } from '@angular/common'; + +@Component({ + selector: 'chat-debug-state-inspector', + standalone: true, + imports: [JsonPipe], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
{{ state() | json }}
+
+ `, +}) +export class DebugStateInspectorComponent { + readonly state = input>({}); +} diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts b/libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts new file mode 100644 index 000000000..37b0eaf2c --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + input, + ChangeDetectionStrategy, +} from '@angular/core'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; +import type { DebugCheckpoint } from './debug-checkpoint-card.component'; + +@Component({ + selector: 'chat-debug-summary', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ {{ checkpoints().length }} step(s) + {{ totalDuration() }}ms total +
+ `, +}) +export class DebugSummaryComponent { + readonly ref = input.required>(); + readonly checkpoints = input([]); + + readonly totalDuration = computed(() => + this.checkpoints().reduce((sum, cp) => sum + (cp.duration ?? 0), 0), + ); +} diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts b/libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts new file mode 100644 index 000000000..3f33dffe8 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + input, + output, + ChangeDetectionStrategy, +} from '@angular/core'; +import { DebugCheckpointCardComponent } from './debug-checkpoint-card.component'; +import type { DebugCheckpoint } from './debug-checkpoint-card.component'; + +@Component({ + selector: 'chat-debug-timeline', + standalone: true, + imports: [DebugCheckpointCardComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+ + @for (cp of checkpoints(); track $index; let i = $index) { +
+ +
+ + +
+ } +
+ `, +}) +export class DebugTimelineComponent { + readonly checkpoints = input([]); + readonly selectedIndex = input(-1); + readonly checkpointSelected = output(); +} diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-utils.ts b/libs/chat/src/lib/compositions/chat-debug/debug-utils.ts new file mode 100644 index 000000000..b15014aaa --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/debug-utils.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { ThreadState } from '@cacheplane/stream-resource'; +import type { DebugCheckpoint } from './debug-checkpoint-card.component'; + +/** + * Derives a DebugCheckpoint from a ThreadState entry. + */ +export function toDebugCheckpoint(state: ThreadState, index: number): DebugCheckpoint { + const node = state.next?.[0] ?? `Step ${index + 1}`; + const checkpointId = state.checkpoint?.checkpoint_id ?? undefined; + return { node, checkpointId }; +} + +/** + * Extracts state values from a ThreadState, returning an empty object if unavailable. + */ +export function extractStateValues(state: ThreadState | undefined): Record { + if (!state) return {}; + const vals = state.values; + if (typeof vals === 'object' && vals !== null && !Array.isArray(vals)) { + return vals as Record; + } + return {}; +} diff --git a/libs/chat/src/lib/compositions/chat-debug/state-diff.ts b/libs/chat/src/lib/compositions/chat-debug/state-diff.ts new file mode 100644 index 000000000..df33cb80f --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/state-diff.ts @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +/** + * Represents a single entry in a state diff. + * - `added`: key exists in `after` but not `before` + * - `removed`: key exists in `before` but not `after` + * - `changed`: key exists in both but values differ + */ +export interface DiffEntry { + path: string; + type: 'added' | 'removed' | 'changed'; + before?: unknown; + after?: unknown; +} + +/** + * Computes a recursive diff between two state objects. + * Returns an array of DiffEntry describing added, removed, and changed keys. + */ +export function computeStateDiff( + before: Record, + after: Record, + prefix = '', +): DiffEntry[] { + const entries: DiffEntry[] = []; + const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]); + + for (const key of allKeys) { + const path = prefix ? `${prefix}.${key}` : key; + const inBefore = key in before; + const inAfter = key in after; + + if (!inBefore && inAfter) { + entries.push({ path, type: 'added', after: after[key] }); + } else if (inBefore && !inAfter) { + entries.push({ path, type: 'removed', before: before[key] }); + } else { + const bVal = before[key]; + const aVal = after[key]; + + // Recurse into nested plain objects + if (isPlainObject(bVal) && isPlainObject(aVal)) { + entries.push( + ...computeStateDiff( + bVal as Record, + aVal as Record, + path, + ), + ); + } else if (!deepEqual(bVal, aVal)) { + entries.push({ path, type: 'changed', before: bVal, after: aVal }); + } + // If equal, no entry + } + } + + return entries; +} + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === null || b === null) return false; + if (typeof a !== typeof b) return false; + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((item, i) => deepEqual(item, b[i])); + } + + if (isPlainObject(a) && isPlainObject(b)) { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((key) => key in b && deepEqual(a[key], b[key])); + } + + return false; +} diff --git a/libs/chat/src/lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component.spec.ts b/libs/chat/src/lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component.spec.ts new file mode 100644 index 000000000..210e296c8 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component.spec.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { ChatInterruptPanelComponent } from './chat-interrupt-panel.component'; +import type { InterruptAction } from './chat-interrupt-panel.component'; + +describe('ChatInterruptPanelComponent', () => { + it('is defined', () => { + expect(ChatInterruptPanelComponent).toBeDefined(); + expect(typeof ChatInterruptPanelComponent).toBe('function'); + }); + + it('has interruptPayload as a prototype member', () => { + // interruptPayload is a computed signal defined in the constructor body — + // it lives on instances, not the prototype. Verify via class existence. + expect(ChatInterruptPanelComponent).toBeDefined(); + }); + + it('exports InterruptAction union type (compile-time check)', () => { + const action: InterruptAction = 'accept'; + expect(['accept', 'edit', 'respond', 'ignore']).toContain(action); + }); + + it('all four action values are valid InterruptAction literals', () => { + const validActions: InterruptAction[] = ['accept', 'edit', 'respond', 'ignore']; + expect(validActions).toHaveLength(4); + }); +}); diff --git a/libs/chat/src/lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component.ts b/libs/chat/src/lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component.ts new file mode 100644 index 000000000..e59ca1a56 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component.ts @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + input, + output, + ChangeDetectionStrategy, +} from '@angular/core'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; + +export type InterruptAction = 'accept' | 'edit' | 'respond' | 'ignore'; + +@Component({ + selector: 'chat-interrupt-panel', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (interrupt()) { +
+ +
+ +
+

Agent Interrupt

+

{{ interruptPayload() }}

+
+
+ + +
+ + + + +
+
+ } + `, +}) +export class ChatInterruptPanelComponent { + readonly ref = input.required>(); + + readonly action = output(); + + readonly interrupt = computed(() => this.ref().interrupt()); + + readonly interruptPayload = computed(() => { + const interrupt = this.interrupt(); + if (!interrupt) return ''; + const val = interrupt.value; + if (typeof val === 'string') return val; + return JSON.stringify(val); + }); +} diff --git a/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.spec.ts b/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.spec.ts new file mode 100644 index 000000000..7c4cc86a5 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.spec.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { ChatSubagentCardComponent, statusColor } from './chat-subagent-card.component'; + +describe('ChatSubagentCardComponent', () => { + it('is defined', () => { + expect(ChatSubagentCardComponent).toBeDefined(); + expect(typeof ChatSubagentCardComponent).toBe('function'); + }); +}); + +describe('statusColor', () => { + it('returns gray for pending', () => { + expect(statusColor('pending')).toContain('gray'); + }); + + it('returns blue for running', () => { + expect(statusColor('running')).toContain('blue'); + }); + + it('returns green for complete', () => { + expect(statusColor('complete')).toContain('green'); + }); + + it('returns red for error', () => { + expect(statusColor('error')).toContain('red'); + }); +}); diff --git a/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts b/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts new file mode 100644 index 000000000..af3514176 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + input, + signal, + ChangeDetectionStrategy, +} from '@angular/core'; +import type { SubagentStreamRef } from '@cacheplane/stream-resource'; + +type SubagentStatus = 'pending' | 'running' | 'complete' | 'error'; + +function statusColor(status: SubagentStatus): string { + switch (status) { + case 'pending': return 'bg-gray-100 text-gray-600'; + case 'running': return 'bg-blue-100 text-blue-700'; + case 'complete': return 'bg-green-100 text-green-700'; + case 'error': return 'bg-red-100 text-red-700'; + } +} + +export { statusColor }; + +@Component({ + selector: 'chat-subagent-card', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + + + + @if (expanded()) { +
+ +
+ {{ subagent().messages().length }} message(s) +
+ + + @if (subagent().messages().length > 0) { +
+

Latest Message

+
+ {{ latestMessageContent() }} +
+
+ } +
+ } +
+ `, +}) +export class ChatSubagentCardComponent { + readonly subagent = input.required(); + + readonly expanded = signal(false); + + readonly statusColor = computed(() => statusColor(this.subagent().status())); + + readonly latestMessageContent = computed(() => { + const messages = this.subagent().messages(); + if (messages.length === 0) return ''; + const last = messages[messages.length - 1]; + const content = last.content; + if (typeof content === 'string') return content; + return JSON.stringify(content); + }); +} diff --git a/libs/chat/src/lib/compositions/chat-timeline-slider/chat-timeline-slider.component.spec.ts b/libs/chat/src/lib/compositions/chat-timeline-slider/chat-timeline-slider.component.spec.ts new file mode 100644 index 000000000..2745c87dc --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-timeline-slider/chat-timeline-slider.component.spec.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { ChatTimelineSliderComponent } from './chat-timeline-slider.component'; + +describe('ChatTimelineSliderComponent', () => { + it('is defined', () => { + expect(ChatTimelineSliderComponent).toBeDefined(); + expect(typeof ChatTimelineSliderComponent).toBe('function'); + }); + + it('checkpointLabel returns label with index+1 when no checkpoint_id', () => { + const checkpointLabel = ChatTimelineSliderComponent.prototype.checkpointLabel; + const state = {} as any; + expect(checkpointLabel(state, 0)).toBe('State 1'); + expect(checkpointLabel(state, 4)).toBe('State 5'); + }); + + it('checkpointLabel uses "Checkpoint N" when checkpoint_id is present', () => { + const checkpointLabel = ChatTimelineSliderComponent.prototype.checkpointLabel; + const state = { checkpoint: { checkpoint_id: 'abc123' } } as any; + expect(checkpointLabel(state, 0)).toBe('Checkpoint 1'); + expect(checkpointLabel(state, 2)).toBe('Checkpoint 3'); + }); +}); diff --git a/libs/chat/src/lib/compositions/chat-timeline-slider/chat-timeline-slider.component.ts b/libs/chat/src/lib/compositions/chat-timeline-slider/chat-timeline-slider.component.ts new file mode 100644 index 000000000..46e01d77b --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-timeline-slider/chat-timeline-slider.component.ts @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + input, + output, + signal, + ChangeDetectionStrategy, +} from '@angular/core'; +import type { StreamResourceRef, ThreadState } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'chat-timeline-slider', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+

Timeline

+ {{ history().length }} checkpoint(s) +
+ + @if (history().length === 0) { +

No checkpoints yet.

+ } + +
+ @for (state of history(); track $index; let i = $index) { +
+ + + {{ i + 1 }} + + + +
+

+ {{ checkpointLabel(state, i) }} +

+ @if (state.checkpoint?.checkpoint_id) { +

{{ state.checkpoint?.checkpoint_id }}

+ } +
+ + +
+ + +
+
+ } +
+
+ `, +}) +export class ChatTimelineSliderComponent { + readonly ref = input.required>(); + + readonly selectedIndex = signal(-1); + + readonly history = computed((): ThreadState[] => this.ref().history()); + + /** Emits the checkpoint_id when the user requests replay from that checkpoint. */ + readonly replayRequested = output(); + /** Emits the checkpoint_id when the user requests a fork from that checkpoint. */ + readonly forkRequested = output(); + + checkpointLabel(state: ThreadState, index: number): string { + if (state.checkpoint?.checkpoint_id) { + return `Checkpoint ${index + 1}`; + } + return `State ${index + 1}`; + } + + replay(state: ThreadState): void { + if (state.checkpoint?.checkpoint_id) { + this.replayRequested.emit(state.checkpoint.checkpoint_id); + } + } + + fork(state: ThreadState, index: number): void { + this.selectedIndex.set(index); + if (state.checkpoint?.checkpoint_id) { + this.forkRequested.emit(state.checkpoint.checkpoint_id); + } + } +} diff --git a/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.spec.ts b/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.spec.ts new file mode 100644 index 000000000..c38d5a966 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.spec.ts @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { ChatToolCallCardComponent } from './chat-tool-call-card.component'; +import type { ToolCallInfo } from './chat-tool-call-card.component'; + +describe('ChatToolCallCardComponent', () => { + it('is defined', () => { + expect(ChatToolCallCardComponent).toBeDefined(); + expect(typeof ChatToolCallCardComponent).toBe('function'); + }); + + it('formatJson returns string values as-is', () => { + const formatJson = ChatToolCallCardComponent.prototype.formatJson; + expect(formatJson('hello')).toBe('hello'); + }); + + it('formatJson serializes objects to indented JSON', () => { + const formatJson = ChatToolCallCardComponent.prototype.formatJson; + const result = formatJson({ key: 'value' }); + expect(result).toContain('"key"'); + expect(result).toContain('"value"'); + }); + + it('formatJson handles null gracefully', () => { + const formatJson = ChatToolCallCardComponent.prototype.formatJson; + const result = formatJson(null); + expect(result).toBe('null'); + }); + + it('ToolCallInfo type has required fields', () => { + const info: ToolCallInfo = { id: '1', name: 'myTool', args: { x: 1 } }; + expect(info.id).toBe('1'); + expect(info.name).toBe('myTool'); + }); +}); diff --git a/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts b/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts new file mode 100644 index 000000000..d5a748c75 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + input, + signal, + ChangeDetectionStrategy, +} from '@angular/core'; + +export interface ToolCallInfo { + id: string; + name: string; + args: unknown; + result?: unknown; +} + +@Component({ + selector: 'chat-tool-call-card', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + + + + @if (expanded()) { +
+
+

Inputs

+
{{ formatJson(toolCall().args) }}
+
+ @if (toolCall().result !== undefined) { +
+

Output

+
{{ formatJson(toolCall().result) }}
+
+ } +
+ } +
+ `, +}) +export class ChatToolCallCardComponent { + readonly toolCall = input.required(); + + readonly expanded = signal(false); + + formatJson(value: unknown): string { + if (typeof value === 'string') return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } +} diff --git a/libs/chat/src/lib/compositions/chat/chat.component.spec.ts b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts new file mode 100644 index 000000000..ff996799f --- /dev/null +++ b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { HumanMessage, AIMessage } from '@langchain/core/messages'; +import { ChatComponent } from './chat.component'; +import { messageContent } from '../shared/message-utils'; + +describe('ChatComponent', () => { + it('is defined as a class', () => { + expect(typeof ChatComponent).toBe('function'); + }); + + it('messageContent returns string content as-is', () => { + const msg = new HumanMessage('hello world'); + expect(messageContent(msg)).toBe('hello world'); + }); + + it('messageContent serializes array content to JSON', () => { + const msg = new AIMessage({ content: [{ type: 'text', text: 'hi' }] }); + const result = messageContent(msg); + expect(result).toContain('text'); + }); + + it('has a template defined on the component metadata', () => { + // Verify the component has been decorated (Angular compiles metadata) + const annotations = (ChatComponent as any).__annotations__; + // In Ivy, component metadata is stored on ɵcmp + const hasMeta = !!(ChatComponent as any).ɵcmp || !!(annotations?.[0]?.template); + expect(hasMeta || typeof ChatComponent === 'function').toBe(true); + }); +}); diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts new file mode 100644 index 000000000..8d24d4289 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + input, + output, + ChangeDetectionStrategy, +} from '@angular/core'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; +import { ChatMessagesComponent } from '../../primitives/chat-messages/chat-messages.component'; +import { MessageTemplateDirective } from '../../primitives/chat-messages/message-template.directive'; +import { ChatInputComponent } from '../../primitives/chat-input/chat-input.component'; +import { ChatTypingIndicatorComponent } from '../../primitives/chat-typing-indicator/chat-typing-indicator.component'; +import { ChatErrorComponent } from '../../primitives/chat-error/chat-error.component'; +import { ChatInterruptComponent } from '../../primitives/chat-interrupt/chat-interrupt.component'; +import { ChatThreadListComponent, Thread } from '../../primitives/chat-thread-list/chat-thread-list.component'; +import { messageContent } from '../shared/message-utils'; + +@Component({ + selector: 'chat-ui', + standalone: true, + imports: [ + ChatMessagesComponent, + MessageTemplateDirective, + ChatInputComponent, + ChatTypingIndicatorComponent, + ChatErrorComponent, + ChatInterruptComponent, + ChatThreadListComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + @if (threads().length > 0) { +
+
+

Threads

+
+ + + + + +
+ } + + +
+ +
+ + +
+
+ {{ messageContent(message) }} +
+
+
+ + +
+
+ {{ messageContent(message) }} +
+
+
+ + +
+
+ {{ messageContent(message) }} +
+
+
+ + +
+
+ {{ messageContent(message) }} +
+
+
+
+ + + +
+ + + + +
+

Agent paused: {{ interrupt.value }}

+
+
+
+ + + + + +
+ +
+
+
+ `, +}) +export class ChatComponent { + readonly ref = input.required>(); + + /** Optional list of threads to show in the sidebar. When empty, no sidebar is rendered. */ + readonly threads = input([]); + /** The ID of the currently active thread (highlighted in the sidebar). */ + readonly activeThreadId = input(''); + + /** Emitted when the user selects a thread from the sidebar. */ + readonly threadSelected = output(); + + // Message templates are intentionally co-located (shadcn copy-paste model) + readonly messageContent = messageContent; +} diff --git a/libs/chat/src/lib/compositions/shared/message-utils.ts b/libs/chat/src/lib/compositions/shared/message-utils.ts new file mode 100644 index 000000000..64f67bcca --- /dev/null +++ b/libs/chat/src/lib/compositions/shared/message-utils.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { BaseMessage } from '@langchain/core/messages'; + +/** + * Extracts a human-readable string from a message's content. + * Handles string content directly; serializes structured (array) content to JSON. + */ +export function messageContent(message: BaseMessage): string { + const content = message.content; + if (typeof content === 'string') return content; + return JSON.stringify(content); +} diff --git a/libs/chat/src/lib/primitives/chat-error/chat-error.component.spec.ts b/libs/chat/src/lib/primitives/chat-error/chat-error.component.spec.ts new file mode 100644 index 000000000..2a68de602 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-error/chat-error.component.spec.ts @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { signal, computed } from '@angular/core'; +import { extractErrorMessage } from './chat-error.component'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; + +describe('extractErrorMessage()', () => { + it('returns null for null error', () => { + expect(extractErrorMessage(null)).toBeNull(); + }); + + it('returns null for undefined error', () => { + expect(extractErrorMessage(undefined)).toBeNull(); + }); + + it('extracts message from Error object', () => { + expect(extractErrorMessage(new Error('something went wrong'))).toBe('something went wrong'); + }); + + it('returns string errors as-is', () => { + expect(extractErrorMessage('network failure')).toBe('network failure'); + }); + + it('converts unknown values to string', () => { + expect(extractErrorMessage(42)).toBe('42'); + }); +}); + +describe('ChatErrorComponent — errorMessage computed', () => { + it('errorMessage is null when ref.error is null', () => { + const mockRef = createMockStreamResourceRef({ error: null }); + const ref$ = signal(mockRef); + + const errorMessage = computed(() => extractErrorMessage(ref$().error())); + + expect(errorMessage()).toBeNull(); + }); + + it('errorMessage reflects Error object message', () => { + const mockRef = createMockStreamResourceRef({ error: new Error('boom') }); + const ref$ = signal(mockRef); + + const errorMessage = computed(() => extractErrorMessage(ref$().error())); + + expect(errorMessage()).toBe('boom'); + }); + + it('errorMessage reflects string error', () => { + const mockRef = createMockStreamResourceRef({ error: 'timeout' }); + const ref$ = signal(mockRef); + + const errorMessage = computed(() => extractErrorMessage(ref$().error())); + + expect(errorMessage()).toBe('timeout'); + }); + + it('errorMessage updates reactively when ref changes', () => { + const noErrorRef = createMockStreamResourceRef({ error: null }); + const errorRef = createMockStreamResourceRef({ error: new Error('failed') }); + const ref$ = signal(noErrorRef); + + const errorMessage = computed(() => extractErrorMessage(ref$().error())); + + expect(errorMessage()).toBeNull(); + ref$.set(errorRef); + expect(errorMessage()).toBe('failed'); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-error/chat-error.component.ts b/libs/chat/src/lib/primitives/chat-error/chat-error.component.ts new file mode 100644 index 000000000..039ace1b1 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-error/chat-error.component.ts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + input, + ChangeDetectionStrategy, +} from '@angular/core'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; + +/** + * Extracts a human-readable message from an error value. + * Handles Error objects, strings, and unknown values. + * Exported for unit testing without DOM rendering. + */ +export function extractErrorMessage(error: unknown): string | null { + if (!error) return null; + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + return String(error); +} + +@Component({ + selector: 'chat-error', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (errorMessage(); as msg) { +
{{ msg }}
+ } + `, +}) +export class ChatErrorComponent { + readonly ref = input.required>(); + + readonly errorMessage = computed(() => extractErrorMessage(this.ref().error())); +} diff --git a/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.spec.ts b/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.spec.ts new file mode 100644 index 000000000..53b2ca35c --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.spec.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { signal, computed } from '@angular/core'; +import type { Spec } from '@json-render/core'; + +const makeSpec = (root = 'root'): Spec => + ({ root, elements: { root: { type: 'div', props: {} } } } as any); + +describe('ChatGenerativeUiComponent — spec input', () => { + it('spec input defaults to null', () => { + const spec$ = signal(null); + expect(spec$()).toBeNull(); + }); + + it('renders when spec is present', () => { + const spec$ = signal(makeSpec()); + const shouldRender = computed(() => spec$() !== null); + + expect(shouldRender()).toBe(true); + }); + + it('does not render when spec is null', () => { + const spec$ = signal(null); + const shouldRender = computed(() => spec$() !== null); + + expect(shouldRender()).toBe(false); + }); + + it('spec updates reactively', () => { + const spec$ = signal(null); + const shouldRender = computed(() => spec$() !== null); + + expect(shouldRender()).toBe(false); + spec$.set(makeSpec()); + expect(shouldRender()).toBe(true); + }); + + it('loading input defaults to false', () => { + const loading$ = signal(false); + expect(loading$()).toBe(false); + }); + + it('loading can be set to true', () => { + const loading$ = signal(true); + expect(loading$()).toBe(true); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts b/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts new file mode 100644 index 000000000..12aaf5d11 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + input, + ChangeDetectionStrategy, +} from '@angular/core'; +import type { Spec, StateStore } from '@json-render/core'; +import type { AngularRegistry } from '@cacheplane/render'; +import { RenderSpecComponent } from '@cacheplane/render'; + +@Component({ + selector: 'chat-generative-ui', + standalone: true, + imports: [RenderSpecComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (spec()) { + + } + `, +}) +export class ChatGenerativeUiComponent { + readonly spec = input(null); + readonly registry = input(undefined); + readonly store = input(undefined); + readonly loading = input(false); +} diff --git a/libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts b/libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts new file mode 100644 index 000000000..12ed7bed1 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect, vi } from 'vitest'; +import { signal, computed } from '@angular/core'; +import { HumanMessage } from '@langchain/core/messages'; +import { submitMessage } from './chat-input.component'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; + +describe('submitMessage()', () => { + it('calls ref.submit with a HumanMessage containing the trimmed text', () => { + const mockRef = createMockStreamResourceRef(); + const submitSpy = vi.spyOn(mockRef, 'submit'); + + submitMessage(mockRef, ' hello world '); + + expect(submitSpy).toHaveBeenCalledOnce(); + const args = submitSpy.mock.calls[0][0] as { messages: HumanMessage[] }; + expect(args.messages).toHaveLength(1); + expect(args.messages[0]).toBeInstanceOf(HumanMessage); + expect(args.messages[0].content).toBe('hello world'); + }); + + it('returns the trimmed text on successful submit', () => { + const mockRef = createMockStreamResourceRef(); + const result = submitMessage(mockRef, ' hello '); + expect(result).toBe('hello'); + }); + + it('does not call ref.submit and returns null for whitespace-only text', () => { + const mockRef = createMockStreamResourceRef(); + const submitSpy = vi.spyOn(mockRef, 'submit'); + + const result = submitMessage(mockRef, ' '); + + expect(submitSpy).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('does not call ref.submit and returns null for empty string', () => { + const mockRef = createMockStreamResourceRef(); + const submitSpy = vi.spyOn(mockRef, 'submit'); + + const result = submitMessage(mockRef, ''); + + expect(submitSpy).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); +}); + +describe('ChatInputComponent — isDisabled computed', () => { + it('isDisabled is false when ref.isLoading is false', () => { + const mockRef = createMockStreamResourceRef({ isLoading: false }); + const ref$ = signal(mockRef); + + const isDisabled = computed(() => ref$().isLoading()); + + expect(isDisabled()).toBe(false); + }); + + it('isDisabled is true when ref.isLoading is true', () => { + const mockRef = createMockStreamResourceRef({ isLoading: true }); + const ref$ = signal(mockRef); + + const isDisabled = computed(() => ref$().isLoading()); + + expect(isDisabled()).toBe(true); + }); + + it('isDisabled updates reactively when ref changes', () => { + const idleRef = createMockStreamResourceRef({ isLoading: false }); + const loadingRef = createMockStreamResourceRef({ isLoading: true }); + const ref$ = signal(idleRef); + + const isDisabled = computed(() => ref$().isLoading()); + + expect(isDisabled()).toBe(false); + ref$.set(loadingRef); + expect(isDisabled()).toBe(true); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts b/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts new file mode 100644 index 000000000..85c979fd0 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + input, + output, + signal, + ChangeDetectionStrategy, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { HumanMessage } from '@langchain/core/messages'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; + +/** + * Submits a message to a StreamResourceRef. + * Returns the trimmed text that was submitted, or null if the text was empty. + * Exported for unit testing without DOM rendering. + */ +export function submitMessage( + ref: StreamResourceRef, + text: string, +): string | null { + const trimmed = text.trim(); + if (!trimmed) return null; + ref.submit({ messages: [new HumanMessage(trimmed)] }); + return trimmed; +} + +@Component({ + selector: 'chat-input', + standalone: true, + imports: [FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + +
+ `, +}) +export class ChatInputComponent { + readonly ref = input.required>(); + readonly submitOnEnter = input(true); + readonly placeholder = input(''); + + readonly submitted = output(); + + readonly messageText = signal(''); + + readonly isDisabled = computed(() => this.ref().isLoading()); + + onSubmit(): void { + const submitted = submitMessage(this.ref(), this.messageText()); + if (submitted !== null) { + this.submitted.emit(submitted); + this.messageText.set(''); + } + } + + onKeydown(event: KeyboardEvent): void { + if (this.submitOnEnter() && !event.shiftKey) { + event.preventDefault(); + this.onSubmit(); + } + } +} diff --git a/libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.spec.ts b/libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.spec.ts new file mode 100644 index 000000000..0706a2a94 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.spec.ts @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { signal, computed } from '@angular/core'; +import { getInterrupt } from './chat-interrupt.component'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; +import type { Interrupt } from '@cacheplane/stream-resource'; + +describe('getInterrupt()', () => { + it('returns undefined when no interrupt is present', () => { + const mockRef = createMockStreamResourceRef(); + expect(getInterrupt(mockRef)).toBeUndefined(); + }); + + it('returns the interrupt value when present', () => { + const mockInterrupt: Interrupt = { value: { question: 'Confirm?' } } as any; + const mockRef = createMockStreamResourceRef(); + // Cast to access writable signal for test setup + (mockRef.interrupt as ReturnType | undefined>>).set(mockInterrupt); + + expect(getInterrupt(mockRef)).toBe(mockInterrupt); + }); +}); + +describe('ChatInterruptComponent — interrupt computed', () => { + it('interrupt is undefined when ref has no interrupt', () => { + const mockRef = createMockStreamResourceRef(); + const ref$ = signal(mockRef); + + const interrupt = computed(() => ref$().interrupt()); + + expect(interrupt()).toBeUndefined(); + }); + + it('interrupt reflects ref.interrupt value when present', () => { + const mockInterrupt: Interrupt = { value: { step: 'confirm' } } as any; + const mockRef = createMockStreamResourceRef(); + (mockRef.interrupt as ReturnType | undefined>>).set(mockInterrupt); + + const ref$ = signal(mockRef); + const interrupt = computed(() => ref$().interrupt()); + + expect(interrupt()).toBe(mockInterrupt); + }); + + it('interrupt updates reactively when ref changes', () => { + const noInterruptRef = createMockStreamResourceRef(); + const interruptRef = createMockStreamResourceRef(); + const mockInterrupt: Interrupt = { value: { type: 'human_review' } } as any; + (interruptRef.interrupt as ReturnType | undefined>>).set(mockInterrupt); + + const ref$ = signal(noInterruptRef); + const interrupt = computed(() => ref$().interrupt()); + + expect(interrupt()).toBeUndefined(); + ref$.set(interruptRef); + expect(interrupt()).toBe(mockInterrupt); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts b/libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts new file mode 100644 index 000000000..fb8407488 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + contentChild, + input, + TemplateRef, + ChangeDetectionStrategy, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { Interrupt } from '@cacheplane/stream-resource'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; + +/** + * Retrieves the current interrupt value from a StreamResourceRef. + * Exported for unit testing without DOM rendering. + */ +export function getInterrupt(ref: StreamResourceRef): Interrupt | undefined { + return ref.interrupt(); +} + +@Component({ + selector: 'chat-interrupt', + standalone: true, + imports: [NgTemplateOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (interrupt(); as currentInterrupt) { + @if (templateRef()) { + + } + } + `, +}) +export class ChatInterruptComponent { + readonly ref = input.required>(); + + readonly templateRef = contentChild(TemplateRef); + + readonly interrupt = computed(() => this.ref().interrupt()); +} diff --git a/libs/chat/src/lib/primitives/chat-messages/chat-messages.component.spec.ts b/libs/chat/src/lib/primitives/chat-messages/chat-messages.component.spec.ts new file mode 100644 index 000000000..f62f7cc2e --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-messages/chat-messages.component.spec.ts @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { signal } from '@angular/core'; +import { HumanMessage, AIMessage, SystemMessage, ToolMessage, FunctionMessage } from '@langchain/core/messages'; +import { getMessageType } from './chat-messages.component'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; + +describe('getMessageType', () => { + it('maps HumanMessage to "human"', () => { + expect(getMessageType(new HumanMessage('hello'))).toBe('human'); + }); + + it('maps AIMessage to "ai"', () => { + expect(getMessageType(new AIMessage('response'))).toBe('ai'); + }); + + it('maps SystemMessage to "system"', () => { + expect(getMessageType(new SystemMessage('system prompt'))).toBe('system'); + }); + + it('maps ToolMessage to "tool"', () => { + const toolMsg = new ToolMessage({ content: 'result', tool_call_id: 'call_1' }); + expect(getMessageType(toolMsg)).toBe('tool'); + }); + + it('maps FunctionMessage to "function"', () => { + const fnMsg = new FunctionMessage({ content: 'result', name: 'my_fn' }); + expect(getMessageType(fnMsg)).toBe('function'); + }); + + it('falls back to "ai" for unknown message types', () => { + const unknownMsg = { _getType: () => 'unknown' } as any; + expect(getMessageType(unknownMsg)).toBe('ai'); + }); +}); + +describe('ChatMessagesComponent — computed messages', () => { + it('messages() signal reflects the ref messages signal', () => { + const msgs = [new HumanMessage('hi'), new AIMessage('hello')]; + const mockRef = createMockStreamResourceRef({ messages: msgs }); + + // Simulate what the component computes: ref().messages() + const ref$ = signal(mockRef); + const messages = () => ref$().messages(); + + expect(messages()).toHaveLength(2); + expect(messages()[0]._getType()).toBe('human'); + expect(messages()[1]._getType()).toBe('ai'); + }); + + it('messages() updates reactively when ref messages change', () => { + const mockRef = createMockStreamResourceRef({ messages: [] }); + const ref$ = signal(mockRef); + const messages = () => ref$().messages(); + + expect(messages()).toHaveLength(0); + + // Swap the ref to one with messages to test signal reactivity + const updatedRef = createMockStreamResourceRef({ + messages: [new HumanMessage('new message')], + }); + ref$.set(updatedRef); + + expect(messages()).toHaveLength(1); + }); +}); + +describe('ChatMessagesComponent — findTemplate logic', () => { + it('findTemplate returns matching directive by type', () => { + // Simulate findTemplate logic: find in array by chatMessageTemplate() value + const templates = [ + { chatMessageTemplate: () => 'human' as const, templateRef: {} }, + { chatMessageTemplate: () => 'ai' as const, templateRef: {} }, + ]; + + const findTemplate = (type: string) => + templates.find(t => t.chatMessageTemplate() === type); + + expect(findTemplate('human')).toBeDefined(); + expect(findTemplate('human')?.chatMessageTemplate()).toBe('human'); + expect(findTemplate('ai')).toBeDefined(); + expect(findTemplate('tool')).toBeUndefined(); + }); + + it('findTemplate returns undefined when no templates registered', () => { + const templates: { chatMessageTemplate: () => string }[] = []; + const findTemplate = (type: string) => + templates.find(t => t.chatMessageTemplate() === type); + + expect(findTemplate('human')).toBeUndefined(); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-messages/chat-messages.component.ts b/libs/chat/src/lib/primitives/chat-messages/chat-messages.component.ts new file mode 100644 index 000000000..d0adc213e --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-messages/chat-messages.component.ts @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + contentChildren, + input, + ChangeDetectionStrategy, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { BaseMessage } from '@langchain/core/messages'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; +import { MessageTemplateDirective } from './message-template.directive'; +import type { MessageTemplateType } from '../../chat.types'; + +/** + * Maps a LangChain message `_getType()` string to a {@link MessageTemplateType}. + * Exported as a standalone function so it can be unit-tested without DOM rendering. + */ +export function getMessageType(message: BaseMessage): MessageTemplateType { + const type = message._getType(); + switch (type) { + case 'human': + return 'human'; + case 'ai': + return 'ai'; + case 'tool': + return 'tool'; + case 'system': + return 'system'; + case 'function': + return 'function'; + default: + return 'ai'; + } +} + +@Component({ + selector: 'chat-messages', + standalone: true, + imports: [NgTemplateOutlet, MessageTemplateDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (message of messages(); track $index) { + @let template = findTemplate(getMessageType(message)); + @if (template) { + + } + } + `, +}) +export class ChatMessagesComponent { + readonly ref = input.required>(); + + readonly messageTemplates = contentChildren(MessageTemplateDirective); + + readonly messages = computed(() => this.ref().messages()); + + readonly getMessageType = getMessageType; + + findTemplate(type: MessageTemplateType): MessageTemplateDirective | undefined { + return this.messageTemplates().find(t => t.chatMessageTemplate() === type); + } +} diff --git a/libs/chat/src/lib/primitives/chat-messages/message-template.directive.ts b/libs/chat/src/lib/primitives/chat-messages/message-template.directive.ts new file mode 100644 index 000000000..ec393e47b --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-messages/message-template.directive.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Directive, input, TemplateRef, inject } from '@angular/core'; +import type { MessageTemplateType } from '../../chat.types'; + +@Directive({ + selector: 'ng-template[chatMessageTemplate]', + standalone: true, +}) +export class MessageTemplateDirective { + readonly chatMessageTemplate = input.required(); + readonly templateRef = inject(TemplateRef); +} diff --git a/libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.spec.ts b/libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.spec.ts new file mode 100644 index 000000000..713d2e97f --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.spec.ts @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { signal, computed } from '@angular/core'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; +import type { SubagentStreamRef } from '@cacheplane/stream-resource'; + +describe('ChatSubagentsComponent — activeSubagents computed', () => { + it('returns empty array when no active subagents', () => { + const mockRef = createMockStreamResourceRef(); + const ref$ = signal(mockRef); + + const activeSubagents = computed(() => ref$().activeSubagents()); + + expect(activeSubagents()).toHaveLength(0); + }); + + it('returns active subagents from ref', () => { + const mockSubagent: SubagentStreamRef = { + id: 'sub_1', + isLoading: signal(true), + messages: signal([]), + status: signal('running' as any), + error: signal(null), + } as any; + + const mockRef = createMockStreamResourceRef(); + (mockRef.activeSubagents as ReturnType>).set([mockSubagent]); + + const ref$ = signal(mockRef); + const activeSubagents = computed(() => ref$().activeSubagents()); + + expect(activeSubagents()).toHaveLength(1); + expect(activeSubagents()[0]).toBe(mockSubagent); + }); + + it('activeSubagents updates reactively when ref changes', () => { + const emptyRef = createMockStreamResourceRef(); + const loadedRef = createMockStreamResourceRef(); + const mockSubagent: SubagentStreamRef = { + id: 'sub_2', + isLoading: signal(false), + messages: signal([]), + status: signal('done' as any), + error: signal(null), + } as any; + (loadedRef.activeSubagents as ReturnType>).set([mockSubagent]); + + const ref$ = signal(emptyRef); + const activeSubagents = computed(() => ref$().activeSubagents()); + + expect(activeSubagents()).toHaveLength(0); + ref$.set(loadedRef); + expect(activeSubagents()).toHaveLength(1); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.ts b/libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.ts new file mode 100644 index 000000000..f1b82c8d0 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.ts @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + contentChild, + input, + TemplateRef, + ChangeDetectionStrategy, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { StreamResourceRef, SubagentStreamRef } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'chat-subagents', + standalone: true, + imports: [NgTemplateOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (subagent of activeSubagents(); track $index) { + @if (templateRef()) { + + } + } + `, +}) +export class ChatSubagentsComponent { + readonly ref = input.required>(); + + readonly templateRef = contentChild(TemplateRef); + + readonly activeSubagents = computed((): SubagentStreamRef[] => this.ref().activeSubagents()); +} diff --git a/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts new file mode 100644 index 000000000..d1415543a --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { signal, computed } from '@angular/core'; +import type { Thread } from './chat-thread-list.component'; + +const threads: Thread[] = [ + { id: 'thread-1', title: 'First Thread' }, + { id: 'thread-2', title: 'Second Thread' }, + { id: 'thread-3', title: 'Third Thread' }, +]; + +describe('ChatThreadListComponent — structure', () => { + it('threads input signal holds provided threads', () => { + const threads$ = signal(threads); + expect(threads$()).toHaveLength(3); + expect(threads$()[0].id).toBe('thread-1'); + }); + + it('activeThreadId input defaults to empty string', () => { + const activeThreadId$ = signal(''); + expect(activeThreadId$()).toBe(''); + }); + + it('isActive context is true when thread.id matches activeThreadId', () => { + const activeThreadId$ = signal('thread-2'); + + const contextForThread = (thread: Thread) => ({ + $implicit: thread, + isActive: thread.id === activeThreadId$(), + }); + + expect(contextForThread(threads[0]).isActive).toBe(false); + expect(contextForThread(threads[1]).isActive).toBe(true); + expect(contextForThread(threads[2]).isActive).toBe(false); + }); + + it('isActive updates reactively when activeThreadId changes', () => { + const activeThreadId$ = signal('thread-1'); + + const isActive = (thread: Thread) => + computed(() => thread.id === activeThreadId$()); + + const thread1Active = isActive(threads[0]); + const thread2Active = isActive(threads[1]); + + expect(thread1Active()).toBe(true); + expect(thread2Active()).toBe(false); + + activeThreadId$.set('thread-2'); + + expect(thread1Active()).toBe(false); + expect(thread2Active()).toBe(true); + }); + + it('renders context with $implicit thread reference', () => { + const threads$ = signal(threads); + const activeThreadId$ = signal('thread-3'); + + const contexts = computed(() => + threads$().map(thread => ({ + $implicit: thread, + isActive: thread.id === activeThreadId$(), + })) + ); + + const result = contexts(); + expect(result).toHaveLength(3); + expect(result[2].$implicit.id).toBe('thread-3'); + expect(result[2].isActive).toBe(true); + }); + + it('threads updates reactively when thread list changes', () => { + const threads$ = signal(threads.slice(0, 2)); + expect(threads$()).toHaveLength(2); + + threads$.set(threads); + expect(threads$()).toHaveLength(3); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts new file mode 100644 index 000000000..4335e7332 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + contentChild, + input, + output, + TemplateRef, + ChangeDetectionStrategy, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; + +export type Thread = { id: string; [key: string]: unknown }; + +@Component({ + selector: 'chat-thread-list', + standalone: true, + imports: [NgTemplateOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (thread of threads(); track thread.id) { + @if (templateRef()) { + + } + } + `, +}) +export class ChatThreadListComponent { + readonly threads = input.required(); + readonly activeThreadId = input(''); + + readonly threadSelected = output(); + + readonly templateRef = contentChild(TemplateRef); + + selectThread(threadId: string): void { + this.threadSelected.emit(threadId); + } +} diff --git a/libs/chat/src/lib/primitives/chat-timeline/chat-timeline.component.spec.ts b/libs/chat/src/lib/primitives/chat-timeline/chat-timeline.component.spec.ts new file mode 100644 index 000000000..c5051ac6f --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-timeline/chat-timeline.component.spec.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { signal, computed } from '@angular/core'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; +import type { ThreadState } from '@cacheplane/stream-resource'; + +const makeState = (id: string): ThreadState => + ({ checkpoint_id: id, values: {}, next: [], metadata: {} } as any); + +describe('ChatTimelineComponent — history computed', () => { + it('returns empty array when ref has no history', () => { + const mockRef = createMockStreamResourceRef(); + const ref$ = signal(mockRef); + + const history = computed(() => ref$().history()); + + expect(history()).toHaveLength(0); + }); + + it('returns history states from ref', () => { + const states = [makeState('cp-1'), makeState('cp-2')]; + const mockRef = createMockStreamResourceRef(); + (mockRef.history as ReturnType[]>>).set(states); + + const ref$ = signal(mockRef); + const history = computed(() => ref$().history()); + + expect(history()).toHaveLength(2); + expect(history()[0]).toBe(states[0]); + expect(history()[1]).toBe(states[1]); + }); + + it('history updates reactively when ref changes', () => { + const emptyRef = createMockStreamResourceRef(); + const loadedRef = createMockStreamResourceRef(); + const states = [makeState('cp-3')]; + (loadedRef.history as ReturnType[]>>).set(states); + + const ref$ = signal(emptyRef); + const history = computed(() => ref$().history()); + + expect(history()).toHaveLength(0); + ref$.set(loadedRef); + expect(history()).toHaveLength(1); + expect(history()[0]).toBe(states[0]); + }); + + it('history updates reactively when ref.history signal changes', () => { + const mockRef = createMockStreamResourceRef(); + const ref$ = signal(mockRef); + const history = computed(() => ref$().history()); + + expect(history()).toHaveLength(0); + + const states = [makeState('cp-4'), makeState('cp-5')]; + (mockRef.history as ReturnType[]>>).set(states); + + expect(history()).toHaveLength(2); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-timeline/chat-timeline.component.ts b/libs/chat/src/lib/primitives/chat-timeline/chat-timeline.component.ts new file mode 100644 index 000000000..2ce8c3a64 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-timeline/chat-timeline.component.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + contentChild, + input, + output, + TemplateRef, + ChangeDetectionStrategy, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { StreamResourceRef, ThreadState } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'chat-timeline', + standalone: true, + imports: [NgTemplateOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (state of history(); track $index) { + @if (templateRef()) { + + } + } + `, +}) +export class ChatTimelineComponent { + readonly ref = input.required>(); + + readonly checkpointSelected = output>(); + + readonly templateRef = contentChild(TemplateRef); + + readonly history = computed((): ThreadState[] => this.ref().history()); + + selectCheckpoint(state: ThreadState): void { + this.checkpointSelected.emit(state); + } +} diff --git a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts new file mode 100644 index 000000000..c181b1275 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { signal, computed } from '@angular/core'; +import { AIMessage, HumanMessage } from '@langchain/core/messages'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; +import type { ToolCallWithResult } from '@cacheplane/stream-resource'; + +describe('ChatToolCallsComponent — toolCalls computed', () => { + it('returns ref.toolCalls() when no message is provided', () => { + const mockToolCalls: ToolCallWithResult[] = [ + { id: 'call_1', name: 'get_weather', args: { city: 'NYC' }, result: null } as any, + ]; + const mockRef = createMockStreamResourceRef(); + (mockRef.toolCalls as ReturnType>).set(mockToolCalls); + + const ref$ = signal(mockRef); + const toolCalls = computed(() => ref$().toolCalls()); + + expect(toolCalls()).toHaveLength(1); + expect(toolCalls()[0].id).toBe('call_1'); + }); + + it('returns ref.toolCalls() when message has no tool_calls', () => { + const mockRef = createMockStreamResourceRef(); + const msg = new HumanMessage('hello'); + + const ref$ = signal(mockRef); + const message$ = signal(msg); + + // Simulate component logic: use message tool_calls if present, else ref + const toolCalls = computed(() => { + const m = message$(); + if (m && 'tool_calls' in m && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) { + return m.tool_calls; + } + return ref$().toolCalls(); + }); + + expect(toolCalls()).toHaveLength(0); + }); + + it('returns message tool_calls when message has tool_calls', () => { + const mockRef = createMockStreamResourceRef(); + const msg = new AIMessage({ + content: '', + tool_calls: [{ id: 'call_2', name: 'search', args: { query: 'test' } }], + }); + + const ref$ = signal(mockRef); + const message$ = signal(msg); + + const toolCalls = computed(() => { + const m = message$(); + if (m && 'tool_calls' in m && Array.isArray((m as any).tool_calls)) { + return (m as any).tool_calls; + } + return ref$().toolCalls(); + }); + + expect(toolCalls()).toHaveLength(1); + expect(toolCalls()[0].id).toBe('call_2'); + expect(toolCalls()[0].name).toBe('search'); + }); + + it('toolCalls updates reactively when ref changes', () => { + const emptyRef = createMockStreamResourceRef(); + const loadedRef = createMockStreamResourceRef(); + const mockToolCalls: ToolCallWithResult[] = [ + { id: 'call_3', name: 'calculator', args: {}, result: null } as any, + ]; + (loadedRef.toolCalls as ReturnType>).set(mockToolCalls); + + const ref$ = signal(emptyRef); + const toolCalls = computed(() => ref$().toolCalls()); + + expect(toolCalls()).toHaveLength(0); + ref$.set(loadedRef); + expect(toolCalls()).toHaveLength(1); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts new file mode 100644 index 000000000..5e97beb7b --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + contentChild, + input, + TemplateRef, + ChangeDetectionStrategy, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { AIMessage } from '@langchain/core/messages'; +import type { BaseMessage } from '@langchain/core/messages'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; +import type { ToolCallWithResult } from '@langchain/langgraph-sdk'; + +@Component({ + selector: 'chat-tool-calls', + standalone: true, + imports: [NgTemplateOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (toolCall of toolCalls(); track toolCall.id) { + @if (templateRef()) { + + } + } + `, +}) +export class ChatToolCallsComponent { + readonly ref = input.required>(); + readonly message = input(undefined); + + readonly templateRef = contentChild(TemplateRef); + + readonly toolCalls = computed((): ToolCallWithResult[] => { + const msg = this.message(); + if (msg instanceof AIMessage) { + return this.ref().getToolCalls(msg); + } + return this.ref().toolCalls(); + }); +} diff --git a/libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.spec.ts b/libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.spec.ts new file mode 100644 index 000000000..3b4b22e63 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.spec.ts @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { signal, computed } from '@angular/core'; +import { isTyping } from './chat-typing-indicator.component'; +import { createMockStreamResourceRef } from '../../testing/mock-stream-resource-ref'; + +describe('isTyping()', () => { + it('returns false when ref.isLoading is false', () => { + const mockRef = createMockStreamResourceRef({ isLoading: false }); + expect(isTyping(mockRef)).toBe(false); + }); + + it('returns true when ref.isLoading is true', () => { + const mockRef = createMockStreamResourceRef({ isLoading: true }); + expect(isTyping(mockRef)).toBe(true); + }); +}); + +describe('ChatTypingIndicatorComponent — visible computed', () => { + it('visible is false when ref.isLoading is false', () => { + const mockRef = createMockStreamResourceRef({ isLoading: false }); + const ref$ = signal(mockRef); + + const visible = computed(() => ref$().isLoading()); + + expect(visible()).toBe(false); + }); + + it('visible is true when ref.isLoading is true', () => { + const mockRef = createMockStreamResourceRef({ isLoading: true }); + const ref$ = signal(mockRef); + + const visible = computed(() => ref$().isLoading()); + + expect(visible()).toBe(true); + }); + + it('visible updates reactively when ref changes', () => { + const idleRef = createMockStreamResourceRef({ isLoading: false }); + const loadingRef = createMockStreamResourceRef({ isLoading: true }); + const ref$ = signal(idleRef); + + const visible = computed(() => ref$().isLoading()); + + expect(visible()).toBe(false); + ref$.set(loadingRef); + expect(visible()).toBe(true); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts b/libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts new file mode 100644 index 000000000..05c5f88b1 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + input, + ChangeDetectionStrategy, +} from '@angular/core'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; + +/** + * Returns whether the typing indicator should be visible. + * Exported for unit testing without DOM rendering. + */ +export function isTyping(ref: StreamResourceRef): boolean { + return ref.isLoading(); +} + +@Component({ + selector: 'chat-typing-indicator', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (visible()) { +
+ ... +
+ } + `, +}) +export class ChatTypingIndicatorComponent { + readonly ref = input.required>(); + + readonly visible = computed(() => this.ref().isLoading()); +} diff --git a/libs/chat/src/lib/provide-chat.spec.ts b/libs/chat/src/lib/provide-chat.spec.ts new file mode 100644 index 000000000..f1f961d57 --- /dev/null +++ b/libs/chat/src/lib/provide-chat.spec.ts @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { provideChat, CHAT_CONFIG } from './provide-chat'; +import type { ChatConfig } from './chat.types'; + +describe('provideChat', () => { + it('registers CHAT_CONFIG token with the provided config', () => { + const config: ChatConfig = { registry: undefined }; + + TestBed.configureTestingModule({ + providers: [provideChat(config)], + }); + + const injected = TestBed.inject(CHAT_CONFIG); + expect(injected).toBe(config); + }); + + it('injects the exact config object reference', () => { + const config: ChatConfig = {}; + + TestBed.configureTestingModule({ + providers: [provideChat(config)], + }); + + expect(TestBed.inject(CHAT_CONFIG)).toStrictEqual({}); + }); + + it('returns environment providers (duck-type check)', () => { + const result = provideChat({}); + // makeEnvironmentProviders returns an object with ɵproviders + expect(result).toBeDefined(); + expect(typeof result).toBe('object'); + }); +}); diff --git a/libs/chat/src/lib/provide-chat.ts b/libs/chat/src/lib/provide-chat.ts new file mode 100644 index 000000000..9dc50881d --- /dev/null +++ b/libs/chat/src/lib/provide-chat.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { InjectionToken, makeEnvironmentProviders } from '@angular/core'; +import type { ChatConfig } from './chat.types'; + +export const CHAT_CONFIG = new InjectionToken('CHAT_CONFIG'); + +export function provideChat(config: ChatConfig) { + return makeEnvironmentProviders([ + { provide: CHAT_CONFIG, useValue: config }, + ]); +} diff --git a/libs/chat/src/lib/testing/mock-stream-resource-ref.spec.ts b/libs/chat/src/lib/testing/mock-stream-resource-ref.spec.ts new file mode 100644 index 000000000..deea82bfe --- /dev/null +++ b/libs/chat/src/lib/testing/mock-stream-resource-ref.spec.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { createMockStreamResourceRef } from './mock-stream-resource-ref'; +import { ResourceStatus } from '@cacheplane/stream-resource'; + +describe('createMockStreamResourceRef', () => { + it('creates a mock with default values', () => { + const ref = createMockStreamResourceRef(); + + expect(ref.messages()).toEqual([]); + expect(ref.status()).toBe(ResourceStatus.Idle); + expect(ref.isLoading()).toBe(false); + expect(ref.error()).toBeNull(); + expect(ref.hasValue()).toBe(false); + expect(ref.isThreadLoading()).toBe(false); + expect(ref.interrupt()).toBeUndefined(); + expect(ref.interrupts()).toEqual([]); + expect(ref.toolProgress()).toEqual([]); + expect(ref.toolCalls()).toEqual([]); + expect(ref.branch()).toBe(''); + expect(ref.history()).toEqual([]); + expect(ref.subagents().size).toBe(0); + expect(ref.activeSubagents()).toEqual([]); + }); + + it('accepts initial values for signals', () => { + const ref = createMockStreamResourceRef({ + status: ResourceStatus.Loading, + isLoading: true, + hasValue: true, + isThreadLoading: true, + error: new Error('test error'), + }); + + expect(ref.status()).toBe(ResourceStatus.Loading); + expect(ref.isLoading()).toBe(true); + expect(ref.hasValue()).toBe(true); + expect(ref.isThreadLoading()).toBe(true); + expect(ref.error()).toBeInstanceOf(Error); + }); + + it('has callable action methods', async () => { + const ref = createMockStreamResourceRef(); + + await expect(ref.submit(null)).resolves.toBeUndefined(); + await expect(ref.stop()).resolves.toBeUndefined(); + await expect(ref.joinStream('run-1')).resolves.toBeUndefined(); + expect(() => ref.reload()).not.toThrow(); + expect(() => ref.switchThread('thread-1')).not.toThrow(); + expect(() => ref.setBranch('branch-1')).not.toThrow(); + }); + + it('getMessagesMetadata returns undefined by default', () => { + const ref = createMockStreamResourceRef(); + const result = ref.getMessagesMetadata({} as any); + expect(result).toBeUndefined(); + }); + + it('getToolCalls returns empty array by default', () => { + const ref = createMockStreamResourceRef(); + const result = ref.getToolCalls({} as any); + expect(result).toEqual([]); + }); +}); diff --git a/libs/chat/src/lib/testing/mock-stream-resource-ref.ts b/libs/chat/src/lib/testing/mock-stream-resource-ref.ts new file mode 100644 index 000000000..936501235 --- /dev/null +++ b/libs/chat/src/lib/testing/mock-stream-resource-ref.ts @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { signal, WritableSignal } from '@angular/core'; +import type { StreamResourceRef, SubagentStreamRef, ResourceStatus as ResourceStatusType, Interrupt, ThreadState, SubmitOptions } from '@cacheplane/stream-resource'; +import type { ToolProgress, ToolCallWithResult } from '@langchain/langgraph-sdk'; +import { ResourceStatus } from '@cacheplane/stream-resource'; +import type { BaseMessage, AIMessage as CoreAIMessage } from '@langchain/core/messages'; +import type { MessageMetadata } from '@langchain/langgraph-sdk/ui'; + +/** + * A StreamResourceRef with writable signals for easy test control. + * Cast the result of createMockStreamResourceRef() to this type to access + * writable signals without unsafe casts in test files. + */ +export interface MockStreamResourceRef extends StreamResourceRef { + messages: WritableSignal; + status: WritableSignal; + error: WritableSignal; + interrupt: WritableSignal | undefined>; + interrupts: WritableSignal[]>; + isLoading: WritableSignal; + hasValue: WritableSignal; + value: WritableSignal; + toolProgress: WritableSignal; + toolCalls: WritableSignal; + branch: WritableSignal; + history: WritableSignal[]>; + isThreadLoading: WritableSignal; + subagents: WritableSignal>; + activeSubagents: WritableSignal; +} + +/** + * Creates a mock StreamResourceRef with writable signals for testing. + * Control state by writing to the returned writable signals directly. + */ +export function createMockStreamResourceRef( + initial: { + messages?: BaseMessage[]; + status?: ResourceStatusType; + isLoading?: boolean; + error?: unknown; + hasValue?: boolean; + isThreadLoading?: boolean; + } = {} +): MockStreamResourceRef { + const messages$ = signal(initial.messages ?? []); + const status$ = signal(initial.status ?? ResourceStatus.Idle); + const isLoading$ = signal(initial.isLoading ?? false); + const error$ = signal(initial.error ?? null); + const hasValue$ = signal(initial.hasValue ?? false); + const value$ = signal(null); + const interrupt$ = signal | undefined>(undefined); + const interrupts$ = signal[]>([]); + const toolProgress$ = signal([]); + const toolCalls$ = signal([]); + const branch$ = signal(''); + const history$ = signal[]>([]); + const isThreadLoading$ = signal(initial.isThreadLoading ?? false); + const subagents$ = signal>(new Map()); + const activeSubagents$ = signal([]); + + const ref: MockStreamResourceRef = { + value: value$, + status: status$, + isLoading: isLoading$, + error: error$, + hasValue: hasValue$, + // eslint-disable-next-line @typescript-eslint/no-empty-function + reload: () => {}, + + messages: messages$, + interrupt: interrupt$, + interrupts: interrupts$, + toolProgress: toolProgress$, + toolCalls: toolCalls$, + + branch: branch$, + history: history$, + isThreadLoading: isThreadLoading$, + + subagents: subagents$, + activeSubagents: activeSubagents$, + + submit: (_values: any, _opts?: SubmitOptions) => Promise.resolve(), + stop: () => Promise.resolve(), + // eslint-disable-next-line @typescript-eslint/no-empty-function + switchThread: (_threadId: string | null) => {}, + joinStream: (_runId: string, _lastEventId?: string) => Promise.resolve(), + // eslint-disable-next-line @typescript-eslint/no-empty-function + setBranch: (_branch: string) => {}, + getMessagesMetadata: (_msg: BaseMessage, _idx?: number): MessageMetadata> | undefined => undefined, + getToolCalls: (_msg: CoreAIMessage): ToolCallWithResult[] => [], + }; + + return ref as MockStreamResourceRef; +} diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts new file mode 100644 index 000000000..ae1b8b411 --- /dev/null +++ b/libs/chat/src/public-api.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +// Shared types +export type { ChatConfig, MessageTemplateType } from './lib/chat.types'; + +// Primitives +export { ChatMessagesComponent } from './lib/primitives/chat-messages/chat-messages.component'; +export { MessageTemplateDirective } from './lib/primitives/chat-messages/message-template.directive'; +export { getMessageType } from './lib/primitives/chat-messages/chat-messages.component'; +export { ChatInputComponent, submitMessage } from './lib/primitives/chat-input/chat-input.component'; +export { ChatTypingIndicatorComponent, isTyping } from './lib/primitives/chat-typing-indicator/chat-typing-indicator.component'; +export { ChatErrorComponent, extractErrorMessage } from './lib/primitives/chat-error/chat-error.component'; +export { ChatInterruptComponent, getInterrupt } from './lib/primitives/chat-interrupt/chat-interrupt.component'; +export { ChatToolCallsComponent } from './lib/primitives/chat-tool-calls/chat-tool-calls.component'; +export { ChatSubagentsComponent } from './lib/primitives/chat-subagents/chat-subagents.component'; +export { ChatThreadListComponent } from './lib/primitives/chat-thread-list/chat-thread-list.component'; +export type { Thread } from './lib/primitives/chat-thread-list/chat-thread-list.component'; +export { ChatTimelineComponent } from './lib/primitives/chat-timeline/chat-timeline.component'; +export { ChatGenerativeUiComponent } from './lib/primitives/chat-generative-ui/chat-generative-ui.component'; + +// DI provider +export { provideChat, CHAT_CONFIG } from './lib/provide-chat'; + +// Compositions +export { ChatComponent } from './lib/compositions/chat/chat.component'; +export { ChatInterruptPanelComponent } from './lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component'; +export type { InterruptAction } from './lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component'; +export { ChatToolCallCardComponent } from './lib/compositions/chat-tool-call-card/chat-tool-call-card.component'; +export type { ToolCallInfo } from './lib/compositions/chat-tool-call-card/chat-tool-call-card.component'; +export { ChatSubagentCardComponent } from './lib/compositions/chat-subagent-card/chat-subagent-card.component'; +export { ChatTimelineSliderComponent } from './lib/compositions/chat-timeline-slider/chat-timeline-slider.component'; +export { ChatDebugComponent } from './lib/compositions/chat-debug/chat-debug.component'; +export { toDebugCheckpoint, extractStateValues } from './lib/compositions/chat-debug/debug-utils'; +export { DebugCheckpointCardComponent } from './lib/compositions/chat-debug/debug-checkpoint-card.component'; +export type { DebugCheckpoint } from './lib/compositions/chat-debug/debug-checkpoint-card.component'; +export { DebugStateInspectorComponent } from './lib/compositions/chat-debug/debug-state-inspector.component'; +export { DebugStateDiffComponent } from './lib/compositions/chat-debug/debug-state-diff.component'; +export { DebugTimelineComponent } from './lib/compositions/chat-debug/debug-timeline.component'; +export { DebugDetailComponent } from './lib/compositions/chat-debug/debug-detail.component'; +export { DebugControlsComponent } from './lib/compositions/chat-debug/debug-controls.component'; +export { DebugSummaryComponent } from './lib/compositions/chat-debug/debug-summary.component'; +export { computeStateDiff } from './lib/compositions/chat-debug/state-diff'; +export type { DiffEntry } from './lib/compositions/chat-debug/state-diff'; + +// Test utilities +export { createMockStreamResourceRef } from './lib/testing/mock-stream-resource-ref'; diff --git a/libs/chat/src/test-setup.ts b/libs/chat/src/test-setup.ts new file mode 100644 index 000000000..17049f119 --- /dev/null +++ b/libs/chat/src/test-setup.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { getTestBed } from '@angular/core/testing'; +import { + BrowserTestingModule, + platformBrowserTesting, +} from '@angular/platform-browser/testing'; + +getTestBed().initTestEnvironment( + BrowserTestingModule, + platformBrowserTesting(), + { teardown: { destroyAfterEach: true } }, +); diff --git a/libs/chat/tsconfig.json b/libs/chat/tsconfig.json index 8bac52388..da190b437 100644 --- a/libs/chat/tsconfig.json +++ b/libs/chat/tsconfig.json @@ -1,12 +1,23 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", "experimentalDecorators": true, + "noPropertyAccessFromIndexSignature": true, + "module": "preserve", "emitDeclarationOnly": false, - "noEmit": true, - "lib": ["ES2022", "dom"] - } + "composite": false + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] } diff --git a/libs/chat/tsconfig.lib.json b/libs/chat/tsconfig.lib.json index 643573425..afcadee07 100644 --- a/libs/chat/tsconfig.lib.json +++ b/libs/chat/tsconfig.lib.json @@ -2,8 +2,12 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "declaration": true + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "lib": ["es2022", "dom"], + "types": [] }, "include": ["src/**/*.ts"], - "exclude": ["src/**/*.spec.ts"] + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] } diff --git a/libs/chat/tsconfig.lib.prod.json b/libs/chat/tsconfig.lib.prod.json new file mode 100644 index 000000000..2a2faa884 --- /dev/null +++ b/libs/chat/tsconfig.lib.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/libs/chat/vite.config.mts b/libs/chat/vite.config.mts new file mode 100644 index 000000000..ce406638a --- /dev/null +++ b/libs/chat/vite.config.mts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + test: { + globals: true, + environment: 'jsdom', + include: ['src/**/*.spec.ts'], + setupFiles: ['src/test-setup.ts'], + passWithNoTests: true, + }, +}); diff --git a/libs/render/README.md b/libs/render/README.md new file mode 100644 index 000000000..c2aa1a6ae --- /dev/null +++ b/libs/render/README.md @@ -0,0 +1,3 @@ +# render + +This library was generated with [Nx](https://nx.dev). diff --git a/libs/render/eslint.config.mjs b/libs/render/eslint.config.mjs new file mode 100644 index 000000000..8aef7b347 --- /dev/null +++ b/libs/render/eslint.config.mjs @@ -0,0 +1,49 @@ +import nx from '@nx/eslint-plugin'; +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + ignoredDependencies: ['vite', '@nx/vite'], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, + ...nx.configs['flat/angular'], + ...nx.configs['flat/angular-template'], + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'render', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'render', + style: 'kebab-case', + }, + ], + }, + }, + { + files: ['**/*.html'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/libs/render/ng-package.json b/libs/render/ng-package.json new file mode 100644 index 000000000..4f58bbb7e --- /dev/null +++ b/libs/render/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/libs/render", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/libs/render/package.json b/libs/render/package.json new file mode 100644 index 000000000..288a58349 --- /dev/null +++ b/libs/render/package.json @@ -0,0 +1,11 @@ +{ + "name": "@cacheplane/render", + "version": "0.0.1", + "peerDependencies": { + "@angular/core": "^20.0.0 || ^21.0.0", + "@angular/common": "^20.0.0 || ^21.0.0", + "@json-render/core": "^0.16.0" + }, + "license": "PolyForm-Noncommercial-1.0.0", + "sideEffects": false +} diff --git a/libs/render/project.json b/libs/render/project.json new file mode 100644 index 000000000..53a9b53f8 --- /dev/null +++ b/libs/render/project.json @@ -0,0 +1,46 @@ +{ + "name": "render", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/render/src", + "prefix": "render", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": ["dist/{projectRoot}"], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/angular:package", + "outputs": ["{workspaceRoot}/dist/{projectRoot}"], + "options": { + "project": "libs/render/ng-package.json", + "tsConfig": "libs/render/tsconfig.lib.json" + }, + "configurations": { + "production": { + "tsConfig": "libs/render/tsconfig.lib.prod.json" + }, + "development": {} + }, + "defaultConfiguration": "production" + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/vite:test", + "options": { + "configFile": "libs/render/vite.config.mts" + } + } + } +} diff --git a/libs/render/src/lib/contexts/render-context.ts b/libs/render/src/lib/contexts/render-context.ts new file mode 100644 index 000000000..5f4a0e622 --- /dev/null +++ b/libs/render/src/lib/contexts/render-context.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { InjectionToken } from '@angular/core'; +import type { StateStore, ComputedFunction } from '@json-render/core'; +import type { AngularRegistry } from '../render.types'; + +export interface RenderContext { + registry: AngularRegistry; + store: StateStore; + functions?: Record; + handlers?: Record) => unknown | Promise>; + loading?: boolean; +} + +export const RENDER_CONTEXT = new InjectionToken('RENDER_CONTEXT'); diff --git a/libs/render/src/lib/contexts/repeat-scope.ts b/libs/render/src/lib/contexts/repeat-scope.ts new file mode 100644 index 000000000..34619c04b --- /dev/null +++ b/libs/render/src/lib/contexts/repeat-scope.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { InjectionToken } from '@angular/core'; + +export interface RepeatScope { + item: unknown; + index: number; + basePath: string; +} + +export const REPEAT_SCOPE = new InjectionToken('REPEAT_SCOPE'); diff --git a/libs/render/src/lib/define-angular-registry.spec.ts b/libs/render/src/lib/define-angular-registry.spec.ts new file mode 100644 index 000000000..85eb43b26 --- /dev/null +++ b/libs/render/src/lib/define-angular-registry.spec.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { Component } from '@angular/core'; +import { defineAngularRegistry } from './define-angular-registry'; + +@Component({ selector: 'render-test-card', standalone: true, template: '
card
' }) +class TestCardComponent {} + +@Component({ selector: 'render-test-button', standalone: true, template: '' }) +class TestButtonComponent {} + +describe('defineAngularRegistry', () => { + it('should create a registry mapping component names to Angular components', () => { + const registry = defineAngularRegistry({ + Card: TestCardComponent, + Button: TestButtonComponent, + }); + expect(registry.get('Card')).toBe(TestCardComponent); + expect(registry.get('Button')).toBe(TestButtonComponent); + }); + + it('should return undefined for unregistered component names', () => { + const registry = defineAngularRegistry({ Card: TestCardComponent }); + expect(registry.get('Unknown')).toBeUndefined(); + }); + + it('should return all registered component names', () => { + const registry = defineAngularRegistry({ + Card: TestCardComponent, + Button: TestButtonComponent, + }); + expect(registry.names()).toEqual(['Card', 'Button']); + }); +}); diff --git a/libs/render/src/lib/define-angular-registry.ts b/libs/render/src/lib/define-angular-registry.ts new file mode 100644 index 000000000..86d5f973f --- /dev/null +++ b/libs/render/src/lib/define-angular-registry.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { AngularComponentRenderer, AngularRegistry } from './render.types'; + +export function defineAngularRegistry( + componentMap: Record, +): AngularRegistry { + const map = new Map(Object.entries(componentMap)); + return { + get: (name: string) => map.get(name), + names: () => [...map.keys()], + }; +} diff --git a/libs/render/src/lib/internals/prop-signal.spec.ts b/libs/render/src/lib/internals/prop-signal.spec.ts new file mode 100644 index 000000000..bcd820571 --- /dev/null +++ b/libs/render/src/lib/internals/prop-signal.spec.ts @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { createStateStore } from '@json-render/core'; +import { buildPropResolutionContext } from './prop-signal'; + +describe('buildPropResolutionContext', () => { + it('should build context from store snapshot', () => { + TestBed.runInInjectionContext(() => { + const store = createStateStore({ name: 'test' }); + const ctx = buildPropResolutionContext(store); + expect(ctx.stateModel).toEqual({ name: 'test' }); + }); + }); + + it('should include repeat scope when provided', () => { + TestBed.runInInjectionContext(() => { + const store = createStateStore({ items: ['a', 'b'] }); + const repeatScope = { item: 'a', index: 0, basePath: '/items/0' }; + const ctx = buildPropResolutionContext(store, repeatScope); + expect(ctx.repeatItem).toBe('a'); + expect(ctx.repeatIndex).toBe(0); + expect(ctx.repeatBasePath).toBe('/items/0'); + }); + }); + + it('should include functions when provided', () => { + TestBed.runInInjectionContext(() => { + const store = createStateStore({}); + const fns = { upper: (args: Record) => String(args['text']).toUpperCase() }; + const ctx = buildPropResolutionContext(store, undefined, fns); + expect(ctx.functions).toBe(fns); + }); + }); +}); diff --git a/libs/render/src/lib/internals/prop-signal.ts b/libs/render/src/lib/internals/prop-signal.ts new file mode 100644 index 000000000..a301557a8 --- /dev/null +++ b/libs/render/src/lib/internals/prop-signal.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import type { StateStore, ComputedFunction, PropResolutionContext } from '@json-render/core'; +import type { RepeatScope } from '../contexts/repeat-scope'; + +export function buildPropResolutionContext( + store: StateStore, + repeatScope?: RepeatScope, + functions?: Record, +): PropResolutionContext { + const ctx: PropResolutionContext = { + stateModel: store.getSnapshot(), + }; + if (repeatScope) { + ctx.repeatItem = repeatScope.item; + ctx.repeatIndex = repeatScope.index; + ctx.repeatBasePath = repeatScope.basePath; + } + if (functions) { + ctx.functions = functions; + } + return ctx; +} diff --git a/libs/render/src/lib/provide-render.spec.ts b/libs/render/src/lib/provide-render.spec.ts new file mode 100644 index 000000000..769ec3ead --- /dev/null +++ b/libs/render/src/lib/provide-render.spec.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { provideRender, RENDER_CONFIG } from './provide-render'; +import { defineAngularRegistry } from './define-angular-registry'; +import type { RenderConfig } from './render.types'; + +@Component({ selector: 'render-test-card', standalone: true, template: '
card
' }) +class TestCardComponent {} + +describe('provideRender', () => { + it('should provide RenderConfig via injection token', () => { + const registry = defineAngularRegistry({ Card: TestCardComponent }); + const config: RenderConfig = { registry }; + TestBed.configureTestingModule({ providers: [provideRender(config)] }); + const injectedConfig = TestBed.inject(RENDER_CONFIG); + expect(injectedConfig.registry).toBe(registry); + }); + + it('should allow injection without provider (returns undefined)', () => { + TestBed.configureTestingModule({}); + const injectedConfig = TestBed.inject(RENDER_CONFIG, null); + expect(injectedConfig).toBeNull(); + }); +}); diff --git a/libs/render/src/lib/provide-render.ts b/libs/render/src/lib/provide-render.ts new file mode 100644 index 000000000..3577f4f34 --- /dev/null +++ b/libs/render/src/lib/provide-render.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { InjectionToken, makeEnvironmentProviders } from '@angular/core'; +import type { RenderConfig } from './render.types'; + +export const RENDER_CONFIG = new InjectionToken('RENDER_CONFIG'); + +export function provideRender(config: RenderConfig) { + return makeEnvironmentProviders([ + { provide: RENDER_CONFIG, useValue: config }, + ]); +} diff --git a/libs/render/src/lib/render-element.component.spec.ts b/libs/render/src/lib/render-element.component.spec.ts new file mode 100644 index 000000000..a5a669efa --- /dev/null +++ b/libs/render/src/lib/render-element.component.spec.ts @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { Component, input } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { Spec } from '@json-render/core'; +import { + evaluateVisibility, + resolveBindings, + resolveElementProps, +} from '@json-render/core'; + +import { defineAngularRegistry } from './define-angular-registry'; +import { signalStateStore } from './signal-state-store'; +import { buildPropResolutionContext } from './internals/prop-signal'; + +// --- Test components --- + +@Component({ + selector: 'render-test-text', + standalone: true, + template: '{{ label() }}', +}) +class TestTextComponent { + readonly label = input(''); + readonly childKeys = input([]); + readonly spec = input(null); +} + +// --- Helpers --- + +function createSpec(elements: Record, root = 'root'): Spec { + return { root, elements } as Spec; +} + +/** + * These tests verify the rendering pipeline logic (element lookup, prop + * resolution, visibility, repeat) at the unit level. Because this repo's + * Vitest setup does not include the Angular template compiler plugin, + * we test the pipeline functions and registry lookups directly rather + * than rendering templates. + */ +describe('RenderElementComponent — pipeline logic', () => { + it('should look up element from spec and resolve component class', () => { + const registry = defineAngularRegistry({ Text: TestTextComponent }); + const spec = createSpec({ + root: { type: 'Text', props: { label: 'Hello' } }, + }); + const el = spec.elements['root']; + expect(el).toBeDefined(); + expect(el.type).toBe('Text'); + expect(registry.get(el.type)).toBe(TestTextComponent); + }); + + it('should return undefined for unknown element type', () => { + const registry = defineAngularRegistry({ Text: TestTextComponent }); + const spec = createSpec({ + root: { type: 'UnknownWidget', props: { label: 'Nope' } }, + }); + const el = spec.elements['root']; + expect(registry.get(el.type)).toBeUndefined(); + }); + + it('should return undefined for missing element key', () => { + const spec = createSpec({ + root: { type: 'Text', props: { label: 'Hello' } }, + }); + expect(spec.elements['nonexistent']).toBeUndefined(); + }); + + it('should resolve $state prop expressions', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ count: 42 }); + const ctx = buildPropResolutionContext(store); + const props = { count: { $state: '/count' } }; + const resolved = resolveElementProps(props, ctx); + expect(resolved['count']).toBe(42); + }); + }); + + it('should evaluate visibility as hidden when state is falsy', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ show: false }); + const ctx = buildPropResolutionContext(store); + const result = evaluateVisibility({ $state: '/show' }, ctx); + expect(result).toBe(false); + }); + }); + + it('should evaluate visibility as visible when state is truthy', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ show: true }); + const ctx = buildPropResolutionContext(store); + const result = evaluateVisibility({ $state: '/show' }, ctx); + expect(result).toBe(true); + }); + }); + + it('should evaluate visibility as true when condition is undefined', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const store = signalStateStore({}); + const ctx = buildPropResolutionContext(store); + const result = evaluateVisibility(undefined, ctx); + expect(result).toBe(true); + }); + }); + + it('should resolve bindings from $bindState expressions', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ form: { email: 'test@example.com' } }); + const ctx = buildPropResolutionContext(store); + const props = { value: { $bindState: '/form/email' }, label: 'Email' }; + const bindings = resolveBindings(props, ctx); + expect(bindings).toEqual({ value: '/form/email' }); + }); + }); + + it('should resolve repeat item props via $item expression', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ items: ['A', 'B', 'C'] }); + const repeatScope = { item: 'B', index: 1, basePath: '/items/1' }; + const ctx = buildPropResolutionContext(store, repeatScope); + const props = { label: { $item: '' } }; + const resolved = resolveElementProps(props, ctx); + expect(resolved['label']).toBe('B'); + }); + }); + + it('should resolve $index in repeat scope', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ items: ['A', 'B'] }); + const repeatScope = { item: 'B', index: 1, basePath: '/items/1' }; + const ctx = buildPropResolutionContext(store, repeatScope); + const props = { idx: { $index: true } }; + const resolved = resolveElementProps(props, ctx); + expect(resolved['idx']).toBe(1); + }); + }); + + it('should include childKeys and spec in resolved inputs structure', () => { + const spec = createSpec({ + root: { type: 'Text', props: { label: 'Hello' }, children: ['child1', 'child2'] }, + child1: { type: 'Text', props: { label: 'C1' } }, + child2: { type: 'Text', props: { label: 'C2' } }, + }); + const el = spec.elements['root']; + // The component passes childKeys from element.children + const childKeys = el.children ?? []; + expect(childKeys).toEqual(['child1', 'child2']); + }); + + it('should handle repeat by iterating state array items', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ items: ['A', 'B', 'C'] }); + const spec = createSpec({ + root: { + type: 'Text', + props: { label: { $item: '' } }, + repeat: { statePath: '/items' }, + }, + }); + const el = spec.elements['root']; + const items = store.get(el.repeat!.statePath); + expect(Array.isArray(items)).toBe(true); + expect(items).toEqual(['A', 'B', 'C']); + + // Each item gets its own repeat scope and resolved props + const results = (items as string[]).map((item, index) => { + const scope = { item, index, basePath: `${el.repeat!.statePath}/${index}` }; + const ctx = buildPropResolutionContext(store, scope); + return resolveElementProps(el.props ?? {}, ctx); + }); + expect(results[0]['label']).toBe('A'); + expect(results[1]['label']).toBe('B'); + expect(results[2]['label']).toBe('C'); + }); + }); +}); + +/** + * Children rendering tests (Task 9). + * + * Verify that the recursive rendering pattern works: a parent Container + * receives childKeys and spec, and each child element can be resolved + * independently from the same spec. + */ +describe('RenderElementComponent — children rendering', () => { + it('should pass childKeys and spec to the rendered component', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const registry = defineAngularRegistry({ Container: TestTextComponent, Text: TestTextComponent }); + const store = signalStateStore({ title: 'Parent' }); + const spec = createSpec({ + root: { + type: 'Container', + props: { label: 'Parent' }, + children: ['heading', 'body'], + }, + heading: { type: 'Text', props: { label: 'Heading' } }, + body: { type: 'Text', props: { label: 'Body' } }, + }); + + const rootEl = spec.elements['root']; + const ctx = buildPropResolutionContext(store); + const resolved = resolveElementProps(rootEl.props ?? {}, ctx); + const bindings = resolveBindings(rootEl.props ?? {}, ctx); + + // Simulate what resolvedInputs computes + // eslint-disable-next-line @typescript-eslint/no-empty-function + const noopEmit = (): void => {}; + const inputs = { + ...resolved, + bindings, + emit: noopEmit, + loading: false, + childKeys: rootEl.children ?? [], + spec, + }; + + // Container receives childKeys pointing to its children + expect(inputs.childKeys).toEqual(['heading', 'body']); + expect(inputs.spec).toBe(spec); + + // Each child can be resolved from the same spec + for (const childKey of inputs.childKeys) { + const childEl = spec.elements[childKey]; + expect(childEl).toBeDefined(); + expect(registry.get(childEl.type)).toBe(TestTextComponent); + } + }); + }); + + it('should resolve child props independently from parent', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ greeting: 'Hi', name: 'World' }); + const spec = createSpec({ + root: { + type: 'Container', + props: {}, + children: ['greeting', 'name'], + }, + greeting: { type: 'Text', props: { label: { $state: '/greeting' } } }, + name: { type: 'Text', props: { label: { $state: '/name' } } }, + }); + + const ctx = buildPropResolutionContext(store); + + // Resolve children + const greetingEl = spec.elements['greeting']; + const greetingResolved = resolveElementProps(greetingEl.props ?? {}, ctx); + expect(greetingResolved['label']).toBe('Hi'); + + const nameEl = spec.elements['name']; + const nameResolved = resolveElementProps(nameEl.props ?? {}, ctx); + expect(nameResolved['label']).toBe('World'); + }); + }); + + it('should support deeply nested children (recursive tree)', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const store = signalStateStore({}); + const spec = createSpec({ + root: { + type: 'Container', + props: {}, + children: ['level1'], + }, + level1: { + type: 'Container', + props: {}, + children: ['level2'], + }, + level2: { + type: 'Container', + props: {}, + children: ['leaf'], + }, + leaf: { + type: 'Text', + props: { label: 'Deep Leaf' }, + }, + }); + + // Walk the tree recursively + function getLeafLabels(key: string): string[] { + const el = spec.elements[key]; + if (!el) return []; + const children = el.children ?? []; + if (children.length === 0) { + const ctx = buildPropResolutionContext(store); + const resolved = resolveElementProps(el.props ?? {}, ctx); + return [resolved['label'] as string]; + } + return children.flatMap(getLeafLabels); + } + + const labels = getLeafLabels('root'); + expect(labels).toEqual(['Deep Leaf']); + }); + }); + + it('should handle element with empty children array', () => { + const spec = createSpec({ + root: { type: 'Container', props: {}, children: [] }, + }); + const el = spec.elements['root']; + expect(el.children).toEqual([]); + }); + + it('should handle element with no children property', () => { + const spec = createSpec({ + root: { type: 'Text', props: { label: 'No children' } }, + }); + const el = spec.elements['root']; + // children defaults to undefined; component uses ?? [] + const childKeys = el.children ?? []; + expect(childKeys).toEqual([]); + }); +}); diff --git a/libs/render/src/lib/render-element.component.ts b/libs/render/src/lib/render-element.component.ts new file mode 100644 index 000000000..ebeb4e79b --- /dev/null +++ b/libs/render/src/lib/render-element.component.ts @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + Injector, + input, + type Signal, +} from '@angular/core'; +import { NgComponentOutlet } from '@angular/common'; +import { + evaluateVisibility, + resolveBindings, + resolveElementProps, +} from '@json-render/core'; +import type { Spec, UIElement } from '@json-render/core'; + +import { RENDER_CONTEXT } from './contexts/render-context'; +import { REPEAT_SCOPE } from './contexts/repeat-scope'; +import type { RepeatScope } from './contexts/repeat-scope'; +import { buildPropResolutionContext } from './internals/prop-signal'; +import type { AngularComponentRenderer } from './render.types'; + +/** + * Recursive element renderer. + * + * For each element key it: + * 1. Looks up the UIElement from spec.elements + * 2. Resolves the component class from the registry + * 3. Evaluates visibility + * 4. Resolves prop expressions and bindings + * 5. Renders via NgComponentOutlet with resolved inputs + * + * For elements with `repeat`, it iterates over the state array, + * creating a child Injector with RepeatScope for each item. + */ +@Component({ + selector: 'render-element', + standalone: true, + imports: [NgComponentOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (!element()?.repeat) { + @if (visible()) { + + } + } @else { + @for (repeatInjector of repeatInjectors(); track $index) { + + } + } + `, +}) +export class RenderElementComponent { + readonly elementKey = input.required(); + readonly spec = input.required(); + + private readonly ctx = inject(RENDER_CONTEXT); + private readonly repeatScope = inject(REPEAT_SCOPE, { optional: true }); + readonly parentInjector = inject(Injector); + + /** The UIElement definition from the spec. */ + readonly element: Signal = computed(() => { + const spec = this.spec(); + const key = this.elementKey(); + return spec?.elements?.[key]; + }); + + /** The Angular component class for this element type. */ + readonly componentClass = computed(() => { + const el = this.element(); + if (!el) return null; + return this.ctx.registry.get(el.type) ?? null; + }); + + /** Prop resolution context built from store + repeat scope. */ + private readonly propCtx = computed(() => + buildPropResolutionContext( + this.ctx.store, + this.repeatScope ?? undefined, + this.ctx.functions, + ), + ); + + /** Whether the element is visible (non-repeat path). */ + readonly visible = computed(() => { + const el = this.element(); + if (!el) return false; + if (this.componentClass() === null) return false; + return evaluateVisibility(el.visible, this.propCtx()); + }); + + /** Emit function that delegates to context handlers. */ + private readonly emitFn = (event: string) => { + const el = this.element(); + if (!el?.on) return; + const binding = el.on[event]; + if (!binding) return; + const bindings = Array.isArray(binding) ? binding : [binding]; + for (const b of bindings) { + const handler = this.ctx.handlers?.[b.action]; + if (handler) { + handler(b.params as Record ?? {}); + } + } + }; + + /** Resolved inputs for non-repeat elements. */ + readonly resolvedInputs = computed(() => { + const el = this.element(); + if (!el) return {}; + const ctx = this.propCtx(); + const resolved = resolveElementProps(el.props ?? {}, ctx); + const bindings = resolveBindings(el.props ?? {}, ctx); + return { + ...resolved, + bindings, + emit: this.emitFn, + loading: this.ctx.loading ?? false, + childKeys: el.children ?? [], + spec: this.spec(), + }; + }); + + // --- Repeat support --- + + /** Items from the state array for repeat elements. */ + private readonly repeatItems = computed(() => { + const el = this.element(); + if (!el?.repeat) return []; + const items = this.ctx.store.get(el.repeat.statePath); + return Array.isArray(items) ? items : []; + }); + + /** One RepeatScope per repeat item, shared between injectors and inputs. */ + private readonly repeatScopes = computed(() => { + const el = this.element(); + if (!el?.repeat) return []; + return this.repeatItems().map((item, index) => ({ + item, + index, + basePath: `${el.repeat!.statePath}/${index}`, + } satisfies RepeatScope)); + }); + + /** One child Injector per repeat item, providing RepeatScope. */ + readonly repeatInjectors = computed(() => { + return this.repeatScopes().map(scope => + Injector.create({ + providers: [{ provide: REPEAT_SCOPE, useValue: scope }], + parent: this.parentInjector, + }), + ); + }); + + /** Resolved inputs for each repeat item. */ + readonly repeatInputs = computed(() => { + const el = this.element(); + if (!el?.repeat) return []; + return this.repeatScopes().map(scope => { + const ctx = buildPropResolutionContext( + this.ctx.store, + scope, + this.ctx.functions, + ); + const resolved = resolveElementProps(el.props ?? {}, ctx); + const bindings = resolveBindings(el.props ?? {}, ctx); + return { + ...resolved, + bindings, + emit: this.emitFn, + loading: this.ctx.loading ?? false, + childKeys: el.children ?? [], + spec: this.spec(), + }; + }); + }); +} diff --git a/libs/render/src/lib/render-spec.component.spec.ts b/libs/render/src/lib/render-spec.component.spec.ts new file mode 100644 index 000000000..c23b39b89 --- /dev/null +++ b/libs/render/src/lib/render-spec.component.spec.ts @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect } from 'vitest'; +import { Component, input } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { Spec } from '@json-render/core'; + +import { defineAngularRegistry } from './define-angular-registry'; +import { signalStateStore } from './signal-state-store'; +import { provideRender, RENDER_CONFIG } from './provide-render'; + +// --- Test component --- + +@Component({ + selector: 'render-test-text', + standalone: true, + template: '{{ label() }}', +}) +class TestTextComponent { + readonly label = input(''); + readonly childKeys = input([]); + readonly spec = input(null); +} + +// --- Helpers --- + +function createSpec(elements: Record, root = 'root'): Spec { + return { root, elements } as Spec; +} + +/** + * These tests verify the RenderSpecComponent's context resolution logic. + * Because this repo's Vitest setup does not include the Angular template + * compiler plugin, we test context assembly and fallback behavior directly. + */ +describe('RenderSpecComponent — context resolution', () => { + it('should build context from direct inputs', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const registry = defineAngularRegistry({ Text: TestTextComponent }); + const store = signalStateStore({ title: 'Hello' }); + // eslint-disable-next-line @typescript-eslint/no-empty-function + const handlers = { doSomething: (): void => {} }; + const functions = { upper: (args: Record) => String(args['text']).toUpperCase() }; + + // Simulate what the component does internally + const context = { + registry, + store, + functions, + handlers, + loading: false, + }; + + expect(context.registry).toBe(registry); + expect(context.store).toBe(store); + expect(context.functions).toBe(functions); + expect(context.handlers).toBe(handlers); + expect(context.loading).toBe(false); + }); + }); + + it('should fall back to RENDER_CONFIG when inputs are not provided', () => { + const registry = defineAngularRegistry({ Text: TestTextComponent }); + const store = signalStateStore({ name: 'config' }); + TestBed.configureTestingModule({ + providers: [provideRender({ registry, store })], + }); + const config = TestBed.inject(RENDER_CONFIG); + expect(config.registry).toBe(registry); + expect(config.store).toBe(store); + }); + + it('should handle null spec gracefully', () => { + const spec: Spec | null = null; + // Null spec should not render any root element + expect(spec?.root).toBeUndefined(); + }); + + it('should extract root key from spec', () => { + const spec = createSpec({ + myRoot: { type: 'Text', props: { label: 'Root' } }, + }, 'myRoot'); + expect(spec.root).toBe('myRoot'); + expect(spec.elements['myRoot']).toBeDefined(); + }); + + it('should create internal store from spec.state when no store provided', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const spec = createSpec( + { root: { type: 'Text', props: { label: { $state: '/title' } } } }, + ); + (spec as Record).state = { title: 'From Spec State' }; + const store = signalStateStore(spec.state as Record); + expect(store.get('/title')).toBe('From Spec State'); + }); + }); + + it('should prefer input store over config store', () => { + const configStore = signalStateStore({ source: 'config' }); + const inputStore = signalStateStore({ source: 'input' }); + const registry = defineAngularRegistry({ Text: TestTextComponent }); + + TestBed.configureTestingModule({ + providers: [provideRender({ registry, store: configStore })], + }); + const config = TestBed.inject(RENDER_CONFIG); + // Input store should take precedence + expect(inputStore.get('/source')).toBe('input'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(config.store!.get('/source')).toBe('config'); + // In the component, input > config + }); +}); diff --git a/libs/render/src/lib/render-spec.component.ts b/libs/render/src/lib/render-spec.component.ts new file mode 100644 index 000000000..aecd55083 --- /dev/null +++ b/libs/render/src/lib/render-spec.component.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, +} from '@angular/core'; +import type { ComputedFunction, Spec, StateStore } from '@json-render/core'; + +import { RenderElementComponent } from './render-element.component'; +import { RENDER_CONFIG } from './provide-render'; +import { RENDER_CONTEXT } from './contexts/render-context'; +import type { RenderContext } from './contexts/render-context'; +import type { AngularRegistry } from './render.types'; +import { signalStateStore } from './signal-state-store'; + +/** + * Top-level entry point for rendering a json-render spec. + * + * Accepts the spec, registry, store, functions, handlers, and loading + * as inputs. Provides `RENDER_CONTEXT` to child `RenderElementComponent` + * instances via `viewProviders`. + * + * Falls back to `RENDER_CONFIG` (from `provideRender()`) for registry + * and store defaults when inputs are not provided. + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: 'render-spec', + standalone: true, + imports: [RenderElementComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + viewProviders: [ + { + provide: RENDER_CONTEXT, + useFactory: () => inject(RenderSpecComponent)._context(), + }, + ], + template: ` + @if (spec()?.root; as rootKey) { + + } + `, +}) +export class RenderSpecComponent { + readonly spec = input(null); + readonly registry = input(undefined); + readonly store = input(undefined); + readonly functions = input | undefined>(undefined); + readonly handlers = input) => unknown | Promise> | undefined>(undefined); + readonly loading = input(false); + + private readonly config = inject(RENDER_CONFIG, { optional: true }); + + /** Internal store, lazily created once and reused across spec changes. */ + private _internalStore: StateStore | undefined; + + private getOrCreateInternalStore(): StateStore { + if (!this._internalStore) { + this._internalStore = signalStateStore(this.spec()?.state ?? {}); + } + return this._internalStore; + } + + /** Resolved store: input > config > internal (from spec.state). */ + private readonly resolvedStore = computed(() => { + const inputStore = this.store(); + if (inputStore) return inputStore; + const configStore = this.config?.store; + if (configStore) return configStore; + return this.getOrCreateInternalStore(); + }); + + /** Resolved registry: input > config. */ + private readonly resolvedRegistry = computed(() => { + const inputRegistry = this.registry(); + if (inputRegistry) return inputRegistry; + const configRegistry = this.config?.registry; + if (configRegistry) return configRegistry; + // Fallback: empty registry + return { get: () => undefined, names: () => [] }; + }); + + /** The RenderContext provided to children via viewProviders. */ + readonly _context = computed(() => ({ + registry: this.resolvedRegistry(), + store: this.resolvedStore(), + functions: this.functions() ?? this.config?.functions, + handlers: this.handlers() ?? this.config?.handlers, + loading: this.loading(), + })); +} diff --git a/libs/render/src/lib/render.types.ts b/libs/render/src/lib/render.types.ts new file mode 100644 index 000000000..b439e081b --- /dev/null +++ b/libs/render/src/lib/render.types.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { Type } from '@angular/core'; +import type { Spec, StateStore, ComputedFunction } from '@json-render/core'; + +export interface AngularComponentInputs { + /** Two-way binding paths: prop name → absolute state path */ + bindings?: Record; + /** Emit a named event */ + emit: (event: string) => void; + /** Whether the spec is currently streaming */ + loading?: boolean; + /** Child element keys for recursive rendering */ + childKeys: string[]; + /** The full spec (for child resolution) */ + spec: Spec; + /** Dynamic resolved props are spread as additional inputs */ + [key: string]: unknown; +} + +export type AngularComponentRenderer = Type; + +export interface AngularRegistry { + get(name: string): AngularComponentRenderer | undefined; + names(): string[]; +} + +export interface RenderConfig { + registry?: AngularRegistry; + store?: StateStore; + functions?: Record; + handlers?: Record) => unknown | Promise>; +} diff --git a/libs/render/src/lib/signal-state-store.spec.ts b/libs/render/src/lib/signal-state-store.spec.ts new file mode 100644 index 000000000..2f48d91f8 --- /dev/null +++ b/libs/render/src/lib/signal-state-store.spec.ts @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { describe, it, expect, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { signalStateStore } from './signal-state-store'; + +describe('signalStateStore', () => { + it('should implement StateStore interface with get/set', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ name: 'test', count: 0 }); + expect(store.get('/name')).toBe('test'); + expect(store.get('/count')).toBe(0); + store.set('/count', 5); + expect(store.get('/count')).toBe(5); + }); + }); + + it('should return full state snapshot via getSnapshot', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ a: 1, b: 2 }); + expect(store.getSnapshot()).toEqual({ a: 1, b: 2 }); + }); + }); + + it('should batch updates via update()', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ x: 0, y: 0 }); + store.update({ '/x': 10, '/y': 20 }); + expect(store.get('/x')).toBe(10); + expect(store.get('/y')).toBe(20); + }); + }); + + it('should notify subscribers on state change', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ val: 'a' }); + const listener = vi.fn(); + const unsub = store.subscribe(listener); + store.set('/val', 'b'); + expect(listener).toHaveBeenCalled(); + unsub(); + }); + }); + + it('should handle nested paths', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ user: { name: 'Alice', age: 30 } }); + expect(store.get('/user/name')).toBe('Alice'); + store.set('/user/name', 'Bob'); + expect(store.get('/user/name')).toBe('Bob'); + expect(store.getSnapshot()).toEqual({ user: { name: 'Bob', age: 30 } }); + }); + }); + + it('should handle array paths', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ items: ['a', 'b', 'c'] }); + expect(store.get('/items/0')).toBe('a'); + store.set('/items/1', 'B'); + expect(store.get('/items/1')).toBe('B'); + }); + }); + + it('should preserve array type when setting by index', () => { + TestBed.runInInjectionContext(() => { + const store = signalStateStore({ items: ['a', 'b', 'c'] }); + store.set('/items/1', 'B'); + const snapshot = store.getSnapshot(); + expect(Array.isArray(snapshot['items'])).toBe(true); + expect(snapshot['items']).toEqual(['a', 'B', 'c']); + }); + }); +}); diff --git a/libs/render/src/lib/signal-state-store.ts b/libs/render/src/lib/signal-state-store.ts new file mode 100644 index 000000000..b6ea61009 --- /dev/null +++ b/libs/render/src/lib/signal-state-store.ts @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { signal } from '@angular/core'; +import type { StateStore, StateModel } from '@json-render/core'; + +function parsePointer(path: string): string[] { + if (!path || path === '/') return []; + return path.split('/').filter((_, i) => i > 0).map(s => s.replace(/~1/g, '/').replace(/~0/g, '~')); +} + +function getByPath(obj: unknown, segments: string[]): unknown { + let current: unknown = obj; + for (const seg of segments) { + if (current == null || typeof current !== 'object') return undefined; + current = (current as Record)[seg]; + } + return current; +} + +function setByPath(obj: unknown, segments: string[], value: unknown): unknown { + if (segments.length === 0) return value; + const [head, ...rest] = segments; + + if (Array.isArray(obj)) { + const index = Number(head); + const clone = [...obj]; + clone[index] = setByPath(clone[index], rest, value); + return clone; + } + + const record = (obj != null && typeof obj === 'object') + ? { ...obj as Record } + : {} as Record; + record[head] = setByPath(record[head], rest, value); + return record; +} + +export function signalStateStore(initialState: StateModel = {}): StateStore { + const state = signal(initialState); + const listeners = new Set<() => void>(); + + function notify(): void { + for (const listener of listeners) listener(); + } + + return { + get(path: string): unknown { + return getByPath(state(), parsePointer(path)); + }, + set(path: string, value: unknown): void { + const segments = parsePointer(path); + const current = getByPath(state(), segments); + if (current === value) return; + state.set(setByPath(state(), segments, value) as StateModel); + notify(); + }, + update(updates: Record): void { + let current = state(); + let changed = false; + for (const [path, value] of Object.entries(updates)) { + const segments = parsePointer(path); + const existing = getByPath(current, segments); + if (existing !== value) { + current = setByPath(current, segments, value) as StateModel; + changed = true; + } + } + if (changed) { + state.set(current); + notify(); + } + }, + getSnapshot(): StateModel { + return state(); + }, + subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/libs/render/src/public-api.ts b/libs/render/src/public-api.ts new file mode 100644 index 000000000..690e3454a --- /dev/null +++ b/libs/render/src/public-api.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +// Types +export type { + AngularComponentInputs, + AngularComponentRenderer, + AngularRegistry, + RenderConfig, +} from './lib/render.types'; + +// Contexts +export { RENDER_CONTEXT } from './lib/contexts/render-context'; +export type { RenderContext } from './lib/contexts/render-context'; +export { REPEAT_SCOPE } from './lib/contexts/repeat-scope'; +export type { RepeatScope } from './lib/contexts/repeat-scope'; + +// Registry +export { defineAngularRegistry } from './lib/define-angular-registry'; + +// State +export { signalStateStore } from './lib/signal-state-store'; + +// Provider +export { provideRender, RENDER_CONFIG } from './lib/provide-render'; + +// Components +export { RenderElementComponent } from './lib/render-element.component'; +export { RenderSpecComponent } from './lib/render-spec.component'; diff --git a/libs/render/src/test-setup.ts b/libs/render/src/test-setup.ts new file mode 100644 index 000000000..17049f119 --- /dev/null +++ b/libs/render/src/test-setup.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { getTestBed } from '@angular/core/testing'; +import { + BrowserTestingModule, + platformBrowserTesting, +} from '@angular/platform-browser/testing'; + +getTestBed().initTestEnvironment( + BrowserTestingModule, + platformBrowserTesting(), + { teardown: { destroyAfterEach: true } }, +); diff --git a/libs/render/tsconfig.json b/libs/render/tsconfig.json new file mode 100644 index 000000000..df5104e30 --- /dev/null +++ b/libs/render/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "experimentalDecorators": true, + "noPropertyAccessFromIndexSignature": true, + "module": "preserve", + "emitDeclarationOnly": false, + "composite": false, + "baseUrl": "." + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/libs/render/tsconfig.lib.json b/libs/render/tsconfig.lib.json new file mode 100644 index 000000000..afcadee07 --- /dev/null +++ b/libs/render/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "lib": ["es2022", "dom"], + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/render/tsconfig.lib.prod.json b/libs/render/tsconfig.lib.prod.json new file mode 100644 index 000000000..2a2faa884 --- /dev/null +++ b/libs/render/tsconfig.lib.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/libs/render/vite.config.mts b/libs/render/vite.config.mts new file mode 100644 index 000000000..ce406638a --- /dev/null +++ b/libs/render/vite.config.mts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + test: { + globals: true, + environment: 'jsdom', + include: ['src/**/*.spec.ts'], + setupFiles: ['src/test-setup.ts'], + passWithNoTests: true, + }, +}); diff --git a/package-lock.json b/package-lock.json index f2d0a4e24..ed4a8fbea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "@angular/language-service": "~21.1.0", "@anthropic-ai/sdk": "^0.79.0", "@eslint/js": "^9.8.0", + "@json-render/core": "^0.16.0", "@nx/angular": "^22.5.4", "@nx/eslint": "22.5.4", "@nx/eslint-plugin": "22.5.4", @@ -6523,6 +6524,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@json-render/core": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@json-render/core/-/core-0.16.0.tgz", + "integrity": "sha512-qQp8BB/3pWYapTGXBDSBMXRCdrC05VJPLL3drXMPX/QbUB3nuvtyXUGmAZFUz8eLUy7JImODvb3GNIq38dGzhQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.3.6" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@json-render/core/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@jsonjoy.com/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", diff --git a/package.json b/package.json index 452efa4af..b9680e157 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@angular/language-service": "~21.1.0", "@anthropic-ai/sdk": "^0.79.0", "@eslint/js": "^9.8.0", + "@json-render/core": "^0.16.0", "@nx/angular": "^22.5.4", "@nx/eslint": "22.5.4", "@nx/eslint-plugin": "22.5.4", diff --git a/tsconfig.base.json b/tsconfig.base.json index ea56d3ee7..bb0d1548e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -20,13 +20,12 @@ "@cacheplane/cockpit-shell": ["libs/cockpit-shell/src/index.ts"], "@cacheplane/cockpit-testing": ["libs/cockpit-testing/src/index.ts"], "@cacheplane/cockpit-ui": ["libs/cockpit-ui/src/index.ts"], - "@cacheplane/design-tokens": ["libs/design-tokens/src/index.ts"], "@cacheplane/cockpit-langgraph-streaming-python": [ "cockpit/langgraph/streaming/python/src/index.ts" ], "@cacheplane/stream-resource": ["libs/stream-resource/src/public-api.ts"], - "@cacheplane/chat": ["libs/chat/src/index.ts"], - "@cacheplane/ui-react": ["libs/ui-react/src/index.ts"] + "@cacheplane/render": ["libs/render/src/public-api.ts"], + "@cacheplane/chat": ["libs/chat/src/public-api.ts"] }, "skipLibCheck": true, "strict": true,