/*--------------------------------------------------------------------------------------------- * 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()); 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; private tree: WorkbenchObjectTree; 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(); 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, '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, '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[] { const idToEvent = new Map(); const idToChildren = new Map(); 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 => { 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()); } }