From a3c571f7cd737930b49d90d76397879e508b8ae7 Mon Sep 17 00:00:00 2001 From: hugo Date: Thu, 26 Feb 2026 12:09:27 +0100 Subject: [PATCH] fix: layout fixes --- A2UI.md | 4 +- src/main/a2ui/generator.ts | 4 +- src/main/engine/ChatEngine.ts | 2 +- src/main/engine/OpenCodeManager.ts | 18 +- src/renderer/a2ui/components/A2UIChart.tsx | 103 ++++++++- .../AssistantSidebar/AssistantSidebar.css | 131 ++++++++--- tests/engine/a2ui/generator.test.ts | 50 ++++ tests/renderer/a2ui/A2UIChart.test.tsx | 215 ++++++++++++++++++ 8 files changed, 481 insertions(+), 46 deletions(-) create mode 100644 tests/renderer/a2ui/A2UIChart.test.tsx diff --git a/A2UI.md b/A2UI.md index 6ee6b88..65bc786 100644 --- a/A2UI.md +++ b/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 | |------|---------|-------------| -| `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_form` | Show input form | `updateComponents` with form fields | | `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 2. Create `src/main/a2ui/catalog.ts` — defines our component catalog 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_form({ title, fields, submitAction })` - `render_card({ title, body, subtitle, actions })` diff --git a/src/main/a2ui/generator.ts b/src/main/a2ui/generator.ts index 6a805e9..898b2d9 100644 --- a/src/main/a2ui/generator.ts +++ b/src/main/a2ui/generator.ts @@ -56,9 +56,9 @@ function createSurfaceMessages( // ---- Tool argument interfaces ---- export interface RenderChartArgs { - chartType: 'bar' | 'line' | 'pie'; + chartType: 'bar' | 'stacked-bar' | 'line' | 'pie'; title?: string; - series: Array<{ label: string; value: number }>; + series: Array<{ label: string; value: number; segments?: Array<{ label: string; value: number }> }>; } export interface RenderTableArgs { diff --git a/src/main/engine/ChatEngine.ts b/src/main/engine/ChatEngine.ts index 3c84957..4cee617 100644 --- a/src/main/engine/ChatEngine.ts +++ b/src/main/engine/ChatEngine.ts @@ -323,7 +323,7 @@ Available Data Tools: - get_media_posts: Get posts that use a specific media file. 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_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. diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index 2fd39da..c25fcd0 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -924,7 +924,7 @@ export class OpenCodeManager { input_schema: { type: 'object', 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' }, series: { type: 'array', @@ -932,11 +932,23 @@ export class OpenCodeManager { type: 'object', properties: { 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'], + }, + 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', + description: 'Array of data points with label and value. For stacked-bar charts, include segments.', }, }, required: ['chartType', 'series'], diff --git a/src/renderer/a2ui/components/A2UIChart.tsx b/src/renderer/a2ui/components/A2UIChart.tsx index 97cbaab..20d3976 100644 --- a/src/renderer/a2ui/components/A2UIChart.tsx +++ b/src/renderer/a2ui/components/A2UIChart.tsx @@ -9,28 +9,117 @@ interface A2UIComponentProps { renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode; } +interface SegmentEntry { + label: string; + value: number; +} + interface SeriesEntry { label: string; 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(); + 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 = ({ component }) => { const chartType = String(component.properties.chartType ?? 'bar'); const title = component.properties.title as string | undefined; 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 (
{title &&

{title}

}
{chartType}
- {series.map((entry, index) => ( -
- {entry.label} - - {entry.value} + {series.map((entry, index) => { + const totalValue = isStacked && entry.segments + ? entry.segments.reduce((sum, s) => sum + s.value, 0) + : entry.value; + + return ( +
+ {entry.label} +
+ {isStacked && entry.segments ? ( + entry.segments.map((seg, si) => { + const segWidth = maxValue > 0 ? (seg.value / maxValue) * 100 : 0; + return ( +
+ ); + }) + ) : ( +
0 ? (entry.value / maxValue) * 100 : 0}%` }} + /> + )} +
+ {totalValue} +
+ ); + })} + {isStacked && segmentLabels.length > 0 && ( +
+ {segmentLabels.map((label, i) => ( + + + {label} + + ))}
- ))} + )}
); }; diff --git a/src/renderer/components/AssistantSidebar/AssistantSidebar.css b/src/renderer/components/AssistantSidebar/AssistantSidebar.css index 0b364f9..9daf599 100644 --- a/src/renderer/components/AssistantSidebar/AssistantSidebar.css +++ b/src/renderer/components/AssistantSidebar/AssistantSidebar.css @@ -74,7 +74,7 @@ padding-top: 10px; } -.assistant-sidebar-metric { +.assistant-panel-metric { display: flex; justify-content: space-between; align-items: baseline; @@ -83,22 +83,22 @@ border-radius: 6px; } -.assistant-sidebar-metric-label { +.assistant-panel-metric-label { font-size: 12px; opacity: 0.85; } -.assistant-sidebar-metric-value { +.assistant-panel-metric-value { font-size: 14px; } -.assistant-sidebar-table { +.assistant-panel-table { width: 100%; border-collapse: collapse; } -.assistant-sidebar-table th, -.assistant-sidebar-table td { +.assistant-panel-table th, +.assistant-panel-table td { border: 1px solid var(--vscode-panel-border); padding: 6px; font-size: 12px; @@ -112,30 +112,30 @@ white-space: pre-wrap; } -.assistant-sidebar-widget-block { +.assistant-panel-widget-block { display: flex; flex-direction: column; gap: 6px; } -.assistant-sidebar-widget-label { +.assistant-panel-widget-label { font-size: 12px; opacity: 0.9; } -.assistant-sidebar-widget-input { +.assistant-panel-widget-input { width: 100%; padding: 8px; } -.assistant-sidebar-checkbox { +.assistant-panel-checkbox { display: flex; align-items: center; gap: 8px; font-size: 12px; } -.assistant-sidebar-chart { +.assistant-panel-chart { display: flex; flex-direction: column; gap: 6px; @@ -144,30 +144,99 @@ padding: 8px; } -.assistant-sidebar-chart-title { +.assistant-panel-chart-title { margin: 0; font-weight: 600; } -.assistant-sidebar-chart-type { +.assistant-panel-chart-type { font-size: 11px; text-transform: uppercase; opacity: 0.7; } -.assistant-sidebar-chart-item { +.assistant-panel-chart-item { display: grid; - grid-template-columns: minmax(48px, auto) 1fr auto; + grid-template-columns: auto 1fr auto; gap: 8px; align-items: center; font-size: 12px; } -.assistant-sidebar-chart-item progress { - width: 100%; +.assistant-panel-chart-label { + 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; flex-direction: column; gap: 8px; @@ -176,12 +245,12 @@ padding: 8px; } -.assistant-sidebar-form-title { +.assistant-panel-form-title { margin: 0; font-weight: 600; } -.assistant-sidebar-card { +.assistant-panel-card { border: 1px solid var(--vscode-panel-border); border-radius: 6px; padding: 8px; @@ -190,57 +259,57 @@ gap: 6px; } -.assistant-sidebar-card h4, -.assistant-sidebar-card p { +.assistant-panel-card h4, +.assistant-panel-card p { margin: 0; } -.assistant-sidebar-card-subtitle { +.assistant-panel-card-subtitle { font-size: 12px; opacity: 0.8; } -.assistant-sidebar-card-actions { +.assistant-panel-card-actions { display: flex; gap: 6px; flex-wrap: wrap; } -.assistant-sidebar-image { +.assistant-panel-image { margin: 0; display: flex; flex-direction: column; gap: 6px; } -.assistant-sidebar-image img { +.assistant-panel-image img { max-width: 100%; border-radius: 6px; border: 1px solid var(--vscode-panel-border); } -.assistant-sidebar-image figcaption { +.assistant-panel-image figcaption { font-size: 12px; opacity: 0.85; } -.assistant-sidebar-tabs { +.assistant-panel-tabs { display: flex; flex-direction: column; gap: 8px; } -.assistant-sidebar-tab-strip { +.assistant-panel-tab-strip { display: flex; gap: 6px; flex-wrap: wrap; } -.assistant-sidebar-tab-button.active { +.assistant-panel-tab-button.active { border-color: var(--vscode-focusBorder); } -.assistant-sidebar-tab-panel { +.assistant-panel-tab-panel { border: 1px solid var(--vscode-panel-border); border-radius: 6px; padding: 8px; diff --git a/tests/engine/a2ui/generator.test.ts b/tests/engine/a2ui/generator.test.ts index 2d277ff..bc3bcac 100644 --- a/tests/engine/a2ui/generator.test.ts +++ b/tests/engine/a2ui/generator.test.ts @@ -82,6 +82,56 @@ describe('A2UI generator', () => { { 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; + expect(updateMsg.components[0].properties.chartType).toBe('stacked-bar'); + + const dataMsg = messages[2] as Extract; + 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', () => { diff --git a/tests/renderer/a2ui/A2UIChart.test.tsx b/tests/renderer/a2ui/A2UIChart.test.tsx new file mode 100644 index 0000000..5e4b30b --- /dev/null +++ b/tests/renderer/a2ui/A2UIChart.test.tsx @@ -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 = {}, + 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( + , + ); + expect(screen.getByText('Test Chart')).toBeInTheDocument(); + expect(container.querySelector('.assistant-panel-chart-title')).not.toBeNull(); + }); + + it('renders chart type label', () => { + const comp = makeChartComponent(); + render(); + expect(screen.getByText('bar')).toBeInTheDocument(); + }); + + it('renders all series labels', () => { + const comp = makeChartComponent(); + render(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Beta')).toBeInTheDocument(); + expect(screen.getByText('Gamma')).toBeInTheDocument(); + }); + + it('renders all series values', () => { + const comp = makeChartComponent(); + render(); + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(container.querySelector('.assistant-panel-chart-title')).toBeNull(); + }); + + it('renders empty state when series is empty', () => { + const comp = makeChartComponent({}, []); + const { container } = render( + , + ); + 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( + , + ); + 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( + , + ); + // 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( + , + ); + // 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( + , + ); + 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(); + + 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( + , + ); + expect(container.querySelector('.assistant-panel-chart-legend')).toBeNull(); + }); + }); +});