Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4de7bcd
feat(agent): add custom event support to AgentRef
blove Apr 14, 2026
e917a7f
fix(agent): add custom$ to spec factory and reset in joinStream
blove Apr 14, 2026
431b848
test(agent): add custom event routing tests
blove Apr 14, 2026
e4fe5bb
feat(chat): route custom state_update events to render store
blove Apr 14, 2026
e5333f9
fix(chat): create internal store for custom event routing when views …
blove Apr 14, 2026
bac9a39
feat(graph): add mock SaaS data tools for dashboard example
blove Apr 14, 2026
d86faed
feat(graph): add dashboard agent system prompt
blove Apr 14, 2026
caaf9b6
feat(graph): replace generative-ui with multi-node dashboard graph
blove Apr 14, 2026
ec744e7
feat(generative-ui): add shared skeleton shimmer CSS
blove Apr 14, 2026
f24e85f
feat(generative-ui): add direction prop to container component
blove Apr 14, 2026
53da4b1
feat(generative-ui): add dashboard-grid layout component
blove Apr 14, 2026
63a4c0a
feat(generative-ui): upgrade stat-card with delta and skeleton
blove Apr 14, 2026
abef087
feat(generative-ui): add SVG line chart component with skeleton
blove Apr 14, 2026
5549730
feat(generative-ui): add SVG bar chart component with skeleton
blove Apr 14, 2026
e570736
feat(generative-ui): add data grid component with skeleton
blove Apr 14, 2026
c369ad5
feat(generative-ui): register dashboard views, remove weather card
blove Apr 14, 2026
299c844
test(generative-ui): update e2e test for dashboard example
blove Apr 14, 2026
81184bf
fix(generative-ui): exclude spec files from production build
blove Apr 14, 2026
f21c176
feat(generative-ui): update standalone graph to Phase 2 dashboard
blove Apr 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 8 additions & 12 deletions cockpit/chat/generative-ui/angular/e2e/generative-ui.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import { expect, test } from '@playwright/test';

test.describe('Chat Generative UI Example', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:4508');
await page.waitForSelector('app-generative-ui', { state: 'attached' });
test.describe('Generative UI - SaaS Dashboard', () => {
test('chat interface loads', async ({ page }) => {
await page.goto('/');
await expect(page.locator('chat')).toBeVisible({ timeout: 10000 });
});

test('renders the chat interface with generative UI sidebar', async ({ page }) => {
await expect(page.locator('chat')).toBeVisible();
await expect(page.locator('aside')).toBeVisible();
await expect(page.locator('aside h3')).toHaveText('Generative UI');
});

test('displays how it works description', async ({ page }) => {
await expect(page.locator('aside')).toContainText('render specs');
test('sidebar renders generative-ui description', async ({ page }) => {
await page.goto('/');
const sidebar = page.locator('aside, [role="complementary"]');
await expect(sidebar.getByText(/render specs|dashboard/i)).toBeVisible({ timeout: 10000 });
});
});
6 changes: 6 additions & 0 deletions cockpit/chat/generative-ui/angular/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@
"proxyConfig": "cockpit/chat/generative-ui/angular/proxy.conf.json"
}
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"tsConfig": "cockpit/chat/generative-ui/angular/tsconfig.spec.json"
}
},
"smoke": {
"executor": "nx:run-commands",
"options": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,21 @@ import { ChatComponent, views } from '@cacheplane/chat';
import { agent } from '@cacheplane/angular';
import { ExampleChatLayoutComponent } from '@cacheplane/example-layouts';
import { environment } from '../environments/environment';
import { WeatherCardComponent } from './views/weather-card.component';

import { StatCardComponent } from './views/stat-card.component';
import { ContainerComponent } from './views/container.component';
import { DashboardGridComponent } from './views/dashboard-grid.component';
import { LineChartComponent } from './views/line-chart.component';
import { BarChartComponent } from './views/bar-chart.component';
import { DataGridComponent } from './views/data-grid.component';

const myViews = views({
weather_card: WeatherCardComponent,
const dashboardViews = views({
stat_card: StatCardComponent,
container: ContainerComponent,
dashboard_grid: DashboardGridComponent,
line_chart: LineChartComponent,
bar_chart: BarChartComponent,
data_grid: DataGridComponent,
});

@Component({
Expand All @@ -20,7 +27,7 @@ const myViews = views({
imports: [ChatComponent, ExampleChatLayoutComponent],
template: `
<example-chat-layout>
<chat main [ref]="agentRef" [views]="myViews" class="flex-1 min-w-0" />
<chat main [ref]="agentRef" [views]="dashboardViews" class="flex-1 min-w-0" />
</example-chat-layout>
`,
})
Expand All @@ -29,5 +36,5 @@ export class GenerativeUiComponent {
apiUrl: environment.langGraphApiUrl,
assistantId: environment.generativeUiAssistantId,
});
protected readonly myViews = myViews;
protected readonly dashboardViews = dashboardViews;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BarChartComponent } from './bar-chart.component';

describe('BarChartComponent', () => {
let fixture: ComponentFixture<BarChartComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BarChartComponent],
}).compileComponents();
fixture = TestBed.createComponent(BarChartComponent);
});

