Path: blob/main/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.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 { Orientation, Sash, SashState } from '../../../../../base/browser/ui/sash/sash.js';8import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js';9import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js';10import { Codicon } from '../../../../../base/common/codicons.js';11import { Emitter } from '../../../../../base/common/event.js';12import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';13import { localize } from '../../../../../nls.js';14import { ILanguageService } from '../../../../../editor/common/languages/language.js';15import { IModelService } from '../../../../../editor/common/services/model.js';16import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js';17import { IHoverService } from '../../../../../platform/hover/browser/hover.js';18import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';19import { ILabelService } from '../../../../../platform/label/common/label.js';20import { IOpenerService } from '../../../../../platform/opener/common/opener.js';21import { IUntitledTextResourceEditorInput } from '../../../../common/editor.js';22import { IEditorService } from '../../../../services/editor/common/editorService.js';23import { IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js';24import { formatEventDetail } from './chatDebugEventDetailRenderer.js';25import { renderCustomizationDiscoveryContent, fileListToPlainText, renderCustomizationSummaryContent, customizationSummaryToPlainText } from './chatCustomizationDiscoveryRenderer.js';26import { renderUserMessageContent, renderAgentResponseContent, messageEventToPlainText, renderResolvedMessageContent, resolvedMessageToPlainText } from './chatDebugMessageContentRenderer.js';27import { renderToolCallContent, toolCallContentToPlainText } from './chatDebugToolCallContentRenderer.js';28import { renderModelTurnContent, modelTurnContentToPlainText } from './chatDebugModelTurnContentRenderer.js';29import { renderHookContent, hookContentToPlainText } from './chatDebugHookContentRenderer.js';3031const $ = DOM.$;3233const DETAIL_PANEL_DEFAULT_WIDTH = 350;34const DETAIL_PANEL_MIN_WIDTH = 200;35const DETAIL_PANEL_MAX_WIDTH = 800;3637/**38* Reusable detail panel that resolves and displays the content of a39* single {@link IChatDebugEvent}. Used by both the logs view and the40* flow chart view.41*/42export class ChatDebugDetailPanel extends Disposable {4344private readonly _onDidHide = this._register(new Emitter<void>());45readonly onDidHide = this._onDidHide.event;4647private readonly _onDidChangeWidth = this._register(new Emitter<number>());48readonly onDidChangeWidth = this._onDidChangeWidth.event;4950readonly element: HTMLElement;51private readonly contentContainer: HTMLElement;52private readonly scrollable: DomScrollableElement;53private readonly sash: Sash;54private headerElement: HTMLElement | undefined;55private readonly detailDisposables = this._register(new DisposableStore());56private currentDetailText: string = '';57private currentDetailEventId: string | undefined;58private firstFocusableElement: HTMLElement | undefined;59private _width: number = DETAIL_PANEL_DEFAULT_WIDTH;6061get width(): number {62return this._width;63}6465constructor(66parent: HTMLElement,67@IChatDebugService private readonly chatDebugService: IChatDebugService,68@IInstantiationService private readonly instantiationService: IInstantiationService,69@IEditorService private readonly editorService: IEditorService,70@IClipboardService private readonly clipboardService: IClipboardService,71@IHoverService private readonly hoverService: IHoverService,72@IOpenerService private readonly openerService: IOpenerService,73@ILanguageService private readonly languageService: ILanguageService,74) {75super();76this.element = DOM.append(parent, $('.chat-debug-detail-panel'));77this.contentContainer = $('.chat-debug-detail-content');78this.scrollable = this._register(new DomScrollableElement(this.contentContainer, {79horizontal: ScrollbarVisibility.Hidden,80vertical: ScrollbarVisibility.Auto,81}));82this.element.style.width = `${this._width}px`;83DOM.hide(this.element);8485// Sash on the parent container, positioned at the left edge of the detail panel86this.sash = this._register(new Sash(parent, {87getVerticalSashLeft: () => parent.offsetWidth - this._width,88}, { orientation: Orientation.VERTICAL }));89this.sash.state = SashState.Disabled;9091let sashStartWidth: number | undefined;92this._register(this.sash.onDidStart(() => sashStartWidth = this._width));93this._register(this.sash.onDidEnd(() => {94sashStartWidth = undefined;95this.sash.layout();96}));97this._register(this.sash.onDidChange(e => {98if (sashStartWidth === undefined) {99return;100}101// Dragging left (negative currentX delta) should increase width102const delta = e.startX - e.currentX;103const newWidth = Math.max(DETAIL_PANEL_MIN_WIDTH, Math.min(DETAIL_PANEL_MAX_WIDTH, sashStartWidth + delta));104this._width = newWidth;105this.element.style.width = `${newWidth}px`;106this.sash.layout();107this._onDidChangeWidth.fire(newWidth);108}));109110// Handle Ctrl+A / Cmd+A to select all within the detail panel111this._register(DOM.addDisposableListener(this.element, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => {112if ((e.ctrlKey || e.metaKey) && e.key === 'a') {113const target = e.target as HTMLElement | null;114if (target && this.element.contains(target)) {115e.preventDefault();116const targetWindow = DOM.getWindow(target);117const selection = targetWindow.getSelection();118if (selection) {119const range = targetWindow.document.createRange();120range.selectNodeContents(target);121selection.removeAllRanges();122selection.addRange(range);123}124}125}126}));127}128129async show(event: IChatDebugEvent): Promise<void> {130// Skip re-rendering if we're already showing this event's detail131if (event.id && event.id === this.currentDetailEventId) {132return;133}134this.currentDetailEventId = event.id;135136const resolved = event.id ? await this.chatDebugService.resolveEvent(event.id) : undefined;137138DOM.show(this.element);139this.sash.state = SashState.Enabled;140this.sash.layout();141DOM.clearNode(this.element);142DOM.clearNode(this.contentContainer);143this.detailDisposables.clear();144145// Header with action buttons146const header = DOM.append(this.element, $('.chat-debug-detail-header'));147this.headerElement = header;148this.element.appendChild(this.scrollable.getDomNode());149150const fullScreenButton = this.detailDisposables.add(new Button(header, { ariaLabel: localize('chatDebug.openInEditor', "Open in Editor"), title: localize('chatDebug.openInEditor', "Open in Editor") }));151fullScreenButton.element.classList.add('chat-debug-detail-button');152fullScreenButton.icon = Codicon.goToFile;153this.firstFocusableElement = fullScreenButton.element;154this.detailDisposables.add(fullScreenButton.onDidClick(() => {155this.editorService.openEditor({ contents: this.currentDetailText, resource: undefined } satisfies IUntitledTextResourceEditorInput);156}));157158const copyButton = this.detailDisposables.add(new Button(header, { ariaLabel: localize('chatDebug.copyToClipboard', "Copy"), title: localize('chatDebug.copyToClipboard', "Copy") }));159copyButton.element.classList.add('chat-debug-detail-button');160copyButton.icon = Codicon.copy;161this.detailDisposables.add(copyButton.onDidClick(() => {162this.clipboardService.writeText(this.currentDetailText);163}));164165const closeButton = this.detailDisposables.add(new Button(header, { ariaLabel: localize('chatDebug.closeDetail', "Close"), title: localize('chatDebug.closeDetail', "Close") }));166closeButton.element.classList.add('chat-debug-detail-button');167closeButton.icon = Codicon.close;168this.detailDisposables.add(closeButton.onDidClick(() => {169this.hide();170}));171172if (resolved && resolved.kind === 'fileList') {173this.currentDetailText = fileListToPlainText(resolved);174const { element: contentEl, disposables: contentDisposables } = this.instantiationService.invokeFunction(accessor =>175renderCustomizationDiscoveryContent(resolved, this.openerService, accessor.get(IModelService), this.languageService, this.hoverService, accessor.get(ILabelService), this.scrollable)176);177this.detailDisposables.add(contentDisposables);178this.contentContainer.appendChild(contentEl);179} else if (resolved && resolved.kind === 'customizationSummary') {180this.currentDetailText = customizationSummaryToPlainText(resolved);181const { element: contentEl, disposables: contentDisposables } = this.instantiationService.invokeFunction(accessor =>182renderCustomizationSummaryContent(resolved, this.openerService, accessor.get(IModelService), this.languageService, this.hoverService, accessor.get(ILabelService), this.scrollable)183);184this.detailDisposables.add(contentDisposables);185this.contentContainer.appendChild(contentEl);186} else if (resolved && resolved.kind === 'toolCall') {187this.currentDetailText = toolCallContentToPlainText(resolved);188const { element: contentEl, disposables: contentDisposables } = await renderToolCallContent(resolved, this.languageService, this.clipboardService, this.scrollable);189if (this.currentDetailEventId !== event.id) {190// Another event was selected while we were rendering191contentDisposables.dispose();192return;193}194this.detailDisposables.add(contentDisposables);195this.contentContainer.appendChild(contentEl);196} else if (resolved && resolved.kind === 'message') {197this.currentDetailText = resolvedMessageToPlainText(resolved);198const { element: contentEl, disposables: contentDisposables } = await renderResolvedMessageContent(resolved, this.languageService, this.clipboardService, this.scrollable);199if (this.currentDetailEventId !== event.id) {200contentDisposables.dispose();201return;202}203this.detailDisposables.add(contentDisposables);204this.contentContainer.appendChild(contentEl);205} else if (resolved && resolved.kind === 'modelTurn') {206this.currentDetailText = modelTurnContentToPlainText(resolved);207const { element: contentEl, disposables: contentDisposables } = await renderModelTurnContent(resolved, this.languageService, this.clipboardService, this.scrollable);208if (this.currentDetailEventId !== event.id) {209// Another event was selected while we were rendering210contentDisposables.dispose();211return;212}213this.detailDisposables.add(contentDisposables);214this.contentContainer.appendChild(contentEl);215} else if (resolved && resolved.kind === 'hook') {216this.currentDetailText = hookContentToPlainText(resolved);217const { element: contentEl, disposables: contentDisposables } = await renderHookContent(resolved, this.languageService, this.clipboardService, this.scrollable);218if (this.currentDetailEventId !== event.id) {219// Another event was selected while we were rendering220contentDisposables.dispose();221return;222}223this.detailDisposables.add(contentDisposables);224this.contentContainer.appendChild(contentEl);225} else if (event.kind === 'userMessage') {226this.currentDetailText = messageEventToPlainText(event);227const { element: contentEl, disposables: contentDisposables } = await renderUserMessageContent(event, this.languageService, this.clipboardService, this.scrollable);228if (this.currentDetailEventId !== event.id) {229contentDisposables.dispose();230return;231}232this.detailDisposables.add(contentDisposables);233this.contentContainer.appendChild(contentEl);234} else if (event.kind === 'agentResponse') {235this.currentDetailText = messageEventToPlainText(event);236const { element: contentEl, disposables: contentDisposables } = await renderAgentResponseContent(event, this.languageService, this.clipboardService, this.scrollable);237if (this.currentDetailEventId !== event.id) {238contentDisposables.dispose();239return;240}241this.detailDisposables.add(contentDisposables);242this.contentContainer.appendChild(contentEl);243} else {244const pre = DOM.append(this.contentContainer, $('pre'));245pre.tabIndex = 0;246if (resolved) {247this.currentDetailText = resolved.value;248} else {249this.currentDetailText = formatEventDetail(event);250}251pre.textContent = this.currentDetailText;252}253254// Compute height from the parent container and set explicit255// dimensions so the scrollable element can show proper scrollbars.256const parentHeight = this.element.parentElement?.clientHeight ?? 0;257if (parentHeight > 0) {258this.layout(parentHeight);259} else {260this.scrollable.scanDomNode();261}262}263264get isVisible(): boolean {265return this.element.style.display !== 'none';266}267268focus(): void {269this.firstFocusableElement?.focus();270}271272/**273* Set explicit dimensions on the scrollable element so the scrollbar274* can compute its size. Call after the panel is shown and whenever275* the available space changes.276*/277layout(height: number): void {278const headerHeight = this.headerElement?.offsetHeight ?? 0;279const scrollableHeight = Math.max(0, height - headerHeight);280// Preserve scroll position across layout changes (e.g. when opening281// an editor causes the workbench to re-layout this panel).282const scrollPos = this.scrollable.getScrollPosition();283this.contentContainer.style.height = `${scrollableHeight}px`;284this.scrollable.scanDomNode();285this.scrollable.setScrollPosition({ scrollTop: scrollPos.scrollTop });286this.sash.layout();287}288289layoutSash(): void {290this.sash.layout();291}292293hide(): void {294this.currentDetailEventId = undefined;295this.firstFocusableElement = undefined;296this.headerElement = undefined;297DOM.hide(this.element);298this.sash.state = SashState.Disabled;299DOM.clearNode(this.element);300DOM.clearNode(this.contentContainer);301this.detailDisposables.clear();302this._onDidHide.fire();303}304}305306307