Path: blob/main/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.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 * as DOM from '../../../../../base/browser/dom.js';6import { Button } from '../../../../../base/browser/ui/button/button.js';7import { BreadcrumbsWidget } from '../../../../../base/browser/ui/breadcrumbs/breadcrumbsWidget.js';8import { Codicon } from '../../../../../base/common/codicons.js';9import { Emitter } from '../../../../../base/common/event.js';10import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';11import { RunOnceScheduler } from '../../../../../base/common/async.js';12import { URI } from '../../../../../base/common/uri.js';13import { localize } from '../../../../../nls.js';14import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';15import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';16import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';17import { defaultBreadcrumbsWidgetStyles, defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js';18import { FilterWidget } from '../../../../browser/parts/views/viewFilter.js';19import { IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js';20import { IChatService } from '../../common/chatService/chatService.js';21import { LocalChatSessionUri } from '../../common/model/chatUri.js';22import { setupBreadcrumbKeyboardNavigation, TextBreadcrumbItem } from './chatDebugTypes.js';23import { ChatDebugFilterState, bindFilterContextKeys } from './chatDebugFilters.js';24import { buildFlowGraph, filterFlowNodes, sliceFlowNodes, mergeDiscoveryNodes, mergeToolCallNodes, layoutFlowGraph, renderFlowChartSVG, FlowChartRenderResult } from './chatDebugFlowChart.js';25import { ChatDebugDetailPanel } from './chatDebugDetailPanel.js';2627const $ = DOM.$;2829const MIN_SCALE = 0.1;30const MAX_SCALE = 5;31const ZOOM_STEP = 0.15;32const WHEEL_ZOOM_FACTOR = 0.002;33const CLICK_THRESHOLD_SQ = 25;34const PAGE_SIZE = 100;3536export const enum FlowChartNavigation {37Home = 'home',38Overview = 'overview',39}4041export class ChatDebugFlowChartView extends Disposable {4243private readonly _onNavigate = this._register(new Emitter<FlowChartNavigation>());44readonly onNavigate = this._onNavigate.event;4546readonly container: HTMLElement;47private readonly content: HTMLElement;48private readonly breadcrumbWidget: BreadcrumbsWidget;49private readonly filterWidget: FilterWidget;50private readonly headerContainer: HTMLElement;51private readonly loadDisposables = this._register(new DisposableStore());5253// Pan/zoom state54private scale = 1;55private translateX = 0;56private translateY = 0;57private isPanning = false;58private startX = 0;59private startY = 0;6061// Click detection (distinguish click from drag)62private mouseDownX = 0;63private mouseDownY = 0;6465// Direct element references (avoid querySelector)66private svgWrapper: HTMLElement | undefined;67private svgElement: SVGElement | undefined;68private renderResult: FlowChartRenderResult | undefined;6970private currentSessionResource: URI | undefined;71private lastEventCount: number = 0;72private hasUserPanned: boolean = false;7374// Focus state — preserved across re-renders75private focusedElementId: string | undefined;7677// Collapse state — persists across refreshes, resets on session change78private readonly collapsedNodeIds = new Set<string>();7980// Expanded merged-discovery nodes — persists across refreshes, resets on session change81private readonly expandedMergedIds = new Set<string>();8283// Pagination state84private visibleLimit: number = PAGE_SIZE;8586// Detail panel87private readonly detailPanel: ChatDebugDetailPanel;88private eventById = new Map<string, IChatDebugEvent>();89private readonly refreshScheduler: RunOnceScheduler;9091constructor(92parent: HTMLElement,93private readonly filterState: ChatDebugFilterState,94@IChatService private readonly chatService: IChatService,95@IChatDebugService private readonly chatDebugService: IChatDebugService,96@IContextKeyService private readonly contextKeyService: IContextKeyService,97@IInstantiationService private readonly instantiationService: IInstantiationService,98) {99super();100this.container = DOM.append(parent, $('.chat-debug-flowchart'));101DOM.hide(this.container);102103// Breadcrumb104const breadcrumbContainer = DOM.append(this.container, $('.chat-debug-breadcrumb'));105this.breadcrumbWidget = this._register(new BreadcrumbsWidget(breadcrumbContainer, 3, undefined, Codicon.chevronRight, defaultBreadcrumbsWidgetStyles));106this._register(setupBreadcrumbKeyboardNavigation(breadcrumbContainer, this.breadcrumbWidget));107this._register(this.breadcrumbWidget.onDidSelectItem(e => {108if (e.type === 'select' && e.item instanceof TextBreadcrumbItem) {109this.breadcrumbWidget.setSelection(undefined);110const items = this.breadcrumbWidget.getItems();111const idx = items.indexOf(e.item);112if (idx === 0) {113this._onNavigate.fire(FlowChartNavigation.Home);114} else if (idx === 1) {115this._onNavigate.fire(FlowChartNavigation.Overview);116}117}118}));119120// Header with FilterWidget121this.headerContainer = DOM.append(this.container, $('.chat-debug-editor-header'));122const headerContainer = this.headerContainer;123const scopedContextKeyService = this._register(this.contextKeyService.createScoped(headerContainer));124const syncContextKeys = bindFilterContextKeys(this.filterState, scopedContextKeyService);125syncContextKeys();126127const childInstantiationService = this._register(this.instantiationService.createChild(128new ServiceCollection([IContextKeyService, scopedContextKeyService])129));130this.filterWidget = this._register(childInstantiationService.createInstance(FilterWidget, {131placeholder: localize('chatDebug.flowchart.search', "Filter nodes..."),132ariaLabel: localize('chatDebug.flowchart.filterAriaLabel', "Filter flow chart nodes"),133}));134const filterContainer = DOM.append(headerContainer, $('.viewpane-filter-container'));135filterContainer.appendChild(this.filterWidget.element);136137this._register(this.filterWidget.onDidChangeFilterText(text => {138this.filterState.setTextFilter(text);139}));140141// React to shared filter state changes142this._register(this.filterState.onDidChange(() => {143syncContextKeys();144this.filterWidget.checkMoreFilters(!this.filterState.isAllFiltersDefault());145this.visibleLimit = PAGE_SIZE;146// Reset pan/zoom so filtered content is visible147this.hasUserPanned = false;148this.lastEventCount = 0;149this.load();150}));151152// Content wrapper (flex row: chart canvas + detail panel)153const contentWrapper = DOM.append(this.container, $('.chat-debug-flowchart-content-wrapper'));154this.content = DOM.append(contentWrapper, $('.chat-debug-flowchart-content'));155156// Detail panel (sibling of chart canvas)157this.detailPanel = this._register(this.instantiationService.createInstance(ChatDebugDetailPanel, contentWrapper));158159// Set up pan/zoom event listeners and keyboard handling160this.setupPanZoom();161this.setupKeyboard();162163this.refreshScheduler = this._register(new RunOnceScheduler(() => this.load(), 100));164}165166setSession(sessionResource: URI): void {167if (!this.currentSessionResource || this.currentSessionResource.toString() !== sessionResource.toString()) {168// Reset pan/zoom, focus, collapse, and pagination state on session change169this.scale = 1;170this.translateX = 0;171this.translateY = 0;172this.lastEventCount = 0;173this.hasUserPanned = false;174this.focusedElementId = undefined;175this.collapsedNodeIds.clear();176this.expandedMergedIds.clear();177this.visibleLimit = PAGE_SIZE;178this.detailPanel.hide();179}180this.currentSessionResource = sessionResource;181}182183show(): void {184DOM.show(this.container);185this.load();186}187188hide(): void {189DOM.hide(this.container);190this.refreshScheduler.cancel();191}192193refresh(): void {194if (this.container.style.display !== 'none') {195if (!this.refreshScheduler.isScheduled()) {196this.refreshScheduler.schedule();197}198}199}200201updateBreadcrumb(): void {202if (!this.currentSessionResource) {203return;204}205const sessionTitle = this.chatService.getSessionTitle(this.currentSessionResource) || LocalChatSessionUri.parseLocalSessionId(this.currentSessionResource) || this.currentSessionResource.toString();206this.breadcrumbWidget.setItems([207new TextBreadcrumbItem(localize('chatDebug.title', "Agent Debug Logs"), true),208new TextBreadcrumbItem(sessionTitle, true),209new TextBreadcrumbItem(localize('chatDebug.flowChart', "Agent Flow Chart")),210]);211}212213private load(): void {214// Check whether the chart content currently has focus before clearing it,215// so we only restore focus if it was taken away by the re-render.216const hadFocus = DOM.isAncestorOfActiveElement(this.content);217218DOM.clearNode(this.content);219this.loadDisposables.clear();220this.updateBreadcrumb();221222const events = this.chatDebugService.getEvents(this.currentSessionResource);223const isFirstLoad = this.lastEventCount === 0;224this.lastEventCount = events.length;225226// Build event ID → event map for detail panel lookups227this.eventById.clear();228for (const e of events) {229if (e.id) {230this.eventById.set(e.id, e);231}232}233234if (events.length === 0) {235const emptyMsg = DOM.append(this.content, $('.chat-debug-flowchart-empty'));236emptyMsg.textContent = localize('chatDebug.flowChart.noEvents', "No events recorded for this session.");237return;238}239240// Build, filter, slice, and render the flow chart241const flowNodes = buildFlowGraph(events);242const filtered = filterFlowNodes(flowNodes, {243isKindVisible: (kind, category) => this.filterState.isKindVisible(kind, category),244textFilter: this.filterState.textFilter,245});246247if (filtered.length === 0) {248const emptyMsg = DOM.append(this.content, $('.chat-debug-flowchart-empty'));249emptyMsg.textContent = localize('chatDebug.flowChart.noMatches', "No nodes match the current filter.");250return;251}252253const slice = sliceFlowNodes(filtered, this.visibleLimit);254const merged = mergeToolCallNodes(mergeDiscoveryNodes(slice.nodes));255const layout = layoutFlowGraph(merged, { collapsedIds: this.collapsedNodeIds, expandedMergedIds: this.expandedMergedIds });256this.renderResult = renderFlowChartSVG(layout);257258this.svgWrapper = DOM.append(this.content, $('.chat-debug-flowchart-svg-wrapper'));259this.svgWrapper.appendChild(this.renderResult.svg);260this.svgElement = this.renderResult.svg;261262// Show "Show More" button below the chart when there are more nodes263if (slice.shownCount < slice.totalCount) {264const remaining = slice.totalCount - slice.shownCount;265const showMoreContainer = DOM.append(this.svgWrapper, $('.chat-debug-flowchart-show-more'));266const showMoreBtn = this.loadDisposables.add(new Button(showMoreContainer, { ...defaultButtonStyles, secondary: true, title: localize('chatDebug.flowChart.showMoreTitle', "Load more nodes") }));267showMoreBtn.label = localize('chatDebug.flowChart.showMore', "Show More ({0})", remaining);268this.loadDisposables.add(showMoreBtn.onDidClick(() => {269this.visibleLimit += PAGE_SIZE;270this.load();271}));272}273274// Only center on first load when user hasn't panned yet275if (isFirstLoad && !this.hasUserPanned) {276DOM.getWindow(this.content).requestAnimationFrame(() => {277this.centerContent();278});279} else {280// Apply existing transform to preserve position281this.applyTransform();282}283284// Restore focus after re-render only when the chart itself had focus285// before clearNode removed it (e.g. after collapse toggle). Skip when286// focus was elsewhere (detail panel, filter, or outside the chart)287// so that new events arriving don't steal focus.288if (this.focusedElementId && hadFocus && !DOM.isAncestorOfActiveElement(this.headerContainer)) {289this.restoreFocus(this.focusedElementId);290}291}292293private setupPanZoom(): void {294this._register(DOM.addDisposableListener(this.content, DOM.EventType.MOUSE_DOWN, e => this.handleMouseDown(e)));295const targetDocument = DOM.getWindow(this.content).document;296this._register(DOM.addDisposableListener(targetDocument, DOM.EventType.MOUSE_MOVE, e => this.handleMouseMove(e)));297this._register(DOM.addDisposableListener(targetDocument, DOM.EventType.MOUSE_UP, e => this.handleMouseUp(e)));298this._register(DOM.addDisposableListener(this.content, 'wheel', e => this.handleWheel(e), { passive: false }));299}300301private setupKeyboard(): void {302// Track which node/header gets focus303this._register(DOM.addDisposableListener(this.content, DOM.EventType.FOCUS_IN, (e: FocusEvent) => {304const el = e.target as Element | null;305if (!el) {306return;307}308// Check for subgraph header or node309const subgraphId = el.getAttribute?.('data-subgraph-id');310if (subgraphId) {311this.focusedElementId = `sg:${subgraphId}`;312return;313}314const nodeId = el.getAttribute?.('data-node-id');315if (nodeId) {316this.focusedElementId = nodeId;317}318}));319320// Handle keyboard actions321this._register(DOM.addDisposableListener(this.content, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => {322const target = e.target as Element | null;323if (!target) {324return;325}326const subgraphId = target.getAttribute?.('data-subgraph-id');327328switch (e.key) {329case 'Tab': {330// Navigate between flow chart nodes. When at the boundary,331// explicitly move focus to the detail panel (forward) or332// let it leave the chart (backward). We cannot rely on333// natural tab-out because DOM order of SVG elements does334// not match the visual sorted order, which would cause335// focus to jump to a random chart node instead of leaving.336if (this.focusedElementId) {337const moved = this.focusAdjacentElement(this.focusedElementId, e.shiftKey ? -1 : 1);338if (moved) {339e.preventDefault();340} else if (!e.shiftKey && this.detailPanel.isVisible) {341// Forward Tab at end of chart: move to the detail panel342e.preventDefault();343this.detailPanel.focus();344}345} else if (!e.shiftKey) {346e.preventDefault();347this.focusFirstElement();348}349break;350}351case 'Enter':352case ' ':353if (subgraphId) {354e.preventDefault();355e.stopPropagation();356this.detailPanel.hide();357this.toggleSubgraph(subgraphId);358} else {359const nodeId = target.getAttribute?.('data-node-id');360if (nodeId) {361e.preventDefault();362if (target.getAttribute?.('data-is-toggle')) {363this.detailPanel.hide();364this.toggleMergedDiscovery(nodeId);365} else {366const event = this.eventById.get(nodeId);367if (event) {368this.detailPanel.show(event);369}370}371}372}373break;374case 'ArrowDown':375e.preventDefault();376if (this.focusedElementId) {377this.focusEdgeNeighbor(this.focusedElementId, 'next');378} else {379this.focusFirstElement();380}381break;382case 'ArrowRight':383e.preventDefault();384if (this.focusedElementId) {385// Expand collapsed subgraph or merged discovery node,386// then jump focus to the first revealed child.387if (subgraphId && this.collapsedNodeIds.has(subgraphId)) {388this.detailPanel.hide();389this.collapsedNodeIds.delete(subgraphId);390this.focusedElementId = `sg:${subgraphId}`;391this.load();392this.focusFirstChildOf(`sg:${subgraphId}`);393} else if (target.getAttribute?.('data-is-toggle')) {394if (!this.expandedMergedIds.has(this.focusedElementId)) {395// Expand and jump to the first child396this.detailPanel.hide();397const mergedId = this.focusedElementId;398this.expandedMergedIds.add(mergedId);399this.focusedElementId = mergedId;400this.load();401this.focusFirstChildOf(mergedId);402} else {403// Already expanded: jump to the first child404this.focusFirstChildOf(this.focusedElementId);405}406}407} else {408this.focusFirstElement();409}410break;411case 'ArrowUp':412e.preventDefault();413if (this.focusedElementId) {414this.focusEdgeNeighbor(this.focusedElementId, 'prev');415} else {416this.focusFirstElement();417}418break;419case 'ArrowLeft':420e.preventDefault();421if (this.focusedElementId) {422// Collapse expanded subgraph or merged discovery node423if (subgraphId && !this.collapsedNodeIds.has(subgraphId)) {424this.detailPanel.hide();425this.toggleSubgraph(subgraphId);426} else if (target.getAttribute?.('data-is-toggle') && this.expandedMergedIds.has(this.focusedElementId)) {427this.detailPanel.hide();428this.toggleMergedDiscovery(this.focusedElementId);429} else {430// Navigate back to parent (follow edge backward)431this.focusEdgeNeighbor(this.focusedElementId, 'prev');432}433}434break;435case 'Home':436e.preventDefault();437this.focusFirstElement();438break;439case 'End':440e.preventDefault();441this.focusLastElement();442break;443case '=':444case '+':445if (!e.ctrlKey && !e.metaKey) {446e.preventDefault();447this.zoomBy(ZOOM_STEP);448}449break;450case '-':451if (!e.ctrlKey && !e.metaKey) {452e.preventDefault();453this.zoomBy(-ZOOM_STEP);454}455break;456}457}));458}459460private toggleSubgraph(subgraphId: string): void {461if (this.collapsedNodeIds.has(subgraphId)) {462this.collapsedNodeIds.delete(subgraphId);463} else {464this.collapsedNodeIds.add(subgraphId);465}466this.focusedElementId = `sg:${subgraphId}`;467this.load();468}469470private toggleMergedDiscovery(mergedId: string): void {471if (this.expandedMergedIds.has(mergedId)) {472this.expandedMergedIds.delete(mergedId);473} else {474this.expandedMergedIds.add(mergedId);475}476this.focusedElementId = mergedId;477this.load();478}479480private focusFirstElement(): void {481if (!this.renderResult) {482return;483}484const first = this.renderResult.focusableElements.values().next();485if (!first.done) {486(first.value as SVGElement).focus();487}488}489490private focusLastElement(): void {491if (!this.renderResult) {492return;493}494const entries = [...this.renderResult.focusableElements.values()];495if (entries.length > 0) {496(entries[entries.length - 1] as SVGElement).focus();497}498}499500private focusAdjacentElement(currentMapKey: string, direction: 1 | -1): boolean {501if (!this.renderResult) {502return false;503}504const keys = [...this.renderResult.focusableElements.keys()];505const idx = keys.indexOf(currentMapKey);506if (idx === -1) {507return false;508}509const nextIdx = idx + direction;510if (nextIdx < 0 || nextIdx >= keys.length) {511return false;512}513const el = this.renderResult.focusableElements.get(keys[nextIdx]);514if (el) {515(el as SVGElement).focus();516return true;517}518return false;519}520521private focusEdgeNeighbor(currentId: string, direction: 'next' | 'prev'): boolean {522if (!this.renderResult) {523return false;524}525const entry = this.renderResult.adjacency.get(currentId);526const neighbors = entry?.[direction];527if (!neighbors || neighbors.length === 0) {528return false;529}530// Focus the first neighbor that has a focusable element531for (const id of neighbors) {532const el = this.renderResult.focusableElements.get(id);533if (el) {534(el as SVGElement).focus();535return true;536}537}538return false;539}540541private focusFirstChildOf(parentId: string): void {542if (!this.renderResult) {543return;544}545const entry = this.renderResult.adjacency.get(parentId);546if (!entry?.next || entry.next.length === 0) {547return;548}549// Prefer a neighbor positioned to the right of the parent550// (expanded child) over one below (next in main flow).551const parentPos = this.renderResult.positions.get(parentId);552let bestId: string | undefined;553for (const id of entry.next) {554if (!this.renderResult.focusableElements.has(id)) {555continue;556}557if (!bestId) {558bestId = id;559}560if (parentPos) {561const pos = this.renderResult.positions.get(id);562if (pos && pos.x > parentPos.x) {563bestId = id;564break;565}566}567}568if (bestId) {569const el = this.renderResult.focusableElements.get(bestId);570if (el) {571this.focusedElementId = bestId;572(el as SVGElement).focus();573}574}575}576577private restoreFocus(elementId: string): void {578const el = this.renderResult?.focusableElements.get(elementId);579if (el) {580el.focus();581}582}583584private zoomBy(delta: number): void {585const rect = this.content.getBoundingClientRect();586const centerX = rect.width / 2;587const centerY = rect.height / 2;588const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, this.scale * (1 + delta)));589const scaleFactor = newScale / this.scale;590this.translateX = centerX - (centerX - this.translateX) * scaleFactor;591this.translateY = centerY - (centerY - this.translateY) * scaleFactor;592this.scale = newScale;593this.hasUserPanned = true;594this.applyTransform();595}596597private handleMouseDown(e: MouseEvent): void {598if (e.button !== 0) {599return;600}601e.preventDefault();602this.isPanning = true;603this.hasUserPanned = true;604this.startX = e.clientX - this.translateX;605this.startY = e.clientY - this.translateY;606this.mouseDownX = e.clientX;607this.mouseDownY = e.clientY;608this.content.style.cursor = 'grabbing';609}610611private handleMouseMove(e: MouseEvent): void {612if (!this.isPanning) {613return;614}615if (e.buttons === 0) {616this.handleMouseUp(e);617return;618}619this.translateX = e.clientX - this.startX;620this.translateY = e.clientY - this.startY;621this.applyTransform();622}623624private handleMouseUp(e: MouseEvent): void {625if (this.isPanning) {626this.isPanning = false;627this.content.style.cursor = 'grab';628629// Detect click (not a drag) — distance < 5px630const dx = e.clientX - this.mouseDownX;631const dy = e.clientY - this.mouseDownY;632if (dx * dx + dy * dy < CLICK_THRESHOLD_SQ) {633this.handleClick(e);634}635}636}637638private handleClick(e: MouseEvent): void {639// Walk up from the click target to find a focusable element640let target = e.target as Element | null;641while (target && target !== this.content) {642// Merged-discovery expand toggle643const mergedId = target.getAttribute?.('data-merged-id');644if (mergedId) {645this.detailPanel.hide();646this.toggleMergedDiscovery(mergedId);647return;648}649const subgraphId = target.getAttribute?.('data-subgraph-id');650if (subgraphId) {651this.detailPanel.hide();652this.toggleSubgraph(subgraphId);653return;654}655const nodeId = target.getAttribute?.('data-node-id');656if (nodeId) {657(target as HTMLElement).focus();658if (target.getAttribute?.('data-is-toggle')) {659this.detailPanel.hide();660this.toggleMergedDiscovery(nodeId);661} else {662const event = this.eventById.get(nodeId);663if (event) {664this.detailPanel.show(event);665}666}667return;668}669target = target.parentElement;670}671}672673private handleWheel(e: WheelEvent): void {674e.preventDefault();675e.stopPropagation();676677this.hasUserPanned = true;678679const rect = this.content.getBoundingClientRect();680const mouseX = e.clientX - rect.left;681const mouseY = e.clientY - rect.top;682683const delta = -e.deltaY * WHEEL_ZOOM_FACTOR;684const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, this.scale * (1 + delta)));685686const scaleFactor = newScale / this.scale;687this.translateX = mouseX - (mouseX - this.translateX) * scaleFactor;688this.translateY = mouseY - (mouseY - this.translateY) * scaleFactor;689this.scale = newScale;690691this.applyTransform();692}693694private applyTransform(): void {695if (this.svgWrapper) {696this.svgWrapper.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`;697}698}699700private centerContent(): void {701const containerRect = this.content.getBoundingClientRect();702if (!this.svgElement) {703return;704}705const svgWidth = parseFloat(this.svgElement.getAttribute('width') || '0');706const svgHeight = parseFloat(this.svgElement.getAttribute('height') || '0');707if (svgWidth <= 0 || svgHeight <= 0) {708return;709}710711const PADDING = 20;712// Pin the top of the diagram near the top of the viewport so the start713// of the flow is immediately visible. Center horizontally when the714// diagram fits; otherwise align to the left edge with padding so715// nothing is clipped behind overflow:hidden.716this.translateX = Math.max(PADDING, (containerRect.width - svgWidth) / 2);717this.translateY = PADDING;718this.applyTransform();719}720}721722723