feat: add render_mindmap a2ui tool with d3-hierarchy layout

This commit is contained in:
2026-03-01 22:09:17 +01:00
parent 3addd92728
commit 71d05a0fa5
13 changed files with 683 additions and 7 deletions

59
package-lock.json generated
View File

@@ -34,6 +34,8 @@
"ai": "^6.0.105", "ai": "^6.0.105",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"d3-cloud": "^1.2.8", "d3-cloud": "^1.2.8",
"d3-hierarchy": "^3.1.2",
"d3-shape": "^3.2.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"dropbox": "^10.34.0", "dropbox": "^10.34.0",
@@ -67,6 +69,8 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/chokidar": "^1.7.5", "@types/chokidar": "^1.7.5",
"@types/d3-hierarchy": "^3.1.7",
"@types/d3-shape": "^3.1.8",
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -4868,7 +4872,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=8.0.0" "node": ">=8.0.0"
} }
@@ -5618,6 +5621,30 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/debug": { "node_modules/@types/debug": {
"version": "4.1.12", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -8143,6 +8170,36 @@
"integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/data-uri-to-buffer": { "node_modules/data-uri-to-buffer": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",

View File

@@ -43,6 +43,8 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/chokidar": "^1.7.5", "@types/chokidar": "^1.7.5",
"@types/d3-hierarchy": "^3.1.7",
"@types/d3-shape": "^3.1.8",
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -95,6 +97,8 @@
"ai": "^6.0.105", "ai": "^6.0.105",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"d3-cloud": "^1.2.8", "d3-cloud": "^1.2.8",
"d3-hierarchy": "^3.1.2",
"d3-shape": "^3.2.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"dropbox": "^10.34.0", "dropbox": "^10.34.0",

View File

@@ -26,6 +26,7 @@ const CATALOG_ENTRIES: A2UICatalogEntry[] = [
{ type: 'metric', description: 'Key-value metric display', custom: true }, { type: 'metric', description: 'Key-value metric display', custom: true },
{ type: 'list', description: 'Ordered or unordered item list' }, { type: 'list', description: 'Ordered or unordered item list' },
{ type: 'form', description: 'Form container with fields and submit button', custom: true }, { type: 'form', description: 'Form container with fields and submit button', custom: true },
{ type: 'mindmap', description: 'Mind map tree diagram with a central topic and branching nodes', custom: true },
{ type: 'row', description: 'Horizontal layout container' }, { type: 'row', description: 'Horizontal layout container' },
{ type: 'column', description: 'Vertical layout container' }, { type: 'column', description: 'Vertical layout container' },
{ type: 'divider', description: 'Visual separator' }, { type: 'divider', description: 'Visual separator' },

View File

@@ -113,6 +113,17 @@ export interface RenderTabsArgs {
tabs: RenderTabArgs[]; tabs: RenderTabArgs[];
} }
export interface RenderMindmapNodeArgs {
id: string;
label: string;
children?: string[];
}
export interface RenderMindmapArgs {
title?: string;
nodes: RenderMindmapNodeArgs[];
}
// ---- Generators ---- // ---- Generators ----
export function generateChart( export function generateChart(
@@ -359,6 +370,28 @@ export function generateTabs(
// ---- Tool name to generator dispatch ---- // ---- Tool name to generator dispatch ----
export function generateMindmap(
conversationId: string,
args: RenderMindmapArgs,
): A2UIServerMessage[] {
const mindmapId = makeId('mindmap');
const component: A2UIComponent = {
id: mindmapId,
type: 'mindmap',
properties: {
title: args.title,
},
dataBinding: '/mindmapNodes',
};
return createSurfaceMessages(
conversationId,
[component],
[mindmapId],
[{ path: '/mindmapNodes', value: args.nodes }],
);
}
const GENERATORS: Record<string, (conversationId: string, args: Record<string, unknown>) => A2UIServerMessage[]> = { const GENERATORS: Record<string, (conversationId: string, args: Record<string, unknown>) => A2UIServerMessage[]> = {
render_chart: (cid, args) => generateChart(cid, args as unknown as RenderChartArgs), render_chart: (cid, args) => generateChart(cid, args as unknown as RenderChartArgs),
render_table: (cid, args) => generateTable(cid, args as unknown as RenderTableArgs), render_table: (cid, args) => generateTable(cid, args as unknown as RenderTableArgs),
@@ -367,6 +400,7 @@ const GENERATORS: Record<string, (conversationId: string, args: Record<string, u
render_metric: (cid, args) => generateMetric(cid, args as unknown as RenderMetricArgs), render_metric: (cid, args) => generateMetric(cid, args as unknown as RenderMetricArgs),
render_list: (cid, args) => generateList(cid, args as unknown as RenderListArgs), render_list: (cid, args) => generateList(cid, args as unknown as RenderListArgs),
render_tabs: (cid, args) => generateTabs(cid, args as unknown as RenderTabsArgs), render_tabs: (cid, args) => generateTabs(cid, args as unknown as RenderTabsArgs),
render_mindmap: (cid, args) => generateMindmap(cid, args as unknown as RenderMindmapArgs),
}; };
/** /**

View File

@@ -30,7 +30,8 @@ export type A2UIComponentType =
| 'list' | 'list'
| 'row' | 'row'
| 'column' | 'column'
| 'divider'; | 'divider'
| 'mindmap';
export interface A2UIComponent { export interface A2UIComponent {
id: string; id: string;

View File

@@ -138,6 +138,19 @@ export function createA2UITools() {
}), }),
execute: async (_input) => ({ success: true }), execute: async (_input) => ({ success: true }),
}), }),
render_mindmap: tool({
description: 'Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.',
inputSchema: z.object({
title: z.string().optional().describe('Optional mind map title'),
nodes: z.array(z.object({
id: z.string().describe('Unique node identifier'),
label: z.string().describe('Node label text'),
children: z.array(z.string()).optional().describe('IDs of child nodes'),
})).describe('Flat array of nodes. The first node is the root. Each node references children by ID.'),
}),
execute: async (_input) => ({ success: true }),
}),
}; };
} }

View File

@@ -24,6 +24,7 @@ import { A2UIList } from './components/A2UIList';
import { A2UIRow } from './components/A2UIRow'; import { A2UIRow } from './components/A2UIRow';
import { A2UIColumn } from './components/A2UIColumn'; import { A2UIColumn } from './components/A2UIColumn';
import { A2UIDivider } from './components/A2UIDivider'; import { A2UIDivider } from './components/A2UIDivider';
import { A2UIMindmap } from './components/A2UIMindmap';
export interface A2UIComponentProps { export interface A2UIComponentProps {
component: A2UIResolvedComponent; component: A2UIResolvedComponent;
@@ -53,6 +54,7 @@ const COMPONENT_REGISTRY: Record<string, ComponentRenderer> = {
row: A2UIRow, row: A2UIRow,
column: A2UIColumn, column: A2UIColumn,
divider: A2UIDivider, divider: A2UIDivider,
mindmap: A2UIMindmap,
}; };
interface A2UIRendererProps { interface A2UIRendererProps {

View File

@@ -0,0 +1,252 @@
/**
* A2UI Mindmap Component
*
* Renders a mind map diagram using d3-hierarchy for layout
* and d3-shape for link paths, with inline SVG output.
*/
import React, { useMemo } from 'react';
import { hierarchy, tree as d3tree } from 'd3-hierarchy';
import { linkHorizontal } from 'd3-shape';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
interface A2UIComponentProps {
component: A2UIResolvedComponent;
surfaceId: string;
onAction: (action: A2UIClientAction) => void;
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
interface MindmapNode {
id: string;
label: string;
children?: string[];
}
interface TreeNode {
id: string;
label: string;
children: TreeNode[];
}
const NODE_COLORS = [
'var(--vscode-charts-blue, #75beff)',
'var(--vscode-charts-green, #89d185)',
'var(--vscode-charts-orange, #d18616)',
'var(--vscode-charts-purple, #b180d7)',
'var(--vscode-charts-red, #f14c4c)',
'var(--vscode-charts-yellow, #e2e210)',
];
function getNodeColor(depth: number): string {
if (depth === 0) return 'var(--vscode-focusBorder, #007fd4)';
return NODE_COLORS[(depth - 1) % NODE_COLORS.length];
}
/** Build a nested tree from a flat node array. First node is the root. */
export function buildTree(nodes: MindmapNode[]): TreeNode | null {
if (nodes.length === 0) return null;
const nodeMap = new Map<string, MindmapNode>();
for (const node of nodes) {
nodeMap.set(node.id, node);
}
function toTreeNode(id: string): TreeNode | null {
const node = nodeMap.get(id);
if (!node) return null;
const children: TreeNode[] = [];
if (node.children) {
for (const childId of node.children) {
const child = toTreeNode(childId);
if (child) children.push(child);
}
}
return { id: node.id, label: node.label, children };
}
return toTreeNode(nodes[0].id);
}
/** Estimate text width in pixels (rough: 7px per char at 12px font). */
function estimateTextWidth(text: string): number {
return Math.max(text.length * 7, 40);
}
const NODE_HEIGHT = 28;
const NODE_PADDING_X = 12;
const NODE_PADDING_Y = 6;
const NODE_RADIUS = 6;
const VERTICAL_GAP = 8;
interface LayoutResult {
svgWidth: number;
svgHeight: number;
nodes: Array<{
id: string;
label: string;
x: number;
y: number;
width: number;
depth: number;
}>;
links: Array<{
source: { x: number; y: number };
target: { x: number; y: number };
}>;
}
export function computeLayout(root: TreeNode): LayoutResult {
const h = hierarchy(root, (d) => (d.children.length > 0 ? d.children : null));
// Node sizes: [vertical spacing, horizontal spacing]
const nodeVSpacing = NODE_HEIGHT + VERTICAL_GAP;
const nodeHSpacing = 180;
const treeLayout = d3tree<TreeNode>().nodeSize([nodeVSpacing, nodeHSpacing]);
const treeRoot = treeLayout(h);
// Collect all positioned nodes
const allNodes = treeRoot.descendants();
const allLinks = treeRoot.links();
// Compute node widths based on label text
const positioned = allNodes.map((n) => {
const textW = estimateTextWidth(n.data.label);
const width = textW + NODE_PADDING_X * 2;
return {
id: n.data.id,
label: n.data.label,
// d3.tree uses x for vertical, y for horizontal (depth axis)
x: n.y, // horizontal position (depth)
y: n.x, // vertical position
width,
depth: n.depth,
};
});
// Compute links — connect right edge of source to left edge of target
const nodeById = new Map(positioned.map((n) => [n.id, n]));
const links = allLinks.map((link) => {
const source = nodeById.get(link.source.data.id)!;
const target = nodeById.get(link.target.data.id)!;
return {
source: { x: source.x + source.width / 2, y: source.y },
target: { x: target.x - target.width / 2, y: target.y },
};
});
// Compute bounding box
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
for (const n of positioned) {
const left = n.x - n.width / 2;
const right = n.x + n.width / 2;
const top = n.y - NODE_HEIGHT / 2;
const bottom = n.y + NODE_HEIGHT / 2;
if (left < minX) minX = left;
if (right > maxX) maxX = right;
if (top < minY) minY = top;
if (bottom > maxY) maxY = bottom;
}
// Add padding
const pad = 16;
minX -= pad;
minY -= pad;
maxX += pad;
maxY += pad;
// Translate all coordinates so they start at (0,0)
const offsetX = -minX;
const offsetY = -minY;
return {
svgWidth: maxX - minX,
svgHeight: maxY - minY,
nodes: positioned.map((n) => ({
...n,
x: n.x + offsetX,
y: n.y + offsetY,
})),
links: links.map((l) => ({
source: { x: l.source.x + offsetX, y: l.source.y + offsetY },
target: { x: l.target.x + offsetX, y: l.target.y + offsetY },
})),
};
}
const linkGenerator = linkHorizontal<
{ source: { x: number; y: number }; target: { x: number; y: number } },
{ x: number; y: number }
>()
.x((d) => d.x)
.y((d) => d.y);
export const A2UIMindmap: React.FC<A2UIComponentProps> = ({ component }) => {
const title = component.properties.title as string | undefined;
const nodes = (component.boundValue as MindmapNode[]) ??
(component.properties.nodes as MindmapNode[]) ?? [];
const layout = useMemo(() => {
const root = buildTree(nodes);
if (!root) return null;
return computeLayout(root);
}, [nodes]);
if (!layout) return null;
return (
<div className="assistant-panel-mindmap">
{title && <p className="assistant-panel-mindmap-title">{title}</p>}
<svg
className="assistant-panel-mindmap-svg"
viewBox={`0 0 ${layout.svgWidth} ${layout.svgHeight}`}
preserveAspectRatio="xMidYMid meet"
>
{/* Links */}
{layout.links.map((link, i) => (
<path
key={`${component.id}-link-${i}`}
className="assistant-panel-mindmap-link"
d={linkGenerator(link) ?? ''}
fill="none"
/>
))}
{/* Nodes */}
{layout.nodes.map((node) => {
const color = getNodeColor(node.depth);
return (
<g key={`${component.id}-node-${node.id}`}>
<rect
className="assistant-panel-mindmap-node"
x={node.x - node.width / 2}
y={node.y - NODE_HEIGHT / 2}
width={node.width}
height={NODE_HEIGHT}
rx={NODE_RADIUS}
ry={NODE_RADIUS}
fill={color}
fillOpacity={node.depth === 0 ? 0.25 : 0.15}
stroke={color}
strokeWidth={node.depth === 0 ? 2 : 1}
>
<title>{node.label}</title>
</rect>
<text
className="assistant-panel-mindmap-label"
x={node.x}
y={node.y}
textAnchor="middle"
dominantBaseline="central"
>
{node.label}
</text>
</g>
);
})}
</svg>
</div>
);
};

View File

@@ -430,3 +430,32 @@
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
/* ---- Mindmap ---- */
.assistant-panel-mindmap {
display: flex;
flex-direction: column;
gap: 6px;
}
.assistant-panel-mindmap-title {
margin: 0;
font-weight: 600;
}
.assistant-panel-mindmap-svg {
width: 100%;
max-height: 400px;
}
.assistant-panel-mindmap-link {
stroke: var(--vscode-panel-border);
stroke-width: 1.5;
}
.assistant-panel-mindmap-label {
font-size: 12px;
fill: var(--vscode-foreground);
pointer-events: none;
}

View File

@@ -22,10 +22,11 @@ describe('A2UI Tools — createA2UITools', () => {
'render_metric', 'render_metric',
'render_list', 'render_list',
'render_tabs', 'render_tabs',
'render_mindmap',
]; ];
it('returns all 7 tools', () => { it('returns all 8 tools', () => {
expect(Object.keys(tools)).toHaveLength(7); expect(Object.keys(tools)).toHaveLength(8);
for (const name of expectedToolNames) { for (const name of expectedToolNames) {
expect(tools).toHaveProperty(name); expect(tools).toHaveProperty(name);
} }
@@ -54,6 +55,9 @@ describe('A2UI Tools — createA2UITools', () => {
title: 'Test', title: 'Test',
tabs: [{ label: 'Tab1', content: [{ type: 'text', data: 'Hello' }] }], tabs: [{ label: 'Tab1', content: [{ type: 'text', data: 'Hello' }] }],
}, },
render_mindmap: {
nodes: [{ id: 'root', label: 'Topic' }],
},
}; };
const result = await tool.execute!( const result = await tool.execute!(

View File

@@ -9,9 +9,9 @@ import {
import { BDS_CATALOG_ID } from '../../../src/main/a2ui/types'; import { BDS_CATALOG_ID } from '../../../src/main/a2ui/types';
describe('A2UI catalog', () => { describe('A2UI catalog', () => {
it('returns all 17 catalog entries', () => { it('returns all 18 catalog entries', () => {
const entries = getCatalogEntries(); const entries = getCatalogEntries();
expect(entries).toHaveLength(17); expect(entries).toHaveLength(18);
}); });
it('returns a copy of catalog entries to prevent mutation', () => { it('returns a copy of catalog entries to prevent mutation', () => {
@@ -25,7 +25,7 @@ describe('A2UI catalog', () => {
const types = [ const types = [
'text', 'button', 'card', 'chart', 'table', 'form', 'text', 'button', 'card', 'chart', 'table', 'form',
'textField', 'checkBox', 'dateTimeInput', 'choicePicker', 'textField', 'checkBox', 'dateTimeInput', 'choicePicker',
'image', 'tabs', 'metric', 'list', 'row', 'column', 'divider', 'image', 'tabs', 'metric', 'list', 'mindmap', 'row', 'column', 'divider',
]; ];
for (const type of types) { for (const type of types) {
@@ -74,6 +74,7 @@ describe('A2UI catalog', () => {
expect(customTypes).toContain('table'); expect(customTypes).toContain('table');
expect(customTypes).toContain('metric'); expect(customTypes).toContain('metric');
expect(customTypes).toContain('form'); expect(customTypes).toContain('form');
expect(customTypes).toContain('mindmap');
expect(customTypes).not.toContain('text'); expect(customTypes).not.toContain('text');
expect(customTypes).not.toContain('button'); expect(customTypes).not.toContain('button');
}); });

View File

@@ -9,6 +9,7 @@ import {
generateMetric, generateMetric,
generateList, generateList,
generateTabs, generateTabs,
generateMindmap,
} from '../../../src/main/a2ui/generator'; } from '../../../src/main/a2ui/generator';
import type { A2UIServerMessage } from '../../../src/main/a2ui/types'; import type { A2UIServerMessage } from '../../../src/main/a2ui/types';
@@ -22,6 +23,7 @@ describe('A2UI generator', () => {
expect(isRenderTool('render_metric')).toBe(true); expect(isRenderTool('render_metric')).toBe(true);
expect(isRenderTool('render_list')).toBe(true); expect(isRenderTool('render_list')).toBe(true);
expect(isRenderTool('render_tabs')).toBe(true); expect(isRenderTool('render_tabs')).toBe(true);
expect(isRenderTool('render_mindmap')).toBe(true);
}); });
it('returns false for non-render tools', () => { it('returns false for non-render tools', () => {
@@ -365,4 +367,68 @@ describe('A2UI generator', () => {
]); ]);
}); });
}); });
describe('generateMindmap', () => {
it('creates surface with mindmap component and node data', () => {
const messages = generateMindmap('conv-1', {
title: 'Project Plan',
nodes: [
{ id: 'root', label: 'Project', children: ['a', 'b'] },
{ id: 'a', label: 'Design', children: ['a1'] },
{ id: 'b', label: 'Development' },
{ id: 'a1', label: 'Wireframes' },
],
});
expect(messages).toHaveLength(3); // createSurface + updateComponents + updateDataModel
const createMsg = messages[0] as Extract<A2UIServerMessage, { type: 'createSurface' }>;
expect(createMsg.type).toBe('createSurface');
expect(createMsg.conversationId).toBe('conv-1');
const updateMsg = messages[1] as Extract<A2UIServerMessage, { type: 'updateComponents' }>;
expect(updateMsg.type).toBe('updateComponents');
expect(updateMsg.components).toHaveLength(1);
expect(updateMsg.components[0].type).toBe('mindmap');
expect(updateMsg.components[0].properties.title).toBe('Project Plan');
expect(updateMsg.components[0].dataBinding).toBe('/mindmapNodes');
expect(updateMsg.rootIds).toHaveLength(1);
const dataMsg = messages[2] as Extract<A2UIServerMessage, { type: 'updateDataModel' }>;
expect(dataMsg.type).toBe('updateDataModel');
expect(dataMsg.path).toBe('/mindmapNodes');
expect(dataMsg.value).toEqual([
{ id: 'root', label: 'Project', children: ['a', 'b'] },
{ id: 'a', label: 'Design', children: ['a1'] },
{ id: 'b', label: 'Development' },
{ id: 'a1', label: 'Wireframes' },
]);
});
it('works with a single root node', () => {
const messages = generateMindmap('conv-1', {
nodes: [{ id: 'root', label: 'Central Topic' }],
});
expect(messages).toHaveLength(3);
const dataMsg = messages[2] as Extract<A2UIServerMessage, { type: 'updateDataModel' }>;
expect(dataMsg.value).toEqual([
{ id: 'root', label: 'Central Topic' },
]);
});
it('is dispatched via generateFromToolCall', () => {
const messages = generateFromToolCall('conv-1', 'render_mindmap', {
nodes: [
{ id: 'root', label: 'Topic', children: ['a'] },
{ id: 'a', label: 'Sub' },
],
});
expect(messages).not.toBeNull();
expect(messages!.length).toBeGreaterThanOrEqual(2);
expect(messages![0].type).toBe('createSurface');
});
});
}); });

View File

@@ -0,0 +1,212 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { A2UIMindmap, buildTree, computeLayout } from '../../../src/renderer/a2ui/components/A2UIMindmap';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../src/main/a2ui/types';
function makeMindmapComponent(
overrides: Partial<A2UIResolvedComponent> = {},
nodes?: unknown,
): A2UIResolvedComponent {
return {
id: 'mindmap-1',
type: 'mindmap',
properties: {
title: 'Test Mindmap',
...(overrides.properties ?? {}),
},
children: [],
boundValue: nodes ?? [
{ id: 'root', label: 'Central Topic', children: ['a', 'b'] },
{ id: 'a', label: 'Branch A', children: ['a1'] },
{ id: 'b', label: 'Branch B' },
{ id: 'a1', label: 'Leaf' },
],
...overrides,
};
}
const noopAction = vi.fn<(action: A2UIClientAction) => void>();
describe('A2UIMindmap', () => {
describe('buildTree', () => {
it('builds nested tree from flat node array', () => {
const tree = buildTree([
{ id: 'root', label: 'Root', children: ['a', 'b'] },
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B', children: ['b1'] },
{ id: 'b1', label: 'B1' },
]);
expect(tree).not.toBeNull();
expect(tree!.id).toBe('root');
expect(tree!.label).toBe('Root');
expect(tree!.children).toHaveLength(2);
expect(tree!.children[0].id).toBe('a');
expect(tree!.children[0].children).toHaveLength(0);
expect(tree!.children[1].id).toBe('b');
expect(tree!.children[1].children).toHaveLength(1);
expect(tree!.children[1].children[0].id).toBe('b1');
});
it('returns null for empty input', () => {
expect(buildTree([])).toBeNull();
});
it('handles single root node', () => {
const tree = buildTree([{ id: 'root', label: 'Solo' }]);
expect(tree).not.toBeNull();
expect(tree!.id).toBe('root');
expect(tree!.children).toHaveLength(0);
});
it('skips children with invalid IDs', () => {
const tree = buildTree([
{ id: 'root', label: 'Root', children: ['a', 'missing'] },
{ id: 'a', label: 'A' },
]);
expect(tree!.children).toHaveLength(1);
expect(tree!.children[0].id).toBe('a');
});
});
describe('computeLayout', () => {
it('computes layout for a simple tree', () => {
const tree = buildTree([
{ id: 'root', label: 'Root', children: ['a', 'b'] },
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B' },
])!;
const layout = computeLayout(tree);
expect(layout.nodes).toHaveLength(3);
expect(layout.links).toHaveLength(2);
expect(layout.svgWidth).toBeGreaterThan(0);
expect(layout.svgHeight).toBeGreaterThan(0);
// Root should be leftmost (smallest x)
const root = layout.nodes.find((n) => n.id === 'root')!;
const nodeA = layout.nodes.find((n) => n.id === 'a')!;
const nodeB = layout.nodes.find((n) => n.id === 'b')!;
expect(root.x).toBeLessThan(nodeA.x);
expect(root.x).toBeLessThan(nodeB.x);
});
it('assigns correct depth values', () => {
const tree = buildTree([
{ id: 'root', label: 'R', children: ['a'] },
{ id: 'a', label: 'A', children: ['a1'] },
{ id: 'a1', label: 'A1' },
])!;
const layout = computeLayout(tree);
expect(layout.nodes.find((n) => n.id === 'root')!.depth).toBe(0);
expect(layout.nodes.find((n) => n.id === 'a')!.depth).toBe(1);
expect(layout.nodes.find((n) => n.id === 'a1')!.depth).toBe(2);
});
it('handles single node', () => {
const tree = buildTree([{ id: 'root', label: 'Solo' }])!;
const layout = computeLayout(tree);
expect(layout.nodes).toHaveLength(1);
expect(layout.links).toHaveLength(0);
expect(layout.svgWidth).toBeGreaterThan(0);
expect(layout.svgHeight).toBeGreaterThan(0);
});
});
describe('rendering', () => {
it('renders the mindmap title', () => {
const comp = makeMindmapComponent();
render(<A2UIMindmap component={comp} surfaceId="s1" onAction={noopAction} />);
expect(screen.getByText('Test Mindmap')).toBeInTheDocument();
});
it('renders SVG with nodes and links', () => {
const comp = makeMindmapComponent();
const { container } = render(
<A2UIMindmap component={comp} surfaceId="s1" onAction={noopAction} />,
);
const svg = container.querySelector('.assistant-panel-mindmap-svg');
expect(svg).not.toBeNull();
// 4 nodes → 4 rect + 4 text
const rects = container.querySelectorAll('.assistant-panel-mindmap-node');
expect(rects).toHaveLength(4);
const labels = container.querySelectorAll('.assistant-panel-mindmap-label');
expect(labels).toHaveLength(4);
// 3 links (root→a, root→b, a→a1)
const links = container.querySelectorAll('.assistant-panel-mindmap-link');
expect(links).toHaveLength(3);
});
it('renders all node labels', () => {
const comp = makeMindmapComponent();
const { container } = render(
<A2UIMindmap component={comp} surfaceId="s1" onAction={noopAction} />,
);
const labels = container.querySelectorAll('.assistant-panel-mindmap-label');
const labelTexts = Array.from(labels).map((el) => el.textContent);
expect(labelTexts).toContain('Central Topic');
expect(labelTexts).toContain('Branch A');
expect(labelTexts).toContain('Branch B');
expect(labelTexts).toContain('Leaf');
});
it('returns null when nodes array is empty', () => {
const comp = makeMindmapComponent({}, []);
const { container } = render(
<A2UIMindmap component={comp} surfaceId="s1" onAction={noopAction} />,
);
expect(container.querySelector('.assistant-panel-mindmap-svg')).toBeNull();
});
it('renders without title when not provided', () => {
const comp = makeMindmapComponent({ properties: {} });
const { container } = render(
<A2UIMindmap component={comp} surfaceId="s1" onAction={noopAction} />,
);
expect(container.querySelector('.assistant-panel-mindmap-title')).toBeNull();
expect(container.querySelector('.assistant-panel-mindmap-svg')).not.toBeNull();
});
it('root node gets depth-0 color styling', () => {
const comp = makeMindmapComponent({}, [
{ id: 'root', label: 'Root' },
]);
const { container } = render(
<A2UIMindmap component={comp} surfaceId="s1" onAction={noopAction} />,
);
const rect = container.querySelector('.assistant-panel-mindmap-node');
expect(rect).not.toBeNull();
expect(rect!.getAttribute('stroke-width')).toBe('2');
expect(rect!.getAttribute('fill-opacity')).toBe('0.25');
});
it('child nodes get depth > 0 color styling', () => {
const comp = makeMindmapComponent({}, [
{ id: 'root', label: 'Root', children: ['a'] },
{ id: 'a', label: 'A' },
]);
const { container } = render(
<A2UIMindmap component={comp} surfaceId="s1" onAction={noopAction} />,
);
const rects = container.querySelectorAll('.assistant-panel-mindmap-node');
expect(rects).toHaveLength(2);
// Second rect is the child
const childRect = rects[1];
expect(childRect.getAttribute('stroke-width')).toBe('1');
expect(childRect.getAttribute('fill-opacity')).toBe('0.15');
});
});
});