Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { IChatDebugEvent } from '../../common/chatDebugService.js';
7
import { FlowLayout, FlowNode, LayoutEdge, LayoutNode, SubgraphRect, FlowChartRenderResult } from './chatDebugFlowGraph.js';
8
9
// ---- Layout constants ----
10
11
const NODE_HEIGHT = 36;
12
const MESSAGE_NODE_HEIGHT = 52;
13
const NODE_MIN_WIDTH = 140;
14
const NODE_MAX_WIDTH = 320;
15
const NODE_PADDING_H = 16;
16
const NODE_PADDING_V = 6;
17
const NODE_GAP_Y = 24;
18
const NODE_BORDER_RADIUS = 6;
19
const EDGE_STROKE_WIDTH = 1.5;
20
const FONT_SIZE = 12;
21
const SUBLABEL_FONT_SIZE = 10;
22
const SUBGRAPH_PADDING = 12;
23
const CANVAS_PADDING = 24;
24
const PARALLEL_GAP_X = 40;
25
const SUBGRAPH_HEADER_HEIGHT = 22;
26
const GUTTER_WIDTH = 3;
27
const MERGED_TOGGLE_WIDTH = 36;
28
29
// ---- Layout internals ----
30
31
interface SubtreeLayout {
32
nodes: LayoutNode[];
33
edges: LayoutEdge[];
34
subgraphs: SubgraphRect[];
35
width: number;
36
height: number;
37
entryNode: LayoutNode;
38
exitNodes: LayoutNode[];
39
}
40
41
interface ChildGroup {
42
readonly type: 'sequential' | 'parallel';
43
readonly children: FlowNode[];
44
}
45
46
/** Deferred expansion of a merged-discovery node, resolved in pass 2. */
47
interface PendingExpansion {
48
/** The merged summary LayoutNode (already placed). */
49
readonly mergedNode: LayoutNode;
50
/** The individual FlowNodes to expand to the right. */
51
readonly children: readonly FlowNode[];
52
}
53
54
// ---- Parallel detection ----
55
56
/** Max time gap (ms) between subagent `created` timestamps to consider them parallel. */
57
const PARALLEL_TIME_THRESHOLD_MS = 5_000;
58
59
/**
60
* Groups a list of sibling nodes into sequential and parallel segments.
61
*
62
* Subagent invocations whose `created` timestamps fall within
63
* {@link PARALLEL_TIME_THRESHOLD_MS} of each other are clustered as parallel.
64
* Non-subagent nodes interleaved within a cluster are emitted as a sequential
65
* group before the parallel fork. When fewer than 2 subagents exist,
66
* everything is sequential.
67
*/
68
function groupChildren(children: FlowNode[]): ChildGroup[] {
69
const subagentIndices: number[] = [];
70
for (let i = 0; i < children.length; i++) {
71
if (children[i].kind === 'subagentInvocation') {
72
subagentIndices.push(i);
73
}
74
}
75
76
if (subagentIndices.length < 2) {
77
return [{ type: 'sequential', children }];
78
}
79
80
// Cluster subagents whose created timestamps are within the threshold.
81
const parallelClusters: number[][] = [];
82
let cluster: number[] = [subagentIndices[0]];
83
for (let k = 1; k < subagentIndices.length; k++) {
84
const prevCreated = children[subagentIndices[k - 1]].created;
85
const currCreated = children[subagentIndices[k]].created;
86
if (Math.abs(currCreated - prevCreated) <= PARALLEL_TIME_THRESHOLD_MS) {
87
cluster.push(subagentIndices[k]);
88
} else {
89
if (cluster.length >= 2) {
90
parallelClusters.push(cluster);
91
}
92
cluster = [subagentIndices[k]];
93
}
94
}
95
if (cluster.length >= 2) {
96
parallelClusters.push(cluster);
97
}
98
99
if (parallelClusters.length === 0) {
100
return [{ type: 'sequential', children }];
101
}
102
103
// Build groups from the timestamp-derived clusters.
104
const parallelIndices = new Set<number>();
105
for (const c of parallelClusters) {
106
for (const idx of c) {
107
parallelIndices.add(idx);
108
}
109
}
110
111
const groups: ChildGroup[] = [];
112
let clusterIdx = 0;
113
let i = 0;
114
while (i < children.length) {
115
if (clusterIdx < parallelClusters.length && i === parallelClusters[clusterIdx][0]) {
116
const cl = parallelClusters[clusterIdx];
117
const lastIdx = cl[cl.length - 1];
118
119
const setup: FlowNode[] = [];
120
const subagents: FlowNode[] = [];
121
for (let j = cl[0]; j <= lastIdx; j++) {
122
if (parallelIndices.has(j)) {
123
subagents.push(children[j]);
124
} else {
125
setup.push(children[j]);
126
}
127
}
128
if (setup.length > 0) {
129
groups.push({ type: 'sequential', children: setup });
130
}
131
groups.push({ type: 'parallel', children: subagents });
132
i = lastIdx + 1;
133
clusterIdx++;
134
} else {
135
const start = i;
136
const nextStart = clusterIdx < parallelClusters.length ? parallelClusters[clusterIdx][0] : children.length;
137
while (i < nextStart && !parallelIndices.has(i)) {
138
i++;
139
}
140
if (i > start) {
141
groups.push({ type: 'sequential', children: children.slice(start, i) });
142
}
143
}
144
}
145
return groups;
146
}
147
148
// ---- Layout engine ----
149
150
function isMessageKind(kind: IChatDebugEvent['kind']): boolean {
151
return kind === 'userMessage' || kind === 'agentResponse';
152
}
153
154
function measureNodeWidth(label: string, sublabel?: string): number {
155
const charWidth = 7;
156
const labelWidth = label.length * charWidth + NODE_PADDING_H * 2;
157
const sublabelWidth = sublabel ? sublabel.length * (charWidth - 1) + NODE_PADDING_H * 2 : 0;
158
return Math.min(NODE_MAX_WIDTH, Math.max(NODE_MIN_WIDTH, labelWidth, sublabelWidth));
159
}
160
161
function subgraphHeaderLabel(node: FlowNode): string {
162
// For subagent nodes, the label already includes the description
163
// (e.g. "Subagent: Count markdown files"), so don't append it again.
164
if (node.kind === 'subagentInvocation') {
165
return node.label;
166
}
167
if (node.description && node.description !== node.label) {
168
return `${node.label}: ${node.description}`;
169
}
170
return node.label;
171
}
172
173
function measureSubgraphHeaderWidth(headerLabel: string): number {
174
return headerLabel.length * 6 + SUBGRAPH_PADDING * 2 + 20; // 20 for chevron
175
}
176
177
function countDescendants(node: FlowNode): number {
178
let count = node.children.length;
179
for (const child of node.children) {
180
count += countDescendants(child);
181
}
182
return count;
183
}
184
185
/**
186
* Lays out grouped children (sequential or parallel) and connects edges.
187
* Shared by both root-level layout and subtree-level layout.
188
*
189
* @returns The final exit nodes, max width, and the y position after the last node.
190
*/
191
function layoutGroups(
192
groups: ChildGroup[],
193
startX: number,
194
startY: number,
195
depth: number,
196
prevExitNodes: LayoutNode[],
197
result: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] },
198
collapsedIds?: ReadonlySet<string>,
199
expandedMergedIds?: ReadonlySet<string>,
200
pendingExpansions?: PendingExpansion[],
201
): { exitNodes: LayoutNode[]; maxWidth: number; endY: number } {
202
let currentY = startY;
203
let maxWidth = 0;
204
let exitNodes = prevExitNodes;
205
206
for (const group of groups) {
207
if (group.type === 'parallel') {
208
const pg = layoutParallelGroup(group.children, startX, currentY, depth, collapsedIds, expandedMergedIds, pendingExpansions);
209
result.nodes.push(...pg.nodes);
210
result.edges.push(...pg.edges);
211
result.subgraphs.push(...pg.subgraphs);
212
213
for (const prev of exitNodes) {
214
for (const entry of pg.entryNodes) {
215
result.edges.push(makeEdge(prev, entry));
216
}
217
}
218
exitNodes = pg.exitNodes;
219
maxWidth = Math.max(maxWidth, pg.width);
220
currentY += pg.height + NODE_GAP_Y;
221
} else {
222
for (const child of group.children) {
223
const sub = layoutSubtree(child, startX, currentY, depth, collapsedIds, expandedMergedIds, pendingExpansions);
224
result.nodes.push(...sub.nodes);
225
result.edges.push(...sub.edges);
226
result.subgraphs.push(...sub.subgraphs);
227
228
for (const prev of exitNodes) {
229
result.edges.push(makeEdge(prev, sub.entryNode));
230
}
231
exitNodes = sub.exitNodes;
232
maxWidth = Math.max(maxWidth, sub.width);
233
currentY += sub.height + NODE_GAP_Y;
234
}
235
}
236
}
237
return { exitNodes, maxWidth, endY: currentY };
238
}
239
240
function makeEdge(from: LayoutNode, to: LayoutNode): LayoutEdge {
241
return {
242
fromId: from.id,
243
toId: to.id,
244
fromX: from.x + from.width / 2,
245
fromY: from.y + from.height,
246
toX: to.x + to.width / 2,
247
toY: to.y,
248
};
249
}
250
251
/**
252
* Lays out a list of flow nodes in a top-down vertical flow.
253
* Parallel subagent invocations are arranged side by side.
254
*/
255
export function layoutFlowGraph(roots: FlowNode[], options?: { collapsedIds?: ReadonlySet<string>; expandedMergedIds?: ReadonlySet<string> }): FlowLayout {
256
if (roots.length === 0) {
257
return { nodes: [], edges: [], subgraphs: [], width: 0, height: 0 };
258
}
259
260
const collapsedIds = options?.collapsedIds;
261
const expandedMergedIds = options?.expandedMergedIds;
262
const groups = groupChildren(roots);
263
const pendingExpansions: PendingExpansion[] = [];
264
const result: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] } = {
265
nodes: [],
266
edges: [],
267
subgraphs: [],
268
};
269
270
// Pass 1: layout the main vertical flow; expanded merged nodes only
271
// place their summary node and defer children to pendingExpansions.
272
const { maxWidth, endY } = layoutGroups(groups, CANVAS_PADDING, CANVAS_PADDING, 0, [], result, collapsedIds, expandedMergedIds, pendingExpansions);
273
274
// Pass 2: resolve deferred expansions — place children to the right,
275
// far enough to clear all existing nodes/subgraphs in the Y range.
276
resolvePendingExpansions(pendingExpansions, result);
277
278
let width = maxWidth + CANVAS_PADDING * 2;
279
let height = endY - NODE_GAP_Y + CANVAS_PADDING;
280
281
// Expand canvas to cover any nodes that float outside the main flow.
282
for (const n of result.nodes) {
283
width = Math.max(width, n.x + n.width + CANVAS_PADDING);
284
height = Math.max(height, n.y + n.height + CANVAS_PADDING);
285
}
286
287
centerLayout(result as FlowLayout & { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] }, width / 2);
288
289
return { nodes: result.nodes, edges: result.edges, subgraphs: result.subgraphs, width, height };
290
}
291
292
/**
293
* Pass 2: For each pending expansion, compute the Y range the children
294
* will occupy, scan all already-placed nodes and subgraphs for the max
295
* right edge overlapping that range, and place the entire column of
296
* children to the right of that edge.
297
*/
298
function resolvePendingExpansions(
299
pendingExpansions: PendingExpansion[],
300
result: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] },
301
): void {
302
for (const expansion of pendingExpansions) {
303
const { mergedNode, children } = expansion;
304
305
// Compute the Y range the children will occupy.
306
const childrenTotalHeight = children.length * NODE_HEIGHT + (children.length - 1) * NODE_GAP_Y;
307
const rangeTop = mergedNode.y;
308
const rangeBottom = mergedNode.y + childrenTotalHeight;
309
310
// Find the max right edge of any existing node or subgraph
311
// that overlaps this Y range.
312
let maxRightX = mergedNode.x + mergedNode.width;
313
for (const n of result.nodes) {
314
if (n.y + n.height > rangeTop && n.y < rangeBottom) {
315
maxRightX = Math.max(maxRightX, n.x + n.width);
316
}
317
}
318
for (const sg of result.subgraphs) {
319
if (sg.y + sg.height > rangeTop && sg.y < rangeBottom) {
320
maxRightX = Math.max(maxRightX, sg.x + sg.width);
321
}
322
}
323
324
const expandX = maxRightX + PARALLEL_GAP_X;
325
let expandY = mergedNode.y;
326
let expandMaxWidth = 0;
327
328
const childNodes: LayoutNode[] = [];
329
for (const child of children) {
330
const childWidth = measureNodeWidth(child.label, child.sublabel);
331
const childNode: LayoutNode = {
332
id: child.id,
333
kind: child.kind,
334
label: child.label,
335
sublabel: child.sublabel,
336
tooltip: child.tooltip,
337
isError: child.isError,
338
x: expandX,
339
y: expandY,
340
width: childWidth,
341
height: NODE_HEIGHT,
342
};
343
childNodes.push(childNode);
344
result.nodes.push(childNode);
345
expandMaxWidth = Math.max(expandMaxWidth, childWidth);
346
expandY += NODE_HEIGHT + NODE_GAP_Y;
347
}
348
349
// Edge from merged node to first expanded child.
350
// Use a horizontal edge aligned with the first child's midpoint
351
// so the orthogonal renderer doesn't need to route upward.
352
const edgeY = childNodes[0].y + childNodes[0].height / 2;
353
result.edges.push({
354
fromId: mergedNode.id,
355
toId: childNodes[0].id,
356
fromX: mergedNode.x + mergedNode.width,
357
fromY: edgeY,
358
toX: expandX,
359
toY: edgeY,
360
});
361
362
// Vertical edges between consecutive children
363
for (let k = 0; k < childNodes.length - 1; k++) {
364
result.edges.push(makeEdge(childNodes[k], childNodes[k + 1]));
365
}
366
}
367
}
368
369
function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, collapsedIds?: ReadonlySet<string>, expandedMergedIds?: ReadonlySet<string>, pendingExpansions?: PendingExpansion[]): SubtreeLayout {
370
const isMerged = (node.mergedNodes?.length ?? 0) >= 2;
371
const isMergedExpanded = isMerged && expandedMergedIds?.has(node.id);
372
const mergedExtra = isMerged ? MERGED_TOGGLE_WIDTH : 0;
373
const nodeWidth = measureNodeWidth(node.label, node.sublabel) + mergedExtra;
374
const isSubagent = node.kind === 'subagentInvocation';
375
const isCollapsed = isSubagent && collapsedIds?.has(node.id);
376
const nodeHeight = isMessageKind(node.kind) && node.sublabel ? MESSAGE_NODE_HEIGHT : NODE_HEIGHT;
377
378
const layoutNode: LayoutNode = {
379
id: node.id,
380
kind: node.kind,
381
label: node.label,
382
sublabel: node.sublabel,
383
tooltip: node.tooltip,
384
isError: node.isError,
385
x: startX,
386
y: y,
387
width: nodeWidth,
388
height: nodeHeight,
389
mergedCount: isMerged ? node.mergedNodes!.length : undefined,
390
isMergedExpanded,
391
};
392
393
const result: SubtreeLayout = {
394
nodes: [layoutNode],
395
edges: [],
396
subgraphs: [],
397
width: nodeWidth,
398
height: nodeHeight,
399
entryNode: layoutNode,
400
exitNodes: [layoutNode],
401
};
402
403
// Expanded merged discovery: defer child placement to pass 2.
404
// Only emit the merged summary node now; children will be placed
405
// to the right after all main-flow nodes have been positioned.
406
if (isMergedExpanded && pendingExpansions) {
407
pendingExpansions.push({ mergedNode: layoutNode, children: node.mergedNodes! });
408
return result;
409
}
410
411
if (node.children.length === 0 && !isCollapsed) {
412
return result;
413
}
414
415
// Collapsed subagent: show just the header + a compact badge area
416
if (isCollapsed) {
417
const collapsedHeight = SUBGRAPH_HEADER_HEIGHT + SUBGRAPH_PADDING * 2;
418
const totalChildCount = countDescendants(node);
419
const sgY = (y + nodeHeight + NODE_GAP_Y) - NODE_GAP_Y / 2;
420
const headerLabel = subgraphHeaderLabel(node);
421
const sgWidth = Math.max(NODE_MIN_WIDTH, measureSubgraphHeaderWidth(headerLabel)) + SUBGRAPH_PADDING * 2;
422
result.subgraphs.push({
423
label: headerLabel,
424
x: startX - SUBGRAPH_PADDING,
425
y: sgY,
426
width: sgWidth,
427
height: collapsedHeight,
428
depth,
429
nodeId: node.id,
430
collapsedChildCount: totalChildCount,
431
});
432
// Draw a connecting edge from the node to the collapsed subgraph
433
result.edges.push({
434
fromX: startX + nodeWidth / 2,
435
fromY: y + nodeHeight,
436
toX: startX - SUBGRAPH_PADDING + sgWidth / 2,
437
toY: sgY,
438
});
439
result.width = Math.max(nodeWidth, sgWidth);
440
result.height = nodeHeight + NODE_GAP_Y + collapsedHeight;
441
return result;
442
}
443
444
if (node.children.length === 0) {
445
return result;
446
}
447
448
const childDepth = isSubagent ? depth + 1 : depth;
449
const indentX = isSubagent ? SUBGRAPH_PADDING : 0;
450
const groups = groupChildren(node.children);
451
452
let childStartY = y + nodeHeight + NODE_GAP_Y;
453
if (isSubagent) {
454
childStartY += SUBGRAPH_HEADER_HEIGHT;
455
}
456
457
const { exitNodes, maxWidth, endY } = layoutGroups(
458
groups, startX + indentX, childStartY, childDepth, [layoutNode], result, collapsedIds, expandedMergedIds, pendingExpansions,
459
);
460
461
const totalChildrenHeight = endY - childStartY - NODE_GAP_Y;
462
463
let sgContentWidth = maxWidth;
464
if (isSubagent) {
465
const headerLabel = subgraphHeaderLabel(node);
466
sgContentWidth = Math.max(maxWidth, measureSubgraphHeaderWidth(headerLabel));
467
result.subgraphs.push({
468
label: headerLabel,
469
x: startX - SUBGRAPH_PADDING,
470
y: (y + nodeHeight + NODE_GAP_Y) - NODE_GAP_Y / 2,
471
width: sgContentWidth + SUBGRAPH_PADDING * 2,
472
height: totalChildrenHeight + SUBGRAPH_HEADER_HEIGHT + NODE_GAP_Y,
473
depth,
474
nodeId: node.id,
475
});
476
}
477
478
result.width = Math.max(nodeWidth, maxWidth + indentX * 2, isSubagent ? sgContentWidth + indentX * 2 : 0);
479
result.height = nodeHeight + NODE_GAP_Y + totalChildrenHeight + (isSubagent ? SUBGRAPH_HEADER_HEIGHT : 0);
480
result.exitNodes = exitNodes;
481
482
return result;
483
}
484
485
function layoutParallelGroup(children: FlowNode[], startX: number, y: number, depth: number, collapsedIds?: ReadonlySet<string>, expandedMergedIds?: ReadonlySet<string>, pendingExpansions?: PendingExpansion[]): {
486
nodes: LayoutNode[];
487
edges: LayoutEdge[];
488
subgraphs: SubgraphRect[];
489
entryNodes: LayoutNode[];
490
exitNodes: LayoutNode[];
491
width: number;
492
height: number;
493
} {
494
const subtreeLayouts: SubtreeLayout[] = [];
495
let totalWidth = 0;
496
let maxHeight = 0;
497
498
for (const child of children) {
499
const subtree = layoutSubtree(child, 0, y, depth, collapsedIds, expandedMergedIds, pendingExpansions);
500
subtreeLayouts.push(subtree);
501
totalWidth += subtree.width;
502
maxHeight = Math.max(maxHeight, subtree.height);
503
}
504
totalWidth += (children.length - 1) * PARALLEL_GAP_X;
505
506
const nodes: LayoutNode[] = [];
507
const edges: LayoutEdge[] = [];
508
const subgraphs: SubgraphRect[] = [];
509
const entryNodes: LayoutNode[] = [];
510
const exitNodes: LayoutNode[] = [];
511
512
let currentX = startX;
513
for (const subtree of subtreeLayouts) {
514
const dx = currentX;
515
const offsetNodes = subtree.nodes.map(n => ({ ...n, x: n.x + dx }));
516
const offsetEdges = subtree.edges.map(e => ({
517
fromId: e.fromId, toId: e.toId,
518
fromX: e.fromX + dx, fromY: e.fromY,
519
toX: e.toX + dx, toY: e.toY,
520
}));
521
const offsetSubgraphs = subtree.subgraphs.map(s => ({ ...s, x: s.x + dx }));
522
523
nodes.push(...offsetNodes);
524
edges.push(...offsetEdges);
525
subgraphs.push(...offsetSubgraphs);
526
entryNodes.push(offsetNodes.find(n => n.id === subtree.entryNode.id)!);
527
528
const exitIds = new Set(subtree.exitNodes.map(n => n.id));
529
exitNodes.push(...offsetNodes.filter(n => exitIds.has(n.id)));
530
currentX += subtree.width + PARALLEL_GAP_X;
531
}
532
533
return { nodes, edges, subgraphs, entryNodes, exitNodes, width: totalWidth, height: maxHeight };
534
}
535
536
function centerLayout(layout: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] }, centerX: number): void {
537
if (layout.nodes.length === 0) {
538
return;
539
}
540
541
let minX = Infinity;
542
let maxX = -Infinity;
543
for (const node of layout.nodes) {
544
minX = Math.min(minX, node.x);
545
maxX = Math.max(maxX, node.x + node.width);
546
}
547
const dx = centerX - (minX + maxX) / 2;
548
549
for (let i = 0; i < layout.nodes.length; i++) {
550
const n = layout.nodes[i];
551
(layout.nodes as LayoutNode[])[i] = { ...n, x: n.x + dx };
552
}
553
for (let i = 0; i < layout.edges.length; i++) {
554
const e = layout.edges[i];
555
(layout.edges as LayoutEdge[])[i] = { fromId: e.fromId, toId: e.toId, fromX: e.fromX + dx, fromY: e.fromY, toX: e.toX + dx, toY: e.toY };
556
}
557
for (let i = 0; i < layout.subgraphs.length; i++) {
558
const s = layout.subgraphs[i];
559
(layout.subgraphs as SubgraphRect[])[i] = { ...s, x: s.x + dx };
560
}
561
}
562
563
// ---- SVG Rendering ----
564
565
const SVG_NS = 'http://www.w3.org/2000/svg';
566
567
function svgEl<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Record<string, string | number>): SVGElementTagNameMap[K] {
568
const el = document.createElementNS(SVG_NS, tag);
569
for (const [k, v] of Object.entries(attrs)) {
570
el.setAttribute(k, String(v));
571
}
572
return el;
573
}
574
575
function getNodeColor(kind: IChatDebugEvent['kind'], isError?: boolean): string {
576
if (isError) {
577
return 'var(--vscode-errorForeground)';
578
}
579
switch (kind) {
580
case 'userMessage':
581
return 'var(--vscode-textLink-foreground)';
582
case 'modelTurn':
583
return 'var(--vscode-charts-blue, var(--vscode-textLink-foreground))';
584
case 'toolCall':
585
return 'var(--vscode-testing-iconPassed, #73c991)';
586
case 'subagentInvocation':
587
return 'var(--vscode-charts-purple, #b267e6)';
588
case 'agentResponse':
589
return 'var(--vscode-foreground)';
590
case 'generic':
591
return 'var(--vscode-descriptionForeground)';
592
}
593
}
594
595
const SUBGRAPH_COLORS = [
596
'var(--vscode-charts-purple, #b267e6)',
597
'var(--vscode-charts-blue, #3dc9b0)',
598
'var(--vscode-charts-yellow, #e5c07b)',
599
'var(--vscode-charts-orange, #d19a66)',
600
];
601
602
export function renderFlowChartSVG(layout: FlowLayout): FlowChartRenderResult {
603
const focusableElements = new Map<string, SVGElement>();
604
const svg = svgEl('svg', {
605
width: layout.width,
606
height: layout.height,
607
viewBox: `0 0 ${layout.width} ${layout.height}`,
608
role: 'img',
609
'aria-label': `Agent flow chart with ${layout.nodes.length} nodes`,
610
});
611
svg.classList.add('chat-debug-flowchart-svg');
612
613
renderSubgraphs(svg, layout.subgraphs, focusableElements);
614
renderEdges(svg, layout.edges);
615
renderNodes(svg, layout.nodes, focusableElements);
616
617
// Sort focusable elements by visual position (top-to-bottom, left-to-right)
618
// so keyboard navigation follows the flow chart order.
619
const positionByKey = new Map<string, { y: number; x: number }>();
620
for (const sg of layout.subgraphs) {
621
positionByKey.set(`sg:${sg.nodeId}`, { y: sg.y, x: sg.x });
622
}
623
for (const node of layout.nodes) {
624
positionByKey.set(node.id, { y: node.y, x: node.x });
625
}
626
const sortedFocusable = new Map(
627
[...focusableElements.entries()].sort((a, b) => {
628
const posA = positionByKey.get(a[0]);
629
const posB = positionByKey.get(b[0]);
630
if (!posA || !posB) {
631
return 0;
632
}
633
return posA.y !== posB.y ? posA.y - posB.y : posA.x - posB.x;
634
})
635
);
636
637
// Build adjacency map from edges so keyboard navigation can follow
638
// graph directionality instead of visual sort order.
639
const adjacency = new Map<string, { next: string[]; prev: string[] }>();
640
for (const edge of layout.edges) {
641
if (edge.fromId && edge.toId) {
642
let fromEntry = adjacency.get(edge.fromId);
643
if (!fromEntry) {
644
fromEntry = { next: [], prev: [] };
645
adjacency.set(edge.fromId, fromEntry);
646
}
647
fromEntry.next.push(edge.toId);
648
649
let toEntry = adjacency.get(edge.toId);
650
if (!toEntry) {
651
toEntry = { next: [], prev: [] };
652
adjacency.set(edge.toId, toEntry);
653
}
654
toEntry.prev.push(edge.fromId);
655
}
656
}
657
658
return { svg, focusableElements: sortedFocusable, adjacency, positions: positionByKey };
659
}
660
661
function renderSubgraphs(svg: SVGElement, subgraphs: readonly SubgraphRect[], focusableElements: Map<string, SVGElement>): void {
662
for (let sgIdx = 0; sgIdx < subgraphs.length; sgIdx++) {
663
const sg = subgraphs[sgIdx];
664
const color = SUBGRAPH_COLORS[sg.depth % SUBGRAPH_COLORS.length];
665
const isCollapsed = sg.collapsedChildCount !== undefined;
666
const g = document.createElementNS(SVG_NS, 'g');
667
g.classList.add('chat-debug-flowchart-subgraph');
668
669
const rectAttrs = { x: sg.x, y: sg.y, width: sg.width, height: sg.height, rx: NODE_BORDER_RADIUS, ry: NODE_BORDER_RADIUS };
670
const clipId = `sg-clip-${sgIdx}`;
671
672
// ClipPath for rounded corners
673
const clipPath = svgEl('clipPath', { id: clipId });
674
clipPath.appendChild(svgEl('rect', rectAttrs));
675
svg.appendChild(clipPath);
676
677
// Filled background
678
g.appendChild(svgEl('rect', { ...rectAttrs, fill: color, opacity: 0.06 + sg.depth * 0.02 }));
679
680
// Dashed border
681
g.appendChild(svgEl('rect', { ...rectAttrs, fill: 'none', stroke: color, 'stroke-width': 1, 'stroke-dasharray': '6,3', opacity: 0.5 }));
682
683
// Gutter line
684
g.appendChild(svgEl('rect', { x: sg.x, y: sg.y, width: GUTTER_WIDTH, height: sg.height, fill: color, opacity: 0.7, 'clip-path': `url(#${clipId})` }));
685
686
// Header group (clickable, keyboard accessible)
687
const headerGroup = document.createElementNS(SVG_NS, 'g');
688
headerGroup.setAttribute('data-subgraph-id', sg.nodeId);
689
headerGroup.classList.add('chat-debug-flowchart-subgraph-header');
690
headerGroup.setAttribute('tabindex', '0');
691
headerGroup.setAttribute('role', 'button');
692
headerGroup.setAttribute('aria-expanded', String(!isCollapsed));
693
headerGroup.setAttribute('aria-label', `${sg.label}: ${isCollapsed ? 'collapsed' : 'expanded'}${isCollapsed && sg.collapsedChildCount !== undefined ? `, ${sg.collapsedChildCount} items hidden` : ''}`);
694
695
const headerBar = svgEl('rect', { x: sg.x, y: sg.y, width: sg.width, height: SUBGRAPH_HEADER_HEIGHT, fill: color, opacity: 0.15, 'clip-path': `url(#${clipId})` });
696
headerGroup.appendChild(headerBar);
697
698
// Chevron + header label
699
const chevron = isCollapsed ? '\u25B6' : '\u25BC';
700
const headerText = svgEl('text', {
701
x: sg.x + GUTTER_WIDTH + 8,
702
y: sg.y + SUBGRAPH_HEADER_HEIGHT / 2 + 4,
703
'font-size': SUBLABEL_FONT_SIZE,
704
fill: color,
705
'font-family': 'var(--vscode-font-family, sans-serif)',
706
'font-weight': '600',
707
});
708
headerText.textContent = `${chevron} ${sg.label}`;
709
headerGroup.appendChild(headerText);
710
g.appendChild(headerGroup);
711
focusableElements.set(`sg:${sg.nodeId}`, headerGroup as unknown as SVGElement);
712
713
// Collapsed badge
714
if (isCollapsed && sg.collapsedChildCount !== undefined) {
715
const badgeText = svgEl('text', {
716
x: sg.x + sg.width / 2,
717
y: sg.y + SUBGRAPH_HEADER_HEIGHT + SUBGRAPH_PADDING + 4,
718
'font-size': SUBLABEL_FONT_SIZE,
719
fill: 'var(--vscode-descriptionForeground)',
720
'font-family': 'var(--vscode-font-family, sans-serif)',
721
'font-style': 'italic',
722
'text-anchor': 'middle',
723
});
724
badgeText.textContent = `+${sg.collapsedChildCount} items`;
725
g.appendChild(badgeText);
726
}
727
728
svg.appendChild(g);
729
}
730
}
731
732
function renderEdges(svg: SVGElement, edges: readonly LayoutEdge[]): void {
733
const strokeAttrs = { fill: 'none', stroke: 'var(--vscode-descriptionForeground)', 'stroke-width': EDGE_STROKE_WIDTH, 'stroke-linecap': 'round' };
734
// allow-any-unicode-next-line
735
const r = 6; // corner radius for 90° bends
736
737
for (const edge of edges) {
738
const midY = (edge.fromY + edge.toY) / 2;
739
let d: string;
740
const isHorizontal = edge.fromY === edge.toY;
741
742
if (isHorizontal) {
743
// Horizontally aligned: straight line (used by expanded merged nodes)
744
d = `M ${edge.fromX} ${edge.fromY} L ${edge.toX} ${edge.toY}`;
745
} else if (edge.fromX === edge.toX) {
746
// Vertically aligned: straight line
747
d = `M ${edge.fromX} ${edge.fromY} L ${edge.toX} ${edge.toY}`;
748
} else {
749
// allow-any-unicode-next-line
750
// Orthogonal routing: down, 90° horizontal, 90° down
751
const dx = edge.toX - edge.fromX;
752
const signX = dx > 0 ? 1 : -1;
753
const absDx = Math.abs(dx);
754
const cr = Math.min(r, absDx / 2, (edge.toY - edge.fromY) / 4);
755
756
d = `M ${edge.fromX} ${edge.fromY}`
757
// Down to first bend
758
+ ` L ${edge.fromX} ${midY - cr}`
759
// allow-any-unicode-next-line
760
// 90° arc turning horizontal
761
+ ` Q ${edge.fromX} ${midY}, ${edge.fromX + signX * cr} ${midY}`
762
// Horizontal to second bend
763
+ ` L ${edge.toX - signX * cr} ${midY}`
764
// allow-any-unicode-next-line
765
// 90° arc turning down
766
+ ` Q ${edge.toX} ${midY}, ${edge.toX} ${midY + cr}`
767
// Down to target
768
+ ` L ${edge.toX} ${edge.toY}`;
769
}
770
771
svg.appendChild(svgEl('path', { ...strokeAttrs, d }));
772
773
// Arrowhead: right-pointing for horizontal edges, down-pointing otherwise
774
const a = 5;
775
let arrowD: string;
776
if (isHorizontal) {
777
const signX = edge.toX > edge.fromX ? 1 : -1;
778
arrowD = `M ${edge.toX - signX * a * 1.5} ${edge.toY - a} L ${edge.toX} ${edge.toY} L ${edge.toX - signX * a * 1.5} ${edge.toY + a}`;
779
} else {
780
arrowD = `M ${edge.toX - a} ${edge.toY - a * 1.5} L ${edge.toX} ${edge.toY} L ${edge.toX + a} ${edge.toY - a * 1.5}`;
781
}
782
svg.appendChild(svgEl('path', {
783
...strokeAttrs,
784
'stroke-linejoin': 'round',
785
d: arrowD,
786
}));
787
}
788
}
789
790
function renderNodes(svg: SVGElement, nodes: readonly LayoutNode[], focusableElements: Map<string, SVGElement>): void {
791
const fontFamily = 'var(--vscode-font-family, sans-serif)';
792
const nodeFill = 'var(--vscode-editor-background, var(--vscode-editorWidget-background))';
793
794
for (const node of nodes) {
795
const g = document.createElementNS(SVG_NS, 'g');
796
g.classList.add('chat-debug-flowchart-node');
797
g.setAttribute('data-node-id', node.id);
798
g.setAttribute('tabindex', '0');
799
g.setAttribute('role', 'img');
800
801
const ariaLabel = node.sublabel ? `${node.label}, ${node.sublabel}` : node.label;
802
g.setAttribute('aria-label', ariaLabel);
803
focusableElements.set(node.id, g as unknown as SVGElement);
804
805
if (node.tooltip) {
806
const title = document.createElementNS(SVG_NS, 'title');
807
title.textContent = node.tooltip;
808
g.appendChild(title);
809
}
810
811
const color = getNodeColor(node.kind, node.isError);
812
const safeId = node.id.replace(/[^a-zA-Z0-9]/g, '_');
813
const rectAttrs = { x: node.x, y: node.y, width: node.width, height: node.height, rx: NODE_BORDER_RADIUS, ry: NODE_BORDER_RADIUS };
814
815
// Clip path shared by gutter bar and text
816
const clipId = `clip-${safeId}`;
817
const clipPath = svgEl('clipPath', { id: clipId });
818
clipPath.appendChild(svgEl('rect', rectAttrs));
819
svg.appendChild(clipPath);
820
821
// Focus ring (hidden by default, shown on :focus via CSS)
822
const focusOffset = 3;
823
g.appendChild(svgEl('rect', {
824
class: 'chat-debug-flowchart-focus-ring',
825
x: node.x - focusOffset,
826
y: node.y - focusOffset,
827
width: node.width + focusOffset * 2,
828
height: node.height + focusOffset * 2,
829
rx: NODE_BORDER_RADIUS + focusOffset,
830
ry: NODE_BORDER_RADIUS + focusOffset,
831
fill: 'none',
832
stroke: 'var(--vscode-focusBorder)',
833
'stroke-width': 2,
834
}));
835
836
// Node rectangle
837
g.appendChild(svgEl('rect', { ...rectAttrs, fill: nodeFill, stroke: color, 'stroke-width': node.isError ? 2 : 1.5 }));
838
839
// Kind indicator (colored gutter bar)
840
g.appendChild(svgEl('rect', { x: node.x, y: node.y, width: 4, height: node.height, fill: color, 'clip-path': `url(#${clipId})` }));
841
842
// Label text
843
const textX = node.x + NODE_PADDING_H;
844
const isMessage = isMessageKind(node.kind);
845
if (isMessage && node.sublabel) {
846
// Message nodes: small header label + larger message text
847
const header = svgEl('text', { x: textX, y: node.y + NODE_PADDING_V + SUBLABEL_FONT_SIZE, 'font-size': SUBLABEL_FONT_SIZE, fill: 'var(--vscode-descriptionForeground)', 'font-family': fontFamily, 'clip-path': `url(#${clipId})` });
848
header.textContent = node.label;
849
g.appendChild(header);
850
851
const msg = svgEl('text', { x: textX, y: node.y + node.height - NODE_PADDING_V - 2, 'font-size': FONT_SIZE, fill: 'var(--vscode-foreground)', 'font-family': fontFamily, 'clip-path': `url(#${clipId})` });
852
msg.textContent = node.sublabel;
853
g.appendChild(msg);
854
} else if (node.sublabel) {
855
const label = svgEl('text', { x: textX, y: node.y + NODE_PADDING_V + FONT_SIZE, 'font-size': FONT_SIZE, fill: 'var(--vscode-foreground)', 'font-family': fontFamily, 'clip-path': `url(#${clipId})` });
856
label.textContent = node.label;
857
g.appendChild(label);
858
859
const sub = svgEl('text', { x: textX, y: node.y + node.height - NODE_PADDING_V, 'font-size': SUBLABEL_FONT_SIZE, fill: 'var(--vscode-descriptionForeground)', 'font-family': fontFamily, 'clip-path': `url(#${clipId})` });
860
sub.textContent = node.sublabel;
861
g.appendChild(sub);
862
} else {
863
const label = svgEl('text', { x: textX, y: node.y + node.height / 2 + FONT_SIZE / 2 - 1, 'font-size': FONT_SIZE, fill: 'var(--vscode-foreground)', 'font-family': fontFamily, 'clip-path': `url(#${clipId})` });
864
label.textContent = node.label;
865
g.appendChild(label);
866
}
867
868
// Merged-discovery expand/collapse toggle on the right side
869
if (node.mergedCount) {
870
g.setAttribute('data-is-toggle', 'true');
871
renderMergedToggle(g, node, color, fontFamily);
872
}
873
874
svg.appendChild(g);
875
}
876
}
877
878
function renderMergedToggle(g: Element, node: LayoutNode, color: string, fontFamily: string): void {
879
const toggleX = node.x + node.width - MERGED_TOGGLE_WIDTH;
880
const toggleGroup = document.createElementNS(SVG_NS, 'g');
881
toggleGroup.classList.add('chat-debug-flowchart-merged-toggle');
882
toggleGroup.setAttribute('data-merged-id', node.id);
883
884
// Separator line
885
toggleGroup.appendChild(svgEl('line', {
886
x1: toggleX, y1: node.y + 4,
887
x2: toggleX, y2: node.y + node.height - 4,
888
stroke: 'var(--vscode-descriptionForeground)',
889
'stroke-width': 0.5,
890
opacity: 0.4,
891
}));
892
893
// allow-any-unicode-next-line
894
// Expand chevron (▶ collapsed, ◀ expanded)
895
const chevronX = toggleX + MERGED_TOGGLE_WIDTH / 2;
896
const chevronY = node.y + node.height / 2;
897
const chevron = svgEl('text', {
898
x: chevronX,
899
y: chevronY + 4,
900
'font-size': 9,
901
fill: color,
902
'font-family': fontFamily,
903
'text-anchor': 'middle',
904
cursor: 'pointer',
905
});
906
// allow-any-unicode-next-line
907
chevron.textContent = node.isMergedExpanded ? '\u25C0' : '\u25B6'; // ◀ or ▶
908
toggleGroup.appendChild(chevron);
909
910
// Hit area for the toggle — invisible rect covering the toggle zone
911
toggleGroup.appendChild(svgEl('rect', {
912
x: toggleX,
913
y: node.y,
914
width: MERGED_TOGGLE_WIDTH,
915
height: node.height,
916
fill: 'transparent',
917
cursor: 'pointer',
918
}));
919
920
g.appendChild(toggleGroup);
921
}
922
923