diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 115c207bd..ea318860c 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -5873,6 +5873,12 @@ "type": "string", "description": "Present when role === 'tool'.", "optional": true + }, + { + "name": "toolCallIds", + "type": "string[]", + "description": "IDs of tool calls emitted BY this assistant message. Populated by\nadapters from provider-native fields (LangGraph: `tool_calls[].id`;\nAnthropic: `tool_use` content blocks). Used by `` to\nscope its rendering to a single message — otherwise it falls back to\nthe agent's global toolCalls list, which renders duplicates across\nevery AI message in a thread.", + "optional": true } ], "examples": [] diff --git a/cockpit/chat/generative-ui/angular/src/app/views/bar-chart.component.ts b/cockpit/chat/generative-ui/angular/src/app/views/bar-chart.component.ts index cd7e155ed..56f315ce8 100644 --- a/cockpit/chat/generative-ui/angular/src/app/views/bar-chart.component.ts +++ b/cockpit/chat/generative-ui/angular/src/app/views/bar-chart.component.ts @@ -5,25 +5,94 @@ import { Component, computed, input } from '@angular/core'; selector: 'app-bar-chart', standalone: true, template: ` -
-
{{ title() }}
+
+
{{ title() }}
@if (isSkeleton()) {
} @else { - + + + @for (bar of bars(); track $index) { - - - - {{ bar.value }} - - {{ bar.label }} + + {{ bar.value }} + {{ bar.label }} } + + + + + + }
`, styleUrls: ['./skeleton.css'], + styles: [` + .chart-card { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 18px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + backdrop-filter: blur(4px); + } + .chart-card__title { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.65); + letter-spacing: 0.01em; + } + .chart-card__svg { + width: 100%; + height: auto; + max-height: 280px; + display: block; + } + .bar { + transition: opacity 120ms ease; + } + .bar:hover { + opacity: 0.85; + } + `], }) export class BarChartComponent { readonly title = input(''); @@ -32,8 +101,8 @@ export class BarChartComponent { readonly valueKey = input(''); readonly width = 400; - readonly height = 200; - readonly padding = { top: 30, right: 20, bottom: 30, left: 20 }; + readonly height = 220; + readonly padding = { top: 28, right: 16, bottom: 28, left: 16 }; readonly isSkeleton = computed(() => this.data() == null); @@ -46,7 +115,7 @@ export class BarChartComponent { 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 gap = 12; const barW = (plotW - gap * (d.length - 1)) / d.length; return d.map((item, i) => { diff --git a/cockpit/chat/generative-ui/angular/src/app/views/container.component.spec.ts b/cockpit/chat/generative-ui/angular/src/app/views/container.component.spec.ts index 9f7029a84..f12f7a565 100644 --- a/cockpit/chat/generative-ui/angular/src/app/views/container.component.spec.ts +++ b/cockpit/chat/generative-ui/angular/src/app/views/container.component.spec.ts @@ -37,21 +37,19 @@ describe('ContainerComponent', () => { fixture = TestBed.createComponent(ContainerComponent); }); - it('applies column flex classes by default', () => { + it('sets column direction attribute by default', () => { fixture.componentRef.setInput('spec', emptySpec); fixture.detectChanges(); - const wrapper = fixture.nativeElement.querySelector('div'); - expect(wrapper?.className).toContain('flex-col'); - expect(wrapper?.className).not.toContain('flex-row'); + const wrapper = fixture.nativeElement.querySelector('div.container'); + expect(wrapper?.getAttribute('data-direction')).toBe('column'); }); - it('applies row flex classes when direction is "row"', () => { + it('sets row direction attribute when direction is "row"', () => { fixture.componentRef.setInput('spec', emptySpec); fixture.componentRef.setInput('direction', 'row'); fixture.detectChanges(); - const wrapper = fixture.nativeElement.querySelector('div'); - expect(wrapper?.className).toContain('flex-row'); - expect(wrapper?.className).not.toContain('flex-col'); + const wrapper = fixture.nativeElement.querySelector('div.container'); + expect(wrapper?.getAttribute('data-direction')).toBe('row'); }); it('renders one render-element per childKey', () => { diff --git a/cockpit/chat/generative-ui/angular/src/app/views/container.component.ts b/cockpit/chat/generative-ui/angular/src/app/views/container.component.ts index 2065775ea..8dc91a7e3 100644 --- a/cockpit/chat/generative-ui/angular/src/app/views/container.component.ts +++ b/cockpit/chat/generative-ui/angular/src/app/views/container.component.ts @@ -8,21 +8,41 @@ import { RenderElementComponent } from '@ngaf/render'; standalone: true, imports: [RenderElementComponent], template: ` -
+
@for (key of childKeys(); track key) { }
`, + styles: [` + .container { + display: grid; + gap: 12px; + min-width: 0; + } + .container[data-direction="column"] { + grid-template-columns: 1fr; + } + .container[data-direction="row"] { + grid-template-columns: repeat(var(--container-cols, 1), minmax(0, 1fr)); + } + /* Responsive collapse: at narrow widths, row containers stack to single column */ + @media (max-width: 720px) { + .container[data-direction="row"] { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + @media (max-width: 480px) { + .container[data-direction="row"] { + grid-template-columns: 1fr; + } + } + `], }) export class ContainerComponent { readonly childKeys = input([]); readonly spec = input.required(); readonly direction = input<'row' | 'column'>('column'); - readonly layoutClass = computed(() => - this.direction() === 'row' - ? 'flex flex-row flex-wrap gap-3' - : 'flex flex-col gap-3' - ); + readonly rowChildCount = computed(() => Math.max(1, this.childKeys().length)); } diff --git a/cockpit/chat/generative-ui/angular/src/app/views/dashboard-grid.component.spec.ts b/cockpit/chat/generative-ui/angular/src/app/views/dashboard-grid.component.spec.ts index 5556b16b8..5aa26daed 100644 --- a/cockpit/chat/generative-ui/angular/src/app/views/dashboard-grid.component.spec.ts +++ b/cockpit/chat/generative-ui/angular/src/app/views/dashboard-grid.component.spec.ts @@ -34,12 +34,11 @@ describe('DashboardGridComponent', () => { fixture = TestBed.createComponent(DashboardGridComponent); }); - it('applies vertical flex layout with section spacing', () => { + it('applies the dashboard-grid layout class', () => { fixture.componentRef.setInput('spec', emptySpec); fixture.detectChanges(); - const wrapper = fixture.nativeElement.querySelector('div'); - expect(wrapper?.className).toContain('flex-col'); - expect(wrapper?.className).toContain('gap-6'); + const wrapper = fixture.nativeElement.querySelector('div.dashboard-grid'); + expect(wrapper).toBeTruthy(); }); it('renders one render-element per childKey', () => { diff --git a/cockpit/chat/generative-ui/angular/src/app/views/dashboard-grid.component.ts b/cockpit/chat/generative-ui/angular/src/app/views/dashboard-grid.component.ts index 81a14ef97..eb2b8d942 100644 --- a/cockpit/chat/generative-ui/angular/src/app/views/dashboard-grid.component.ts +++ b/cockpit/chat/generative-ui/angular/src/app/views/dashboard-grid.component.ts @@ -8,12 +8,22 @@ import { RenderElementComponent } from '@ngaf/render'; standalone: true, imports: [RenderElementComponent], template: ` -
+
@for (key of childKeys(); track key) { }
`, + styles: [` + .dashboard-grid { + display: flex; + flex-direction: column; + gap: 16px; + padding: 4px 0; + width: 100%; + min-width: 0; + } + `], }) export class DashboardGridComponent { readonly childKeys = input([]); diff --git a/cockpit/chat/generative-ui/angular/src/app/views/data-grid.component.ts b/cockpit/chat/generative-ui/angular/src/app/views/data-grid.component.ts index 56d3d1a8e..0ec2b71d3 100644 --- a/cockpit/chat/generative-ui/angular/src/app/views/data-grid.component.ts +++ b/cockpit/chat/generative-ui/angular/src/app/views/data-grid.component.ts @@ -5,35 +5,88 @@ import { Component, computed, input } from '@angular/core'; selector: 'app-data-grid', standalone: true, template: ` -
-
{{ title() }}
+
+
{{ title() }}
@if (isSkeleton()) { @for (i of skeletonRows; track i) {
} } @else { - - - - @for (col of formattedColumns(); track col.key) { - - } - - - - @for (row of rows(); track $index) { - +
+
{{ col.label }}
+ + @for (col of formattedColumns(); track col.key) { - + } - } - -
{{ row[col.key] }}{{ col.label }}
+ + + @for (row of rows(); track $index) { + + @for (col of formattedColumns(); track col.key) { + {{ row[col.key] }} + } + + } + + +
}
`, styleUrls: ['./skeleton.css'], + styles: [` + .data-grid { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 18px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + backdrop-filter: blur(4px); + } + .data-grid__title { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.65); + letter-spacing: 0.01em; + } + .data-grid__scroll { + overflow-x: auto; + margin: 0 -4px; + } + .data-grid__table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + font-variant-numeric: tabular-nums; + } + .data-grid__table th { + text-align: left; + padding: 8px 10px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + white-space: nowrap; + } + .data-grid__table td { + padding: 10px; + color: rgba(255, 255, 255, 0.85); + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + white-space: nowrap; + } + .data-grid__table tbody tr:nth-child(even) td { + background: rgba(255, 255, 255, 0.02); + } + .data-grid__table tbody tr:last-child td { + border-bottom: none; + } + `], }) export class DataGridComponent { readonly title = input(''); diff --git a/cockpit/chat/generative-ui/angular/src/app/views/line-chart.component.ts b/cockpit/chat/generative-ui/angular/src/app/views/line-chart.component.ts index 19edcdaca..249144ec2 100644 --- a/cockpit/chat/generative-ui/angular/src/app/views/line-chart.component.ts +++ b/cockpit/chat/generative-ui/angular/src/app/views/line-chart.component.ts @@ -5,32 +5,100 @@ import { Component, computed, input } from '@angular/core'; selector: 'app-line-chart', standalone: true, template: ` -
-
{{ title() }}
+
+
{{ title() }}
@if (isSkeleton()) {
} @else { - - + + @for (y of yGridLines(); track y.value) { - - {{ y.label }} + + {{ y.label }} } + + - + @for (pt of points(); track $index) { - + } @for (pt of xLabels(); track $index) { - {{ pt.label }} + {{ pt.label }} } + + + + + + }
`, styleUrls: ['./skeleton.css'], + styles: [` + .chart-card { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 18px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + backdrop-filter: blur(4px); + } + .chart-card__title { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.65); + letter-spacing: 0.01em; + } + .chart-card__svg { + width: 100%; + height: auto; + max-height: 280px; + display: block; + } + `], }) export class LineChartComponent { readonly title = input(''); @@ -39,8 +107,8 @@ export class LineChartComponent { readonly yKey = input(''); readonly width = 400; - readonly height = 200; - readonly padding = { top: 20, right: 20, bottom: 30, left: 50 }; + readonly height = 220; + readonly padding = { top: 16, right: 16, bottom: 28, left: 44 }; readonly isSkeleton = computed(() => this.data() == null); @@ -67,6 +135,14 @@ export class LineChartComponent { this.points().map(p => `${p.x},${p.y}`).join(' ') ); + readonly areaPath = computed((): string => { + const pts = this.points(); + if (pts.length === 0) return ''; + const baseline = this.padding.top + (this.height - this.padding.top - this.padding.bottom); + const top = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' '); + return `${top} L ${pts[pts.length - 1].x} ${baseline} L ${pts[0].x} ${baseline} Z`; + }); + readonly xLabels = computed(() => { const pts = this.points(); if (pts.length <= 6) return pts; diff --git a/cockpit/chat/generative-ui/angular/src/app/views/stat-card.component.spec.ts b/cockpit/chat/generative-ui/angular/src/app/views/stat-card.component.spec.ts index 021e5667d..714f99720 100644 --- a/cockpit/chat/generative-ui/angular/src/app/views/stat-card.component.spec.ts +++ b/cockpit/chat/generative-ui/angular/src/app/views/stat-card.component.spec.ts @@ -31,21 +31,21 @@ describe('StatCardComponent', () => { expect(el.textContent).toContain('+8.2%'); }); - it('applies positive color to positive delta', () => { + it('marks positive delta with up trend', () => { fixture.componentRef.setInput('label', 'MRR'); fixture.componentRef.setInput('value', 42000); fixture.componentRef.setInput('delta', '+8.2%'); fixture.detectChanges(); const deltaEl = fixture.nativeElement.querySelector('[data-testid="delta"]'); - expect(deltaEl?.classList.contains('text-emerald-400')).toBe(true); + expect(deltaEl?.getAttribute('data-trend')).toBe('up'); }); - it('applies negative color to negative delta', () => { + it('marks negative delta with down trend', () => { fixture.componentRef.setInput('label', 'Churn'); fixture.componentRef.setInput('value', '3.2%'); fixture.componentRef.setInput('delta', '-0.4%'); fixture.detectChanges(); const deltaEl = fixture.nativeElement.querySelector('[data-testid="delta"]'); - expect(deltaEl?.classList.contains('text-red-400')).toBe(true); + expect(deltaEl?.getAttribute('data-trend')).toBe('down'); }); }); diff --git a/cockpit/chat/generative-ui/angular/src/app/views/stat-card.component.ts b/cockpit/chat/generative-ui/angular/src/app/views/stat-card.component.ts index a8815c01e..cc74fdcb4 100644 --- a/cockpit/chat/generative-ui/angular/src/app/views/stat-card.component.ts +++ b/cockpit/chat/generative-ui/angular/src/app/views/stat-card.component.ts @@ -5,20 +5,57 @@ import { Component, computed, input } from '@angular/core'; selector: 'app-stat-card', standalone: true, template: ` -
-
{{ label() }}
+
+
{{ label() }}
@if (isSkeleton()) { -
-
+
+
} @else { -
{{ formattedValue() }}
+
{{ formattedValue() }}
@if (delta()) { -
{{ delta() }}
+
+ {{ delta() }} +
} }
`, styleUrls: ['./skeleton.css'], + styles: [` + .stat-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 16px 18px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + backdrop-filter: blur(4px); + min-width: 0; + } + .stat-card__label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); + } + .stat-card__value { + font-size: 24px; + font-weight: 600; + line-height: 1.1; + color: rgba(255, 255, 255, 0.95); + font-variant-numeric: tabular-nums; + } + .stat-card__delta { + font-size: 12px; + font-weight: 500; + font-variant-numeric: tabular-nums; + color: rgba(255, 255, 255, 0.55); + } + .stat-card__delta[data-trend="up"] { color: #5cd393; } + .stat-card__delta[data-trend="down"] { color: #f08585; } + `], }) export class StatCardComponent { readonly label = input(''); @@ -34,11 +71,11 @@ export class StatCardComponent { return String(v); }); - readonly deltaColor = computed(() => { + readonly deltaTrend = computed((): 'up' | 'down' | 'flat' => { const d = this.delta(); - if (!d) return ''; - if (d.startsWith('+')) return 'text-emerald-400'; - if (d.startsWith('-')) return 'text-red-400'; - return 'text-white/60'; + if (!d) return 'flat'; + if (d.startsWith('+')) return 'up'; + if (d.startsWith('-') || d.startsWith('−')) return 'down'; + return 'flat'; }); } diff --git a/examples/chat/angular/e2e/model-picker.spec.ts b/examples/chat/angular/e2e/model-picker.spec.ts index 0ad36e399..2dee1a4cb 100644 --- a/examples/chat/angular/e2e/model-picker.spec.ts +++ b/examples/chat/angular/e2e/model-picker.spec.ts @@ -18,8 +18,7 @@ test('model picker: configured models render, persist, and reach backend state', // Open the chat-select menu and assert the three model options are listed. await modelTrigger.click(); const modelMenu = page - .locator('.demo-shell__field') - .filter({ hasText: 'Model' }) + .locator('.demo-shell__field[data-field="model"]') .locator('chat-select .chat-select__menu'); await expect(modelMenu.locator('.chat-select__option')).toHaveText([ 'gpt-5', diff --git a/examples/chat/angular/e2e/test-helpers.ts b/examples/chat/angular/e2e/test-helpers.ts index 69d5ee104..c5ab320bd 100644 --- a/examples/chat/angular/e2e/test-helpers.ts +++ b/examples/chat/angular/e2e/test-helpers.ts @@ -50,11 +50,27 @@ export function sendButton(page: Page): Locator { * The toolbar's four dropdowns (Model, Effort, Gen UI, Theme) use the * @ngaf/chat `chat-select` primitive — not native