Skip to content

Commit f9fde32

Browse files
bloveclaudegithub-actions[bot]
authored
fix(chat): scope tool-call rendering per-message + style genui demo views (#446)
* fix(chat): scope chat-tool-calls to its message via toolCallIds chat-tool-calls.toolCalls fell back to the agent's GLOBAL toolCalls list whenever the message had string content — which is the LangGraph adapter's default (extractTextContent always reduces complex content to a string). Every AI bubble in a thread re-rendered the entire thread's tool calls; with 3 AI messages and 4 tool calls, the chat showed 3 identical tool-call blocks. Fix: add `Message.toolCallIds?: string[]` populated by the LangGraph adapter from raw `tool_calls[].id`. chat-tool-calls prefers this per-message list when present; the legacy Anthropic-style content-block path stays as a fallback; the global-list fallback now fires ONLY when no message context is provided at all. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(c-generative-ui): replace Tailwind classes with scoped CSS The cockpit-chat-generative-ui-angular app doesn't have Tailwind configured — styles.css only imports theme tokens. All Tailwind classes (rounded-lg, border-white/10, bg-white/5, flex, gap-6, etc.) were silently no-ops, rendering stat cards as bare stacked text and charts/tables without their card chrome. Convert all 6 view components to scoped Angular styles (or styleUrls) using raw CSS: - stat-card: card chrome, uppercase tracked label, large value, colored delta (green for up, red for down via data-trend attr) - bar-chart + line-chart: card chrome, gradient fills, gridlines, consistent axis-label and title styling - line-chart: area-fill path under the line via SVG path d= - data-grid: card chrome, uppercase header row, zebra striping, tabular-nums, horizontal scroll for narrow widths - dashboard-grid: vertical flex stack, 16px gap - container: CSS grid with row direction = equal columns based on child count, collapses to 2-col then 1-col at responsive breakpoints Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(c-generative-ui): update view specs for new attribute-based styling * chore(docs): regenerate api docs * fix(e2e): toolbar field locators use stable data-field attributes PR #444 dropped visible label text from the demo shell toolbar to tighten the layout, but the examples/chat e2e helpers used `.filter({ hasText: label })` to find the four toolbar fields (Model, Effort, Gen UI, Theme) — with labels gone, ~5 tests failed on every CI run since #444 merged. Add `data-field` attributes to the four `.demo-shell__field` wrappers and switch the e2e helpers + the one inlined locator in model-picker.spec.ts to query by attribute. Helper signatures unchanged (label arg now maps to data-field via a small lookup table) so call sites in control-palette, color-scheme, model-picker, etc. need no changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 48e5c40 commit f9fde32

16 files changed

Lines changed: 398 additions & 87 deletions

File tree

apps/website/content/docs/chat/api/api-docs.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5873,6 +5873,12 @@
58735873
"type": "string",
58745874
"description": "Present when role === 'tool'.",
58755875
"optional": true
5876+
},
5877+
{
5878+
"name": "toolCallIds",
5879+
"type": "string[]",
5880+
"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 `<chat-tool-calls>` 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.",
5881+
"optional": true
58765882
}
58775883
],
58785884
"examples": []

