Files
vscode/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts
Vijay Upadya 34bfd71aea Revert "Revert "Debug Panel: oTel data source support and Import/export (#299256)"" (#300477)
Revert "Revert "Debug Panel: oTel data source support and Import/export (#299…"

This reverts commit 11246017b6.
2026-03-10 17:32:58 +00:00

553 lines
21 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as DOM from '../../../../../base/browser/dom.js';
import { Dimension } from '../../../../../base/browser/dom.js';
import { BreadcrumbsWidget } from '../../../../../base/browser/ui/breadcrumbs/breadcrumbsWidget.js';
import { Button } from '../../../../../base/browser/ui/button/button.js';
import { IObjectTreeElement } from '../../../../../base/browser/ui/tree/tree.js';
import { Codicon } from '../../../../../base/common/codicons.js';
import { Emitter } from '../../../../../base/common/event.js';
import { combinedDisposable, Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js';
import { autorun } from '../../../../../base/common/observable.js';
import { RunOnceScheduler } from '../../../../../base/common/async.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { URI } from '../../../../../base/common/uri.js';
import { localize } from '../../../../../nls.js';
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';
import { WorkbenchList, WorkbenchObjectTree } from '../../../../../platform/list/browser/listService.js';
import { defaultBreadcrumbsWidgetStyles, defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js';
import { FilterWidget } from '../../../../browser/parts/views/viewFilter.js';
import { IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js';
import { IChatService } from '../../common/chatService/chatService.js';
import { LocalChatSessionUri } from '../../common/model/chatUri.js';
import { ChatDebugEventRenderer, ChatDebugEventDelegate, ChatDebugEventTreeRenderer } from './chatDebugEventList.js';
import { setupBreadcrumbKeyboardNavigation, TextBreadcrumbItem, LogsViewMode } from './chatDebugTypes.js';
import { ChatDebugFilterState, bindFilterContextKeys } from './chatDebugFilters.js';
import { ChatDebugDetailPanel } from './chatDebugDetailPanel.js';
import { IChatWidgetService } from '../chat.js';
import { createDebugEventsAttachment } from './chatDebugAttachment.js';
const $ = DOM.$;
export const enum LogsNavigation {
Home = 'home',
Overview = 'overview',
}
export class ChatDebugLogsView extends Disposable {
private readonly _onNavigate = this._register(new Emitter<LogsNavigation>());
readonly onNavigate = this._onNavigate.event;
readonly container: HTMLElement;
private readonly breadcrumbWidget: BreadcrumbsWidget;
private readonly headerContainer: HTMLElement;
private readonly tableHeader: HTMLElement;
private readonly bodyContainer: HTMLElement;
private readonly listContainer: HTMLElement;
private readonly treeContainer: HTMLElement;
private readonly detailPanel: ChatDebugDetailPanel;
private readonly filterWidget: FilterWidget;
private readonly viewModeToggle: Button;
private list: WorkbenchList<IChatDebugEvent>;
private tree: WorkbenchObjectTree<IChatDebugEvent, void>;
private currentSessionResource: URI | undefined;
private logsViewMode: LogsViewMode = LogsViewMode.List;
private events: IChatDebugEvent[] = [];
private currentDimension: Dimension | undefined;
private readonly eventListener = this._register(new MutableDisposable());
private readonly sessionStateDisposable = this._register(new MutableDisposable());
private readonly refreshScheduler: RunOnceScheduler;
private shimmerRow!: HTMLElement;
constructor(
parent: HTMLElement,
private readonly filterState: ChatDebugFilterState,
@IChatService private readonly chatService: IChatService,
@IChatDebugService private readonly chatDebugService: IChatDebugService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IContextKeyService private readonly contextKeyService: IContextKeyService,
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
) {
super();
this.refreshScheduler = this._register(new RunOnceScheduler(() => this.refreshList(), 50));
this.container = DOM.append(parent, $('.chat-debug-logs'));
DOM.hide(this.container);
// Breadcrumb
const breadcrumbContainer = DOM.append(this.container, $('.chat-debug-breadcrumb'));
this.breadcrumbWidget = this._register(new BreadcrumbsWidget(breadcrumbContainer, 3, undefined, Codicon.chevronRight, defaultBreadcrumbsWidgetStyles));
this._register(setupBreadcrumbKeyboardNavigation(breadcrumbContainer, this.breadcrumbWidget));
this._register(this.breadcrumbWidget.onDidSelectItem(e => {
if (e.type === 'select' && e.item instanceof TextBreadcrumbItem) {
this.breadcrumbWidget.setSelection(undefined);
const items = this.breadcrumbWidget.getItems();
const idx = items.indexOf(e.item);
if (idx === 0) {
this._onNavigate.fire(LogsNavigation.Home);
} else if (idx === 1) {
this._onNavigate.fire(LogsNavigation.Overview);
}
}
}));
// Header (filter)
this.headerContainer = DOM.append(this.container, $('.chat-debug-editor-header'));
// Scoped context key service for filter menu items
const scopedContextKeyService = this._register(this.contextKeyService.createScoped(this.headerContainer));
const syncContextKeys = bindFilterContextKeys(this.filterState, scopedContextKeyService);
syncContextKeys();
const childInstantiationService = this._register(this.instantiationService.createChild(
new ServiceCollection([IContextKeyService, scopedContextKeyService])
));
this.filterWidget = this._register(childInstantiationService.createInstance(FilterWidget, {
placeholder: localize('chatDebug.search', "Filter (e.g. text, !exclude, before:YYYY-MM-DDTHH:MM:SS)"),
ariaLabel: localize('chatDebug.filterAriaLabel', "Filter debug events"),
}));
// View mode toggle
this.viewModeToggle = this._register(new Button(this.headerContainer, { ...defaultButtonStyles, secondary: true, title: localize('chatDebug.toggleViewMode', "Toggle between list and tree view") }));
this.viewModeToggle.element.classList.add('chat-debug-view-mode-toggle', 'monaco-text-button');
this.updateViewModeToggle();
this._register(this.viewModeToggle.onDidClick(() => {
this.toggleViewMode();
}));
const filterContainer = DOM.append(this.headerContainer, $('.viewpane-filter-container'));
filterContainer.appendChild(this.filterWidget.element);
// Troubleshoot button
const troubleshootButton = this._register(new Button(this.headerContainer, { ...defaultButtonStyles, secondary: true, title: localize('chatDebug.troubleshoot', "Add snapshot to Chat") }));
troubleshootButton.element.classList.add('chat-debug-troubleshoot-button', 'monaco-text-button');
DOM.append(troubleshootButton.element, $(`span${ThemeIcon.asCSSSelector(Codicon.chatSparkle)}`));
this._register(troubleshootButton.onDidClick(async () => {
if (!this.currentSessionResource) {
return;
}
const widget = await this.chatWidgetService.openSession(this.currentSessionResource);
if (widget) {
const attachment = await createDebugEventsAttachment(this.currentSessionResource, this.chatDebugService);
widget.attachmentModel.addContext(attachment);
widget.focusInput();
}
}));
this._register(this.filterWidget.onDidChangeFilterText(text => {
this.filterState.setTextFilter(text);
}));
// React to shared filter state changes
this._register(this.filterState.onDidChange(() => {
syncContextKeys();
this.updateMoreFiltersChecked();
this.refreshList();
}));
// Content wrapper (flex row: main column + detail panel)
const contentContainer = DOM.append(this.container, $('.chat-debug-logs-content'));
// Main column (table header + list/tree body)
const mainColumn = DOM.append(contentContainer, $('.chat-debug-logs-main'));
// Table header
this.tableHeader = DOM.append(mainColumn, $('.chat-debug-table-header'));
DOM.append(this.tableHeader, $('span.chat-debug-col-created', undefined, localize('chatDebug.col.created', "Created")));
DOM.append(this.tableHeader, $('span.chat-debug-col-name', undefined, localize('chatDebug.col.name', "Name")));
DOM.append(this.tableHeader, $('span.chat-debug-col-details', undefined, localize('chatDebug.col.details', "Details")));
// Body container
this.bodyContainer = DOM.append(mainColumn, $('.chat-debug-logs-body'));
// List container
this.listContainer = DOM.append(this.bodyContainer, $('.chat-debug-list-container'));
const accessibilityProvider = {
getAriaLabel: (e: IChatDebugEvent) => {
switch (e.kind) {
case 'toolCall': return localize('chatDebug.aria.toolCall', "Tool call: {0}{1}", e.toolName, e.result ? ` (${e.result})` : '');
case 'modelTurn': return localize('chatDebug.aria.modelTurn', "Model turn: {0}{1}", e.model ?? localize('chatDebug.aria.model', "model"), e.totalTokens ? localize('chatDebug.aria.tokenCount', " {0} tokens", e.totalTokens) : '');
case 'generic': return `${e.category ? e.category + ': ' : ''}${e.name}: ${e.details ?? ''}`;
case 'subagentInvocation': return localize('chatDebug.aria.subagent', "Subagent: {0}{1}", e.agentName, e.description ? ` - ${e.description}` : '');
case 'userMessage': return localize('chatDebug.aria.userMessage', "User message: {0}", e.message);
case 'agentResponse': return localize('chatDebug.aria.agentResponse', "Agent response: {0}", e.message);
}
},
getWidgetAriaLabel: () => localize('chatDebug.ariaLabel', "Chat Debug Events"),
};
let nextFallbackId = 0;
const fallbackIds = new WeakMap<IChatDebugEvent, string>();
const identityProvider = {
getId: (e: IChatDebugEvent) => {
if (e.id) {
return e.id;
}
let fallback = fallbackIds.get(e);
if (!fallback) {
fallback = `_fallback_${nextFallbackId++}`;
fallbackIds.set(e, fallback);
}
return fallback;
}
};
this.list = this._register(this.instantiationService.createInstance(
WorkbenchList<IChatDebugEvent>,
'ChatDebugEvents',
this.listContainer,
new ChatDebugEventDelegate(),
[new ChatDebugEventRenderer()],
{ identityProvider, accessibilityProvider }
));
// Tree container (initially hidden)
this.treeContainer = DOM.append(this.bodyContainer, $('.chat-debug-list-container'));
DOM.hide(this.treeContainer);
this.tree = this._register(this.instantiationService.createInstance(
WorkbenchObjectTree<IChatDebugEvent, void>,
'ChatDebugEventsTree',
this.treeContainer,
new ChatDebugEventDelegate(),
[new ChatDebugEventTreeRenderer()],
{ identityProvider, accessibilityProvider }
));
// Shimmer row (positioned right below last row to indicate session is running)
this.shimmerRow = DOM.append(this.bodyContainer, $('.chat-debug-logs-shimmer-row'));
this.shimmerRow.setAttribute('aria-label', localize('chatDebug.loadingMore', "Loading more events…"));
this.shimmerRow.setAttribute('aria-busy', 'true');
DOM.append(this.shimmerRow, $('span.chat-debug-logs-shimmer-bar'));
DOM.hide(this.shimmerRow);
// Detail panel (sibling of main column so it aligns with table header)
this.detailPanel = this._register(this.instantiationService.createInstance(ChatDebugDetailPanel, contentContainer));
this._register(this.detailPanel.onDidHide(() => {
if (this.list.getSelection().length > 0) {
this.list.setSelection([]);
}
if (this.tree.getSelection().length > 0) {
this.tree.setSelection([]);
}
}));
// Resolve event details on selection
this._register(this.list.onDidChangeSelection(e => {
const selected = e.elements[0];
if (selected) {
this.detailPanel.show(selected);
} else {
this.detailPanel.hide();
}
}));
this._register(this.tree.onDidChangeSelection(e => {
const selected = e.elements[0];
if (selected) {
this.detailPanel.show(selected);
} else {
this.detailPanel.hide();
}
}));
}
setSession(sessionResource: URI): void {
this.currentSessionResource = sessionResource;
}
setFilterText(text: string): void {
this.filterWidget.setFilterText(text);
}
show(): void {
DOM.show(this.container);
this.loadEvents();
this.refreshList();
}
hide(): void {
DOM.hide(this.container);
}
focus(): void {
if (this.logsViewMode === LogsViewMode.Tree) {
this.tree.domFocus();
} else {
this.list.domFocus();
}
}
updateBreadcrumb(): void {
if (!this.currentSessionResource) {
return;
}
const sessionTitle = this.chatService.getSessionTitle(this.currentSessionResource) || LocalChatSessionUri.parseLocalSessionId(this.currentSessionResource) || this.currentSessionResource.toString();
this.breadcrumbWidget.setItems([
new TextBreadcrumbItem(localize('chatDebug.title', "Agent Debug Panel"), true),
new TextBreadcrumbItem(sessionTitle, true),
new TextBreadcrumbItem(localize('chatDebug.logs', "Logs")),
]);
}
layout(dimension: Dimension): void {
this.currentDimension = dimension;
const breadcrumbHeight = 22;
const headerHeight = this.headerContainer.offsetHeight;
const tableHeaderHeight = this.tableHeader.offsetHeight;
const detailVisible = this.detailPanel.element.style.display !== 'none';
const detailWidth = detailVisible ? this.detailPanel.element.offsetWidth : 0;
const listHeight = dimension.height - breadcrumbHeight - headerHeight - tableHeaderHeight;
const listWidth = dimension.width - detailWidth;
if (this.logsViewMode === LogsViewMode.Tree) {
this.tree.layout(listHeight, listWidth);
} else {
this.list.layout(listHeight, listWidth);
}
}
refreshList(): void {
let filtered = this.events;
// Filter by kind toggles (pass category for generic events so only
// discovery-category events are affected by the Prompt Discovery toggle)
filtered = filtered.filter(e => {
const category = e.kind === 'generic' ? e.category : undefined;
return this.filterState.isKindVisible(e.kind, category);
});
// Filter by timestamp (before:/after: syntax)
filtered = filtered.filter(e => this.filterState.isTimestampVisible(e.created));
// Filter by text search (excluding before:/after: tokens)
const filterText = this.filterState.textFilterWithoutTimestamps;
if (filterText) {
const terms = filterText.split(/\s*,\s*/).filter(t => t.length > 0);
const includeTerms = terms.filter(t => !t.startsWith('!')).map(t => t.trim());
const excludeTerms = terms.filter(t => t.startsWith('!')).map(t => t.slice(1).trim()).filter(t => t.length > 0);
filtered = filtered.filter(e => {
const matchesText = (term: string): boolean => {
if (e.kind.toLowerCase().includes(term)) {
return true;
}
switch (e.kind) {
case 'toolCall':
return e.toolName.toLowerCase().includes(term) ||
(e.input?.toLowerCase().includes(term) ?? false) ||
(e.output?.toLowerCase().includes(term) ?? false);
case 'modelTurn':
return (e.model?.toLowerCase().includes(term) ?? false);
case 'generic':
return e.name.toLowerCase().includes(term) ||
(e.details?.toLowerCase().includes(term) ?? false) ||
(e.category?.toLowerCase().includes(term) ?? false);
case 'subagentInvocation':
return e.agentName.toLowerCase().includes(term) ||
(e.description?.toLowerCase().includes(term) ?? false);
case 'userMessage':
return e.message.toLowerCase().includes(term) ||
e.sections.some(s => s.name.toLowerCase().includes(term) || s.content.toLowerCase().includes(term));
case 'agentResponse':
return e.message.toLowerCase().includes(term) ||
e.sections.some(s => s.name.toLowerCase().includes(term) || s.content.toLowerCase().includes(term));
}
};
// Exclude terms: if any exclude term matches, filter out the event
if (excludeTerms.some(term => matchesText(term))) {
return false;
}
// Include terms: if present, at least one must match
if (includeTerms.length > 0) {
return includeTerms.some(term => matchesText(term));
}
return true;
});
}
if (this.logsViewMode === LogsViewMode.List) {
this.list.splice(0, this.list.length, filtered);
} else {
this.refreshTree(filtered);
}
this.updateShimmerPosition(filtered.length);
}
private updateShimmerPosition(itemCount: number): void {
this.shimmerRow.style.top = `${itemCount * 28}px`;
}
addEvent(event: IChatDebugEvent): void {
// Binary-insert to maintain chronological order without a full sort.
// Events almost always arrive in order, so the insertion point is
// typically at the end (O(log n) comparison, O(1) splice).
const time = event.created.getTime();
let lo = 0;
let hi = this.events.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (this.events[mid].created.getTime() <= time) {
lo = mid + 1;
} else {
hi = mid;
}
}
if (lo === this.events.length) {
this.events.push(event);
} else {
this.events.splice(lo, 0, event);
}
this.scheduleRefresh();
}
private scheduleRefresh(): void {
if (!this.refreshScheduler.isScheduled()) {
this.refreshScheduler.schedule();
}
}
private loadEvents(): void {
this.events = [...this.chatDebugService.getEvents(this.currentSessionResource || undefined)];
const addEventDisposable = this.chatDebugService.onDidAddEvent(e => {
if (!this.currentSessionResource || e.sessionResource.toString() === this.currentSessionResource.toString()) {
this.addEvent(e);
}
});
// Reload events when provider events are cleared (before re-invoking providers)
const clearEventsDisposable = this.chatDebugService.onDidClearProviderEvents(sessionResource => {
if (!this.currentSessionResource || sessionResource.toString() === this.currentSessionResource.toString()) {
this.events = [...this.chatDebugService.getEvents(this.currentSessionResource || undefined)];
this.refreshList();
}
});
this.eventListener.value = combinedDisposable(addEventDisposable, clearEventsDisposable);
this.updateBreadcrumb();
this.trackSessionState();
}
private trackSessionState(): void {
if (!this.currentSessionResource) {
DOM.hide(this.shimmerRow);
this.sessionStateDisposable.clear();
return;
}
const model = this.chatService.getSession(this.currentSessionResource);
if (!model) {
DOM.hide(this.shimmerRow);
this.sessionStateDisposable.clear();
return;
}
this.sessionStateDisposable.value = autorun(reader => {
const inProgress = model.requestInProgress.read(reader);
if (inProgress) {
DOM.show(this.shimmerRow);
} else {
DOM.hide(this.shimmerRow);
}
});
}
private refreshTree(filtered: IChatDebugEvent[]): void {
const treeElements = this.buildTreeHierarchy(filtered);
this.tree.setChildren(null, treeElements);
}
private buildTreeHierarchy(events: IChatDebugEvent[]): IObjectTreeElement<IChatDebugEvent>[] {
const idToEvent = new Map<string, IChatDebugEvent>();
const idToChildren = new Map<string, IChatDebugEvent[]>();
const roots: IChatDebugEvent[] = [];
for (const event of events) {
if (event.id) {
idToEvent.set(event.id, event);
}
}
for (const event of events) {
if (event.parentEventId && idToEvent.has(event.parentEventId)) {
let children = idToChildren.get(event.parentEventId);
if (!children) {
children = [];
idToChildren.set(event.parentEventId, children);
}
children.push(event);
} else {
roots.push(event);
}
}
const toTreeElement = (event: IChatDebugEvent): IObjectTreeElement<IChatDebugEvent> => {
const children = event.id ? idToChildren.get(event.id) : undefined;
return {
element: event,
children: children?.map(toTreeElement),
collapsible: (children?.length ?? 0) > 0,
collapsed: false,
};
};
return roots.map(toTreeElement);
}
private toggleViewMode(): void {
if (this.logsViewMode === LogsViewMode.List) {
this.logsViewMode = LogsViewMode.Tree;
DOM.hide(this.listContainer);
DOM.show(this.treeContainer);
} else {
this.logsViewMode = LogsViewMode.List;
DOM.show(this.listContainer);
DOM.hide(this.treeContainer);
}
this.updateViewModeToggle();
this.refreshList();
if (this.currentDimension) {
this.layout(this.currentDimension);
}
}
private updateViewModeToggle(): void {
const el = this.viewModeToggle.element;
DOM.clearNode(el);
const isTree = this.logsViewMode === LogsViewMode.Tree;
DOM.append(el, $(`span${ThemeIcon.asCSSSelector(isTree ? Codicon.listTree : Codicon.listFlat)}`));
const labelContainer = DOM.append(el, $('span.chat-debug-view-mode-labels'));
const treeLabel = DOM.append(labelContainer, $('span.chat-debug-view-mode-label'));
treeLabel.textContent = localize('chatDebug.treeView', "Tree View");
const listLabel = DOM.append(labelContainer, $('span.chat-debug-view-mode-label'));
listLabel.textContent = localize('chatDebug.listView', "List View");
if (isTree) {
listLabel.classList.add('hidden');
} else {
treeLabel.classList.add('hidden');
}
const activeLabel = isTree
? localize('chatDebug.switchToListView', "Switch to List View")
: localize('chatDebug.switchToTreeView', "Switch to Tree View");
el.setAttribute('aria-label', activeLabel);
this.viewModeToggle.setTitle(activeLabel);
}
private updateMoreFiltersChecked(): void {
this.filterWidget.checkMoreFilters(!this.filterState.isAllFiltersDefault());
}
}