it('renders skeleton when data is null', () => {
fixture.componentRef.setInput('title', 'Plans');
fixture.componentRef.setInput('data', null);
fixture.componentRef.setInput('labelKey', 'plan');
fixture.componentRef.setInput('valueKey', 'count');
fixture.detectChanges();
const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector('.skeleton-chart')).toBeTruthy();
expect(el.querySelector('svg')).toBeFalsy();
});

it('renders correct number of bars', () => {
const data = [
{ plan: 'free', count: 1200 },
{ plan: 'starter', count: 850 },
{ plan: 'pro', count: 420 },
{ plan: 'enterprise', count: 95 },
];
fixture.componentRef.setInput('title', 'Plans');
fixture.componentRef.setInput('data', data);
fixture.componentRef.setInput('labelKey', 'plan');
fixture.componentRef.setInput('valueKey', 'count');
fixture.detectChanges();
const rects = fixture.nativeElement.querySelectorAll('rect.bar');
expect(rects.length).toBe(4);
});

it('renders title', () => {
fixture.componentRef.setInput('title', 'Subscribers by Plan');
fixture.componentRef.setInput('data', [{ plan: 'free', count: 100 }]);
fixture.componentRef.setInput('labelKey', 'plan');
fixture.componentRef.setInput('valueKey', 'count');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Subscribers by Plan');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
import { Component, computed, input } from '@angular/core';

@Component({
selector: 'app-bar-chart',
standalone: true,
template: `
<div class="rounded-lg border border-white/10 bg-white/5 p-4 backdrop-blur-sm">
<div class="text-sm font-medium text-white/60 mb-3">{{ title() }}</div>
@if (isSkeleton()) {
<div class="skeleton skeleton-chart"></div>
} @else {
<svg [attr.viewBox]="'0 0 ' + width + ' ' + height" class="w-full" preserveAspectRatio="xMidYMid meet">
@for (bar of bars(); track $index) {
<!-- Bar -->
<rect class="bar" [attr.x]="bar.x" [attr.y]="bar.y" [attr.width]="bar.w" [attr.height]="bar.h" fill="#d4aa6a" rx="2" />
<!-- Value above bar -->
<text [attr.x]="bar.x + bar.w / 2" [attr.y]="bar.y - 6" text-anchor="middle" fill="rgba(255,255,255,0.5)" font-size="10">{{ bar.value }}</text>
<!-- Label below bar -->
<text [attr.x]="bar.x + bar.w / 2" [attr.y]="height - 4" text-anchor="middle" fill="rgba(255,255,255,0.3)" font-size="10">{{ bar.label }}</text>
}
</svg>
}
</div>
`,
styleUrls: ['./skeleton.css'],
})
export class BarChartComponent {
readonly title = input<string>('');
readonly data = input<Record<string, unknown>[] | null>(null);
readonly labelKey = input<string>('');
readonly valueKey = input<string>('');

readonly width = 400;
readonly height = 200;
readonly padding = { top: 30, right: 20, bottom: 30, left: 20 };

readonly isSkeleton = computed(() => this.data() == null);

readonly bars = computed(() => {
const d = this.data();
if (!d || d.length === 0) return [];
const lk = this.labelKey();
const vk = this.valueKey();
const values = d.map(item => Number(item[vk]) || 0);
const maxVal = Math.max(...values) || 1;
const plotW = this.width - this.padding.left - this.padding.right;
const plotH = this.height - this.padding.top - this.padding.bottom;
const gap = 8;
const barW = (plotW - gap * (d.length - 1)) / d.length;

return d.map((item, i) => {
const val = Number(item[vk]) || 0;
const h = (val / maxVal) * plotH;
return {
x: this.padding.left + i * (barW + gap),
y: this.padding.top + plotH - h,
w: barW,
h,
label: String(item[lk] ?? ''),
value: val.toLocaleString(),
};
});
});
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
import { Component, input } from '@angular/core';
import { Component, computed, input } from '@angular/core';
import type { Spec } from '@json-render/core';
import { RenderElementComponent } from '@cacheplane/render';

Expand All @@ -8,7 +8,7 @@ import { RenderElementComponent } from '@cacheplane/render';
standalone: true,
imports: [RenderElementComponent],
template: `
<div class="flex flex-col gap-3">
<div [class]="layoutClass()">
@for (key of childKeys(); track key) {
<render-element [elementKey]="key" [spec]="spec()" />
}
Expand All @@ -18,4 +18,11 @@ import { RenderElementComponent } from '@cacheplane/render';
export class ContainerComponent {
readonly childKeys = input<string[]>([]);
readonly spec = input.required<Spec>();
readonly direction = input<'row' | 'column'>('column');

readonly layoutClass = computed(() =>
this.direction() === 'row'
? 'flex flex-row flex-wrap gap-3'
: 'flex flex-col gap-3'
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
import { Component, input } from '@angular/core';
import type { Spec } from '@json-render/core';
import { RenderElementComponent } from '@cacheplane/render';

@Component({
selector: 'app-dashboard-grid',
standalone: true,
imports: [RenderElementComponent],
template: `
<div class="flex flex-col gap-6 p-4">
@for (key of childKeys(); track key) {
<render-element [elementKey]="key" [spec]="spec()" />
}
</div>
`,
})
export class DashboardGridComponent {
readonly childKeys = input<string[]>([]);
readonly spec = input.required<Spec>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DataGridComponent } from './data-grid.component';

describe('DataGridComponent', () => {
let fixture: ComponentFixture<DataGridComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DataGridComponent],
}).compileComponents();
fixture = TestBed.createComponent(DataGridComponent);
});

it('renders skeleton rows when rows is null', () => {
fixture.componentRef.setInput('title', 'Churned');
fixture.componentRef.setInput('rows', null);
fixture.componentRef.setInput('columns', ['name', 'plan']);
fixture.detectChanges();
const el = fixture.nativeElement as HTMLElement;
const skeletonRows = el.querySelectorAll('.skeleton-row');
expect(skeletonRows.length).toBeGreaterThanOrEqual(3);
});

it('renders correct number of data rows', () => {
const rows = [
{ name: 'Acme', plan: 'pro', mrr_lost: 450 },
{ name: 'Widget', plan: 'starter', mrr_lost: 120 },
];
fixture.componentRef.setInput('title', 'Churned');
fixture.componentRef.setInput('rows', rows);
fixture.componentRef.setInput('columns', ['name', 'plan', 'mrr_lost']);
fixture.detectChanges();
const tbody = fixture.nativeElement.querySelector('tbody');
expect(tbody.querySelectorAll('tr').length).toBe(2);
});

it('renders title-cased column headers', () => {
fixture.componentRef.setInput('title', 'Churned');
fixture.componentRef.setInput('rows', [{ name: 'Acme', mrr_lost: 450 }]);
fixture.componentRef.setInput('columns', ['name', 'mrr_lost']);
fixture.detectChanges();
const headers = fixture.nativeElement.querySelectorAll('th');
expect(headers[0].textContent.trim()).toBe('Name');
expect(headers[1].textContent.trim()).toBe('MRR Lost');
});

it('renders title', () => {
fixture.componentRef.setInput('title', 'Recently Churned');
fixture.componentRef.setInput('rows', []);
fixture.componentRef.setInput('columns', ['name']);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Recently Churned');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
import { Component, computed, input } from '@angular/core';

@Component({
selector: 'app-data-grid',
standalone: true,
template: `
<div class="rounded-lg border border-white/10 bg-white/5 p-4 backdrop-blur-sm">
<div class="text-sm font-medium text-white/60 mb-3">{{ title() }}</div>
@if (isSkeleton()) {
@for (i of skeletonRows; track i) {
<div class="skeleton skeleton-row"></div>
}
} @else {
<table class="w-full text-sm">
<thead>
<tr class="border-b border-white/10">
@for (col of formattedColumns(); track col.key) {
<th class="text-left text-xs font-medium uppercase tracking-wider text-white/40 py-2 px-2">{{ col.label }}</th>
}
</tr>
</thead>
<tbody>
@for (row of rows(); track $index) {
<tr class="border-b border-white/5" [class.bg-white/5]="$index % 2 === 1">
@for (col of formattedColumns(); track col.key) {
<td class="py-2 px-2 text-white/80">{{ row[col.key] }}</td>
}
</tr>
}
</tbody>
</table>
}
</div>
`,
styleUrls: ['./skeleton.css'],
})
export class DataGridComponent {
readonly title = input<string>('');
readonly rows = input<Record<string, unknown>[] | null>(null);
readonly columns = input<string[]>([]);

readonly skeletonRows = [0, 1, 2, 3];

readonly isSkeleton = computed(() => this.rows() == null);

readonly formattedColumns = computed(() =>
this.columns().map(key => ({
key,
label: key
.split('_')
.map(word =>
word.length <= 3
? word.toUpperCase()
: word.charAt(0).toUpperCase() + word.slice(1)
)
.join(' '),
}))
);
}
Loading
Loading