cockpit/chat/generative-ui/angular/src/app/views/bar-chart.component.ts

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,94 @@ import { Component, computed, input } from '@angular/core';
55
selector: 'app-bar-chart',
66
standalone: true,
77
template: `
8-
<div class="rounded-lg border border-white/10 bg-white/5 p-4 backdrop-blur-sm">
9-
<div class="text-sm font-medium text-white/60 mb-3">{{ title() }}</div>
8+
<div class="chart-card">
9+
<div class="chart-card__title">{{ title() }}</div>
1010
@if (isSkeleton()) {
1111
<div class="skeleton skeleton-chart"></div>
1212
} @else {
13-
<svg [attr.viewBox]="'0 0 ' + width + ' ' + height" class="w-full" preserveAspectRatio="xMidYMid meet">
13+
<svg
14+
[attr.viewBox]="'0 0 ' + width + ' ' + height"
15+
class="chart-card__svg"
16+
preserveAspectRatio="xMidYMid meet"
17+
role="img"
18+
[attr.aria-label]="title()"
19+
>
20+
<!-- Baseline -->
21+
<line
22+
[attr.x1]="padding.left"
23+
[attr.x2]="width - padding.right"
24+
[attr.y1]="height - padding.bottom"
25+
[attr.y2]="height - padding.bottom"
26+
stroke="rgba(255,255,255,0.08)"
27+
stroke-width="1"
28+
/>
1429
@for (bar of bars(); track $index) {
15-
<!-- Bar -->
16-
<rect class="bar" [attr.x]="bar.x" [attr.y]="bar.y" [attr.width]="bar.w" [attr.height]="bar.h" fill="#d4aa6a" rx="2" />
17-
<!-- Value above bar -->
18-
<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>
19-
<!-- Label below bar -->
20-
<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>
30+
<rect
31+
class="bar"
32+
[attr.x]="bar.x"
33+
[attr.y]="bar.y"
34+
[attr.width]="bar.w"
35+
[attr.height]="bar.h"
36+
[attr.rx]="3"
37+
fill="url(#bar-gradient)"
38+
/>
39+
<text
40+
[attr.x]="bar.x + bar.w / 2"
41+
[attr.y]="bar.y - 6"
42+
text-anchor="middle"
43+
fill="rgba(255,255,255,0.7)"
44+
font-size="11"
45+
font-weight="500"
46+
>{{ bar.value }}</text>
47+
<text
48+
[attr.x]="bar.x + bar.w / 2"
49+
[attr.y]="height - padding.bottom + 16"
50+
text-anchor="middle"
51+
fill="rgba(255,255,255,0.5)"
52+
font-size="11"
53+
>{{ bar.label }}</text>
2154
}
55+
<defs>
56+
<linearGradient id="bar-gradient" x1="0" y1="0" x2="0" y2="1">
57+
<stop offset="0%" stop-color="#e0b87a" stop-opacity="1"/>
58+
<stop offset="100%" stop-color="#d4aa6a" stop-opacity="0.75"/>
59+
</linearGradient>
60+
</defs>
2261
</svg>
2362
}
2463
</div>
2564
`,
2665
styleUrls: ['./skeleton.css'],
66+
styles: [`
67+
.chart-card {
68+
display: flex;
69+
flex-direction: column;
70+
gap: 12px;
71+
padding: 16px 18px;
72+
border: 1px solid rgba(255, 255, 255, 0.08);
73+
border-radius: 10px;
74+
background: rgba(255, 255, 255, 0.03);
75+
backdrop-filter: blur(4px);
76+
}
77+
.chart-card__title {
78+
font-size: 13px;
79+
font-weight: 600;
80+
color: rgba(255, 255, 255, 0.65);
81+
letter-spacing: 0.01em;
82+
}
83+
.chart-card__svg {
84+
width: 100%;
85+
height: auto;
86+
max-height: 280px;
87+
display: block;
88+
}
89+
.bar {
90+
transition: opacity 120ms ease;
91+
}
92+
.bar:hover {
93+
opacity: 0.85;
94+
}
95+
`],
2796
})
2897
export class BarChartComponent {
2998
readonly title = input<string>('');
@@ -32,8 +101,8 @@ export class BarChartComponent {
32101
readonly valueKey = input<string>('');
33102

34103
readonly width = 400;
35-
readonly height = 200;
36-
readonly padding = { top: 30, right: 20, bottom: 30, left: 20 };
104+
readonly height = 220;
105+
readonly padding = { top: 28, right: 16, bottom: 28, left: 16 };
37106

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

@@ -46,7 +115,7 @@ export class BarChartComponent {
46115
const maxVal = Math.max(...values) || 1;
47116
const plotW = this.width - this.padding.left - this.padding.right;
48117
const plotH = this.height - this.padding.top - this.padding.bottom;
49-
const gap = 8;
118+
const gap = 12;
50119
const barW = (plotW - gap * (d.length - 1)) / d.length;
51120

52121
return d.map((item, i) => {

cockpit/chat/generative-ui/angular/src/app/views/container.component.spec.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,19 @@ describe('ContainerComponent', () => {
3737
fixture = TestBed.createComponent(ContainerComponent);
3838
});
3939

40-
it('applies column flex classes by default', () => {
40+
it('sets column direction attribute by default', () => {
4141
fixture.componentRef.setInput('spec', emptySpec);
4242
fixture.detectChanges();
43-
const wrapper = fixture.nativeElement.querySelector('div');
44-
expect(wrapper?.className).toContain('flex-col');
45-
expect(wrapper?.className).not.toContain('flex-row');
43+
const wrapper = fixture.nativeElement.querySelector('div.container');
44+
expect(wrapper?.getAttribute('data-direction')).toBe('column');
4645
});
4746

48-
it('applies row flex classes when direction is "row"', () => {
47+
it('sets row direction attribute when direction is "row"', () => {
4948
fixture.componentRef.setInput('spec', emptySpec);
5049
fixture.componentRef.setInput('direction', 'row');
5150
fixture.detectChanges();
52-
const wrapper = fixture.nativeElement.querySelector('div');
53-
expect(wrapper?.className).toContain('flex-row');
54-
expect(wrapper?.className).not.toContain('flex-col');
51+
const wrapper = fixture.nativeElement.querySelector('div.container');
52+
expect(wrapper?.getAttribute('data-direction')).toBe('row');
5553
});
5654

5755
it('renders one render-element per childKey', () => {

cockpit/chat/generative-ui/angular/src/app/views/container.component.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,41 @@ import { RenderElementComponent } from '@ngaf/render';
88
standalone: true,
99
imports: [RenderElementComponent],
1010
template: `
11-
<div [class]="layoutClass()">
11+
<div class="container" [attr.data-direction]="direction()" [style.--container-cols]="rowChildCount()">
1212
@for (key of childKeys(); track key) {
1313
<render-element [elementKey]="key" [spec]="spec()" />
1414
}
1515
</div>
1616
`,
17+
styles: [`
18+
.container {
19+
display: grid;
20+
gap: 12px;
21+
min-width: 0;
22+
}
23+
.container[data-direction="column"] {
24+
grid-template-columns: 1fr;
25+
}
26+
.container[data-direction="row"] {
27+
grid-template-columns: repeat(var(--container-cols, 1), minmax(0, 1fr));
28+
}
29+
/* Responsive collapse: at narrow widths, row containers stack to single column */
30+
@media (max-width: 720px) {
31+
.container[data-direction="row"] {
32+
grid-template-columns: repeat(2, minmax(0, 1fr));
33+
}
34+
}
35+
@media (max-width: 480px) {
36+
.container[data-direction="row"] {
37+
grid-template-columns: 1fr;
38+
}
39+
}
40+
`],
1741
})
1842
export class ContainerComponent {
1943
readonly childKeys = input<string[]>([]);
2044
readonly spec = input.required<Spec>();
2145
readonly direction = input<'row' | 'column'>('column');
2246

23-
readonly layoutClass = computed(() =>
24-
this.direction() === 'row'
25-
? 'flex flex-row flex-wrap gap-3'
26-
: 'flex flex-col gap-3'
27-
);
47+
readonly rowChildCount = computed(() => Math.max(1, this.childKeys().length));
2848
}

cockpit/chat/generative-ui/angular/src/app/views/dashboard-grid.component.spec.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,11 @@ describe('DashboardGridComponent', () => {
3434
fixture = TestBed.createComponent(DashboardGridComponent);
3535
});
3636

37-
it('applies vertical flex layout with section spacing', () => {
37+
it('applies the dashboard-grid layout class', () => {
3838
fixture.componentRef.setInput('spec', emptySpec);
3939
fixture.detectChanges();
40-
const wrapper = fixture.nativeElement.querySelector('div');
41-
expect(wrapper?.className).toContain('flex-col');
42-
expect(wrapper?.className).toContain('gap-6');
40+
const wrapper = fixture.nativeElement.querySelector('div.dashboard-grid');
41+
expect(wrapper).toBeTruthy();
4342
});
4443

4544
it('renders one render-element per childKey', () => {

cockpit/chat/generative-ui/angular/src/app/views/dashboard-grid.component.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,22 @@ import { RenderElementComponent } from '@ngaf/render';
88
standalone: true,
99
imports: [RenderElementComponent],
1010
template: `
11-
<div class="flex flex-col gap-6 p-4">
11+
<div class="dashboard-grid">
1212
@for (key of childKeys(); track key) {
1313
<render-element [elementKey]="key" [spec]="spec()" />
1414
}
1515
</div>
1616
`,
17+
styles: [`
18+
.dashboard-grid {
19+
display: flex;
20+
flex-direction: column;
21+
gap: 16px;
22+
padding: 4px 0;
23+
width: 100%;
24+
min-width: 0;
25+
}
26+
`],
1727
})
1828
export class DashboardGridComponent {
1929
readonly childKeys = input<string[]>([]);

cockpit/chat/generative-ui/angular/src/app/views/data-grid.component.ts

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,35 +5,88 @@ import { Component, computed, input } from '@angular/core';
55
selector: 'app-data-grid',
66
standalone: true,
77
template: `
8-
<div class="rounded-lg border border-white/10 bg-white/5 p-4 backdrop-blur-sm">
9-
<div class="text-sm font-medium text-white/60 mb-3">{{ title() }}</div>
8+
<div class="data-grid">
9+
<div class="data-grid__title">{{ title() }}</div>
1010
@if (isSkeleton()) {
1111
@for (i of skeletonRows; track i) {
1212
<div class="skeleton skeleton-row"></div>
1313
}
1414
} @else {
15-
<table class="w-full text-sm">
16-
<thead>
17-
<tr class="border-b border-white/10">
18-
@for (col of formattedColumns(); track col.key) {
19-
<th class="text-left text-xs font-medium uppercase tracking-wider text-white/40 py-2 px-2">{{ col.label }}</th>
20-
}
21-
</tr>
22-
</thead>
23-
<tbody>
24-
@for (row of rows(); track $index) {
25-
<tr class="border-b border-white/5" [class.bg-white/5]="$index % 2 === 1">
15+
<div class="data-grid__scroll">
16+
<table class="data-grid__table">
17+
<thead>
18+
<tr>
2619
@for (col of formattedColumns(); track col.key) {
27-
<td class="py-2 px-2 text-white/80">{{ row[col.key] }}</td>
20+
<th>{{ col.label }}</th>
2821
}
2922
</tr>
30-
}
31-
</tbody>
32-
</table>
23+
</thead>
24+
<tbody>
25+
@for (row of rows(); track $index) {
26+
<tr>
27+
@for (col of formattedColumns(); track col.key) {
28+
<td>{{ row[col.key] }}</td>
29+
}
30+
</tr>
31+
}
32+
</tbody>
33+
</table>
34+
</div>
3335
}
3436
</div>
3537
`,
3638
styleUrls: ['./skeleton.css'],
39+
styles: [`
40+
.data-grid {
41+
display: flex;
42+
flex-direction: column;
43+
gap: 12px;
44+
padding: 16px 18px;
45+
border: 1px solid rgba(255, 255, 255, 0.08);
46+
border-radius: 10px;
47+
background: rgba(255, 255, 255, 0.03);
48+
backdrop-filter: blur(4px);
49+
}
50+
.data-grid__title {
51+
font-size: 13px;
52+
font-weight: 600;
53+
color: rgba(255, 255, 255, 0.65);
54+
letter-spacing: 0.01em;
55+
}
56+
.data-grid__scroll {
57+
overflow-x: auto;
58+
margin: 0 -4px;
59+
}
60+
.data-grid__table {
61+
width: 100%;
62+
border-collapse: collapse;
63+
font-size: 13px;
64+
font-variant-numeric: tabular-nums;
65+
}
66+
.data-grid__table th {
67+
text-align: left;
68+
padding: 8px 10px;
69+
font-size: 11px;
70+
font-weight: 600;
71+
letter-spacing: 0.06em;
72+
text-transform: uppercase;
73+
color: rgba(255, 255, 255, 0.45);
74+
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
75+
white-space: nowrap;
76+
}
77+
.data-grid__table td {
78+
padding: 10px;
79+
color: rgba(255, 255, 255, 0.85);
80+
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
81+
white-space: nowrap;
82+
}
83+
.data-grid__table tbody tr:nth-child(even) td {
84+
background: rgba(255, 255, 255, 0.02);
85+
}
86+
.data-grid__table tbody tr:last-child td {
87+
border-bottom: none;
88+
}
89+
`],
3790
})
3891
export class DataGridComponent {
3992
readonly title = input<string>('');

0 commit comments

Comments
 (0)