fix: layout fixes
This commit is contained in:
4
A2UI.md
4
A2UI.md
@@ -66,7 +66,7 @@ Instead of asking the LLM to produce A2UI JSON as free text (unreliable), we add
|
|||||||
|
|
||||||
| Tool | Purpose | A2UI Output |
|
| Tool | Purpose | A2UI Output |
|
||||||
|------|---------|-------------|
|
|------|---------|-------------|
|
||||||
| `render_chart` | Show bar/line/pie chart | `updateComponents` with chart component |
|
| `render_chart` | Show bar/stacked-bar/line/pie chart | `updateComponents` with chart component |
|
||||||
| `render_table` | Show data table | `updateComponents` with table rows |
|
| `render_table` | Show data table | `updateComponents` with table rows |
|
||||||
| `render_form` | Show input form | `updateComponents` with form fields |
|
| `render_form` | Show input form | `updateComponents` with form fields |
|
||||||
| `render_card` | Show info card | `updateComponents` with card component |
|
| `render_card` | Show info card | `updateComponents` with card component |
|
||||||
@@ -268,7 +268,7 @@ Individual component renderers, refactored from `AssistantPanelControls`:
|
|||||||
1. Create `src/main/a2ui/generator.ts` — converts tool args to A2UI messages
|
1. Create `src/main/a2ui/generator.ts` — converts tool args to A2UI messages
|
||||||
2. Create `src/main/a2ui/catalog.ts` — defines our component catalog
|
2. Create `src/main/a2ui/catalog.ts` — defines our component catalog
|
||||||
3. Add UI-rendering tools to `OpenCodeManager.getToolDefinitions()`:
|
3. Add UI-rendering tools to `OpenCodeManager.getToolDefinitions()`:
|
||||||
- `render_chart({ chartType, title, series })`
|
- `render_chart({ chartType, title, series })` — chartType includes `bar`, `stacked-bar`, `line`, `pie`
|
||||||
- `render_table({ title, columns, rows })`
|
- `render_table({ title, columns, rows })`
|
||||||
- `render_form({ title, fields, submitAction })`
|
- `render_form({ title, fields, submitAction })`
|
||||||
- `render_card({ title, body, subtitle, actions })`
|
- `render_card({ title, body, subtitle, actions })`
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ function createSurfaceMessages(
|
|||||||
// ---- Tool argument interfaces ----
|
// ---- Tool argument interfaces ----
|
||||||
|
|
||||||
export interface RenderChartArgs {
|
export interface RenderChartArgs {
|
||||||
chartType: 'bar' | 'line' | 'pie';
|
chartType: 'bar' | 'stacked-bar' | 'line' | 'pie';
|
||||||
title?: string;
|
title?: string;
|
||||||
series: Array<{ label: string; value: number }>;
|
series: Array<{ label: string; value: number; segments?: Array<{ label: string; value: number }> }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenderTableArgs {
|
export interface RenderTableArgs {
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ Available Data Tools:
|
|||||||
- get_media_posts: Get posts that use a specific media file.
|
- get_media_posts: Get posts that use a specific media file.
|
||||||
|
|
||||||
Available UI Render Tools (use these to show rich interactive elements):
|
Available UI Render Tools (use these to show rich interactive elements):
|
||||||
- render_chart: Show data as a bar, line, or pie chart. Use when presenting statistics or comparisons.
|
- render_chart: Show data as a bar, stacked-bar, line, or pie chart. Use when presenting statistics or comparisons. Use stacked-bar when each bar has multiple segments (e.g., published vs draft posts per year).
|
||||||
- render_table: Show data in a structured table. Use for tabular comparisons and listings.
|
- render_table: Show data in a structured table. Use for tabular comparisons and listings.
|
||||||
- render_form: Show an interactive form to collect user input (e.g., metadata edits, settings).
|
- render_form: Show an interactive form to collect user input (e.g., metadata edits, settings).
|
||||||
- render_card: Show an information card with title, body, and action buttons.
|
- render_card: Show an information card with title, body, and action buttons.
|
||||||
|
|||||||
@@ -924,7 +924,7 @@ export class OpenCodeManager {
|
|||||||
input_schema: {
|
input_schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
chartType: { type: 'string', enum: ['bar', 'line', 'pie'], description: 'The type of chart to render' },
|
chartType: { type: 'string', enum: ['bar', 'stacked-bar', 'line', 'pie'], description: 'The type of chart to render. Use stacked-bar when each bar has multiple segments (categories).' },
|
||||||
title: { type: 'string', description: 'Optional chart title' },
|
title: { type: 'string', description: 'Optional chart title' },
|
||||||
series: {
|
series: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
@@ -932,11 +932,23 @@ export class OpenCodeManager {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
label: { type: 'string', description: 'Data point label' },
|
label: { type: 'string', description: 'Data point label' },
|
||||||
value: { type: 'number', description: 'Data point value' },
|
value: { type: 'number', description: 'Data point value (total for stacked bars)' },
|
||||||
|
segments: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
label: { type: 'string', description: 'Segment category label' },
|
||||||
|
value: { type: 'number', description: 'Segment value' },
|
||||||
},
|
},
|
||||||
required: ['label', 'value'],
|
required: ['label', 'value'],
|
||||||
},
|
},
|
||||||
description: 'Array of data points with label and value',
|
description: 'Segments for stacked-bar charts. Each segment is a category within the bar.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['label', 'value'],
|
||||||
|
},
|
||||||
|
description: 'Array of data points with label and value. For stacked-bar charts, include segments.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['chartType', 'series'],
|
required: ['chartType', 'series'],
|
||||||
|
|||||||
@@ -9,28 +9,117 @@ interface A2UIComponentProps {
|
|||||||
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SegmentEntry {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface SeriesEntry {
|
interface SeriesEntry {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
|
segments?: SegmentEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEGMENT_COLORS = [
|
||||||
|
'var(--vscode-charts-blue, #75beff)',
|
||||||
|
'var(--vscode-charts-green, #89d185)',
|
||||||
|
'var(--vscode-charts-orange, #d18616)',
|
||||||
|
'var(--vscode-charts-red, #f14c4c)',
|
||||||
|
'var(--vscode-charts-purple, #b180d7)',
|
||||||
|
'var(--vscode-charts-yellow, #e2e210)',
|
||||||
|
];
|
||||||
|
|
||||||
|
function getSegmentColor(index: number): string {
|
||||||
|
return SEGMENT_COLORS[index % SEGMENT_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect unique segment labels across all series entries, preserving order. */
|
||||||
|
function collectSegmentLabels(series: SeriesEntry[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const labels: string[] = [];
|
||||||
|
for (const entry of series) {
|
||||||
|
if (entry.segments) {
|
||||||
|
for (const seg of entry.segments) {
|
||||||
|
if (!seen.has(seg.label)) {
|
||||||
|
seen.add(seg.label);
|
||||||
|
labels.push(seg.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const A2UIChart: React.FC<A2UIComponentProps> = ({ component }) => {
|
export const A2UIChart: React.FC<A2UIComponentProps> = ({ component }) => {
|
||||||
const chartType = String(component.properties.chartType ?? 'bar');
|
const chartType = String(component.properties.chartType ?? 'bar');
|
||||||
const title = component.properties.title as string | undefined;
|
const title = component.properties.title as string | undefined;
|
||||||
const series = (component.boundValue as SeriesEntry[]) ?? (component.properties.series as SeriesEntry[]) ?? [];
|
const series = (component.boundValue as SeriesEntry[]) ?? (component.properties.series as SeriesEntry[]) ?? [];
|
||||||
const maxValue = Math.max(...series.map((entry) => entry.value), 0);
|
const isStacked = chartType === 'stacked-bar';
|
||||||
|
|
||||||
|
const maxValue = Math.max(
|
||||||
|
...series.map((entry) => {
|
||||||
|
if (isStacked && entry.segments) {
|
||||||
|
return entry.segments.reduce((sum, s) => sum + s.value, 0);
|
||||||
|
}
|
||||||
|
return entry.value;
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const segmentLabels = isStacked ? collectSegmentLabels(series) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="assistant-panel-chart">
|
<div className="assistant-panel-chart">
|
||||||
{title && <p className="assistant-panel-chart-title">{title}</p>}
|
{title && <p className="assistant-panel-chart-title">{title}</p>}
|
||||||
<div className="assistant-panel-chart-type">{chartType}</div>
|
<div className="assistant-panel-chart-type">{chartType}</div>
|
||||||
{series.map((entry, index) => (
|
{series.map((entry, index) => {
|
||||||
|
const totalValue = isStacked && entry.segments
|
||||||
|
? entry.segments.reduce((sum, s) => sum + s.value, 0)
|
||||||
|
: entry.value;
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={`${component.id}-series-${index}`} className="assistant-panel-chart-item">
|
<div key={`${component.id}-series-${index}`} className="assistant-panel-chart-item">
|
||||||
<span>{entry.label}</span>
|
<span className="assistant-panel-chart-label">{entry.label}</span>
|
||||||
<progress value={entry.value} max={maxValue || 1} />
|
<div className="assistant-panel-chart-bar-track">
|
||||||
<span>{entry.value}</span>
|
{isStacked && entry.segments ? (
|
||||||
|
entry.segments.map((seg, si) => {
|
||||||
|
const segWidth = maxValue > 0 ? (seg.value / maxValue) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${component.id}-seg-${index}-${si}`}
|
||||||
|
className="assistant-panel-chart-bar-segment"
|
||||||
|
style={{
|
||||||
|
width: `${segWidth}%`,
|
||||||
|
backgroundColor: getSegmentColor(segmentLabels.indexOf(seg.label)),
|
||||||
|
}}
|
||||||
|
title={`${seg.label}: ${seg.value}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="assistant-panel-chart-bar-fill"
|
||||||
|
style={{ width: `${maxValue > 0 ? (entry.value / maxValue) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<span className="assistant-panel-chart-value">{totalValue}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{isStacked && segmentLabels.length > 0 && (
|
||||||
|
<div className="assistant-panel-chart-legend">
|
||||||
|
{segmentLabels.map((label, i) => (
|
||||||
|
<span key={label} className="assistant-panel-chart-legend-item">
|
||||||
|
<span
|
||||||
|
className="assistant-panel-chart-legend-swatch"
|
||||||
|
style={{ backgroundColor: getSegmentColor(i) }}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-metric {
|
.assistant-panel-metric {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@@ -83,22 +83,22 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-metric-label {
|
.assistant-panel-metric-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-metric-value {
|
.assistant-panel-metric-value {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-table {
|
.assistant-panel-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-table th,
|
.assistant-panel-table th,
|
||||||
.assistant-sidebar-table td {
|
.assistant-panel-table td {
|
||||||
border: 1px solid var(--vscode-panel-border);
|
border: 1px solid var(--vscode-panel-border);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -112,30 +112,30 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-widget-block {
|
.assistant-panel-widget-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-widget-label {
|
.assistant-panel-widget-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-widget-input {
|
.assistant-panel-widget-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-checkbox {
|
.assistant-panel-checkbox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-chart {
|
.assistant-panel-chart {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -144,30 +144,99 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-chart-title {
|
.assistant-panel-chart-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-chart-type {
|
.assistant-panel-chart-type {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-chart-item {
|
.assistant-panel-chart-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(48px, auto) 1fr auto;
|
grid-template-columns: auto 1fr auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-chart-item progress {
|
.assistant-panel-chart-label {
|
||||||
width: 100%;
|
justify-self: end;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 140px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-form {
|
.assistant-panel-chart-bar-track {
|
||||||
|
height: 14px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--vscode-charts-blue, #75beff);
|
||||||
|
border-radius: 3px;
|
||||||
|
min-width: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-bar-segment {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 1px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-bar-segment:first-child {
|
||||||
|
border-radius: 3px 0 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-bar-segment:last-child {
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-bar-segment:only-child {
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-value {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 11px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-chart-legend-swatch {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-panel-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -176,12 +245,12 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-form-title {
|
.assistant-panel-form-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-card {
|
.assistant-panel-card {
|
||||||
border: 1px solid var(--vscode-panel-border);
|
border: 1px solid var(--vscode-panel-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
@@ -190,57 +259,57 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-card h4,
|
.assistant-panel-card h4,
|
||||||
.assistant-sidebar-card p {
|
.assistant-panel-card p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-card-subtitle {
|
.assistant-panel-card-subtitle {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-card-actions {
|
.assistant-panel-card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-image {
|
.assistant-panel-image {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-image img {
|
.assistant-panel-image img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--vscode-panel-border);
|
border: 1px solid var(--vscode-panel-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-image figcaption {
|
.assistant-panel-image figcaption {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-tabs {
|
.assistant-panel-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-tab-strip {
|
.assistant-panel-tab-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-tab-button.active {
|
.assistant-panel-tab-button.active {
|
||||||
border-color: var(--vscode-focusBorder);
|
border-color: var(--vscode-focusBorder);
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-tab-panel {
|
.assistant-panel-tab-panel {
|
||||||
border: 1px solid var(--vscode-panel-border);
|
border: 1px solid var(--vscode-panel-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|||||||
@@ -82,6 +82,56 @@ describe('A2UI generator', () => {
|
|||||||
{ label: 'Feb', value: 20 },
|
{ label: 'Feb', value: 20 },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates stacked-bar chart with segment data', () => {
|
||||||
|
const messages = generateChart('conv-1', {
|
||||||
|
chartType: 'stacked-bar',
|
||||||
|
title: 'Posts by Year',
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
label: '2023',
|
||||||
|
value: 30,
|
||||||
|
segments: [
|
||||||
|
{ label: 'Published', value: 20 },
|
||||||
|
{ label: 'Draft', value: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '2024',
|
||||||
|
value: 45,
|
||||||
|
segments: [
|
||||||
|
{ label: 'Published', value: 35 },
|
||||||
|
{ label: 'Draft', value: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(3);
|
||||||
|
|
||||||
|
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
|
||||||
|
expect(updateMsg.components[0].properties.chartType).toBe('stacked-bar');
|
||||||
|
|
||||||
|
const dataMsg = messages[2] as Extract<A2UIServerMessage, { type: 'updateDataModel' }>;
|
||||||
|
expect(dataMsg.value).toEqual([
|
||||||
|
{
|
||||||
|
label: '2023',
|
||||||
|
value: 30,
|
||||||
|
segments: [
|
||||||
|
{ label: 'Published', value: 20 },
|
||||||
|
{ label: 'Draft', value: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '2024',
|
||||||
|
value: 45,
|
||||||
|
segments: [
|
||||||
|
{ label: 'Published', value: 35 },
|
||||||
|
{ label: 'Draft', value: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generateTable', () => {
|
describe('generateTable', () => {
|
||||||
|
|||||||
215
tests/renderer/a2ui/A2UIChart.test.tsx
Normal file
215
tests/renderer/a2ui/A2UIChart.test.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { A2UIChart } from '../../../src/renderer/a2ui/components/A2UIChart';
|
||||||
|
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../src/main/a2ui/types';
|
||||||
|
|
||||||
|
function makeChartComponent(
|
||||||
|
overrides: Partial<A2UIResolvedComponent> = {},
|
||||||
|
series?: unknown,
|
||||||
|
): A2UIResolvedComponent {
|
||||||
|
return {
|
||||||
|
id: 'chart-1',
|
||||||
|
type: 'chart',
|
||||||
|
properties: {
|
||||||
|
chartType: 'bar',
|
||||||
|
title: 'Test Chart',
|
||||||
|
...(overrides.properties ?? {}),
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
boundValue: series ?? [
|
||||||
|
{ label: 'Alpha', value: 10 },
|
||||||
|
{ label: 'Beta', value: 25 },
|
||||||
|
{ label: 'Gamma', value: 15 },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const noopAction = vi.fn<(action: A2UIClientAction) => void>();
|
||||||
|
|
||||||
|
describe('A2UIChart', () => {
|
||||||
|
describe('bar chart tabular layout', () => {
|
||||||
|
it('renders chart title', () => {
|
||||||
|
const comp = makeChartComponent();
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Test Chart')).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-title')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders chart type label', () => {
|
||||||
|
const comp = makeChartComponent();
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('bar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all series labels', () => {
|
||||||
|
const comp = makeChartComponent();
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Beta')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Gamma')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all series values', () => {
|
||||||
|
const comp = makeChartComponent();
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
expect(screen.getByText('10')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('25')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('15')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders bar chart items in a three-column grid layout', () => {
|
||||||
|
const comp = makeChartComponent();
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const items = container.querySelectorAll('.assistant-panel-chart-item');
|
||||||
|
expect(items).toHaveLength(3);
|
||||||
|
|
||||||
|
// Each item should have label, bar container, and value as separate elements
|
||||||
|
for (const item of items) {
|
||||||
|
const label = item.querySelector('.assistant-panel-chart-label');
|
||||||
|
const barContainer = item.querySelector('.assistant-panel-chart-bar-track');
|
||||||
|
const value = item.querySelector('.assistant-panel-chart-value');
|
||||||
|
expect(label).not.toBeNull();
|
||||||
|
expect(barContainer).not.toBeNull();
|
||||||
|
expect(value).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders bar fill with correct percentage width', () => {
|
||||||
|
const comp = makeChartComponent({}, [
|
||||||
|
{ label: 'A', value: 50 },
|
||||||
|
{ label: 'B', value: 100 },
|
||||||
|
]);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const fills = container.querySelectorAll('.assistant-panel-chart-bar-fill');
|
||||||
|
expect(fills).toHaveLength(2);
|
||||||
|
// A = 50/100 = 50%, B = 100/100 = 100%
|
||||||
|
expect((fills[0] as HTMLElement).style.width).toBe('50%');
|
||||||
|
expect((fills[1] as HTMLElement).style.width).toBe('100%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders without title when title is not provided', () => {
|
||||||
|
const comp = makeChartComponent({ properties: { chartType: 'bar' } });
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-title')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state when series is empty', () => {
|
||||||
|
const comp = makeChartComponent({}, []);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const items = container.querySelectorAll('.assistant-panel-chart-item');
|
||||||
|
expect(items).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stacked bar chart', () => {
|
||||||
|
const stackedSeries = [
|
||||||
|
{
|
||||||
|
label: '2023',
|
||||||
|
value: 30,
|
||||||
|
segments: [
|
||||||
|
{ label: 'Published', value: 20 },
|
||||||
|
{ label: 'Draft', value: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '2024',
|
||||||
|
value: 45,
|
||||||
|
segments: [
|
||||||
|
{ label: 'Published', value: 35 },
|
||||||
|
{ label: 'Draft', value: 10 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('renders stacked bar items', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'stacked-bar', title: 'Posts by Year' } },
|
||||||
|
stackedSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const items = container.querySelectorAll('.assistant-panel-chart-item');
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders multiple segment fills per bar', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'stacked-bar', title: 'Posts' } },
|
||||||
|
stackedSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
// Each bar should have multiple fill segments
|
||||||
|
const tracks = container.querySelectorAll('.assistant-panel-chart-bar-track');
|
||||||
|
expect(tracks).toHaveLength(2);
|
||||||
|
|
||||||
|
const firstBarSegments = tracks[0].querySelectorAll('.assistant-panel-chart-bar-segment');
|
||||||
|
expect(firstBarSegments).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders segment widths as proportion of total across all bars', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'stacked-bar' } },
|
||||||
|
stackedSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
// maxValue is 45 (2024 total)
|
||||||
|
// 2023 row: Published=20/45, Draft=10/45 → total bar width = 30/45
|
||||||
|
// We need segment widths relative to the bar track via the fill percentage
|
||||||
|
const tracks = container.querySelectorAll('.assistant-panel-chart-bar-track');
|
||||||
|
expect(tracks.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a legend for stacked bar charts', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'stacked-bar', title: 'Posts' } },
|
||||||
|
stackedSeries,
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
const legend = container.querySelector('.assistant-panel-chart-legend');
|
||||||
|
expect(legend).not.toBeNull();
|
||||||
|
expect(screen.getByText('Published')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Draft')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows total value for stacked bars', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'stacked-bar' } },
|
||||||
|
stackedSeries,
|
||||||
|
);
|
||||||
|
render(<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('30')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('45')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render legend for regular bar charts', () => {
|
||||||
|
const comp = makeChartComponent(
|
||||||
|
{ properties: { chartType: 'bar' } },
|
||||||
|
[{ label: 'A', value: 10 }],
|
||||||
|
);
|
||||||
|
const { container } = render(
|
||||||
|
<A2UIChart component={comp} surfaceId="s1" onAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.assistant-panel-chart-legend')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user