feat: add render_mindmap a2ui tool with d3-hierarchy layout
This commit is contained in:
59
package-lock.json
generated
59
package-lock.json
generated
@@ -34,6 +34,8 @@
|
||||
"ai": "^6.0.105",
|
||||
"chokidar": "^5.0.0",
|
||||
"d3-cloud": "^1.2.8",
|
||||
"d3-hierarchy": "^3.1.2",
|
||||
"d3-shape": "^3.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"dropbox": "^10.34.0",
|
||||
@@ -67,6 +69,8 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/chokidar": "^1.7.5",
|
||||
"@types/d3-hierarchy": "^3.1.7",
|
||||
"@types/d3-shape": "^3.1.8",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -4868,7 +4872,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -5618,6 +5621,30 @@
|
||||
"@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": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
@@ -8143,6 +8170,36 @@
|
||||
"integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==",
|
||||
"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": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/chokidar": "^1.7.5",
|
||||
"@types/d3-hierarchy": "^3.1.7",
|
||||
"@types/d3-shape": "^3.1.8",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -95,6 +97,8 @@
|
||||
"ai": "^6.0.105",
|
||||
"chokidar": "^5.0.0",
|
||||
"d3-cloud": "^1.2.8",
|
||||
"d3-hierarchy": "^3.1.2",
|
||||
"d3-shape": "^3.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"dropbox": "^10.34.0",
|
||||
|
||||
@@ -26,6 +26,7 @@ const CATALOG_ENTRIES: A2UICatalogEntry[] = [
|
||||
{ type: 'metric', description: 'Key-value metric display', custom: true },
|
||||
{ type: 'list', description: 'Ordered or unordered item list' },
|
||||
{ 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: 'column', description: 'Vertical layout container' },
|
||||
{ type: 'divider', description: 'Visual separator' },
|
||||
|
||||
@@ -113,6 +113,17 @@ export interface RenderTabsArgs {
|
||||
tabs: RenderTabArgs[];
|
||||
}
|
||||
|
||||
export interface RenderMindmapNodeArgs {
|
||||
id: string;
|
||||
label: string;
|
||||
children?: string[];
|
||||
}
|
||||
|
||||
export interface RenderMindmapArgs {
|
||||
title?: string;
|
||||
nodes: RenderMindmapNodeArgs[];
|
||||
}
|
||||
|
||||
// ---- Generators ----
|
||||
|
||||
export function generateChart(
|
||||
@@ -359,6 +370,28 @@ export function generateTabs(
|
||||
|
||||
// ---- 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[]> = {
|
||||
render_chart: (cid, args) => generateChart(cid, args as unknown as RenderChartArgs),
|
||||
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_list: (cid, args) => generateList(cid, args as unknown as RenderListArgs),
|
||||
render_tabs: (cid, args) => generateTabs(cid, args as unknown as RenderTabsArgs),
|
||||
render_mindmap: (cid, args) => generateMindmap(cid, args as unknown as RenderMindmapArgs),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,7 +30,8 @@ export type A2UIComponentType =
|
||||
| 'list'
|
||||
| 'row'
|
||||
| 'column'
|
||||
| 'divider';
|
||||
| 'divider'
|
||||
| 'mindmap';
|
||||
|
||||
export interface A2UIComponent {
|
||||
id: string;
|
||||
|
||||
@@ -138,6 +138,19 @@ export function createA2UITools() {
|
||||
}),
|
||||
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 }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { A2UIList } from './components/A2UIList';
|
||||
import { A2UIRow } from './components/A2UIRow';
|
||||
import { A2UIColumn } from './components/A2UIColumn';
|
||||
import { A2UIDivider } from './components/A2UIDivider';
|
||||
import { A2UIMindmap } from './components/A2UIMindmap';
|
||||
|
||||
export interface A2UIComponentProps {
|
||||
component: A2UIResolvedComponent;
|
||||
@@ -53,6 +54,7 @@ const COMPONENT_REGISTRY: Record<string, ComponentRenderer> = {
|
||||
row: A2UIRow,
|
||||
column: A2UIColumn,
|
||||
divider: A2UIDivider,
|
||||
mindmap: A2UIMindmap,
|
||||
};
|
||||
|
||||
interface A2UIRendererProps {
|
||||
|
||||
252
src/renderer/a2ui/components/A2UIMindmap.tsx
Normal file
252
src/renderer/a2ui/components/A2UIMindmap.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -430,3 +430,32 @@
|
||||
flex-direction: column;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -22,10 +22,11 @@ describe('A2UI Tools — createA2UITools', () => {
|
||||
'render_metric',
|
||||
'render_list',
|
||||
'render_tabs',
|
||||
'render_mindmap',
|
||||
];
|
||||
|
||||
it('returns all 7 tools', () => {
|
||||
expect(Object.keys(tools)).toHaveLength(7);
|
||||
it('returns all 8 tools', () => {
|
||||
expect(Object.keys(tools)).toHaveLength(8);
|
||||
for (const name of expectedToolNames) {
|
||||
expect(tools).toHaveProperty(name);
|
||||
}
|
||||
@@ -54,6 +55,9 @@ describe('A2UI Tools — createA2UITools', () => {
|
||||
title: 'Test',
|
||||
tabs: [{ label: 'Tab1', content: [{ type: 'text', data: 'Hello' }] }],
|
||||
},
|
||||
render_mindmap: {
|
||||
nodes: [{ id: 'root', label: 'Topic' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await tool.execute!(
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
import { BDS_CATALOG_ID } from '../../../src/main/a2ui/types';
|
||||
|
||||
describe('A2UI catalog', () => {
|
||||
it('returns all 17 catalog entries', () => {
|
||||
it('returns all 18 catalog entries', () => {
|
||||
const entries = getCatalogEntries();
|
||||
expect(entries).toHaveLength(17);
|
||||
expect(entries).toHaveLength(18);
|
||||
});
|
||||
|
||||
it('returns a copy of catalog entries to prevent mutation', () => {
|
||||
@@ -25,7 +25,7 @@ describe('A2UI catalog', () => {
|
||||
const types = [
|
||||
'text', 'button', 'card', 'chart', 'table', 'form',
|
||||
'textField', 'checkBox', 'dateTimeInput', 'choicePicker',
|
||||
'image', 'tabs', 'metric', 'list', 'row', 'column', 'divider',
|
||||
'image', 'tabs', 'metric', 'list', 'mindmap', 'row', 'column', 'divider',
|
||||
];
|
||||
|
||||
for (const type of types) {
|
||||
@@ -74,6 +74,7 @@ describe('A2UI catalog', () => {
|
||||
expect(customTypes).toContain('table');
|
||||
expect(customTypes).toContain('metric');
|
||||
expect(customTypes).toContain('form');
|
||||
expect(customTypes).toContain('mindmap');
|
||||
expect(customTypes).not.toContain('text');
|
||||
expect(customTypes).not.toContain('button');
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
generateMetric,
|
||||
generateList,
|
||||
generateTabs,
|
||||
generateMindmap,
|
||||
} from '../../../src/main/a2ui/generator';
|
||||
import type { A2UIServerMessage } from '../../../src/main/a2ui/types';
|
||||
|
||||
@@ -22,6 +23,7 @@ describe('A2UI generator', () => {
|
||||
expect(isRenderTool('render_metric')).toBe(true);
|
||||
expect(isRenderTool('render_list')).toBe(true);
|
||||
expect(isRenderTool('render_tabs')).toBe(true);
|
||||
expect(isRenderTool('render_mindmap')).toBe(true);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
212
tests/renderer/a2ui/A2UIMindmap.test.tsx
Normal file
212
tests/renderer/a2ui/A2UIMindmap.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user