diff --git a/package-lock.json b/package-lock.json index 99c3652..952eb47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3c5eb46..b9f9557 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/a2ui/catalog.ts b/src/main/a2ui/catalog.ts index 96b2977..1037855 100644 --- a/src/main/a2ui/catalog.ts +++ b/src/main/a2ui/catalog.ts @@ -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' }, diff --git a/src/main/a2ui/generator.ts b/src/main/a2ui/generator.ts index a3256ae..7b208d8 100644 --- a/src/main/a2ui/generator.ts +++ b/src/main/a2ui/generator.ts @@ -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) => 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 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), }; /** diff --git a/src/main/a2ui/types.ts b/src/main/a2ui/types.ts index 03da853..8befd2a 100644 --- a/src/main/a2ui/types.ts +++ b/src/main/a2ui/types.ts @@ -30,7 +30,8 @@ export type A2UIComponentType = | 'list' | 'row' | 'column' - | 'divider'; + | 'divider' + | 'mindmap'; export interface A2UIComponent { id: string; diff --git a/src/main/engine/ai/a2ui-tools.ts b/src/main/engine/ai/a2ui-tools.ts index f762207..2b0842f 100644 --- a/src/main/engine/ai/a2ui-tools.ts +++ b/src/main/engine/ai/a2ui-tools.ts @@ -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 }), + }), }; } diff --git a/src/renderer/a2ui/A2UIRenderer.tsx b/src/renderer/a2ui/A2UIRenderer.tsx index d4af9a8..15cacaa 100644 --- a/src/renderer/a2ui/A2UIRenderer.tsx +++ b/src/renderer/a2ui/A2UIRenderer.tsx @@ -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 = { row: A2UIRow, column: A2UIColumn, divider: A2UIDivider, + mindmap: A2UIMindmap, }; interface A2UIRendererProps { diff --git a/src/renderer/a2ui/components/A2UIMindmap.tsx b/src/renderer/a2ui/components/A2UIMindmap.tsx new file mode 100644 index 0000000..8ed2ae3 --- /dev/null +++ b/src/renderer/a2ui/components/A2UIMindmap.tsx @@ -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(); + 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().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 = ({ 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 ( +
+ {title &&

{title}

} + + {/* Links */} + {layout.links.map((link, i) => ( + + ))} + {/* Nodes */} + {layout.nodes.map((node) => { + const color = getNodeColor(node.depth); + return ( + + + {node.label} + + + {node.label} + + + ); + })} + +
+ ); +}; diff --git a/src/renderer/components/AssistantSidebar/AssistantSidebar.css b/src/renderer/components/AssistantSidebar/AssistantSidebar.css index 5092327..359bf21 100644 --- a/src/renderer/components/AssistantSidebar/AssistantSidebar.css +++ b/src/renderer/components/AssistantSidebar/AssistantSidebar.css @@ -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; +} diff --git a/tests/engine/a2ui-tools.test.ts b/tests/engine/a2ui-tools.test.ts index 6fd9b81..8ff90ea 100644 --- a/tests/engine/a2ui-tools.test.ts +++ b/tests/engine/a2ui-tools.test.ts @@ -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!( diff --git a/tests/engine/a2ui/catalog.test.ts b/tests/engine/a2ui/catalog.test.ts index 3922779..40d837e 100644 --- a/tests/engine/a2ui/catalog.test.ts +++ b/tests/engine/a2ui/catalog.test.ts @@ -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'); }); diff --git a/tests/engine/a2ui/generator.test.ts b/tests/engine/a2ui/generator.test.ts index bc3bcac..b586054 100644 --- a/tests/engine/a2ui/generator.test.ts +++ b/tests/engine/a2ui/generator.test.ts @@ -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; + expect(createMsg.type).toBe('createSurface'); + expect(createMsg.conversationId).toBe('conv-1'); + + const updateMsg = messages[1] as Extract; + 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; + 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; + 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'); + }); + }); }); diff --git a/tests/renderer/a2ui/A2UIMindmap.test.tsx b/tests/renderer/a2ui/A2UIMindmap.test.tsx new file mode 100644 index 0000000..f6214ea --- /dev/null +++ b/tests/renderer/a2ui/A2UIMindmap.test.tsx @@ -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 = {}, + 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(); + expect(screen.getByText('Test Mindmap')).toBeInTheDocument(); + }); + + it('renders SVG with nodes and links', () => { + const comp = makeMindmapComponent(); + const { container } = render( + , + ); + + 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( + , + ); + + 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( + , + ); + expect(container.querySelector('.assistant-panel-mindmap-svg')).toBeNull(); + }); + + it('renders without title when not provided', () => { + const comp = makeMindmapComponent({ properties: {} }); + const { container } = render( + , + ); + 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( + , + ); + + 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( + , + ); + + 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'); + }); + }); +});