Path: blob/main/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { IChatDebugEvent } from '../../common/chatDebugService.js';6import { FlowLayout, FlowNode, LayoutEdge, LayoutNode, SubgraphRect, FlowChartRenderResult } from './chatDebugFlowGraph.js';78// ---- Layout constants ----910const NODE_HEIGHT = 36;11const MESSAGE_NODE_HEIGHT = 52;12const NODE_MIN_WIDTH = 140;13const NODE_MAX_WIDTH = 320;14const NODE_PADDING_H = 16;15const NODE_PADDING_V = 6;16const NODE_GAP_Y = 24;17const NODE_BORDER_RADIUS = 6;18const EDGE_STROKE_WIDTH = 1.5;19const FONT_SIZE = 12;20const SUBLABEL_FONT_SIZE = 10;21const SUBGRAPH_PADDING = 12;22const CANVAS_PADDING = 24;23const PARALLEL_GAP_X = 40;24const SUBGRAPH_HEADER_HEIGHT = 22;25const GUTTER_WIDTH = 3;26const MERGED_TOGGLE_WIDTH = 36;2728// ---- Layout internals ----2930interface SubtreeLayout {31nodes: LayoutNode[];32edges: LayoutEdge[];33subgraphs: SubgraphRect[];34width: number;35height: number;36entryNode: LayoutNode;37exitNodes: LayoutNode[];38}3940interface ChildGroup {41readonly type: 'sequential' | 'parallel';42readonly children: FlowNode[];43}4445/** Deferred expansion of a merged-discovery node, resolved in pass 2. */46interface PendingExpansion {47/** The merged summary LayoutNode (already placed). */48readonly mergedNode: LayoutNode;49/** The individual FlowNodes to expand to the right. */50readonly children: readonly FlowNode[];51}5253// ---- Parallel detection ----5455/** Max time gap (ms) between subagent `created` timestamps to consider them parallel. */56const PARALLEL_TIME_THRESHOLD_MS = 5_000;5758/**59* Groups a list of sibling nodes into sequential and parallel segments.60*61* Subagent invocations whose `created` timestamps fall within62* {@link PARALLEL_TIME_THRESHOLD_MS} of each other are clustered as parallel.63* Non-subagent nodes interleaved within a cluster are emitted as a sequential64* group before the parallel fork. When fewer than 2 subagents exist,65* everything is sequential.66*/67function groupChildren(children: FlowNode[]): ChildGroup[] {68const subagentIndices: number[] = [];69for (let i = 0; i < children.length; i++) {70if (children[i].kind === 'subagentInvocation') {71subagentIndices.push(i);72}73}7475if (subagentIndices.length < 2) {76return [{ type: 'sequential', children }];77}7879// Cluster subagents whose created timestamps are within the threshold.80const parallelClusters: number[][] = [];81let cluster: number[] = [subagentIndices[0]];82for (let k = 1; k < subagentIndices.length; k++) {83const prevCreated = children[subagentIndices[k - 1]].created;84const currCreated = children[subagentIndices[k]].created;85if (Math.abs(currCreated - prevCreated) <= PARALLEL_TIME_THRESHOLD_MS) {86cluster.push(subagentIndices[k]);87} else {88if (cluster.length >= 2) {89parallelClusters.push(cluster);90}91cluster = [subagentIndices[k]];92}93}94if (cluster.length >= 2) {95parallelClusters.push(cluster);96}9798if (parallelClusters.length === 0) {99return [{ type: 'sequential', children }];100}101102// Build groups from the timestamp-derived clusters.103const parallelIndices = new Set<number>();104for (const c of parallelClusters) {105for (const idx of c) {106parallelIndices.add(idx);107}108}109110const groups: ChildGroup[] = [];111let clusterIdx = 0;112let i = 0;113while (i < children.length) {114if (clusterIdx < parallelClusters.length && i === parallelClusters[clusterIdx][0]) {115const cl = parallelClusters[clusterIdx];116const lastIdx = cl[cl.length - 1];117118const setup: FlowNode[] = [];119const subagents: FlowNode[] = [];120for (let j = cl[0]; j <= lastIdx; j++) {121if (parallelIndices.has(j)) {122subagents.push(children[j]);123} else {124setup.push(children[j]);125}126}127if (setup.length > 0) {128groups.push({ type: 'sequential', children: setup });129}130groups.push({ type: 'parallel', children: subagents });131i = lastIdx + 1;132clusterIdx++;133} else {134const start = i;135const nextStart = clusterIdx < parallelClusters.length ? parallelClusters[clusterIdx][0] : children.length;136while (i < nextStart && !parallelIndices.has(i)) {137i++;138}139if (i > start) {140groups.push({ type: 'sequential', children: children.slice(start, i) });141}142}143}144return groups;145}146147// ---- Layout engine ----148149function isMessageKind(kind: IChatDebugEvent['kind']): boolean {150return kind === 'userMessage' || kind === 'agentResponse';151}152153function measureNodeWidth(label: string, sublabel?: string): number {154const charWidth = 7;155const labelWidth = label.length * charWidth + NODE_PADDING_H * 2;156const sublabelWidth = sublabel ? sublabel.length * (charWidth - 1) + NODE_PADDING_H * 2 : 0;157return Math.min(NODE_MAX_WIDTH, Math.max(NODE_MIN_WIDTH, labelWidth, sublabelWidth));158}159160function subgraphHeaderLabel(node: FlowNode): string {161// For subagent nodes, the label already includes the description162// (e.g. "Subagent: Count markdown files"), so don't append it again.163if (node.kind === 'subagentInvocation') {164return node.label;165}166if (node.description && node.description !== node.label) {167return `${node.label}: ${node.description}`;168}169return node.label;170}171172function measureSubgraphHeaderWidth(headerLabel: string): number {173return headerLabel.length * 6 + SUBGRAPH_PADDING * 2 + 20; // 20 for chevron174}175176function countDescendants(node: FlowNode): number {177let count = node.children.length;178for (const child of node.children) {179count += countDescendants(child);180}181return count;182}183184/**185* Lays out grouped children (sequential or parallel) and connects edges.186* Shared by both root-level layout and subtree-level layout.187*188* @returns The final exit nodes, max width, and the y position after the last node.189*/190function layoutGroups(191groups: ChildGroup[],192startX: number,193startY: number,194depth: number,195prevExitNodes: LayoutNode[],196result: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] },197collapsedIds?: ReadonlySet<string>,198expandedMergedIds?: ReadonlySet<string>,199pendingExpansions?: PendingExpansion[],200): { exitNodes: LayoutNode[]; maxWidth: number; endY: number } {201let currentY = startY;202let maxWidth = 0;203let exitNodes = prevExitNodes;204205for (const group of groups) {206if (group.type === 'parallel') {207const pg = layoutParallelGroup(group.children, startX, currentY, depth, collapsedIds, expandedMergedIds, pendingExpansions);208result.nodes.push(...pg.nodes);209result.edges.push(...pg.edges);210result.subgraphs.push(...pg.subgraphs);211212for (const prev of exitNodes) {213for (const entry of pg.entryNodes) {214result.edges.push(makeEdge(prev, entry));215}216}217exitNodes = pg.exitNodes;218maxWidth = Math.max(maxWidth, pg.width);219currentY += pg.height + NODE_GAP_Y;220} else {221for (const child of group.children) {222const sub = layoutSubtree(child, startX, currentY, depth, collapsedIds, expandedMergedIds, pendingExpansions);223result.nodes.push(...sub.nodes);224result.edges.push(...sub.edges);225result.subgraphs.push(...sub.subgraphs);226227for (const prev of exitNodes) {228result.edges.push(makeEdge(prev, sub.entryNode));229}230exitNodes = sub.exitNodes;231maxWidth = Math.max(maxWidth, sub.width);232currentY += sub.height + NODE_GAP_Y;233}234}235}236return { exitNodes, maxWidth, endY: currentY };237}238239function makeEdge(from: LayoutNode, to: LayoutNode): LayoutEdge {240return {241fromId: from.id,242toId: to.id,243fromX: from.x + from.width / 2,244fromY: from.y + from.height,245toX: to.x + to.width / 2,246toY: to.y,247};248}249250/**251* Lays out a list of flow nodes in a top-down vertical flow.252* Parallel subagent invocations are arranged side by side.253*/254export function layoutFlowGraph(roots: FlowNode[], options?: { collapsedIds?: ReadonlySet<string>; expandedMergedIds?: ReadonlySet<string> }): FlowLayout {255if (roots.length === 0) {256return { nodes: [], edges: [], subgraphs: [], width: 0, height: 0 };257}258259const collapsedIds = options?.collapsedIds;260const expandedMergedIds = options?.expandedMergedIds;261const groups = groupChildren(roots);262const pendingExpansions: PendingExpansion[] = [];263const result: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] } = {264nodes: [],265edges: [],266subgraphs: [],267};268269// Pass 1: layout the main vertical flow; expanded merged nodes only270// place their summary node and defer children to pendingExpansions.271const { maxWidth, endY } = layoutGroups(groups, CANVAS_PADDING, CANVAS_PADDING, 0, [], result, collapsedIds, expandedMergedIds, pendingExpansions);272273// Pass 2: resolve deferred expansions — place children to the right,274// far enough to clear all existing nodes/subgraphs in the Y range.275resolvePendingExpansions(pendingExpansions, result);276277let width = maxWidth + CANVAS_PADDING * 2;278let height = endY - NODE_GAP_Y + CANVAS_PADDING;279280// Expand canvas to cover any nodes that float outside the main flow.281for (const n of result.nodes) {282width = Math.max(width, n.x + n.width + CANVAS_PADDING);283height = Math.max(height, n.y + n.height + CANVAS_PADDING);284}285286centerLayout(result as FlowLayout & { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] }, width / 2);287288return { nodes: result.nodes, edges: result.edges, subgraphs: result.subgraphs, width, height };289}290291/**292* Pass 2: For each pending expansion, compute the Y range the children293* will occupy, scan all already-placed nodes and subgraphs for the max294* right edge overlapping that range, and place the entire column of295* children to the right of that edge.296*/297function resolvePendingExpansions(298pendingExpansions: PendingExpansion[],299result: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] },300): void {301for (const expansion of pendingExpansions) {302const { mergedNode, children } = expansion;303304// Compute the Y range the children will occupy.305const childrenTotalHeight = children.length * NODE_HEIGHT + (children.length - 1) * NODE_GAP_Y;306const rangeTop = mergedNode.y;307const rangeBottom = mergedNode.y + childrenTotalHeight;308309// Find the max right edge of any existing node or subgraph310// that overlaps this Y range.311let maxRightX = mergedNode.x + mergedNode.width;312for (const n of result.nodes) {313if (n.y + n.height > rangeTop && n.y < rangeBottom) {314maxRightX = Math.max(maxRightX, n.x + n.width);315}316}317for (const sg of result.subgraphs) {318if (sg.y + sg.height > rangeTop && sg.y < rangeBottom) {319maxRightX = Math.max(maxRightX, sg.x + sg.width);320}321}322323const expandX = maxRightX + PARALLEL_GAP_X;324let expandY = mergedNode.y;325let expandMaxWidth = 0;326327const childNodes: LayoutNode[] = [];328for (const child of children) {329const childWidth = measureNodeWidth(child.label, child.sublabel);330const childNode: LayoutNode = {331id: child.id,332kind: child.kind,333label: child.label,334sublabel: child.sublabel,335tooltip: child.tooltip,336isError: child.isError,337x: expandX,338y: expandY,339width: childWidth,340height: NODE_HEIGHT,341};342childNodes.push(childNode);343result.nodes.push(childNode);344expandMaxWidth = Math.max(expandMaxWidth, childWidth);345expandY += NODE_HEIGHT + NODE_GAP_Y;346}347348// Edge from merged node to first expanded child.349// Use a horizontal edge aligned with the first child's midpoint350// so the orthogonal renderer doesn't need to route upward.351const edgeY = childNodes[0].y + childNodes[0].height / 2;352result.edges.push({353fromId: mergedNode.id,354toId: childNodes[0].id,355fromX: mergedNode.x + mergedNode.width,356fromY: edgeY,357toX: expandX,358toY: edgeY,359});360361// Vertical edges between consecutive children362for (let k = 0; k < childNodes.length - 1; k++) {363result.edges.push(makeEdge(childNodes[k], childNodes[k + 1]));364}365}366}367368function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, collapsedIds?: ReadonlySet<string>, expandedMergedIds?: ReadonlySet<string>, pendingExpansions?: PendingExpansion[]): SubtreeLayout {369const isMerged = (node.mergedNodes?.length ?? 0) >= 2;370const isMergedExpanded = isMerged && expandedMergedIds?.has(node.id);371const mergedExtra = isMerged ? MERGED_TOGGLE_WIDTH : 0;372const nodeWidth = measureNodeWidth(node.label, node.sublabel) + mergedExtra;373const isSubagent = node.kind === 'subagentInvocation';374const isCollapsed = isSubagent && collapsedIds?.has(node.id);375const nodeHeight = isMessageKind(node.kind) && node.sublabel ? MESSAGE_NODE_HEIGHT : NODE_HEIGHT;376377const layoutNode: LayoutNode = {378id: node.id,379kind: node.kind,380label: node.label,381sublabel: node.sublabel,382tooltip: node.tooltip,383isError: node.isError,384x: startX,385y: y,386width: nodeWidth,387height: nodeHeight,388mergedCount: isMerged ? node.mergedNodes!.length : undefined,389isMergedExpanded,390};391392const result: SubtreeLayout = {393nodes: [layoutNode],394edges: [],395subgraphs: [],396width: nodeWidth,397height: nodeHeight,398entryNode: layoutNode,399exitNodes: [layoutNode],400};401402// Expanded merged discovery: defer child placement to pass 2.403// Only emit the merged summary node now; children will be placed404// to the right after all main-flow nodes have been positioned.405if (isMergedExpanded && pendingExpansions) {406pendingExpansions.push({ mergedNode: layoutNode, children: node.mergedNodes! });407return result;408}409410if (node.children.length === 0 && !isCollapsed) {411return result;412}413414// Collapsed subagent: show just the header + a compact badge area415if (isCollapsed) {416const collapsedHeight = SUBGRAPH_HEADER_HEIGHT + SUBGRAPH_PADDING * 2;417const totalChildCount = countDescendants(node);418const sgY = (y + nodeHeight + NODE_GAP_Y) - NODE_GAP_Y / 2;419const headerLabel = subgraphHeaderLabel(node);420const sgWidth = Math.max(NODE_MIN_WIDTH, measureSubgraphHeaderWidth(headerLabel)) + SUBGRAPH_PADDING * 2;421result.subgraphs.push({422label: headerLabel,423x: startX - SUBGRAPH_PADDING,424y: sgY,425width: sgWidth,426height: collapsedHeight,427depth,428nodeId: node.id,429collapsedChildCount: totalChildCount,430});431// Draw a connecting edge from the node to the collapsed subgraph432result.edges.push({433fromX: startX + nodeWidth / 2,434fromY: y + nodeHeight,435toX: startX - SUBGRAPH_PADDING + sgWidth / 2,436toY: sgY,437});438result.width = Math.max(nodeWidth, sgWidth);439result.height = nodeHeight + NODE_GAP_Y + collapsedHeight;440return result;441}442443if (node.children.length === 0) {444return result;445}446447const childDepth = isSubagent ? depth + 1 : depth;448const indentX = isSubagent ? SUBGRAPH_PADDING : 0;449const groups = groupChildren(node.children);450451let childStartY = y + nodeHeight + NODE_GAP_Y;452if (isSubagent) {453childStartY += SUBGRAPH_HEADER_HEIGHT;454}455456const { exitNodes, maxWidth, endY } = layoutGroups(457groups, startX + indentX, childStartY, childDepth, [layoutNode], result, collapsedIds, expandedMergedIds, pendingExpansions,458);459460const totalChildrenHeight = endY - childStartY - NODE_GAP_Y;461462let sgContentWidth = maxWidth;463if (isSubagent) {464const headerLabel = subgraphHeaderLabel(node);465sgContentWidth = Math.max(maxWidth, measureSubgraphHeaderWidth(headerLabel));466result.subgraphs.push({467label: headerLabel,468x: startX - SUBGRAPH_PADDING,469y: (y + nodeHeight + NODE_GAP_Y) - NODE_GAP_Y / 2,470width: sgContentWidth + SUBGRAPH_PADDING * 2,471height: totalChildrenHeight + SUBGRAPH_HEADER_HEIGHT + NODE_GAP_Y,472depth,473nodeId: node.id,474});475}476477result.width = Math.max(nodeWidth, maxWidth + indentX * 2, isSubagent ? sgContentWidth + indentX * 2 : 0);478result.height = nodeHeight + NODE_GAP_Y + totalChildrenHeight + (isSubagent ? SUBGRAPH_HEADER_HEIGHT : 0);479result.exitNodes = exitNodes;480481return result;482}483484function layoutParallelGroup(children: FlowNode[], startX: number, y: number, depth: number, collapsedIds?: ReadonlySet<string>, expandedMergedIds?: ReadonlySet<string>, pendingExpansions?: PendingExpansion[]): {485nodes: LayoutNode[];486edges: LayoutEdge[];487subgraphs: SubgraphRect[];488entryNodes: LayoutNode[];489exitNodes: LayoutNode[];490width: number;491height: number;492} {493const subtreeLayouts: SubtreeLayout[] = [];494let totalWidth = 0;495let maxHeight = 0;496497for (const child of children) {498const subtree = layoutSubtree(child, 0, y, depth, collapsedIds, expandedMergedIds, pendingExpansions);499subtreeLayouts.push(subtree);500totalWidth += subtree.width;501maxHeight = Math.max(maxHeight, subtree.height);502}503totalWidth += (children.length - 1) * PARALLEL_GAP_X;504505const nodes: LayoutNode[] = [];506const edges: LayoutEdge[] = [];507const subgraphs: SubgraphRect[] = [];508const entryNodes: LayoutNode[] = [];509const exitNodes: LayoutNode[] = [];510511let currentX = startX;512for (const subtree of subtreeLayouts) {513const dx = currentX;514const offsetNodes = subtree.nodes.map(n => ({ ...n, x: n.x + dx }));515const offsetEdges = subtree.edges.map(e => ({516fromId: e.fromId, toId: e.toId,517fromX: e.fromX + dx, fromY: e.fromY,518toX: e.toX + dx, toY: e.toY,519}));520const offsetSubgraphs = subtree.subgraphs.map(s => ({ ...s, x: s.x + dx }));521522nodes.push(...offsetNodes);523edges.push(...offsetEdges);524subgraphs.push(...offsetSubgraphs);525entryNodes.push(offsetNodes.find(n => n.id === subtree.entryNode.id)!);526527const exitIds = new Set(subtree.exitNodes.map(n => n.id));528exitNodes.push(...offsetNodes.filter(n => exitIds.has(n.id)));529currentX += subtree.width + PARALLEL_GAP_X;530}531532return { nodes, edges, subgraphs, entryNodes, exitNodes, width: totalWidth, height: maxHeight };533}534535function centerLayout(layout: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] }, centerX: number): void {536if (layout.nodes.length === 0) {537return;538}539540let minX = Infinity;541let maxX = -Infinity;542for (const node of layout.nodes) {543minX = Math.min(minX, node.x);544maxX = Math.max(maxX, node.x + node.width);545}546const dx = centerX - (minX + maxX) / 2;547548for (let i = 0; i < layout.nodes.length; i++) {549const n = layout.nodes[i];550(layout.nodes as LayoutNode[])[i] = { ...n, x: n.x + dx };551}552for (let i = 0; i < layout.edges.length; i++) {553const e = layout.edges[i];554(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 };555}556for (let i = 0; i < layout.subgraphs.length; i++) {557const s = layout.subgraphs[i];558(layout.subgraphs as SubgraphRect[])[i] = { ...s, x: s.x + dx };559}560}561562// ---- SVG Rendering ----563564const SVG_NS = 'http://www.w3.org/2000/svg';565566function svgEl<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Record<string, string | number>): SVGElementTagNameMap[K] {567const el = document.createElementNS(SVG_NS, tag);568for (const [k, v] of Object.entries(attrs)) {569el.setAttribute(k, String(v));570}571return el;572}573574function getNodeColor(kind: IChatDebugEvent['kind'], isError?: boolean): string {575if (isError) {576return 'var(--vscode-errorForeground)';577}578switch (kind) {579case 'userMessage':580return 'var(--vscode-textLink-foreground)';581case 'modelTurn':582return 'var(--vscode-charts-blue, var(--vscode-textLink-foreground))';583case 'toolCall':584return 'var(--vscode-testing-iconPassed, #73c991)';585case 'subagentInvocation':586return 'var(--vscode-charts-purple, #b267e6)';587case 'agentResponse':588return 'var(--vscode-foreground)';589case 'generic':590return 'var(--vscode-descriptionForeground)';591}592}593594const SUBGRAPH_COLORS = [595'var(--vscode-charts-purple, #b267e6)',596'var(--vscode-charts-blue, #3dc9b0)',597'var(--vscode-charts-yellow, #e5c07b)',598'var(--vscode-charts-orange, #d19a66)',599];600601export function renderFlowChartSVG(layout: FlowLayout): FlowChartRenderResult {602const focusableElements = new Map<string, SVGElement>();603const svg = svgEl('svg', {604width: layout.width,605height: layout.height,606viewBox: `0 0 ${layout.width} ${layout.height}`,607role: 'img',608'aria-label': `Agent flow chart with ${layout.nodes.length} nodes`,609});610svg.classList.add('chat-debug-flowchart-svg');611612renderSubgraphs(svg, layout.subgraphs, focusableElements);613renderEdges(svg, layout.edges);614renderNodes(svg, layout.nodes, focusableElements);615616// Sort focusable elements by visual position (top-to-bottom, left-to-right)617// so keyboard navigation follows the flow chart order.618const positionByKey = new Map<string, { y: number; x: number }>();619for (const sg of layout.subgraphs) {620positionByKey.set(`sg:${sg.nodeId}`, { y: sg.y, x: sg.x });621}622for (const node of layout.nodes) {623positionByKey.set(node.id, { y: node.y, x: node.x });624}625const sortedFocusable = new Map(626[...focusableElements.entries()].sort((a, b) => {627const posA = positionByKey.get(a[0]);628const posB = positionByKey.get(b[0]);629if (!posA || !posB) {630return 0;631}632return posA.y !== posB.y ? posA.y - posB.y : posA.x - posB.x;633})634);635636// Build adjacency map from edges so keyboard navigation can follow637// graph directionality instead of visual sort order.638const adjacency = new Map<string, { next: string[]; prev: string[] }>();639for (const edge of layout.edges) {640if (edge.fromId && edge.toId) {641let fromEntry = adjacency.get(edge.fromId);642if (!fromEntry) {643fromEntry = { next: [], prev: [] };644adjacency.set(edge.fromId, fromEntry);645}646fromEntry.next.push(edge.toId);647648let toEntry = adjacency.get(edge.toId);649if (!toEntry) {650toEntry = { next: [], prev: [] };651adjacency.set(edge.toId, toEntry);652}653toEntry.prev.push(edge.fromId);654}655}656657return { svg, focusableElements: sortedFocusable, adjacency, positions: positionByKey };658}659660function renderSubgraphs(svg: SVGElement, subgraphs: readonly SubgraphRect[], focusableElements: Map<string, SVGElement>): void {661for (let sgIdx = 0; sgIdx < subgraphs.length; sgIdx++) {662const sg = subgraphs[sgIdx];663const color = SUBGRAPH_COLORS[sg.depth % SUBGRAPH_COLORS.length];664const isCollapsed = sg.collapsedChildCount !== undefined;665const g = document.createElementNS(SVG_NS, 'g');666g.classList.add('chat-debug-flowchart-subgraph');667668const rectAttrs = { x: sg.x, y: sg.y, width: sg.width, height: sg.height, rx: NODE_BORDER_RADIUS, ry: NODE_BORDER_RADIUS };669const clipId = `sg-clip-${sgIdx}`;670671// ClipPath for rounded corners672const clipPath = svgEl('clipPath', { id: clipId });673clipPath.appendChild(svgEl('rect', rectAttrs));674svg.appendChild(clipPath);675676// Filled background677g.appendChild(svgEl('rect', { ...rectAttrs, fill: color, opacity: 0.06 + sg.depth * 0.02 }));678679// Dashed border680g.appendChild(svgEl('rect', { ...rectAttrs, fill: 'none', stroke: color, 'stroke-width': 1, 'stroke-dasharray': '6,3', opacity: 0.5 }));681682// Gutter line683g.appendChild(svgEl('rect', { x: sg.x, y: sg.y, width: GUTTER_WIDTH, height: sg.height, fill: color, opacity: 0.7, 'clip-path': `url(#${clipId})` }));684685// Header group (clickable, keyboard accessible)686const headerGroup = document.createElementNS(SVG_NS, 'g');687headerGroup.setAttribute('data-subgraph-id', sg.nodeId);688headerGroup.classList.add('chat-debug-flowchart-subgraph-header');689headerGroup.setAttribute('tabindex', '0');690headerGroup.setAttribute('role', 'button');691headerGroup.setAttribute('aria-expanded', String(!isCollapsed));692headerGroup.setAttribute('aria-label', `${sg.label}: ${isCollapsed ? 'collapsed' : 'expanded'}${isCollapsed && sg.collapsedChildCount !== undefined ? `, ${sg.collapsedChildCount} items hidden` : ''}`);693694const 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})` });695headerGroup.appendChild(headerBar);696697// Chevron + header label698const chevron = isCollapsed ? '\u25B6' : '\u25BC';699const headerText = svgEl('text', {700x: sg.x + GUTTER_WIDTH + 8,701y: sg.y + SUBGRAPH_HEADER_HEIGHT / 2 + 4,702'font-size': SUBLABEL_FONT_SIZE,703fill: color,704'font-family': 'var(--vscode-font-family, sans-serif)',705'font-weight': '600',706});707headerText.textContent = `${chevron} ${sg.label}`;708headerGroup.appendChild(headerText);709g.appendChild(headerGroup);710focusableElements.set(`sg:${sg.nodeId}`, headerGroup as unknown as SVGElement);711712// Collapsed badge713if (isCollapsed && sg.collapsedChildCount !== undefined) {714const badgeText = svgEl('text', {715x: sg.x + sg.width / 2,716y: sg.y + SUBGRAPH_HEADER_HEIGHT + SUBGRAPH_PADDING + 4,717'font-size': SUBLABEL_FONT_SIZE,718fill: 'var(--vscode-descriptionForeground)',719'font-family': 'var(--vscode-font-family, sans-serif)',720'font-style': 'italic',721'text-anchor': 'middle',722});723badgeText.textContent = `+${sg.collapsedChildCount} items`;724g.appendChild(badgeText);725}726727svg.appendChild(g);728}729}730731function renderEdges(svg: SVGElement, edges: readonly LayoutEdge[]): void {732const strokeAttrs = { fill: 'none', stroke: 'var(--vscode-descriptionForeground)', 'stroke-width': EDGE_STROKE_WIDTH, 'stroke-linecap': 'round' };733// allow-any-unicode-next-line734const r = 6; // corner radius for 90° bends735736for (const edge of edges) {737const midY = (edge.fromY + edge.toY) / 2;738let d: string;739const isHorizontal = edge.fromY === edge.toY;740741if (isHorizontal) {742// Horizontally aligned: straight line (used by expanded merged nodes)743d = `M ${edge.fromX} ${edge.fromY} L ${edge.toX} ${edge.toY}`;744} else if (edge.fromX === edge.toX) {745// Vertically aligned: straight line746d = `M ${edge.fromX} ${edge.fromY} L ${edge.toX} ${edge.toY}`;747} else {748// allow-any-unicode-next-line749// Orthogonal routing: down, 90° horizontal, 90° down750const dx = edge.toX - edge.fromX;751const signX = dx > 0 ? 1 : -1;752const absDx = Math.abs(dx);753const cr = Math.min(r, absDx / 2, (edge.toY - edge.fromY) / 4);754755d = `M ${edge.fromX} ${edge.fromY}`756// Down to first bend757+ ` L ${edge.fromX} ${midY - cr}`758// allow-any-unicode-next-line759// 90° arc turning horizontal760+ ` Q ${edge.fromX} ${midY}, ${edge.fromX + signX * cr} ${midY}`761// Horizontal to second bend762+ ` L ${edge.toX - signX * cr} ${midY}`763// allow-any-unicode-next-line764// 90° arc turning down765+ ` Q ${edge.toX} ${midY}, ${edge.toX} ${midY + cr}`766// Down to target767+ ` L ${edge.toX} ${edge.toY}`;768}769770svg.appendChild(svgEl('path', { ...strokeAttrs, d }));771772// Arrowhead: right-pointing for horizontal edges, down-pointing otherwise773const a = 5;774let arrowD: string;775if (isHorizontal) {776const signX = edge.toX > edge.fromX ? 1 : -1;777arrowD = `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}`;778} else {779arrowD = `M ${edge.toX - a} ${edge.toY - a * 1.5} L ${edge.toX} ${edge.toY} L ${edge.toX + a} ${edge.toY - a * 1.5}`;780}781svg.appendChild(svgEl('path', {782...strokeAttrs,783'stroke-linejoin': 'round',784d: arrowD,785}));786}787}788789function renderNodes(svg: SVGElement, nodes: readonly LayoutNode[], focusableElements: Map<string, SVGElement>): void {790const fontFamily = 'var(--vscode-font-family, sans-serif)';791const nodeFill = 'var(--vscode-editor-background, var(--vscode-editorWidget-background))';792793for (const node of nodes) {794const g = document.createElementNS(SVG_NS, 'g');795g.classList.add('chat-debug-flowchart-node');796g.setAttribute('data-node-id', node.id);797g.setAttribute('tabindex', '0');798g.setAttribute('role', 'img');799800const ariaLabel = node.sublabel ? `${node.label}, ${node.sublabel}` : node.label;801g.setAttribute('aria-label', ariaLabel);802focusableElements.set(node.id, g as unknown as SVGElement);803804if (node.tooltip) {805const title = document.createElementNS(SVG_NS, 'title');806title.textContent = node.tooltip;807g.appendChild(title);808}809810const color = getNodeColor(node.kind, node.isError);811const safeId = node.id.replace(/[^a-zA-Z0-9]/g, '_');812const rectAttrs = { x: node.x, y: node.y, width: node.width, height: node.height, rx: NODE_BORDER_RADIUS, ry: NODE_BORDER_RADIUS };813814// Clip path shared by gutter bar and text815const clipId = `clip-${safeId}`;816const clipPath = svgEl('clipPath', { id: clipId });817clipPath.appendChild(svgEl('rect', rectAttrs));818svg.appendChild(clipPath);819820// Focus ring (hidden by default, shown on :focus via CSS)821const focusOffset = 3;822g.appendChild(svgEl('rect', {823class: 'chat-debug-flowchart-focus-ring',824x: node.x - focusOffset,825y: node.y - focusOffset,826width: node.width + focusOffset * 2,827height: node.height + focusOffset * 2,828rx: NODE_BORDER_RADIUS + focusOffset,829ry: NODE_BORDER_RADIUS + focusOffset,830fill: 'none',831stroke: 'var(--vscode-focusBorder)',832'stroke-width': 2,833}));834835// Node rectangle836g.appendChild(svgEl('rect', { ...rectAttrs, fill: nodeFill, stroke: color, 'stroke-width': node.isError ? 2 : 1.5 }));837838// Kind indicator (colored gutter bar)839g.appendChild(svgEl('rect', { x: node.x, y: node.y, width: 4, height: node.height, fill: color, 'clip-path': `url(#${clipId})` }));840841// Label text842const textX = node.x + NODE_PADDING_H;843const isMessage = isMessageKind(node.kind);844if (isMessage && node.sublabel) {845// Message nodes: small header label + larger message text846const 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})` });847header.textContent = node.label;848g.appendChild(header);849850const 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})` });851msg.textContent = node.sublabel;852g.appendChild(msg);853} else if (node.sublabel) {854const 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})` });855label.textContent = node.label;856g.appendChild(label);857858const 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})` });859sub.textContent = node.sublabel;860g.appendChild(sub);861} else {862const 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})` });863label.textContent = node.label;864g.appendChild(label);865}866867// Merged-discovery expand/collapse toggle on the right side868if (node.mergedCount) {869g.setAttribute('data-is-toggle', 'true');870renderMergedToggle(g, node, color, fontFamily);871}872873svg.appendChild(g);874}875}876877function renderMergedToggle(g: Element, node: LayoutNode, color: string, fontFamily: string): void {878const toggleX = node.x + node.width - MERGED_TOGGLE_WIDTH;879const toggleGroup = document.createElementNS(SVG_NS, 'g');880toggleGroup.classList.add('chat-debug-flowchart-merged-toggle');881toggleGroup.setAttribute('data-merged-id', node.id);882883// Separator line884toggleGroup.appendChild(svgEl('line', {885x1: toggleX, y1: node.y + 4,886x2: toggleX, y2: node.y + node.height - 4,887stroke: 'var(--vscode-descriptionForeground)',888'stroke-width': 0.5,889opacity: 0.4,890}));891892// allow-any-unicode-next-line893// Expand chevron (▶ collapsed, ◀ expanded)894const chevronX = toggleX + MERGED_TOGGLE_WIDTH / 2;895const chevronY = node.y + node.height / 2;896const chevron = svgEl('text', {897x: chevronX,898y: chevronY + 4,899'font-size': 9,900fill: color,901'font-family': fontFamily,902'text-anchor': 'middle',903cursor: 'pointer',904});905// allow-any-unicode-next-line906chevron.textContent = node.isMergedExpanded ? '\u25C0' : '\u25B6'; // ◀ or ▶907toggleGroup.appendChild(chevron);908909// Hit area for the toggle — invisible rect covering the toggle zone910toggleGroup.appendChild(svgEl('rect', {911x: toggleX,912y: node.y,913width: MERGED_TOGGLE_WIDTH,914height: node.height,915fill: 'transparent',916cursor: 'pointer',917}));918919g.appendChild(toggleGroup);920}921922923