From db7be7cd1b3db04ad84d138d43cfc356d9ff4332 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 24 Feb 2026 15:47:06 +0000 Subject: [PATCH 01/30] Enhance notification icons and functionality for expand/collapse actions Co-authored-by: Copilot --- .../notifications/notificationsActions.ts | 13 ++++++++- .../notifications/notificationsViewer.ts | 22 ++++++++++++-- .../browser/notificationsPosition.test.ts | 29 ++++++++++++++++++- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/browser/parts/notifications/notificationsActions.ts b/src/vs/workbench/browser/parts/notifications/notificationsActions.ts index 36ccd6f2af856..e0912c3f276d2 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsActions.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsActions.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import './media/notificationsActions.css'; -import { INotificationViewItem } from '../../../common/notifications.js'; +import { INotificationViewItem, NotificationsPosition } from '../../../common/notifications.js'; import { localize } from '../../../../nls.js'; import { Action } from '../../../../base/common/actions.js'; import { CLEAR_NOTIFICATION, EXPAND_NOTIFICATION, COLLAPSE_NOTIFICATION, CLEAR_ALL_NOTIFICATIONS, HIDE_NOTIFICATIONS_CENTER, TOGGLE_DO_NOT_DISTURB_MODE, TOGGLE_DO_NOT_DISTURB_MODE_BY_SOURCE } from './notificationsCommands.js'; @@ -19,7 +19,18 @@ const clearAllIcon = registerIcon('notifications-clear-all', Codicon.clearAll, l export const hideIcon = registerIcon('notifications-hide', Codicon.chevronDown, localize('hideIcon', 'Icon for the hide action in notifications.')); export const hideUpIcon = registerIcon('notifications-hide-up', Codicon.chevronUp, localize('hideUpIcon', 'Icon for the hide action in notifications when positioned at the top.')); const expandIcon = registerIcon('notifications-expand', Codicon.chevronUp, localize('expandIcon', 'Icon for the expand action in notifications.')); +const expandDownIcon = registerIcon('notifications-expand-down', Codicon.chevronDown, localize('expandDownIcon', 'Icon for the expand action in notifications when the notification center is at the top.')); const collapseIcon = registerIcon('notifications-collapse', Codicon.chevronDown, localize('collapseIcon', 'Icon for the collapse action in notifications.')); +const collapseUpIcon = registerIcon('notifications-collapse-up', Codicon.chevronUp, localize('collapseUpIcon', 'Icon for the collapse action in notifications when the notification center is at the top.')); + +export function getNotificationExpandIcon(position: NotificationsPosition): ThemeIcon { + return position === NotificationsPosition.TOP_RIGHT ? expandDownIcon : expandIcon; +} + +export function getNotificationCollapseIcon(position: NotificationsPosition): ThemeIcon { + return position === NotificationsPosition.TOP_RIGHT ? collapseUpIcon : collapseIcon; +} + const configureIcon = registerIcon('notifications-configure', Codicon.gear, localize('configureIcon', 'Icon for the configure action in notifications.')); const doNotDisturbIcon = registerIcon('notifications-do-not-disturb', Codicon.bellSlash, localize('doNotDisturbIcon', 'Icon for the mute all action in notifications.')); export const positionIcon = registerIcon('notifications-position', Codicon.arrowSwap, localize('positionIcon', 'Icon for the position action in notifications.')); diff --git a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts index 6512492cae4f4..79d82d23be627 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts @@ -14,8 +14,8 @@ import { ActionRunner, IAction, IActionRunner, Separator, toAction } from '../.. import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { dispose, DisposableStore, Disposable } from '../../../../base/common/lifecycle.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; -import { INotificationViewItem, NotificationViewItem, NotificationViewItemContentChangeKind, INotificationMessage, ChoiceAction } from '../../../common/notifications.js'; -import { ClearNotificationAction, ExpandNotificationAction, CollapseNotificationAction, ConfigureNotificationAction } from './notificationsActions.js'; +import { INotificationViewItem, NotificationViewItem, NotificationViewItemContentChangeKind, INotificationMessage, ChoiceAction, NotificationsSettings, getNotificationsPosition } from '../../../common/notifications.js'; +import { ClearNotificationAction, ExpandNotificationAction, CollapseNotificationAction, ConfigureNotificationAction, getNotificationExpandIcon, getNotificationCollapseIcon } from './notificationsActions.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js'; import { INotificationService, NotificationsFilter, Severity, isNotificationSource } from '../../../../platform/notification/common/notification.js'; @@ -32,6 +32,7 @@ import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import type { IManagedHover } from '../../../../base/browser/ui/hover/hover.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; export class NotificationsListDelegate implements IListVirtualDelegate { @@ -316,6 +317,15 @@ export class NotificationTemplateRenderer extends Disposable { private static expandNotificationAction: ExpandNotificationAction; private static collapseNotificationAction: CollapseNotificationAction; + private static updateExpandCollapseIcons(configurationService: IConfigurationService): void { + if (!NotificationTemplateRenderer.expandNotificationAction) { + return; + } + const position = getNotificationsPosition(configurationService); + NotificationTemplateRenderer.expandNotificationAction.class = ThemeIcon.asClassName(getNotificationExpandIcon(position)); + NotificationTemplateRenderer.collapseNotificationAction.class = ThemeIcon.asClassName(getNotificationCollapseIcon(position)); + } + private static readonly SEVERITIES = [Severity.Info, Severity.Warning, Severity.Error]; private readonly inputDisposables = this._register(new DisposableStore()); @@ -328,6 +338,7 @@ export class NotificationTemplateRenderer extends Disposable { @IKeybindingService private readonly keybindingService: IKeybindingService, @IContextMenuService private readonly contextMenuService: IContextMenuService, @IHoverService private readonly hoverService: IHoverService, + @IConfigurationService configurationService: IConfigurationService, ) { super(); @@ -335,7 +346,14 @@ export class NotificationTemplateRenderer extends Disposable { NotificationTemplateRenderer.closeNotificationAction = instantiationService.createInstance(ClearNotificationAction, ClearNotificationAction.ID, ClearNotificationAction.LABEL); NotificationTemplateRenderer.expandNotificationAction = instantiationService.createInstance(ExpandNotificationAction, ExpandNotificationAction.ID, ExpandNotificationAction.LABEL); NotificationTemplateRenderer.collapseNotificationAction = instantiationService.createInstance(CollapseNotificationAction, CollapseNotificationAction.ID, CollapseNotificationAction.LABEL); + NotificationTemplateRenderer.updateExpandCollapseIcons(configurationService); } + + this._register(configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(NotificationsSettings.NOTIFICATIONS_POSITION)) { + NotificationTemplateRenderer.updateExpandCollapseIcons(configurationService); + } + })); } setInput(notification: INotificationViewItem): void { diff --git a/src/vs/workbench/test/browser/notificationsPosition.test.ts b/src/vs/workbench/test/browser/notificationsPosition.test.ts index 7b39370d37c73..fac02b907daf5 100644 --- a/src/vs/workbench/test/browser/notificationsPosition.test.ts +++ b/src/vs/workbench/test/browser/notificationsPosition.test.ts @@ -9,7 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/comm import { DEFAULT_CUSTOM_TITLEBAR_HEIGHT } from '../../../platform/window/common/window.js'; import { NotificationsPosition, NotificationsSettings } from '../../common/notifications.js'; import { Codicon } from '../../../base/common/codicons.js'; -import { hideIcon, hideUpIcon } from '../../browser/parts/notifications/notificationsActions.js'; +import { hideIcon, hideUpIcon, getNotificationExpandIcon, getNotificationCollapseIcon } from '../../browser/parts/notifications/notificationsActions.js'; suite('Notifications Position', () => { @@ -142,4 +142,31 @@ suite('Notifications Position', () => { assert.strictEqual(Codicon.chevronUp.id, 'chevron-up'); }); }); + + suite('Expand/Collapse Notification Icons', () => { + + test('bottom-right expand uses notifications-expand icon', () => { + assert.strictEqual(getNotificationExpandIcon(NotificationsPosition.BOTTOM_RIGHT).id, 'notifications-expand'); + }); + + test('bottom-left expand uses notifications-expand icon', () => { + assert.strictEqual(getNotificationExpandIcon(NotificationsPosition.BOTTOM_LEFT).id, 'notifications-expand'); + }); + + test('top-right expand uses notifications-expand-down icon', () => { + assert.strictEqual(getNotificationExpandIcon(NotificationsPosition.TOP_RIGHT).id, 'notifications-expand-down'); + }); + + test('bottom-right collapse uses notifications-collapse icon', () => { + assert.strictEqual(getNotificationCollapseIcon(NotificationsPosition.BOTTOM_RIGHT).id, 'notifications-collapse'); + }); + + test('bottom-left collapse uses notifications-collapse icon', () => { + assert.strictEqual(getNotificationCollapseIcon(NotificationsPosition.BOTTOM_LEFT).id, 'notifications-collapse'); + }); + + test('top-right collapse uses notifications-collapse-up icon', () => { + assert.strictEqual(getNotificationCollapseIcon(NotificationsPosition.TOP_RIGHT).id, 'notifications-collapse-up'); + }); + }); }); From 330498949f926cb0f6610e078352655bcd830f1e Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 25 Feb 2026 08:29:46 +0100 Subject: [PATCH 02/30] :lipstick: --- .../browser/parts/notifications/notificationsActions.ts | 7 +++---- .../browser/parts/notifications/notificationsViewer.ts | 1 + 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/browser/parts/notifications/notificationsActions.ts b/src/vs/workbench/browser/parts/notifications/notificationsActions.ts index e0912c3f276d2..a97e459fe3c12 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsActions.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsActions.ts @@ -22,6 +22,9 @@ const expandIcon = registerIcon('notifications-expand', Codicon.chevronUp, local const expandDownIcon = registerIcon('notifications-expand-down', Codicon.chevronDown, localize('expandDownIcon', 'Icon for the expand action in notifications when the notification center is at the top.')); const collapseIcon = registerIcon('notifications-collapse', Codicon.chevronDown, localize('collapseIcon', 'Icon for the collapse action in notifications.')); const collapseUpIcon = registerIcon('notifications-collapse-up', Codicon.chevronUp, localize('collapseUpIcon', 'Icon for the collapse action in notifications when the notification center is at the top.')); +const configureIcon = registerIcon('notifications-configure', Codicon.gear, localize('configureIcon', 'Icon for the configure action in notifications.')); +const doNotDisturbIcon = registerIcon('notifications-do-not-disturb', Codicon.bellSlash, localize('doNotDisturbIcon', 'Icon for the mute all action in notifications.')); +export const positionIcon = registerIcon('notifications-position', Codicon.arrowSwap, localize('positionIcon', 'Icon for the position action in notifications.')); export function getNotificationExpandIcon(position: NotificationsPosition): ThemeIcon { return position === NotificationsPosition.TOP_RIGHT ? expandDownIcon : expandIcon; @@ -31,10 +34,6 @@ export function getNotificationCollapseIcon(position: NotificationsPosition): Th return position === NotificationsPosition.TOP_RIGHT ? collapseUpIcon : collapseIcon; } -const configureIcon = registerIcon('notifications-configure', Codicon.gear, localize('configureIcon', 'Icon for the configure action in notifications.')); -const doNotDisturbIcon = registerIcon('notifications-do-not-disturb', Codicon.bellSlash, localize('doNotDisturbIcon', 'Icon for the mute all action in notifications.')); -export const positionIcon = registerIcon('notifications-position', Codicon.arrowSwap, localize('positionIcon', 'Icon for the position action in notifications.')); - export class ClearNotificationAction extends Action { static readonly ID = CLEAR_NOTIFICATION; diff --git a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts index 79d82d23be627..7c01c6202c395 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts @@ -321,6 +321,7 @@ export class NotificationTemplateRenderer extends Disposable { if (!NotificationTemplateRenderer.expandNotificationAction) { return; } + const position = getNotificationsPosition(configurationService); NotificationTemplateRenderer.expandNotificationAction.class = ThemeIcon.asClassName(getNotificationExpandIcon(position)); NotificationTemplateRenderer.collapseNotificationAction.class = ThemeIcon.asClassName(getNotificationCollapseIcon(position)); From b595f2b94c7a6817880e418e8fa4d5c450b8fa71 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 25 Feb 2026 16:08:31 +0000 Subject: [PATCH 03/30] Add depth shadows for panels in the editor to enhance UI layering Co-authored-by: Copilot --- extensions/theme-2026/themes/styles.css | 120 ++++++++---------------- 1 file changed, 41 insertions(+), 79 deletions(-) diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index c1bc21f212125..727a02c500758 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -17,6 +17,10 @@ --shadow-button-active: inset 0 1px 2px rgba(0, 0, 0, 0.1); --shadow-active-tab: 0 8px 12px rgba(0, 0, 0, 0.02); + /* Panel depth shadows cast onto the editor surface */ + --shadow-depth-x: 5px 0 10px -4px rgba(0, 0, 0, 0.05); + --shadow-depth-y: 0 5px 10px -4px rgba(0, 0, 0, 0.04); + --backdrop-blur-md: blur(20px) saturate(180%); --backdrop-blur-lg: blur(40px) saturate(180%); } @@ -25,16 +29,17 @@ .monaco-workbench.vs-dark { --backdrop-blur-md: blur(20px) saturate(180%) brightness(0.55); --backdrop-blur-lg: blur(40px) saturate(180%) brightness(0.55); -} - -/* Stealth Shadows - shadow-based depth for UI elements, controlled by workbench.stealthShadows.enabled */ -/* Activity Bar */ -.monaco-workbench.vs .part.activitybar { - z-index: 50; - position: relative; + --shadow-depth-x: 5px 0 12px -4px rgba(0, 0, 0, 0.14); + --shadow-depth-y: 0 5px 12px -4px rgba(0, 0, 0, 0.10); } +/* Stealth Shadows - panels appear to float above the editor. + * Instead of z-index on panels (which breaks webviews, iframes, sashes), + * the editor draws its own "received shadow" via a ::after pseudo-element. + * The surrounding panels stay at default stacking — no z-index needed. */ + +/* Activity Bar - only needs shadow when sidebar is hidden */ .monaco-workbench.nosidebar .part.activitybar { box-shadow: var(--shadow-md); } @@ -43,94 +48,55 @@ box-shadow: var(--shadow-md); } -/* Sidebar */ -.monaco-workbench.vs .part.sidebar { - box-shadow: var(--shadow-md); - z-index: 40; - position: relative; -} - -.monaco-workbench.sidebar-right.vs .part.sidebar { - box-shadow: var(--shadow-md); -} - -.monaco-workbench.vs .part.auxiliarybar { - box-shadow: var(--shadow-md); - z-index: 35; - position: relative; -} - -/* Ensure iframe containers in pane-body render above sidebar z-index */ -.monaco-workbench.vs > div[data-keybinding-context] { - z-index: 50 !important; -} - -/* Ensure in-editor pane iframes render below sidebar z-index */ -.monaco-workbench.vs > div[data-parent-flow-to-element-id] { - z-index: 0 !important; -} - - -/* Ensure webview containers render above sidebar z-index */ -.monaco-workbench.vs .part.sidebar .webview, -.monaco-workbench.vs .part.sidebar .webview-container, -.monaco-workbench.vs .part.auxiliarybar .webview, -.monaco-workbench.vs .part.auxiliarybar .webview-container { - position: relative; - z-index: 50; - transform: translateZ(0); -} - -/* Panel */ -.monaco-workbench.vs .part.panel { - box-shadow: var(--shadow-md); - position: relative; -} - -.monaco-workbench.panel-position-left.vs .part.panel { - box-shadow: var(--shadow-md); -} - -.monaco-workbench.panel-position-right.vs .part.panel { - box-shadow: var(--shadow-md); -} - .monaco-pane-view .split-view-view:first-of-type > .pane > .pane-header { border-top: 1px solid var(--vscode-sideBarSectionHeader-border) !important; } -/* Sashes - ensure they extend full height and are above other panels */ -.monaco-workbench.vs .monaco-sash { - z-index: 35; +/* Editor - the ::after pseudo-element draws inset shadows on each edge, + * creating the illusion that sidebar, panel, and auxiliarybar float above it. */ +.monaco-workbench.vs .part.editor { + position: relative; } -.monaco-workbench.vs .monaco-sash.vertical { - z-index: 40; +.monaco-workbench.vs .part.editor::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 10; + box-shadow: + inset var(--shadow-depth-x), + inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.04), + inset 0 calc(-1 * 5px) 10px -4px rgba(0, 0, 0, 0.05); } -.monaco-workbench.vs .monaco-sash.vertical:nth-child(2) { - z-index: 45; +/* When sidebar is on the right, flip the stronger shadow to the right edge */ +.monaco-workbench.sidebar-right.vs .part.editor::after { + box-shadow: + inset 5px 0 10px -4px rgba(0, 0, 0, 0.04), + inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.05), + inset 0 calc(-1 * 5px) 10px -4px rgba(0, 0, 0, 0.05); } -.monaco-workbench.vs .monaco-sash.horizontal { - z-index: 35; +/* Panel positions: strengthen the shadow on whichever edge faces the panel */ +.monaco-workbench.panel-position-left.vs .part.editor::after { + box-shadow: + inset var(--shadow-depth-x), + inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.04); } -/* Editor */ -.monaco-workbench.vs .part.editor { - position: relative; +.monaco-workbench.panel-position-right.vs .part.editor::after { + box-shadow: + inset 5px 0 10px -4px rgba(0, 0, 0, 0.04), + inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.05); } .monaco-workbench.vs .part.editor > .content .editor-group-container > .title { box-shadow: none; - position: relative; - z-index: 10; } .monaco-workbench.vs .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active { box-shadow: inset var(--shadow-active-tab); - position: relative; - z-index: 5; } .monaco-workbench.vs .part.editor > .content .editor-group-container > .title .tabs-container > .tab:hover:not(.active) { @@ -636,10 +602,6 @@ /* Notebook */ -.monaco-workbench .notebookOverlay.notebook-editor { - z-index: 35 !important; -} - .monaco-workbench .notebookOverlay .monaco-list-row .cell-editor-part:before { box-shadow: inset var(--shadow-sm); border-radius: var(--radius-md); From 0c71b2353e7b33b774ac29444d62723f74cf49d5 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 25 Feb 2026 10:27:33 -0600 Subject: [PATCH 04/30] fallback to a model when none is available on the chat widget (#297471) --- .../browser/tools/monitoring/outputMonitor.ts | 35 +++++--- .../test/browser/outputMonitor.test.ts | 83 ++++++++++++++++++- 2 files changed, 104 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index 085c43ed463e9..56f9617ea7bfd 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -627,14 +627,14 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { if (!confirmationPrompt?.options.length) { return undefined; } - const model = this._chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat)[0]?.input.currentLanguageModel; - if (!model) { - return undefined; + const autoReply = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoReplyToPrompts); + let model = this._chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat)[0]?.input.currentLanguageModel; + if (model) { + const models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: model.replaceAll('copilot/', '') }); + model = models[0]; } - - const models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: model.replaceAll('copilot/', '') }); - if (!models.length) { - return undefined; + if (!model) { + model = await this._getLanguageModel(); } const prompt = confirmationPrompt.prompt; const options = confirmationPrompt.options; @@ -648,13 +648,22 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { this._lastPromptMarker = currentMarker; this._lastPrompt = prompt; - const promptText = `Given the following confirmation prompt and options from a terminal output, which option is the default?\nPrompt: "${prompt}"\nOptions: ${JSON.stringify(options)}\nRespond with only the option string.`; - const response = await this._languageModelsService.sendChatRequest(models[0], new ExtensionIdentifier('core'), [ - { role: ChatMessageRole.User, content: [{ type: 'text', value: promptText }] } - ], {}, token); + let suggestedOption = ''; + if (model) { + try { + const promptText = `Given the following confirmation prompt and options from a terminal output, which option is the default?\nPrompt: "${prompt}"\nOptions: ${JSON.stringify(options)}\nRespond with only the option string.`; + const response = await this._languageModelsService.sendChatRequest(model, new ExtensionIdentifier('core'), [ + { role: ChatMessageRole.User, content: [{ type: 'text', value: promptText }] } + ], {}, token); + + suggestedOption = (await getTextResponseFromStream(response)).trim(); + } catch (err) { + this._logService.trace('OutputMonitor: Failed to get suggested option from model', err); + } + } else if (!autoReply) { + return undefined; + } - const suggestedOption = (await getTextResponseFromStream(response)).trim(); - const autoReply = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoReplyToPrompts); let validOption: string; let index: number; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index cb4aa2c1dc081..5e37377e3dfce 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { detectsGenericPressAnyKeyPattern, detectsInputRequiredPattern, detectsNonInteractiveHelpPattern, detectsVSCodeTaskFinishMessage, matchTerminalPromptOption, OutputMonitor } from '../../browser/tools/monitoring/outputMonitor.js'; -import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IExecution, IPollingResult, OutputMonitorState } from '../../browser/tools/monitoring/types.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -19,6 +19,10 @@ import { runWithFakedTimers } from '../../../../../../base/test/common/timeTrave import { IToolInvocationContext } from '../../../../chat/common/tools/languageModelToolsService.js'; import { LocalChatSessionUri } from '../../../../chat/common/model/chatUri.js'; import { isNumber } from '../../../../../../base/common/types.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; +import { IChatWidgetService } from '../../../../chat/browser/chat.js'; suite('OutputMonitor', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -73,6 +77,12 @@ suite('OutputMonitor', () => { } ); instantiationService.stub(ITerminalLogService, new NullLogService()); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [TerminalChatAgentToolsSettingId.AutoReplyToPrompts]: false + })); + instantiationService.stub(IChatWidgetService, { + getWidgetsByLocations: () => [] + }); cts = new CancellationTokenSource(); }); @@ -204,6 +214,77 @@ suite('OutputMonitor', () => { }); }); + test('auto reply sends first option when model lookup is unavailable', async () => { + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [TerminalChatAgentToolsSettingId.AutoReplyToPrompts]: true + })); + instantiationService.stub(ILanguageModelsService, { + selectLanguageModels: async () => [] + }); + + const monitorCts = new CancellationTokenSource(); + monitorCts.cancel(); + monitor = store.add(instantiationService.createInstance(OutputMonitor, execution, undefined, createTestContext('1'), monitorCts.token, 'test command')); + + const outputMonitorWithPrivateMethod = monitor as unknown as { + [key: string]: ((prompt: { prompt: string; options: string[]; detectedRequestForFreeFormInput: boolean }, token: CancellationToken) => Promise<{ suggestedOption: string | { description: string; option: string }; sentToTerminal: boolean } | undefined>) | undefined; + }; + const optionResult = await outputMonitorWithPrivateMethod['_selectAndHandleOption']!({ + prompt: 'Continue?', + options: ['y', 'n'], + detectedRequestForFreeFormInput: false + }, CancellationToken.None); + await Event.toPromise(monitor.onDidFinishCommand); + monitorCts.dispose(); + + assert.strictEqual(sendTextCalled, true, 'sendText should be called when auto reply is enabled'); + assert.strictEqual(optionResult?.sentToTerminal, true, 'option should be auto-sent'); + assert.strictEqual(optionResult?.suggestedOption, 'y', 'first option should be used as fallback'); + }); + + test('auto reply uses fallback model to derive suggested option', async () => { + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [TerminalChatAgentToolsSettingId.AutoReplyToPrompts]: true + })); + + let fallbackModelRequested = false; + instantiationService.stub(ILanguageModelsService, { + selectLanguageModels: async (selector: { id?: string }) => { + if (selector.id === 'copilot-fast') { + fallbackModelRequested = true; + return ['copilot-fast']; + } + return []; + }, + sendChatRequest: async () => ({ + stream: (async function* () { + yield { type: 'text', value: 'n' }; + })(), + result: Promise.resolve(undefined) + }) + }); + + const monitorCts = new CancellationTokenSource(); + monitorCts.cancel(); + monitor = store.add(instantiationService.createInstance(OutputMonitor, execution, undefined, createTestContext('1'), monitorCts.token, 'test command')); + + const outputMonitorWithPrivateMethod = monitor as unknown as { + [key: string]: ((prompt: { prompt: string; options: string[]; detectedRequestForFreeFormInput: boolean }, token: CancellationToken) => Promise<{ suggestedOption: string | { description: string; option: string }; sentToTerminal: boolean } | undefined>) | undefined; + }; + const optionResult = await outputMonitorWithPrivateMethod['_selectAndHandleOption']!({ + prompt: 'Continue?', + options: ['y', 'n'], + detectedRequestForFreeFormInput: false + }, CancellationToken.None); + await Event.toPromise(monitor.onDidFinishCommand); + monitorCts.dispose(); + + assert.strictEqual(fallbackModelRequested, true, 'fallback model should be requested via _getLanguageModel'); + assert.strictEqual(sendTextCalled, true, 'sendText should be called when auto reply is enabled'); + assert.strictEqual(optionResult?.sentToTerminal, true, 'option should be auto-sent'); + assert.strictEqual(optionResult?.suggestedOption, 'n', 'suggested option should be derived from fallback model response'); + }); + suite('detectsInputRequiredPattern', () => { test('detects yes/no confirmation prompts (pairs and variants)', () => { assert.strictEqual(detectsInputRequiredPattern('Continue? (y/N) '), true); From d71377c2ca59dd25be164f19e873ff9803819b2f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 25 Feb 2026 17:35:45 +0100 Subject: [PATCH 05/30] feedback (#297707) --- .../contrib/chat/browser/media/chatWidget.css | 4 +--- .../contrib/chat/browser/newChatViewPane.ts | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 56cb11e5e1e5e..b23633e013fdb 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -38,11 +38,9 @@ } /* Editor */ +/* Height constraints are driven by MIN_EDITOR_HEIGHT / MAX_EDITOR_HEIGHT in newChatViewPane.ts */ .sessions-chat-editor { padding: 0 6px 6px 6px; - height: 50px; - min-height: 50px; - max-height: 200px; flex-shrink: 1; } diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index b50e9249afe1d..9d9c6c67b25b6 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -66,6 +66,8 @@ import { getErrorMessage } from '../../../../base/common/errors.js'; import { SlashCommandHandler } from './slashCommands.js'; const STORAGE_KEY_LAST_MODEL = 'sessions.selectedModel'; +const MIN_EDITOR_HEIGHT = 50; +const MAX_EDITOR_HEIGHT = 200; // #region --- Chat Welcome Widget --- @@ -376,6 +378,7 @@ class NewChatWidget extends Disposable { private _createEditor(container: HTMLElement, overflowWidgetsDomNode: HTMLElement): void { const editorContainer = this._editorContainer = dom.append(container, dom.$('.sessions-chat-editor')); + editorContainer.style.height = `${MIN_EDITOR_HEIGHT}px`; const uri = URI.from({ scheme: 'sessions-chat', path: `input-${Date.now()}` }); const textModel = this._register(this.modelService.createModel('', null, uri, true)); @@ -436,9 +439,17 @@ class NewChatWidget extends Disposable { } })); - this._register(this._editor.onDidContentSizeChange(() => { + let previousHeight = -1; + this._register(this._editor.onDidContentSizeChange(e => { + if (!e.contentHeightChanged) { + return; + } const contentHeight = this._editor.getContentHeight(); - const clampedHeight = Math.min(200, Math.max(50, contentHeight)); + const clampedHeight = Math.min(MAX_EDITOR_HEIGHT, Math.max(MIN_EDITOR_HEIGHT, contentHeight)); + if (clampedHeight === previousHeight) { + return; + } + previousHeight = clampedHeight; this._editorContainer.style.height = `${clampedHeight}px`; this._editor.layout(); })); From 1eb6550a273288739794506ea70bd6b4e0efe331 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 25 Feb 2026 17:38:44 +0100 Subject: [PATCH 06/30] fix focus problem with agent feedback input when find widget is focused --- .../browser/agentFeedbackEditorInputContribution.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts index 4853d21aaf6e6..707a1090912f3 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts @@ -243,7 +243,7 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements this._hide(); }, 0); })); - this._store.add(this._editor.onDidFocusEditorWidget(() => this._onSelectionChanged())); + this._store.add(this._editor.onDidFocusEditorText(() => this._onSelectionChanged())); } private _isWidgetTarget(target: EventTarget | Element | null): boolean { @@ -266,7 +266,7 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements } private _onSelectionChanged(): void { - if (this._mouseDown || !this._editor.hasWidgetFocus()) { + if (this._mouseDown || !this._editor.hasTextFocus()) { return; } @@ -331,6 +331,12 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements return; } + // Only steal focus when the editor text area itself is focused, + // not when an overlay widget (e.g. find widget) has focus + if (!this._editor.hasTextFocus()) { + return; + } + // Don't focus if a modifier key is pressed alone if (e.keyCode === KeyCode.Ctrl || e.keyCode === KeyCode.Shift || e.keyCode === KeyCode.Alt || e.keyCode === KeyCode.Meta) { return; From ba573654e682966520bf204975531eff56441e7a Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 25 Feb 2026 17:45:44 +0100 Subject: [PATCH 07/30] Fix #297172 (#297713) --- .../chat/browser/widget/input/chatModelPicker.ts | 16 ++++++++++++++-- .../browser/widget/input/chatModelPicker.test.ts | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index ce813638d782f..9c8616694e665 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -116,7 +116,7 @@ function createModelAction( * 2. Promoted section (selected + recently used + featured models from control manifest) * - Available models sorted alphabetically, followed by unavailable models * - Unavailable models show upgrade/update/admin status - * 3. Other Models (collapsible toggle, sorted by vendor then name) + * 3. Other Models (collapsible toggle, available first, then sorted by vendor then name) * - Last item is "Manage Models..." (always visible during filtering) */ export function buildModelPickerItems( @@ -265,10 +265,15 @@ export function buildModelPickerItems( } } - // Render promoted section: sorted alphabetically by name + // Render promoted section: available first, then sorted alphabetically by name let hasShownActionLink = false; if (promotedItems.length > 0) { promotedItems.sort((a, b) => { + const aAvail = a.kind === 'available' ? 0 : 1; + const bAvail = b.kind === 'available' ? 0 : 1; + if (aAvail !== bAvail) { + return aAvail - bAvail; + } const aName = a.kind === 'available' ? a.model.metadata.name : a.entry.label; const bName = b.kind === 'available' ? b.model.metadata.name : b.entry.label; return aName.localeCompare(bName); @@ -291,6 +296,13 @@ export function buildModelPickerItems( otherModels = models .filter(m => !placed.has(m.identifier) && !placed.has(m.metadata.id)) .sort((a, b) => { + const aEntry = controlModels[a.metadata.id] ?? controlModels[a.identifier]; + const bEntry = controlModels[b.metadata.id] ?? controlModels[b.identifier]; + const aAvail = aEntry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, aEntry.minVSCodeVersion) ? 1 : 0; + const bAvail = bEntry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, bEntry.minVSCodeVersion) ? 1 : 0; + if (aAvail !== bAvail) { + return aAvail - bAvail; + } const aCopilot = a.metadata.vendor === 'copilot' ? 0 : 1; const bCopilot = b.metadata.vendor === 'copilot' ? 0 : 1; if (aCopilot !== bCopilot) { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts index e39c5df086f98..305a744a5710d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts @@ -374,6 +374,22 @@ suite('buildModelPickerItems', () => { assert.strictEqual(gptItem.disabled, true); }); + test('Other Models places unavailable models after available models', () => { + const auto = createAutoModel(); + const availableModel = createModel('zeta', 'Zeta'); + const unavailableModel = createModel('alpha', 'Alpha'); + const items = callBuild([auto, availableModel, unavailableModel], { + controlModels: { + 'alpha': { label: 'Alpha', minVSCodeVersion: '2.0.0', exists: true }, + }, + currentVSCodeVersion: '1.90.0', + }); + const actions = getActionItems(items); + const otherModelLabels = actions.slice(2).map(a => a.label!).filter(l => !l.includes('Manage Models')); + assert.deepStrictEqual(otherModelLabels, ['Zeta', 'Alpha']); + assert.strictEqual(actions.find(a => a.label === 'Alpha')?.disabled, true); + }); + test('no duplicate models across sections', () => { const auto = createAutoModel(); const modelA = createModel('gpt-4o', 'GPT-4o'); From 8d1f10b0b86c96132c656416ca0439ea9e1b3955 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Wed, 25 Feb 2026 17:13:26 +0000 Subject: [PATCH 08/30] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/theme-2026/themes/styles.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 727a02c500758..14e1bb6cb1020 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -91,6 +91,12 @@ inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.05); } +.monaco-workbench.panel-position-top.vs .part.editor::after { + box-shadow: + inset var(--shadow-depth-x), + inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.04), + inset 0 var(--shadow-depth-y); +} .monaco-workbench.vs .part.editor > .content .editor-group-container > .title { box-shadow: none; } From 26f33e11781feac9b79020ecd735a9862c2d8002 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:27:28 +0000 Subject: [PATCH 09/30] Fix setting link rendering in global auto approve dialog (#297467) --- src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts | 2 +- .../contrib/chat/browser/tools/languageModelToolsService.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 7e1830cee3505..392536b2b8b25 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -173,7 +173,7 @@ export class ChatSlashCommandsContribution extends Disposable { ], custom: { disableCloseAction: true, - markdownDetails: [{ markdown: new MarkdownString(globalAutoApproveDescription.value) }], + markdownDetails: [{ markdown: new MarkdownString(globalAutoApproveDescription.value, { isTrusted: { enabledCommands: ['workbench.action.openSettings'] } }) }], } }); if (result.result !== true) { diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index a367b84a045b7..bc2f193bb5f68 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -79,10 +79,10 @@ export const globalAutoApproveDescription = localize2( '{Locked=\'](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)\'}', '{Locked=\'](https://code.visualstudio.com/docs/copilot/security)\'}', '{Locked=\'**\'}', - '{Locked=\'`#chat.autoReply#`\'}', + '{Locked=\'[`chat.autoReply`](command:workbench.action.openSettings?%5B%22chat.autoReply%22%5D)\'}', ] }, - 'Global auto approve also known as "YOLO mode" disables manual approval completely for _all tools in all workspaces_, allowing the agent to act fully autonomously. This is extremely dangerous and is *never* recommended, even containerized environments like [Codespaces](https://github.com/features/codespaces) and [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) have user keys forwarded into the container that could be compromised.\n\n**This feature disables [critical security protections](https://code.visualstudio.com/docs/copilot/security) and makes it much easier for an attacker to compromise the machine.**\n\nNote: This setting only controls tool approval and does not prevent the agent from asking questions. To automatically answer agent questions, use `#chat.autoReply#`.' + 'Global auto approve also known as "YOLO mode" disables manual approval completely for _all tools in all workspaces_, allowing the agent to act fully autonomously. This is extremely dangerous and is *never* recommended, even containerized environments like [Codespaces](https://github.com/features/codespaces) and [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) have user keys forwarded into the container that could be compromised.\n\n**This feature disables [critical security protections](https://code.visualstudio.com/docs/copilot/security) and makes it much easier for an attacker to compromise the machine.**\n\nNote: This setting only controls tool approval and does not prevent the agent from asking questions. To automatically answer agent questions, use the [`chat.autoReply`](command:workbench.action.openSettings?%5B%22chat.autoReply%22%5D) setting.' ); export class LanguageModelToolsService extends Disposable implements ILanguageModelToolsService { @@ -1109,7 +1109,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo icon: Codicon.warning, disableCloseAction: true, markdownDetails: [{ - markdown: new MarkdownString(globalAutoApproveDescription.value), + markdown: new MarkdownString(globalAutoApproveDescription.value, { isTrusted: { enabledCommands: ['workbench.action.openSettings'] } }), }], } }); From f6d14c527598836f682b0bdaa08ff31e0ca36beb Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:28:16 +0000 Subject: [PATCH 10/30] Align chat model picker ARIA semantics with single-selection menu behavior (#296620) * Initial plan * fix: use radio semantics for chat model picker accessibility Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> * Update src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> Co-authored-by: Megan Rogge Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../browser/widget/input/chatModelPicker.ts | 30 +++++++++++-------- .../widget/input/chatModelPicker.test.ts | 9 +++++- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 9c8616694e665..2eb6414caed91 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -375,6 +375,22 @@ export function buildModelPickerItems( return items; } +export function getModelPickerAccessibilityProvider() { + return { + isChecked(element: IActionListItem) { + return element.kind === ActionListItemKind.Action ? !!element?.item?.checked : undefined; + }, + getRole: (element: IActionListItem) => { + switch (element.kind) { + case ActionListItemKind.Action: return 'menuitemradio'; + case ActionListItemKind.Separator: return 'separator'; + default: return 'separator'; + } + }, + getWidgetRole: () => 'menu', + } as const; +} + function createUnavailableModelItem( id: string, entry: IModelControlEntry, @@ -585,19 +601,7 @@ export class ModelPickerWidget extends Disposable { anchorElement, undefined, [], - { - isChecked(element) { - return element.kind === 'action' && !!element?.item?.checked; - }, - getRole: (e) => { - switch (e.kind) { - case 'action': return 'menuitemcheckbox'; - case 'separator': return 'separator'; - default: return 'separator'; - } - }, - getWidgetRole: () => 'menu', - }, + getModelPickerAccessibilityProvider(), listOptions ); } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts index 305a744a5710d..b9d21246bba9c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts @@ -12,7 +12,7 @@ import { ActionListItemKind, IActionListItem } from '../../../../../../../platfo import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'; import { StateType } from '../../../../../../../platform/update/common/update.js'; -import { buildModelPickerItems } from '../../../../browser/widget/input/chatModelPicker.js'; +import { buildModelPickerItems, getModelPickerAccessibilityProvider } from '../../../../browser/widget/input/chatModelPicker.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, IModelControlEntry } from '../../../../common/languageModels.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../../../services/chat/common/chatEntitlementService.js'; @@ -104,6 +104,13 @@ suite('buildModelPickerItems', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('accessibility provider uses radio semantics for model items', () => { + const provider = getModelPickerAccessibilityProvider(); + assert.strictEqual(provider.getRole({ kind: ActionListItemKind.Action } as IActionListItem), 'menuitemradio'); + assert.strictEqual(provider.getRole({ kind: ActionListItemKind.Separator } as IActionListItem), 'separator'); + assert.strictEqual(provider.getWidgetRole(), 'menu'); + }); + test('auto model always appears first', () => { const auto = createAutoModel(); const modelA = createModel('gpt-4o', 'GPT-4o'); From 37d89378138178bd2847f275082f1875f17c1f4a Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 25 Feb 2026 09:32:12 -0800 Subject: [PATCH 11/30] Richer debug content rendering (#297584) --- .../workbench/workbench-dev.html | 1 + .../electron-browser/workbench/workbench.html | 1 + .../common/extensionsApiProposals.ts | 2 +- .../electron-browser/sessions-dev.html | 1 + .../sessions/electron-browser/sessions.html | 1 + .../workbench/api/common/extHost.api.impl.ts | 2 + .../workbench/api/common/extHost.protocol.ts | 28 +- .../workbench/api/common/extHostChatDebug.ts | 32 ++ src/vs/workbench/api/common/extHostTypes.ts | 34 ++ .../browser/chatDebug/chatDebugDetailPanel.ts | 18 + .../browser/chatDebug/chatDebugFlowChart.ts | 2 +- .../chatDebug/chatDebugFlowChartView.ts | 24 +- .../browser/chatDebug/chatDebugFlowGraph.ts | 365 +++++++++++++----- .../browser/chatDebug/chatDebugFlowLayout.ts | 287 ++++++++++++-- .../chatDebugModelTurnContentRenderer.ts | 137 +++++++ .../chatDebugToolCallContentRenderer.ts | 152 ++++++++ .../browser/chatDebug/media/chatDebug.css | 15 +- .../contrib/chat/common/chatDebugService.ts | 36 +- src/vscode-dts/vscode.proposed.chatDebug.d.ts | 152 +++++++- 19 files changed, 1151 insertions(+), 139 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugModelTurnContentRenderer.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugToolCallContentRenderer.ts diff --git a/src/vs/code/electron-browser/workbench/workbench-dev.html b/src/vs/code/electron-browser/workbench/workbench-dev.html index 13ff778a58cdf..8ccafe7816e1f 100644 --- a/src/vs/code/electron-browser/workbench/workbench-dev.html +++ b/src/vs/code/electron-browser/workbench/workbench-dev.html @@ -65,6 +65,7 @@ tokenizeToString notebookChatEditController richScreenReaderContent + chatDebugTokenizer ; "/> diff --git a/src/vs/code/electron-browser/workbench/workbench.html b/src/vs/code/electron-browser/workbench/workbench.html index dda0dd75b77e4..ce51984cd542d 100644 --- a/src/vs/code/electron-browser/workbench/workbench.html +++ b/src/vs/code/electron-browser/workbench/workbench.html @@ -63,6 +63,7 @@ tokenizeToString notebookChatEditController richScreenReaderContent + chatDebugTokenizer ; "/> diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index db714398cdaad..fb796867b8bc2 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -45,7 +45,7 @@ const _allApiProposals = { }, chatDebug: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatDebug.d.ts', - version: 1 + version: 2 }, chatHooks: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts', diff --git a/src/vs/sessions/electron-browser/sessions-dev.html b/src/vs/sessions/electron-browser/sessions-dev.html index 56f1b22575beb..f453fb51b7fe7 100644 --- a/src/vs/sessions/electron-browser/sessions-dev.html +++ b/src/vs/sessions/electron-browser/sessions-dev.html @@ -65,6 +65,7 @@ tokenizeToString notebookChatEditController richScreenReaderContent + chatDebugTokenizer ; "/> diff --git a/src/vs/sessions/electron-browser/sessions.html b/src/vs/sessions/electron-browser/sessions.html index afb0a45e67ec7..de2f45b136e5c 100644 --- a/src/vs/sessions/electron-browser/sessions.html +++ b/src/vs/sessions/electron-browser/sessions.html @@ -63,6 +63,7 @@ tokenizeToString notebookChatEditController richScreenReaderContent + chatDebugTokenizer ; "/> diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index b1b56427b5be9..a5dfeb3c1d6c0 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -2076,6 +2076,8 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatDebugEventTextContent: extHostTypes.ChatDebugEventTextContent, ChatDebugMessageContentType: extHostTypes.ChatDebugMessageContentType, ChatDebugEventMessageContent: extHostTypes.ChatDebugEventMessageContent, + ChatDebugEventToolCallContent: extHostTypes.ChatDebugEventToolCallContent, + ChatDebugEventModelTurnContent: extHostTypes.ChatDebugEventModelTurnContent, ChatRequestEditorData: extHostTypes.ChatRequestEditorData, ChatRequestNotebookData: extHostTypes.ChatRequestNotebookData, ChatReferenceBinaryData: extHostTypes.ChatReferenceBinaryData, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index ec57ef1c9a40b..3fc7572a3a99c 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1470,7 +1470,33 @@ export interface IChatDebugEventMessageContentDto { readonly sections: readonly IChatDebugMessageSectionDto[]; } -export type IChatDebugResolvedEventContentDto = IChatDebugEventTextContentDto | IChatDebugEventMessageContentDto; +export interface IChatDebugEventToolCallContentDto { + readonly kind: 'toolCall'; + readonly toolName: string; + readonly result?: 'success' | 'error'; + readonly durationInMillis?: number; + readonly input?: string; + readonly output?: string; +} + +export interface IChatDebugEventModelTurnContentDto { + readonly kind: 'modelTurn'; + readonly requestName: string; + readonly model?: string; + readonly status?: string; + readonly durationInMillis?: number; + readonly timeToFirstTokenInMillis?: number; + readonly maxInputTokens?: number; + readonly maxOutputTokens?: number; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly cachedTokens?: number; + readonly totalTokens?: number; + readonly errorMessage?: string; + readonly sections?: readonly IChatDebugMessageSectionDto[]; +} + +export type IChatDebugResolvedEventContentDto = IChatDebugEventTextContentDto | IChatDebugEventMessageContentDto | IChatDebugEventToolCallContentDto | IChatDebugEventModelTurnContentDto; export interface ExtHostChatDebugShape { $provideChatDebugLog(handle: number, sessionResource: UriComponents, token: CancellationToken): Promise; diff --git a/src/vs/workbench/api/common/extHostChatDebug.ts b/src/vs/workbench/api/common/extHostChatDebug.ts index bf9b6495b845e..4790d2141a5e0 100644 --- a/src/vs/workbench/api/common/extHostChatDebug.ts +++ b/src/vs/workbench/api/common/extHostChatDebug.ts @@ -252,6 +252,38 @@ export class ExtHostChatDebug extends Disposable implements ExtHostChatDebugShap sections: msg.sections.map(s => ({ name: s.name, content: s.content })), }; } + case 'toolCallContent': { + const tc = result as vscode.ChatDebugEventToolCallContent; + return { + kind: 'toolCall', + toolName: tc.toolName, + result: tc.result === ChatDebugToolCallResult.Success ? 'success' + : tc.result === ChatDebugToolCallResult.Error ? 'error' + : undefined, + durationInMillis: tc.durationInMillis, + input: tc.input, + output: tc.output, + }; + } + case 'modelTurnContent': { + const mt = result as vscode.ChatDebugEventModelTurnContent; + return { + kind: 'modelTurn', + requestName: mt.requestName, + model: mt.model, + status: mt.status, + durationInMillis: mt.durationInMillis, + timeToFirstTokenInMillis: mt.timeToFirstTokenInMillis, + maxInputTokens: mt.maxInputTokens, + maxOutputTokens: mt.maxOutputTokens, + inputTokens: mt.inputTokens, + outputTokens: mt.outputTokens, + cachedTokens: mt.cachedTokens, + totalTokens: mt.totalTokens, + errorMessage: mt.errorMessage, + sections: mt.sections?.map(s => ({ name: s.name, content: s.content })), + }; + } default: return undefined; } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 890d4cfc677c6..56f751321425b 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3716,6 +3716,40 @@ export class ChatDebugEventMessageContent { } } +export class ChatDebugEventToolCallContent { + readonly _kind = 'toolCallContent'; + toolName: string; + result?: ChatDebugToolCallResult; + durationInMillis?: number; + input?: string; + output?: string; + + constructor(toolName: string) { + this.toolName = toolName; + } +} + +export class ChatDebugEventModelTurnContent { + readonly _kind = 'modelTurnContent'; + requestName: string; + model?: string; + status?: string; + durationInMillis?: number; + timeToFirstTokenInMillis?: number; + maxInputTokens?: number; + maxOutputTokens?: number; + inputTokens?: number; + outputTokens?: number; + cachedTokens?: number; + totalTokens?: number; + errorMessage?: string; + sections?: ChatDebugMessageSection[]; + + constructor(requestName: string) { + this.requestName = requestName; + } +} + export class ChatSessionChangedFile { constructor(public readonly modifiedUri: vscode.Uri, public readonly insertions: number, public readonly deletions: number, public readonly originalUri?: vscode.Uri) { } } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts index 70860d47632a8..19e3cda7612b4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts @@ -22,6 +22,8 @@ import { IChatDebugEvent, IChatDebugService } from '../../common/chatDebugServic import { formatEventDetail } from './chatDebugEventDetailRenderer.js'; import { renderCustomizationDiscoveryContent, fileListToPlainText } from './chatCustomizationDiscoveryRenderer.js'; import { renderUserMessageContent, renderAgentResponseContent, messageEventToPlainText, renderResolvedMessageContent, resolvedMessageToPlainText } from './chatDebugMessageContentRenderer.js'; +import { renderToolCallContent, toolCallContentToPlainText } from './chatDebugToolCallContentRenderer.js'; +import { renderModelTurnContent, modelTurnContentToPlainText } from './chatDebugModelTurnContentRenderer.js'; const $ = DOM.$; @@ -120,11 +122,27 @@ export class ChatDebugDetailPanel extends Disposable { ); this.detailDisposables.add(contentDisposables); this.contentContainer.appendChild(contentEl); + } else if (resolved && resolved.kind === 'toolCall') { + this.currentDetailText = toolCallContentToPlainText(resolved); + const languageService = this.instantiationService.invokeFunction(accessor => accessor.get(ILanguageService)); + const { element: contentEl, disposables: contentDisposables } = await renderToolCallContent(resolved, languageService); + if (this.currentDetailEventId !== event.id) { + // Another event was selected while we were rendering + contentDisposables.dispose(); + return; + } + this.detailDisposables.add(contentDisposables); + this.contentContainer.appendChild(contentEl); } else if (resolved && resolved.kind === 'message') { this.currentDetailText = resolvedMessageToPlainText(resolved); const { element: contentEl, disposables: contentDisposables } = renderResolvedMessageContent(resolved); this.detailDisposables.add(contentDisposables); this.contentContainer.appendChild(contentEl); + } else if (resolved && resolved.kind === 'modelTurn') { + this.currentDetailText = modelTurnContentToPlainText(resolved); + const { element: contentEl, disposables: contentDisposables } = renderModelTurnContent(resolved); + this.detailDisposables.add(contentDisposables); + this.contentContainer.appendChild(contentEl); } else if (event.kind === 'userMessage') { this.currentDetailText = messageEventToPlainText(event); const { element: contentEl, disposables: contentDisposables } = renderUserMessageContent(event); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChart.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChart.ts index b7dfe35052eb1..976da3108506b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChart.ts @@ -5,7 +5,7 @@ // Barrel re-export — keeps existing imports stable. // Data model + graph building (stable): -export { buildFlowGraph, filterFlowNodes, sliceFlowNodes } from './chatDebugFlowGraph.js'; +export { buildFlowGraph, filterFlowNodes, sliceFlowNodes, mergeDiscoveryNodes, mergeToolCallNodes } from './chatDebugFlowGraph.js'; export type { FlowNode, FlowFilterOptions, FlowSliceResult, FlowLayout, FlowChartRenderResult, LayoutNode, LayoutEdge, SubgraphRect } from './chatDebugFlowGraph.js'; // Layout + rendering export { layoutFlowGraph, renderFlowChartSVG } from './chatDebugFlowLayout.js'; diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts index 334d5d0456785..5be099a53b4e3 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts @@ -21,7 +21,7 @@ import { IChatService } from '../../common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../common/model/chatUri.js'; import { setupBreadcrumbKeyboardNavigation, TextBreadcrumbItem } from './chatDebugTypes.js'; import { ChatDebugFilterState, bindFilterContextKeys } from './chatDebugFilters.js'; -import { buildFlowGraph, filterFlowNodes, sliceFlowNodes, layoutFlowGraph, renderFlowChartSVG, FlowChartRenderResult } from './chatDebugFlowChart.js'; +import { buildFlowGraph, filterFlowNodes, sliceFlowNodes, mergeDiscoveryNodes, mergeToolCallNodes, layoutFlowGraph, renderFlowChartSVG, FlowChartRenderResult } from './chatDebugFlowChart.js'; import { ChatDebugDetailPanel } from './chatDebugDetailPanel.js'; const $ = DOM.$; @@ -76,6 +76,9 @@ export class ChatDebugFlowChartView extends Disposable { // Collapse state — persists across refreshes, resets on session change private readonly collapsedNodeIds = new Set(); + // Expanded merged-discovery nodes — persists across refreshes, resets on session change + private readonly expandedMergedIds = new Set(); + // Pagination state private visibleLimit: number = PAGE_SIZE; @@ -165,6 +168,7 @@ export class ChatDebugFlowChartView extends Disposable { this.hasUserPanned = false; this.focusedElementId = undefined; this.collapsedNodeIds.clear(); + this.expandedMergedIds.clear(); this.visibleLimit = PAGE_SIZE; this.detailPanel.hide(); } @@ -235,7 +239,8 @@ export class ChatDebugFlowChartView extends Disposable { } const slice = sliceFlowNodes(filtered, this.visibleLimit); - const layout = layoutFlowGraph(slice.nodes, { collapsedIds: this.collapsedNodeIds }); + const merged = mergeToolCallNodes(mergeDiscoveryNodes(slice.nodes)); + const layout = layoutFlowGraph(merged, { collapsedIds: this.collapsedNodeIds, expandedMergedIds: this.expandedMergedIds }); this.renderResult = renderFlowChartSVG(layout); this.svgWrapper = DOM.append(this.content, $('.chat-debug-flowchart-svg-wrapper')); @@ -385,6 +390,15 @@ export class ChatDebugFlowChartView extends Disposable { this.load(); } + private toggleMergedDiscovery(mergedId: string): void { + if (this.expandedMergedIds.has(mergedId)) { + this.expandedMergedIds.delete(mergedId); + } else { + this.expandedMergedIds.add(mergedId); + } + this.load(); + } + private focusFirstElement(): void { if (!this.renderResult) { return; @@ -489,6 +503,12 @@ export class ChatDebugFlowChartView extends Disposable { // Walk up from the click target to find a focusable element let target = e.target as Element | null; while (target && target !== this.content) { + // Merged-discovery expand toggle + const mergedId = target.getAttribute?.('data-merged-id'); + if (mergedId) { + this.toggleMergedDiscovery(mergedId); + return; + } const subgraphId = target.getAttribute?.('data-subgraph-id'); if (subgraphId) { this.toggleSubgraph(subgraphId); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts index b2cf11e212980..6cfe024552c64 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { localize } from '../../../../../nls.js'; import { IChatDebugEvent } from '../../common/chatDebugService.js'; // ---- Data model ---- @@ -19,6 +20,8 @@ export interface FlowNode { readonly isError?: boolean; readonly created: number; readonly children: FlowNode[]; + /** Present on merged discovery nodes: the individual nodes that were merged. */ + readonly mergedNodes?: FlowNode[]; } export interface FlowFilterOptions { @@ -37,6 +40,10 @@ export interface LayoutNode { readonly y: number; readonly width: number; readonly height: number; + /** Number of individual nodes merged into this one (for discovery merging). */ + readonly mergedCount?: number; + /** Whether the merged node is currently expanded (individual nodes shown to the right). */ + readonly isMergedExpanded?: boolean; } export interface LayoutEdge { @@ -160,12 +167,16 @@ export function buildFlowGraph(events: readonly IChatDebugEvent[]): FlowNode[] { function toFlowNode(event: IChatDebugEvent): FlowNode { const children = event.id ? idToChildren.get(event.id) : undefined; + // Remap generic events with well-known names to their proper kind + // so they get correct styling and sublabel treatment. + const effectiveKind = getEffectiveKind(event); + // For subagent invocations, enrich with description from the // filtered-out completion sibling, or fall back to the event's own field. - let sublabel = getEventSublabel(event); + let sublabel = getEventSublabel(event, effectiveKind); let tooltip = getEventTooltip(event); let description: string | undefined; - if (event.kind === 'subagentInvocation') { + if (effectiveKind === 'subagentInvocation') { description = getSubagentDescription(event); if (description) { sublabel = truncateLabel(description, 30) + (sublabel ? ` \u00b7 ${sublabel}` : ''); @@ -180,9 +191,9 @@ export function buildFlowGraph(events: readonly IChatDebugEvent[]): FlowNode[] { return { id: event.id ?? `event-${events.indexOf(event)}`, - kind: event.kind, + kind: effectiveKind, category: event.kind === 'generic' ? event.category : undefined, - label: getEventLabel(event), + label: getEventLabel(event, effectiveKind), sublabel, description, tooltip, @@ -192,70 +203,7 @@ export function buildFlowGraph(events: readonly IChatDebugEvent[]): FlowNode[] { }; } - return mergeModelTurns(roots.map(toFlowNode)); -} - -/** - * Absorbs model turn nodes into the subsequent sibling node. - * - * Each model turn represents an LLM call that decides what to do next - * (call tools, respond, etc.). Rather than showing model turns as separate - * boxes, we merge their metadata (token count, LLM latency) into the next - * node's sublabel and tooltip so the diagram stays compact while - * preserving the correlation. - */ -function mergeModelTurns(nodes: FlowNode[]): FlowNode[] { - const result: FlowNode[] = []; - let pendingModelTurn: FlowNode | undefined; - - for (const node of nodes) { - if (node.kind === 'modelTurn') { - pendingModelTurn = node; - continue; - } - - const merged = applyModelTurnInfo(node, pendingModelTurn); - pendingModelTurn = undefined; - result.push(merged); - } - - // If the last node was a model turn with no successor, keep it - if (pendingModelTurn) { - result.push(pendingModelTurn); - } - - return result; -} - -/** - * Enriches a node with model turn metadata and recursively - * merges model turns within its children. - */ -function applyModelTurnInfo(node: FlowNode, modelTurn: FlowNode | undefined): FlowNode { - const mergedChildren = node.children.length > 0 ? mergeModelTurns(node.children) : node.children; - - if (!modelTurn) { - return mergedChildren !== node.children ? { ...node, children: mergedChildren } : node; - } - - // Build compact annotation from model turn info (e.g. "500 tok · LLM 2.3s") - const annotation = modelTurn.sublabel; - const newSublabel = annotation - ? (node.sublabel ? `${node.sublabel} \u00b7 ${annotation}` : annotation) - : node.sublabel; - - // Enrich tooltip with model turn details - const modelTooltip = modelTurn.tooltip ?? (modelTurn.label !== 'Model Turn' ? modelTurn.label : undefined); - const newTooltip = modelTooltip - ? (node.tooltip ? `${node.tooltip}\n\nModel: ${modelTooltip}` : `Model: ${modelTooltip}`) - : node.tooltip; - - return { - ...node, - sublabel: newSublabel, - tooltip: newTooltip, - children: mergedChildren, - }; + return roots.map(toFlowNode); } // ---- Flow node filtering ---- @@ -386,59 +334,280 @@ export function sliceFlowNodes(nodes: readonly FlowNode[], maxCount: number): Fl return { nodes: sliced, totalCount, shownCount }; } +// ---- Discovery node merging ---- + +function isDiscoveryNode(node: FlowNode): boolean { + return node.kind === 'generic' && node.category === 'discovery'; +} + +/** + * Merges consecutive prompt-discovery nodes (generic events with + * `category === 'discovery'`) into a single summary node. + * + * The merged node always stays in the graph and carries the individual + * nodes in `mergedNodes`. Expansion (showing the individual nodes to the + * right) is handled at the layout level. + * + * Operates recursively on children. + */ +export function mergeDiscoveryNodes( + nodes: readonly FlowNode[], +): FlowNode[] { + const result: FlowNode[] = []; + + let i = 0; + while (i < nodes.length) { + const node = nodes[i]; + + // Non-discovery node: recurse into children and pass through. + if (!isDiscoveryNode(node)) { + const mergedChildren = mergeDiscoveryNodes(node.children); + result.push(mergedChildren !== node.children ? { ...node, children: mergedChildren } : node); + i++; + continue; + } + + // Accumulate a run of consecutive discovery nodes. + const run: FlowNode[] = [node]; + let j = i + 1; + while (j < nodes.length && isDiscoveryNode(nodes[j])) { + run.push(nodes[j]); + j++; + } + + if (run.length < 2) { + // Single discovery node — nothing to merge. + result.push(node); + i = j; + continue; + } + + // Build a stable id from the first node so the expand state persists. + const mergedId = `merged-discovery:${run[0].id}`; + + // Build a merged summary node. + const labels = run.map(n => n.label); + const uniqueLabels = [...new Set(labels)]; + const summaryLabel = uniqueLabels.length <= 2 + ? uniqueLabels.join(', ') + : localize('discoveryMergedLabel', "{0} +{1} more", uniqueLabels[0], run.length - 1); + + result.push({ + id: mergedId, + kind: 'generic', + category: 'discovery', + label: summaryLabel, + sublabel: localize('discoveryStepsCount', "{0} discovery steps", run.length), + tooltip: run.map(n => n.label + (n.sublabel ? `: ${n.sublabel}` : '')).join('\n'), + created: run[0].created, + children: [], + mergedNodes: run, + }); + i = j; + } + + return result; +} + +// ---- Tool call node merging ---- + +function isToolCallNode(node: FlowNode): boolean { + return node.kind === 'toolCall'; +} + +/** + * Returns the tool name from a tool-call node's label. + * Tool call labels are set to `event.toolName` (possibly with a leading + * emoji prefix stripped), so the label itself is the canonical tool name. + */ +function getToolName(node: FlowNode): string { + return node.label; +} + +/** + * Merges consecutive tool-call nodes that invoke the same tool into a + * single summary node. + * + * This mirrors `mergeDiscoveryNodes`: the merged node carries the + * individual nodes in `mergedNodes` and expansion is handled at the + * layout level. + * + * Operates recursively on children. + */ +export function mergeToolCallNodes( + nodes: readonly FlowNode[], +): FlowNode[] { + const result: FlowNode[] = []; + + let i = 0; + while (i < nodes.length) { + const node = nodes[i]; + + // Non-tool-call node: recurse into children and pass through. + if (!isToolCallNode(node)) { + const mergedChildren = mergeToolCallNodes(node.children); + result.push(mergedChildren !== node.children ? { ...node, children: mergedChildren } : node); + i++; + continue; + } + + // Accumulate a run of consecutive tool-call nodes with the same tool name. + const toolName = getToolName(node); + const run: FlowNode[] = [node]; + let j = i + 1; + while (j < nodes.length && isToolCallNode(nodes[j]) && getToolName(nodes[j]) === toolName) { + run.push(nodes[j]); + j++; + } + + if (run.length < 2) { + // Single tool call — recurse into children, nothing to merge. + const mergedChildren = mergeToolCallNodes(node.children); + result.push(mergedChildren !== node.children ? { ...node, children: mergedChildren } : node); + i = j; + continue; + } + + // Build a stable id from the first node so the expand state persists. + const mergedId = `merged-toolCall:${run[0].id}`; + + result.push({ + id: mergedId, + kind: 'toolCall', + label: toolName, + sublabel: localize('toolCallsCount', "{0} calls", run.length), + tooltip: run.map(n => n.label + (n.sublabel ? `: ${n.sublabel}` : '')).join('\n'), + created: run[0].created, + children: [], + mergedNodes: run, + }); + i = j; + } + + return result; +} + // ---- Event helpers ---- -function getEventLabel(event: IChatDebugEvent): string { - switch (event.kind) { - case 'userMessage': { - const firstLine = event.message.split('\n')[0]; - return firstLine.length > 40 ? firstLine.substring(0, 37) + '...' : firstLine; +/** + * Remaps generic events with well-known names (e.g. "User message", + * "Agent response") to their proper typed kind so they receive + * correct colors, labels, and sublabel treatment in the flow chart. + */ +function getEffectiveKind(event: IChatDebugEvent): IChatDebugEvent['kind'] { + if (event.kind === 'generic') { + const name = event.name.toLowerCase().replace(/[\s_-]+/g, ''); + if (name === 'usermessage' || name === 'userprompt' || name === 'user' || name.startsWith('usermessage')) { + return 'userMessage'; + } + if (name === 'response' || name.startsWith('agentresponse') || name.startsWith('assistantresponse') || name.startsWith('modelresponse')) { + return 'agentResponse'; + } + const cat = event.category?.toLowerCase(); + if (cat === 'user' || cat === 'usermessage') { + return 'userMessage'; } + if (cat === 'response' || cat === 'agentresponse') { + return 'agentResponse'; + } + } + return event.kind; +} + +function getEventLabel(event: IChatDebugEvent, effectiveKind?: IChatDebugEvent['kind']): string { + const kind = effectiveKind ?? event.kind; + switch (kind) { + case 'userMessage': + return localize('userLabel', "User"); case 'modelTurn': - return event.model ?? 'Model Turn'; + return event.kind === 'modelTurn' ? (event.model ?? localize('modelTurnLabel', "Model Turn")) : localize('modelTurnLabel', "Model Turn"); case 'toolCall': - return event.toolName; + return event.kind === 'toolCall' ? event.toolName : event.kind === 'generic' ? event.name : ''; case 'subagentInvocation': - return event.agentName; - case 'agentResponse': - return 'Response'; + return event.kind === 'subagentInvocation' ? event.agentName : ''; + case 'agentResponse': { + if (event.kind === 'agentResponse') { + return event.message || localize('responseLabel', "Response"); + } + // Remapped generic event — extract model name from parenthesized suffix + // e.g. "Agent response (claude-opus-4.5)" → "claude-opus-4.5" + if (event.kind === 'generic') { + const match = /\(([^)]+)\)\s*$/.exec(event.name); + if (match) { + return match[1]; + } + } + return localize('responseLabel', "Response"); + } case 'generic': - return event.name; + return event.kind === 'generic' ? event.name : ''; } } -function getEventSublabel(event: IChatDebugEvent): string | undefined { - switch (event.kind) { +function getEventSublabel(event: IChatDebugEvent, effectiveKind?: IChatDebugEvent['kind']): string | undefined { + const kind = effectiveKind ?? event.kind; + switch (kind) { case 'modelTurn': { const parts: string[] = []; - if (event.totalTokens) { - parts.push(`${event.totalTokens} tokens`); + if (event.kind === 'modelTurn' && event.totalTokens) { + parts.push(localize('tokenCount', "{0} tokens", event.totalTokens)); } - if (event.durationInMillis) { + if (event.kind === 'modelTurn' && event.durationInMillis) { parts.push(formatDuration(event.durationInMillis)); } return parts.length > 0 ? parts.join(' \u00b7 ') : undefined; } case 'toolCall': { const parts: string[] = []; - if (event.result) { + if (event.kind === 'toolCall' && event.result) { parts.push(event.result); } - if (event.durationInMillis) { + if (event.kind === 'toolCall' && event.durationInMillis) { parts.push(formatDuration(event.durationInMillis)); } return parts.length > 0 ? parts.join(' \u00b7 ') : undefined; } case 'subagentInvocation': { const parts: string[] = []; - if (event.status) { + if (event.kind === 'subagentInvocation' && event.status) { parts.push(event.status); } - if (event.durationInMillis) { + if (event.kind === 'subagentInvocation' && event.durationInMillis) { parts.push(formatDuration(event.durationInMillis)); } return parts.length > 0 ? parts.join(' \u00b7 ') : undefined; } + case 'userMessage': + case 'agentResponse': { + // For proper typed events, prefer the first section's content + // (which has the actual message text) over the `message` field + // (which is a short summary/name). Fall back to `message` when + // no sections are available. For remapped generic events, use + // the details property. + let text: string | undefined; + if (event.kind === 'userMessage' || event.kind === 'agentResponse') { + text = event.sections[0]?.content || event.message; + } else if (event.kind === 'generic') { + text = event.details; + } + if (!text) { + return undefined; + } + // Find the first non-empty line (content may start with newlines) + const lines = text.split('\n'); + let firstLine = ''; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed) { + firstLine = trimmed; + break; + } + } + if (!firstLine) { + return undefined; + } + return firstLine.length > 60 ? firstLine.substring(0, 57) + '...' : firstLine; + } default: return undefined; } @@ -472,14 +641,14 @@ function getEventTooltip(event: IChatDebugEvent): string | undefined { const parts: string[] = [event.toolName]; if (event.input) { const input = event.input.trim(); - parts.push(`Input: ${input.length > TOOLTIP_MAX_LENGTH ? input.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : input}`); + parts.push(localize('tooltipInput', "Input: {0}", input.length > TOOLTIP_MAX_LENGTH ? input.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : input)); } if (event.output) { const output = event.output.trim(); - parts.push(`Output: ${output.length > TOOLTIP_MAX_LENGTH ? output.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : output}`); + parts.push(localize('tooltipOutput', "Output: {0}", output.length > TOOLTIP_MAX_LENGTH ? output.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : output)); } if (event.result) { - parts.push(`Result: ${event.result}`); + parts.push(localize('tooltipResult', "Result: {0}", event.result)); } return parts.join('\n'); } @@ -489,13 +658,13 @@ function getEventTooltip(event: IChatDebugEvent): string | undefined { parts.push(event.description); } if (event.status) { - parts.push(`Status: ${event.status}`); + parts.push(localize('tooltipStatus', "Status: {0}", event.status)); } if (event.toolCallCount !== undefined) { - parts.push(`Tool calls: ${event.toolCallCount}`); + parts.push(localize('tooltipToolCalls', "Tool calls: {0}", event.toolCallCount)); } if (event.modelTurnCount !== undefined) { - parts.push(`Model turns: ${event.modelTurnCount}`); + parts.push(localize('tooltipModelTurns', "Model turns: {0}", event.modelTurnCount)); } return parts.join('\n'); } @@ -512,16 +681,16 @@ function getEventTooltip(event: IChatDebugEvent): string | undefined { parts.push(event.model); } if (event.totalTokens) { - parts.push(`Tokens: ${event.totalTokens}`); + parts.push(localize('tooltipTokens', "Tokens: {0}", event.totalTokens)); } if (event.inputTokens) { - parts.push(`Input tokens: ${event.inputTokens}`); + parts.push(localize('tooltipInputTokens', "Input tokens: {0}", event.inputTokens)); } if (event.outputTokens) { - parts.push(`Output tokens: ${event.outputTokens}`); + parts.push(localize('tooltipOutputTokens', "Output tokens: {0}", event.outputTokens)); } if (event.durationInMillis) { - parts.push(`Duration: ${formatDuration(event.durationInMillis)}`); + parts.push(localize('tooltipDuration', "Duration: {0}", formatDuration(event.durationInMillis))); } return parts.length > 0 ? parts.join('\n') : undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts index 5e730d0309c2e..4b35620ab67db 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts @@ -9,6 +9,7 @@ import { FlowLayout, FlowNode, LayoutEdge, LayoutNode, SubgraphRect, FlowChartRe // ---- Layout constants ---- const NODE_HEIGHT = 36; +const MESSAGE_NODE_HEIGHT = 52; const NODE_MIN_WIDTH = 140; const NODE_MAX_WIDTH = 320; const NODE_PADDING_H = 16; @@ -23,6 +24,7 @@ const CANVAS_PADDING = 24; const PARALLEL_GAP_X = 40; const SUBGRAPH_HEADER_HEIGHT = 22; const GUTTER_WIDTH = 3; +const MERGED_TOGGLE_WIDTH = 36; // ---- Layout internals ---- @@ -41,6 +43,14 @@ interface ChildGroup { readonly children: FlowNode[]; } +/** Deferred expansion of a merged-discovery node, resolved in pass 2. */ +interface PendingExpansion { + /** The merged summary LayoutNode (already placed). */ + readonly mergedNode: LayoutNode; + /** The individual FlowNodes to expand to the right. */ + readonly children: readonly FlowNode[]; +} + // ---- Parallel detection ---- /** Max time gap (ms) between subagent `created` timestamps to consider them parallel. */ @@ -137,6 +147,10 @@ function groupChildren(children: FlowNode[]): ChildGroup[] { // ---- Layout engine ---- +function isMessageKind(kind: IChatDebugEvent['kind']): boolean { + return kind === 'userMessage' || kind === 'agentResponse'; +} + function measureNodeWidth(label: string, sublabel?: string): number { const charWidth = 7; const labelWidth = label.length * charWidth + NODE_PADDING_H * 2; @@ -174,6 +188,8 @@ function layoutGroups( prevExitNodes: LayoutNode[], result: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] }, collapsedIds?: ReadonlySet, + expandedMergedIds?: ReadonlySet, + pendingExpansions?: PendingExpansion[], ): { exitNodes: LayoutNode[]; maxWidth: number; endY: number } { let currentY = startY; let maxWidth = 0; @@ -181,7 +197,7 @@ function layoutGroups( for (const group of groups) { if (group.type === 'parallel') { - const pg = layoutParallelGroup(group.children, startX, currentY, depth, collapsedIds); + const pg = layoutParallelGroup(group.children, startX, currentY, depth, collapsedIds, expandedMergedIds, pendingExpansions); result.nodes.push(...pg.nodes); result.edges.push(...pg.edges); result.subgraphs.push(...pg.subgraphs); @@ -196,7 +212,7 @@ function layoutGroups( currentY += pg.height + NODE_GAP_Y; } else { for (const child of group.children) { - const sub = layoutSubtree(child, startX, currentY, depth, collapsedIds); + const sub = layoutSubtree(child, startX, currentY, depth, collapsedIds, expandedMergedIds, pendingExpansions); result.nodes.push(...sub.nodes); result.edges.push(...sub.edges); result.subgraphs.push(...sub.subgraphs); @@ -226,32 +242,123 @@ function makeEdge(from: LayoutNode, to: LayoutNode): LayoutEdge { * Lays out a list of flow nodes in a top-down vertical flow. * Parallel subagent invocations are arranged side by side. */ -export function layoutFlowGraph(roots: FlowNode[], options?: { collapsedIds?: ReadonlySet }): FlowLayout { +export function layoutFlowGraph(roots: FlowNode[], options?: { collapsedIds?: ReadonlySet; expandedMergedIds?: ReadonlySet }): FlowLayout { if (roots.length === 0) { return { nodes: [], edges: [], subgraphs: [], width: 0, height: 0 }; } const collapsedIds = options?.collapsedIds; + const expandedMergedIds = options?.expandedMergedIds; const groups = groupChildren(roots); + const pendingExpansions: PendingExpansion[] = []; const result: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] } = { nodes: [], edges: [], subgraphs: [], }; - const { maxWidth, endY } = layoutGroups(groups, CANVAS_PADDING, CANVAS_PADDING, 0, [], result, collapsedIds); - const width = maxWidth + CANVAS_PADDING * 2; - const height = endY - NODE_GAP_Y + CANVAS_PADDING; + // Pass 1: layout the main vertical flow; expanded merged nodes only + // place their summary node and defer children to pendingExpansions. + const { maxWidth, endY } = layoutGroups(groups, CANVAS_PADDING, CANVAS_PADDING, 0, [], result, collapsedIds, expandedMergedIds, pendingExpansions); + + // Pass 2: resolve deferred expansions — place children to the right, + // far enough to clear all existing nodes/subgraphs in the Y range. + resolvePendingExpansions(pendingExpansions, result); + + let width = maxWidth + CANVAS_PADDING * 2; + let height = endY - NODE_GAP_Y + CANVAS_PADDING; + + // Expand canvas to cover any nodes that float outside the main flow. + for (const n of result.nodes) { + width = Math.max(width, n.x + n.width + CANVAS_PADDING); + height = Math.max(height, n.y + n.height + CANVAS_PADDING); + } centerLayout(result as FlowLayout & { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] }, width / 2); return { nodes: result.nodes, edges: result.edges, subgraphs: result.subgraphs, width, height }; } -function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, collapsedIds?: ReadonlySet): SubtreeLayout { - const nodeWidth = measureNodeWidth(node.label, node.sublabel); +/** + * Pass 2: For each pending expansion, compute the Y range the children + * will occupy, scan all already-placed nodes and subgraphs for the max + * right edge overlapping that range, and place the entire column of + * children to the right of that edge. + */ +function resolvePendingExpansions( + pendingExpansions: PendingExpansion[], + result: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] }, +): void { + for (const expansion of pendingExpansions) { + const { mergedNode, children } = expansion; + + // Compute the Y range the children will occupy. + const childrenTotalHeight = children.length * NODE_HEIGHT + (children.length - 1) * NODE_GAP_Y; + const rangeTop = mergedNode.y; + const rangeBottom = mergedNode.y + childrenTotalHeight; + + // Find the max right edge of any existing node or subgraph + // that overlaps this Y range. + let maxRightX = mergedNode.x + mergedNode.width; + for (const n of result.nodes) { + if (n.y + n.height > rangeTop && n.y < rangeBottom) { + maxRightX = Math.max(maxRightX, n.x + n.width); + } + } + for (const sg of result.subgraphs) { + if (sg.y + sg.height > rangeTop && sg.y < rangeBottom) { + maxRightX = Math.max(maxRightX, sg.x + sg.width); + } + } + + const expandX = maxRightX + PARALLEL_GAP_X; + let expandY = mergedNode.y; + let expandMaxWidth = 0; + + const childNodes: LayoutNode[] = []; + for (const child of children) { + const childWidth = measureNodeWidth(child.label, child.sublabel); + const childNode: LayoutNode = { + id: child.id, + kind: child.kind, + label: child.label, + sublabel: child.sublabel, + tooltip: child.tooltip, + isError: child.isError, + x: expandX, + y: expandY, + width: childWidth, + height: NODE_HEIGHT, + }; + childNodes.push(childNode); + result.nodes.push(childNode); + expandMaxWidth = Math.max(expandMaxWidth, childWidth); + expandY += NODE_HEIGHT + NODE_GAP_Y; + } + + // Horizontal edge from merged node to first child + result.edges.push({ + fromX: mergedNode.x + mergedNode.width, + fromY: mergedNode.y + mergedNode.height / 2, + toX: expandX, + toY: childNodes[0].y + childNodes[0].height / 2, + }); + + // Vertical edges between consecutive children + for (let k = 0; k < childNodes.length - 1; k++) { + result.edges.push(makeEdge(childNodes[k], childNodes[k + 1])); + } + } +} + +function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, collapsedIds?: ReadonlySet, expandedMergedIds?: ReadonlySet, pendingExpansions?: PendingExpansion[]): SubtreeLayout { + const isMerged = (node.mergedNodes?.length ?? 0) >= 2; + const isMergedExpanded = isMerged && expandedMergedIds?.has(node.id); + const mergedExtra = isMerged ? MERGED_TOGGLE_WIDTH : 0; + const nodeWidth = measureNodeWidth(node.label, node.sublabel) + mergedExtra; const isSubagent = node.kind === 'subagentInvocation'; const isCollapsed = isSubagent && collapsedIds?.has(node.id); + const nodeHeight = isMessageKind(node.kind) && node.sublabel ? MESSAGE_NODE_HEIGHT : NODE_HEIGHT; const layoutNode: LayoutNode = { id: node.id, @@ -263,7 +370,9 @@ function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, x: startX, y: y, width: nodeWidth, - height: NODE_HEIGHT, + height: nodeHeight, + mergedCount: isMerged ? node.mergedNodes!.length : undefined, + isMergedExpanded, }; const result: SubtreeLayout = { @@ -271,11 +380,19 @@ function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, edges: [], subgraphs: [], width: nodeWidth, - height: NODE_HEIGHT, + height: nodeHeight, entryNode: layoutNode, exitNodes: [layoutNode], }; + // Expanded merged discovery: defer child placement to pass 2. + // Only emit the merged summary node now; children will be placed + // to the right after all main-flow nodes have been positioned. + if (isMergedExpanded && pendingExpansions) { + pendingExpansions.push({ mergedNode: layoutNode, children: node.mergedNodes! }); + return result; + } + if (node.children.length === 0 && !isCollapsed) { return result; } @@ -284,7 +401,7 @@ function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, if (isCollapsed) { const collapsedHeight = SUBGRAPH_HEADER_HEIGHT + SUBGRAPH_PADDING * 2; const totalChildCount = countDescendants(node); - const sgY = (y + NODE_HEIGHT + NODE_GAP_Y) - NODE_GAP_Y / 2; + const sgY = (y + nodeHeight + NODE_GAP_Y) - NODE_GAP_Y / 2; const headerLabel = subgraphHeaderLabel(node); const sgWidth = Math.max(NODE_MIN_WIDTH, measureSubgraphHeaderWidth(headerLabel)) + SUBGRAPH_PADDING * 2; result.subgraphs.push({ @@ -300,12 +417,12 @@ function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, // Draw a connecting edge from the node to the collapsed subgraph result.edges.push({ fromX: startX + nodeWidth / 2, - fromY: y + NODE_HEIGHT, + fromY: y + nodeHeight, toX: startX - SUBGRAPH_PADDING + sgWidth / 2, toY: sgY, }); result.width = Math.max(nodeWidth, sgWidth); - result.height = NODE_HEIGHT + NODE_GAP_Y + collapsedHeight; + result.height = nodeHeight + NODE_GAP_Y + collapsedHeight; return result; } @@ -317,13 +434,13 @@ function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, const indentX = isSubagent ? SUBGRAPH_PADDING : 0; const groups = groupChildren(node.children); - let childStartY = y + NODE_HEIGHT + NODE_GAP_Y; + let childStartY = y + nodeHeight + NODE_GAP_Y; if (isSubagent) { childStartY += SUBGRAPH_HEADER_HEIGHT; } const { exitNodes, maxWidth, endY } = layoutGroups( - groups, startX + indentX, childStartY, childDepth, [layoutNode], result, collapsedIds, + groups, startX + indentX, childStartY, childDepth, [layoutNode], result, collapsedIds, expandedMergedIds, pendingExpansions, ); const totalChildrenHeight = endY - childStartY - NODE_GAP_Y; @@ -335,7 +452,7 @@ function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, result.subgraphs.push({ label: headerLabel, x: startX - SUBGRAPH_PADDING, - y: (y + NODE_HEIGHT + NODE_GAP_Y) - NODE_GAP_Y / 2, + y: (y + nodeHeight + NODE_GAP_Y) - NODE_GAP_Y / 2, width: sgContentWidth + SUBGRAPH_PADDING * 2, height: totalChildrenHeight + SUBGRAPH_HEADER_HEIGHT + NODE_GAP_Y, depth, @@ -344,13 +461,13 @@ function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, } result.width = Math.max(nodeWidth, maxWidth + indentX * 2, isSubagent ? sgContentWidth + indentX * 2 : 0); - result.height = NODE_HEIGHT + NODE_GAP_Y + totalChildrenHeight + (isSubagent ? SUBGRAPH_HEADER_HEIGHT : 0); + result.height = nodeHeight + NODE_GAP_Y + totalChildrenHeight + (isSubagent ? SUBGRAPH_HEADER_HEIGHT : 0); result.exitNodes = exitNodes; return result; } -function layoutParallelGroup(children: FlowNode[], startX: number, y: number, depth: number, collapsedIds?: ReadonlySet): { +function layoutParallelGroup(children: FlowNode[], startX: number, y: number, depth: number, collapsedIds?: ReadonlySet, expandedMergedIds?: ReadonlySet, pendingExpansions?: PendingExpansion[]): { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[]; @@ -364,7 +481,7 @@ function layoutParallelGroup(children: FlowNode[], startX: number, y: number, de let maxHeight = 0; for (const child of children) { - const subtree = layoutSubtree(child, 0, y, depth, collapsedIds); + const subtree = layoutSubtree(child, 0, y, depth, collapsedIds, expandedMergedIds, pendingExpansions); subtreeLayouts.push(subtree); totalWidth += subtree.width; maxHeight = Math.max(maxHeight, subtree.height); @@ -577,19 +694,58 @@ function renderSubgraphs(svg: SVGElement, subgraphs: readonly SubgraphRect[], fo function renderEdges(svg: SVGElement, edges: readonly LayoutEdge[]): void { const strokeAttrs = { fill: 'none', stroke: 'var(--vscode-descriptionForeground)', 'stroke-width': EDGE_STROKE_WIDTH, 'stroke-linecap': 'round' }; + // allow-any-unicode-next-line + const r = 6; // corner radius for 90° bends for (const edge of edges) { const midY = (edge.fromY + edge.toY) / 2; - svg.appendChild(svgEl('path', { - ...strokeAttrs, - d: `M ${edge.fromX} ${edge.fromY} C ${edge.fromX} ${midY}, ${edge.toX} ${midY}, ${edge.toX} ${edge.toY}`, - })); + let d: string; + const isHorizontal = edge.fromY === edge.toY; + + if (isHorizontal) { + // Horizontally aligned: straight line (used by expanded merged nodes) + d = `M ${edge.fromX} ${edge.fromY} L ${edge.toX} ${edge.toY}`; + } else if (edge.fromX === edge.toX) { + // Vertically aligned: straight line + d = `M ${edge.fromX} ${edge.fromY} L ${edge.toX} ${edge.toY}`; + } else { + // allow-any-unicode-next-line + // Orthogonal routing: down, 90° horizontal, 90° down + const dx = edge.toX - edge.fromX; + const signX = dx > 0 ? 1 : -1; + const absDx = Math.abs(dx); + const cr = Math.min(r, absDx / 2, (edge.toY - edge.fromY) / 4); + + d = `M ${edge.fromX} ${edge.fromY}` + // Down to first bend + + ` L ${edge.fromX} ${midY - cr}` + // allow-any-unicode-next-line + // 90° arc turning horizontal + + ` Q ${edge.fromX} ${midY}, ${edge.fromX + signX * cr} ${midY}` + // Horizontal to second bend + + ` L ${edge.toX - signX * cr} ${midY}` + // allow-any-unicode-next-line + // 90° arc turning down + + ` Q ${edge.toX} ${midY}, ${edge.toX} ${midY + cr}` + // Down to target + + ` L ${edge.toX} ${edge.toY}`; + } - const a = 5; // arrowhead size + svg.appendChild(svgEl('path', { ...strokeAttrs, d })); + + // Arrowhead: right-pointing for horizontal edges, down-pointing otherwise + const a = 5; + let arrowD: string; + if (isHorizontal) { + const signX = edge.toX > edge.fromX ? 1 : -1; + arrowD = `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}`; + } else { + arrowD = `M ${edge.toX - a} ${edge.toY - a * 1.5} L ${edge.toX} ${edge.toY} L ${edge.toX + a} ${edge.toY - a * 1.5}`; + } svg.appendChild(svgEl('path', { ...strokeAttrs, 'stroke-linejoin': 'round', - d: `M ${edge.toX - a} ${edge.toY - a * 1.5} L ${edge.toX} ${edge.toY} L ${edge.toX + a} ${edge.toY - a * 1.5}`, + d: arrowD, })); } } @@ -625,6 +781,21 @@ function renderNodes(svg: SVGElement, nodes: readonly LayoutNode[], focusableEle clipPath.appendChild(svgEl('rect', rectAttrs)); svg.appendChild(clipPath); + // Focus ring (hidden by default, shown on :focus via CSS) + const focusOffset = 3; + g.appendChild(svgEl('rect', { + class: 'chat-debug-flowchart-focus-ring', + x: node.x - focusOffset, + y: node.y - focusOffset, + width: node.width + focusOffset * 2, + height: node.height + focusOffset * 2, + rx: NODE_BORDER_RADIUS + focusOffset, + ry: NODE_BORDER_RADIUS + focusOffset, + fill: 'none', + stroke: 'var(--vscode-focusBorder)', + 'stroke-width': 2, + })); + // Node rectangle g.appendChild(svgEl('rect', { ...rectAttrs, fill: nodeFill, stroke: color, 'stroke-width': node.isError ? 2 : 1.5 })); @@ -633,20 +804,80 @@ function renderNodes(svg: SVGElement, nodes: readonly LayoutNode[], focusableEle // Label text const textX = node.x + NODE_PADDING_H; - if (node.sublabel) { + const isMessage = isMessageKind(node.kind); + if (isMessage && node.sublabel) { + // Message nodes: small header label + larger message text + const 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})` }); + header.textContent = node.label; + g.appendChild(header); + + const 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})` }); + msg.textContent = node.sublabel; + g.appendChild(msg); + } else if (node.sublabel) { const 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})` }); label.textContent = node.label; g.appendChild(label); - const 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})` }); + const 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})` }); sub.textContent = node.sublabel; g.appendChild(sub); } else { - const 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})` }); + const 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})` }); label.textContent = node.label; g.appendChild(label); } + // Merged-discovery expand/collapse toggle on the right side + if (node.mergedCount) { + renderMergedToggle(g, node, color, fontFamily); + } + svg.appendChild(g); } } + +function renderMergedToggle(g: Element, node: LayoutNode, color: string, fontFamily: string): void { + const toggleX = node.x + node.width - MERGED_TOGGLE_WIDTH; + const toggleGroup = document.createElementNS(SVG_NS, 'g'); + toggleGroup.classList.add('chat-debug-flowchart-merged-toggle'); + toggleGroup.setAttribute('data-merged-id', node.id); + + // Separator line + toggleGroup.appendChild(svgEl('line', { + x1: toggleX, y1: node.y + 4, + x2: toggleX, y2: node.y + node.height - 4, + stroke: 'var(--vscode-descriptionForeground)', + 'stroke-width': 0.5, + opacity: 0.4, + })); + + // allow-any-unicode-next-line + // Expand chevron (▶ collapsed, ◀ expanded) + const chevronX = toggleX + MERGED_TOGGLE_WIDTH / 2; + const chevronY = node.y + node.height / 2; + const chevron = svgEl('text', { + x: chevronX, + y: chevronY + 4, + 'font-size': 9, + fill: color, + 'font-family': fontFamily, + 'text-anchor': 'middle', + cursor: 'pointer', + }); + // allow-any-unicode-next-line + chevron.textContent = node.isMergedExpanded ? '\u25C0' : '\u25B6'; // ◀ or ▶ + toggleGroup.appendChild(chevron); + + // Hit area for the toggle — invisible rect covering the toggle zone + toggleGroup.appendChild(svgEl('rect', { + x: toggleX, + y: node.y, + width: MERGED_TOGGLE_WIDTH, + height: node.height, + fill: 'transparent', + cursor: 'pointer', + })); + + g.appendChild(toggleGroup); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugModelTurnContentRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugModelTurnContentRenderer.ts new file mode 100644 index 0000000000000..cc88136bf2b3f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugModelTurnContentRenderer.ts @@ -0,0 +1,137 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { IChatDebugEventModelTurnContent } from '../../common/chatDebugService.js'; +import { renderCollapsibleSection } from './chatDebugCollapsible.js'; + +const $ = DOM.$; + +/** + * Render a resolved model turn content with structured display of + * request metadata, token usage, and timing. + */ +export function renderModelTurnContent(content: IChatDebugEventModelTurnContent): { element: HTMLElement; disposables: DisposableStore } { + const disposables = new DisposableStore(); + const container = $('div.chat-debug-message-content'); + container.tabIndex = 0; + + // Header: Model Turn + DOM.append(container, $('div.chat-debug-message-content-title', undefined, localize('chatDebug.modelTurn.title', "Model Turn"))); + + // Status summary line + const statusParts: string[] = []; + if (content.requestName) { + statusParts.push(content.requestName); + } + if (content.model) { + statusParts.push(content.model); + } + if (content.status) { + statusParts.push(content.status); + } + if (content.durationInMillis !== undefined) { + statusParts.push(localize('chatDebug.modelTurn.duration', "{0}ms", content.durationInMillis)); + } + if (statusParts.length > 0) { + DOM.append(container, $('div.chat-debug-message-content-summary', undefined, statusParts.join(' \u00b7 '))); + } + + // Token usage details + const detailsContainer = DOM.append(container, $('div.chat-debug-model-turn-details')); + + if (content.inputTokens !== undefined) { + DOM.append(detailsContainer, $('div', undefined, localize('chatDebug.modelTurn.inputTokens', "Input tokens: {0}", content.inputTokens))); + } + if (content.outputTokens !== undefined) { + DOM.append(detailsContainer, $('div', undefined, localize('chatDebug.modelTurn.outputTokens', "Output tokens: {0}", content.outputTokens))); + } + if (content.cachedTokens !== undefined) { + DOM.append(detailsContainer, $('div', undefined, localize('chatDebug.modelTurn.cachedTokens', "Cached tokens: {0}", content.cachedTokens))); + } + if (content.totalTokens !== undefined) { + DOM.append(detailsContainer, $('div', undefined, localize('chatDebug.modelTurn.totalTokens', "Total tokens: {0}", content.totalTokens))); + } + if (content.timeToFirstTokenInMillis !== undefined) { + DOM.append(detailsContainer, $('div', undefined, localize('chatDebug.modelTurn.ttft', "Time to first token: {0}ms", content.timeToFirstTokenInMillis))); + } + if (content.maxInputTokens !== undefined) { + DOM.append(detailsContainer, $('div', undefined, localize('chatDebug.modelTurn.maxInputTokens', "Max input tokens: {0}", content.maxInputTokens))); + } + if (content.maxOutputTokens !== undefined) { + DOM.append(detailsContainer, $('div', undefined, localize('chatDebug.modelTurn.maxOutputTokens', "Max output tokens: {0}", content.maxOutputTokens))); + } + if (content.errorMessage) { + DOM.append(detailsContainer, $('div.chat-debug-model-turn-error', undefined, localize('chatDebug.modelTurn.error', "Error: {0}", content.errorMessage))); + } + + // Collapsible sections (e.g., system prompt, user prompt, tools, response) + if (content.sections && content.sections.length > 0) { + const sectionsContainer = DOM.append(container, $('div.chat-debug-message-sections')); + DOM.append(sectionsContainer, $('div.chat-debug-message-sections-label', undefined, + localize('chatDebug.modelTurn.sections', "Sections ({0})", content.sections.length))); + + for (const section of content.sections) { + renderCollapsibleSection(sectionsContainer, section, disposables); + } + } + + return { element: container, disposables }; +} + +/** + * Convert a resolved model turn content to plain text for clipboard / editor output. + */ +export function modelTurnContentToPlainText(content: IChatDebugEventModelTurnContent): string { + const lines: string[] = []; + lines.push(localize('chatDebug.modelTurn.requestLabel', "Request: {0}", content.requestName)); + + if (content.model) { + lines.push(localize('chatDebug.modelTurn.modelLabel', "Model: {0}", content.model)); + } + if (content.status) { + lines.push(localize('chatDebug.modelTurn.statusLabel', "Status: {0}", content.status)); + } + if (content.durationInMillis !== undefined) { + lines.push(localize('chatDebug.modelTurn.durationLabel', "Duration: {0}ms", content.durationInMillis)); + } + if (content.timeToFirstTokenInMillis !== undefined) { + lines.push(localize('chatDebug.modelTurn.ttftLabel', "Time to first token: {0}ms", content.timeToFirstTokenInMillis)); + } + if (content.inputTokens !== undefined) { + lines.push(localize('chatDebug.modelTurn.inputTokensLabel', "Input tokens: {0}", content.inputTokens)); + } + if (content.outputTokens !== undefined) { + lines.push(localize('chatDebug.modelTurn.outputTokensLabel', "Output tokens: {0}", content.outputTokens)); + } + if (content.cachedTokens !== undefined) { + lines.push(localize('chatDebug.modelTurn.cachedTokensLabel', "Cached tokens: {0}", content.cachedTokens)); + } + if (content.totalTokens !== undefined) { + lines.push(localize('chatDebug.modelTurn.totalTokensLabel', "Total tokens: {0}", content.totalTokens)); + } + if (content.maxInputTokens !== undefined) { + lines.push(localize('chatDebug.modelTurn.maxInputTokensLabel', "Max input tokens: {0}", content.maxInputTokens)); + } + if (content.maxOutputTokens !== undefined) { + lines.push(localize('chatDebug.modelTurn.maxOutputTokensLabel', "Max output tokens: {0}", content.maxOutputTokens)); + } + if (content.errorMessage) { + lines.push(localize('chatDebug.modelTurn.errorLabel', "Error: {0}", content.errorMessage)); + } + + if (content.sections && content.sections.length > 0) { + lines.push(''); + for (const section of content.sections) { + lines.push(`--- ${section.name} ---`); + lines.push(section.content); + lines.push(''); + } + } + + return lines.join('\n'); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugToolCallContentRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugToolCallContentRenderer.ts new file mode 100644 index 0000000000000..f8e0618f271ba --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugToolCallContentRenderer.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createTrustedTypesPolicy } from '../../../../../base/browser/trustedTypes.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { tokenizeToString } from '../../../../../editor/common/languages/textToHtmlTokenizer.js'; +import { IChatDebugEventToolCallContent } from '../../common/chatDebugService.js'; +import { setupCollapsibleToggle } from './chatDebugCollapsible.js'; + +const $ = DOM.$; + +const _ttpPolicy = createTrustedTypesPolicy('chatDebugTokenizer', { + createHTML(html: string) { + return html; + } +}); + +function tryParseJSON(text: string): { parsed: unknown; isJSON: true } | { isJSON: false } { + try { + return { parsed: JSON.parse(text), isJSON: true }; + } catch { + return { isJSON: false }; + } +} + +/** + * Render a collapsible section. When `tokenizedHtml` is provided the content + * is rendered as syntax-highlighted HTML; otherwise plain-text is used. + */ +function renderSection( + parent: HTMLElement, + label: string, + plainText: string, + tokenizedHtml: string | undefined, + disposables: DisposableStore, + initiallyCollapsed: boolean = false, +): void { + const sectionEl = DOM.append(parent, $('div.chat-debug-message-section')); + const header = DOM.append(sectionEl, $('div.chat-debug-message-section-header')); + const chevron = DOM.append(header, $('span.chat-debug-message-section-chevron')); + DOM.append(header, $('span.chat-debug-message-section-title', undefined, label)); + + const wrapper = DOM.append(sectionEl, $('div.chat-debug-message-section-content-wrapper')); + const contentEl = DOM.append(wrapper, $('pre.chat-debug-message-section-content')); + contentEl.tabIndex = 0; + + if (tokenizedHtml) { + const trustedHtml = _ttpPolicy?.createHTML(tokenizedHtml) ?? tokenizedHtml; + contentEl.innerHTML = trustedHtml as string; + } else { + contentEl.textContent = plainText; + } + + setupCollapsibleToggle(chevron, header, wrapper, disposables, initiallyCollapsed); +} + +/** + * Render a resolved tool call content with structured sections for + * tool name, status, duration, arguments, and output. + * Reuses the existing message content and collapsible section components. + * When JSON is detected in input/output, renders it with syntax highlighting + * using the editor's tokenization. + */ +export async function renderToolCallContent(content: IChatDebugEventToolCallContent, languageService: ILanguageService): Promise<{ element: HTMLElement; disposables: DisposableStore }> { + const disposables = new DisposableStore(); + const container = $('div.chat-debug-message-content'); + container.tabIndex = 0; + + // Header: tool name + DOM.append(container, $('div.chat-debug-message-content-title', undefined, content.toolName)); + + // Status summary line + const statusParts: string[] = []; + if (content.result) { + statusParts.push(content.result === 'success' + ? localize('chatDebug.toolCall.success', "Success") + : localize('chatDebug.toolCall.error', "Error")); + } + if (content.durationInMillis !== undefined) { + statusParts.push(localize('chatDebug.toolCall.duration', "{0}ms", content.durationInMillis)); + } + if (statusParts.length > 0) { + DOM.append(container, $('div.chat-debug-message-content-summary', undefined, statusParts.join(' \u00b7 '))); + } + + // Build collapsible sections for arguments and output + const sectionsContainer = DOM.append(container, $('div.chat-debug-message-sections')); + + if (content.input) { + const result = tryParseJSON(content.input); + const plainText = result.isJSON ? JSON.stringify(result.parsed, null, 2) : content.input; + const tokenizedHtml = result.isJSON + ? await tokenizeToString(languageService, plainText, 'json') + : undefined; + renderSection(sectionsContainer, localize('chatDebug.toolCall.arguments', "Arguments"), plainText, tokenizedHtml, disposables); + } + + if (content.output) { + const result = tryParseJSON(content.output); + const plainText = result.isJSON ? JSON.stringify(result.parsed, null, 2) : content.output; + const tokenizedHtml = result.isJSON + ? await tokenizeToString(languageService, plainText, 'json') + : undefined; + renderSection(sectionsContainer, localize('chatDebug.toolCall.output', "Output"), plainText, tokenizedHtml, disposables); + } + + return { element: container, disposables }; +} + +/** + * Convert a resolved tool call content to plain text for clipboard / editor output. + */ +export function toolCallContentToPlainText(content: IChatDebugEventToolCallContent): string { + const lines: string[] = []; + lines.push(localize('chatDebug.toolCall.toolLabel', "Tool: {0}", content.toolName)); + + if (content.result) { + lines.push(localize('chatDebug.toolCall.statusLabel', "Status: {0}", content.result)); + } + if (content.durationInMillis !== undefined) { + lines.push(localize('chatDebug.toolCall.durationLabel', "Duration: {0}ms", content.durationInMillis)); + } + + if (content.input) { + lines.push(''); + lines.push(`[${localize('chatDebug.toolCall.arguments', "Arguments")}]`); + try { + const parsed = JSON.parse(content.input); + lines.push(JSON.stringify(parsed, null, 2)); + } catch { + lines.push(content.input); + } + } + + if (content.output) { + lines.push(''); + lines.push(`[${localize('chatDebug.toolCall.output', "Output")}]`); + try { + const parsed = JSON.parse(content.output); + lines.push(JSON.stringify(parsed, null, 2)); + } catch { + lines.push(content.output); + } + } + + return lines.join('\n'); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css index 5e70e6491f7b2..a9d0c0dc2d515 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css @@ -740,14 +740,19 @@ .chat-debug-flowchart-subgraph:hover rect.chat-debug-flowchart-subgraph-header { opacity: 0.25; } -.chat-debug-flowchart-node:focus { - outline: 2px solid var(--vscode-focusBorder); - outline-offset: 2px; - border-radius: 6px; +.chat-debug-flowchart-focus-ring { + display: none; +} +.chat-debug-flowchart-node:focus, +.chat-debug-flowchart-node:focus-visible { + outline: none !important; +} +.chat-debug-flowchart-node:focus .chat-debug-flowchart-focus-ring { + display: inline; } .chat-debug-flowchart-subgraph-header:focus { outline: 2px solid var(--vscode-focusBorder); - outline-offset: 1px; + outline-offset: 2px; } .chat-debug-flowchart-empty { display: flex; diff --git a/src/vs/workbench/contrib/chat/common/chatDebugService.ts b/src/vs/workbench/contrib/chat/common/chatDebugService.ts index 4e83565a941a1..a4028bf9823b0 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugService.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugService.ts @@ -246,10 +246,44 @@ export interface IChatDebugEventMessageContent { readonly sections: readonly IChatDebugMessageSection[]; } +/** + * Structured tool call content for a resolved debug event. + * Contains the tool name, status, arguments, and output for rich rendering. + */ +export interface IChatDebugEventToolCallContent { + readonly kind: 'toolCall'; + readonly toolName: string; + readonly result?: 'success' | 'error'; + readonly durationInMillis?: number; + readonly input?: string; + readonly output?: string; +} + +/** + * Structured model turn content for a resolved debug event. + * Contains request metadata, token usage, and timing for rich rendering. + */ +export interface IChatDebugEventModelTurnContent { + readonly kind: 'modelTurn'; + readonly requestName: string; + readonly model?: string; + readonly status?: string; + readonly durationInMillis?: number; + readonly timeToFirstTokenInMillis?: number; + readonly maxInputTokens?: number; + readonly maxOutputTokens?: number; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly cachedTokens?: number; + readonly totalTokens?: number; + readonly errorMessage?: string; + readonly sections?: readonly IChatDebugMessageSection[]; +} + /** * Union of all resolved event content types. */ -export type IChatDebugResolvedEventContent = IChatDebugEventTextContent | IChatDebugEventFileListContent | IChatDebugEventMessageContent; +export type IChatDebugResolvedEventContent = IChatDebugEventTextContent | IChatDebugEventFileListContent | IChatDebugEventMessageContent | IChatDebugEventToolCallContent | IChatDebugEventModelTurnContent; /** * Provider interface for debug events. diff --git a/src/vscode-dts/vscode.proposed.chatDebug.d.ts b/src/vscode-dts/vscode.proposed.chatDebug.d.ts index 604cc0f0ef710..f74f4e7ba1159 100644 --- a/src/vscode-dts/vscode.proposed.chatDebug.d.ts +++ b/src/vscode-dts/vscode.proposed.chatDebug.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 1 +// version: 2 declare module 'vscode' { /** @@ -142,6 +142,37 @@ declare module 'vscode' { */ durationInMillis?: number; + /** + * The number of cached input tokens reused from a previous request. + */ + cachedTokens?: number; + + /** + * The time in milliseconds from sending the request to receiving the + * first response token. + */ + timeToFirstTokenInMillis?: number; + + /** + * The maximum number of prompt/input tokens allowed for this request. + */ + maxInputTokens?: number; + + /** + * The maximum number of response/output tokens allowed for this request. + */ + maxOutputTokens?: number; + + /** + * The short name or label identifying this request (e.g., "panel/editAgent"). + */ + requestName?: string; + + /** + * The outcome status of the model turn (e.g., "success", "failure", "canceled"). + */ + status?: string; + /** * Create a new ChatDebugModelTurnEvent. * @param created The timestamp when the event was created. @@ -447,13 +478,130 @@ declare module 'vscode' { constructor(type: ChatDebugMessageContentType, message: string, sections: ChatDebugMessageSection[]); } + /** + * Structured tool call content for a resolved chat debug event, + * containing the tool name, status, arguments, and output for rich rendering. + */ + export class ChatDebugEventToolCallContent { + /** + * The name of the tool that was called. + */ + toolName: string; + + /** + * The outcome of the tool call (e.g., "success" or "error"). + */ + result?: ChatDebugToolCallResult; + + /** + * How long the tool call took to complete, in milliseconds. + */ + durationInMillis?: number; + + /** + * The serialized input (arguments) passed to the tool. + */ + input?: string; + + /** + * The serialized output (result) returned by the tool. + */ + output?: string; + + /** + * Create a new ChatDebugEventToolCallContent. + * @param toolName The name of the tool that was called. + */ + constructor(toolName: string); + } + + /** + * Structured model turn content for a resolved chat debug event, + * containing request metadata, token usage, and timing for rich rendering. + */ + export class ChatDebugEventModelTurnContent { + /** + * The short name or label identifying this request (e.g., "panel/editAgent"). + */ + requestName: string; + + /** + * The identifier of the model used (e.g., "claude-sonnet-4.5"). + */ + model?: string; + + /** + * The outcome status of the model turn (e.g., "success", "failure", "canceled"). + */ + status?: string; + + /** + * How long the model turn took to complete, in milliseconds. + */ + durationInMillis?: number; + + /** + * The time in milliseconds from sending the request to receiving the + * first response token. + */ + timeToFirstTokenInMillis?: number; + + /** + * The maximum number of prompt/input tokens allowed for this request. + */ + maxInputTokens?: number; + + /** + * The maximum number of response/output tokens allowed for this request. + */ + maxOutputTokens?: number; + + /** + * The number of tokens in the input/prompt. + */ + inputTokens?: number; + + /** + * The number of tokens in the model's output/completion. + */ + outputTokens?: number; + + /** + * The number of cached input tokens reused from a previous request. + */ + cachedTokens?: number; + + /** + * The total number of tokens consumed (input + output). + */ + totalTokens?: number; + + /** + * An error message, if the model turn failed. + */ + errorMessage?: string; + + /** + * Optional structured sections containing the full request/response details + * (e.g., system prompt, user prompt, tools, response). + * Rendered as collapsible sections in the detail view alongside the metadata. + */ + sections?: ChatDebugMessageSection[]; + + /** + * Create a new ChatDebugEventModelTurnContent. + * @param requestName The short name identifying this request. + */ + constructor(requestName: string); + } + /** * Union of all resolved event content types. * Extensions may also return {@link ChatDebugUserMessageEvent} or * {@link ChatDebugAgentResponseEvent} from resolve, which will be * automatically converted to structured message content. */ - export type ChatDebugResolvedEventContent = ChatDebugEventTextContent | ChatDebugEventMessageContent | ChatDebugUserMessageEvent | ChatDebugAgentResponseEvent; + export type ChatDebugResolvedEventContent = ChatDebugEventTextContent | ChatDebugEventMessageContent | ChatDebugEventToolCallContent | ChatDebugEventModelTurnContent | ChatDebugUserMessageEvent | ChatDebugAgentResponseEvent; /** * Union of all chat debug event types. Each type is a class, From bc3b68862d667e8f50796615323d08aed41a51f8 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:34:50 -0800 Subject: [PATCH 12/30] Apply policy to browser tools setting (#297591) * Apply policy to browser tools setting * update policy jsonc --- build/lib/policies/policyData.jsonc | 38 +++++++++++++------ .../browserView.contribution.ts | 15 +++++++- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 9c1e1e0e87a8f..2ea27460de982 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -39,28 +39,28 @@ ], "policies": [ { - "key": "chat.mcp.gallery.serviceUrl", - "name": "McpGalleryServiceUrl", - "category": "InteractiveSession", - "minimumVersion": "1.101", + "key": "extensions.gallery.serviceUrl", + "name": "ExtensionGalleryServiceUrl", + "category": "Extensions", + "minimumVersion": "1.99", "localization": { "description": { - "key": "mcp.gallery.serviceUrl", - "value": "Configure the MCP Gallery service URL to connect to" + "key": "extensions.gallery.serviceUrl", + "value": "Configure the Marketplace service URL to connect to" } }, "type": "string", "default": "" }, { - "key": "extensions.gallery.serviceUrl", - "name": "ExtensionGalleryServiceUrl", - "category": "Extensions", - "minimumVersion": "1.99", + "key": "chat.mcp.gallery.serviceUrl", + "name": "McpGalleryServiceUrl", + "category": "InteractiveSession", + "minimumVersion": "1.101", "localization": { "description": { - "key": "extensions.gallery.serviceUrl", - "value": "Configure the Marketplace service URL to connect to" + "key": "mcp.gallery.serviceUrl", + "value": "Configure the MCP Gallery service URL to connect to" } }, "type": "string", @@ -286,6 +286,20 @@ }, "type": "boolean", "default": true + }, + { + "key": "workbench.browser.enableChatTools", + "name": "BrowserChatTools", + "category": "InteractiveSession", + "minimumVersion": "1.110", + "localization": { + "description": { + "key": "browser.enableChatTools", + "value": "When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser." + } + }, + "type": "boolean", + "default": false } ] } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index 359ef56102026..be1d5c35ab09c 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -26,6 +26,7 @@ import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDom import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { PolicyCategory } from '../../../../base/common/policy.js'; import { URI } from '../../../../base/common/uri.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; @@ -165,7 +166,19 @@ Registry.as(ConfigurationExtensions.Configuration).regis markdownDescription: localize( { comment: ['This is the description for a setting.'], key: 'browser.enableChatTools' }, 'When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser.' - ) + ), + policy: { + name: 'BrowserChatTools', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.110', + value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, + localization: { + description: { + key: 'browser.enableChatTools', + value: localize('browser.enableChatTools', 'When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser.') + } + }, + } }, 'workbench.browser.dataStorage': { type: 'string', From b0c071d6c360dd18061cef5c311296247cf0e9d6 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 25 Feb 2026 17:37:36 +0000 Subject: [PATCH 13/30] Don't send cancel noop event for inline chat (#297731) --- .../chat/common/chatService/chatService.ts | 2 +- .../chat/common/chatService/chatServiceImpl.ts | 18 ++++++++++-------- .../browser/inlineChatSessionServiceImpl.ts | 2 +- .../chat/browser/terminalChatWidget.ts | 2 +- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 70a63b90a1eee..bdfb80a30cd18 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1409,7 +1409,7 @@ export interface IChatService { resendRequest(request: IChatRequestModel, options?: IChatSendRequestOptions): Promise; adoptRequest(sessionResource: URI, request: IChatRequestModel): Promise; removeRequest(sessionResource: URI, requestId: string): Promise; - cancelCurrentRequestForSession(sessionResource: URI): void; + cancelCurrentRequestForSession(sessionResource: URI, isInlineChat?: boolean): void; /** * Sets yieldRequested on the active request for the given session. */ diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 42b409dc8b31d..d92c02ee69576 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1434,20 +1434,22 @@ export class ChatService extends Disposable implements IChatService { request.response?.complete(); } - cancelCurrentRequestForSession(sessionResource: URI): void { + cancelCurrentRequestForSession(sessionResource: URI, isInlineChat?: boolean): void { this.trace('cancelCurrentRequestForSession', `session: ${sessionResource}`); const pendingRequest = this._pendingRequests.get(sessionResource); if (!pendingRequest) { const model = this._sessionModels.get(sessionResource); const requestInProgress = model?.requestInProgress.get(); const pendingRequestsCount = model?.getPendingRequests().length ?? 0; - this.telemetryService.publicLog2(ChatStopCancellationNoopEventName, { - source: 'chatService', - reason: 'noPendingRequest', - requestInProgress: requestInProgress === undefined ? 'unknown' : requestInProgress ? 'true' : 'false', - pendingRequests: pendingRequestsCount, - }); - this.info('cancelCurrentRequestForSession', `No pending request was found for session ${sessionResource}. requestInProgress=${requestInProgress ?? 'unknown'}, pendingRequests=${pendingRequestsCount}`); + if (!isInlineChat) { + this.telemetryService.publicLog2(ChatStopCancellationNoopEventName, { + source: 'chatService', + reason: 'noPendingRequest', + requestInProgress: requestInProgress === undefined ? 'unknown' : requestInProgress ? 'true' : 'false', + pendingRequests: pendingRequestsCount, + }); + this.info('cancelCurrentRequestForSession', `No pending request was found for session ${sessionResource}. requestInProgress=${requestInProgress ?? 'unknown'}, pendingRequests=${pendingRequestsCount}`); + } return; } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts index 52ea6e4631636..97d99bf403bda 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts @@ -86,7 +86,7 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { const store = new DisposableStore(); store.add(toDisposable(() => { - this._chatService.cancelCurrentRequestForSession(chatModel.sessionResource); + this._chatService.cancelCurrentRequestForSession(chatModel.sessionResource, true); chatModel.editingSession?.reject(); this._sessions.delete(uri); this._onDidChangeSessions.fire(this); diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts index 946f14bcbcd06..606acf234a1b4 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts @@ -412,7 +412,7 @@ export class TerminalChatWidget extends Disposable { if (!model?.sessionResource) { return; } - this._chatService.cancelCurrentRequestForSession(model?.sessionResource); + this._chatService.cancelCurrentRequestForSession(model?.sessionResource, true); } async viewInChat(): Promise { From 42af80dd461e500f5367c19b4b37243c4e6811cb Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 25 Feb 2026 18:52:02 +0100 Subject: [PATCH 14/30] have max height for action list (#297722) --- src/vs/platform/actionWidget/browser/actionList.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 4330686d2e3de..a871c7a2b8fb0 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -787,7 +787,8 @@ export class ActionList extends Disposable { availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7; } - const maxHeight = Math.max(availableHeight, this._actionLineHeight * 3 + filterHeight); + const viewportMaxHeight = Math.floor(targetWindow.innerHeight * 0.4); + const maxHeight = Math.min(Math.max(availableHeight, this._actionLineHeight * 3 + filterHeight), viewportMaxHeight); const height = Math.min(listHeight + filterHeight, maxHeight); return height - filterHeight; } From 8be4c75954d2f9c669ebed37fe82d4bbac1f295a Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:53:42 -0800 Subject: [PATCH 15/30] Improve discoverability of browser open tool (#297724) --- .../browserView/electron-browser/tools/clickBrowserTool.ts | 2 +- .../browserView/electron-browser/tools/dragElementTool.ts | 2 +- .../electron-browser/tools/handleDialogBrowserTool.ts | 2 +- .../browserView/electron-browser/tools/hoverElementTool.ts | 2 +- .../browserView/electron-browser/tools/navigateBrowserTool.ts | 2 +- .../browserView/electron-browser/tools/readBrowserTool.ts | 2 +- .../browserView/electron-browser/tools/runPlaywrightCodeTool.ts | 2 +- .../browserView/electron-browser/tools/screenshotBrowserTool.ts | 2 +- .../browserView/electron-browser/tools/typeBrowserTool.ts | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts index bd911b192fada..aaa19f44e2577 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts @@ -24,7 +24,7 @@ export const ClickBrowserToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID, acquired from context or the open tool.` }, selector: { type: 'string', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts index 15feb72f5ae86..b9288091fd568 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts @@ -24,7 +24,7 @@ export const DragElementToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID, acquired from context or the open tool.` }, fromSelector: { type: 'string', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts index deed4c4b38b55..6e118343190ed 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts @@ -24,7 +24,7 @@ export const HandleDialogBrowserToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID, acquired from context or the open tool.` }, acceptModal: { type: 'boolean', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts index 16c3ed50c9ee6..46e4b65488de4 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts @@ -24,7 +24,7 @@ export const HoverElementToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID, acquired from context or the open tool.` }, selector: { type: 'string', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts index dbad2b31af63f..aabb81819aaab 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts @@ -24,7 +24,7 @@ export const NavigateBrowserToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID to navigate, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID to navigate, acquired from context or the open tool.` }, type: { type: 'string', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts index f7b379e926fdd..63aa66cc7075d 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts @@ -26,7 +26,7 @@ export const ReadBrowserToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID to read, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID to read, acquired from context or the open tool.` }, }, required: ['pageId'], diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts index 0e3d46a1b8adb..bcc288f07ea30 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts @@ -25,7 +25,7 @@ export const RunPlaywrightCodeToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID, acquired from context or the open tool.` }, code: { type: 'string', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts index e0664e36a32f4..831ca2bf8426f 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts @@ -25,7 +25,7 @@ export const ScreenshotBrowserToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID to capture, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID to capture, acquired from context or the open tool.` }, selector: { type: 'string', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts index a6e156e91a5ef..c3e8300c1b06b 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts @@ -24,7 +24,7 @@ export const TypeBrowserToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID, acquired from context or the open tool.` }, text: { type: 'string', From 42da810b51c3ada2de39780a7d8aff6079f118a6 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 25 Feb 2026 12:57:33 -0600 Subject: [PATCH 16/30] rm lightbulb from chat tip (#297738) --- .../browser/widget/chatContentParts/chatTipContentPart.ts | 2 -- .../browser/widget/chatContentParts/media/chatTipContent.css | 5 ----- 2 files changed, 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index f715e54ab038f..b5fdc3024768b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -6,7 +6,6 @@ import './media/chatTipContent.css'; import * as dom from '../../../../../../base/browser/dom.js'; import { StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; -import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { onUnexpectedError } from '../../../../../../base/common/errors.js'; @@ -115,7 +114,6 @@ export class ChatTipContentPart extends Disposable { dom.clearNode(this.domNode); this._toolbar.clear(); - this.domNode.appendChild(renderIcon(Codicon.lightbulb)); const markdownContent = this._renderer.render(tip.content, { actionHandler: (link, md) => { this._handleTipAction(link, md).catch(onUnexpectedError); } }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css index 43a9186f4978c..dba68be9b33e4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css @@ -82,11 +82,6 @@ color: var(--vscode-textLink-activeForeground); } -.chat-getting-started-tip-container .chat-tip-widget .codicon-lightbulb { - font-size: 12px; - color: var(--vscode-notificationsWarningIcon-foreground); -} - .chat-getting-started-tip-container .chat-tip-widget .rendered-markdown p { margin: 0; } From e2c2a7db6ae49eeef996b23e23c215faa6c5b3a2 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 25 Feb 2026 12:57:36 -0600 Subject: [PATCH 17/30] fix chat tip getting stuck when mode changes (#297744) fix #297287 --- .../workbench/contrib/chat/browser/widget/chatWidget.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 9b1dea8a0be3e..3238bb06704a9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -2076,6 +2076,14 @@ export class ChatWidget extends Disposable implements IChatWidget { this.chatSuggestNextWidget.hide(); this.chatTipService.resetSession(); + // Switching sessions resets tip service state; clear any rendered tip so + // empty-state rendering picks a fresh, context-appropriate tip. + this._gettingStartedTipPartRef = undefined; + this._gettingStartedTipPart.clear(); + const tipContainer = this.inputPart.gettingStartedTipContainerElement; + dom.clearNode(tipContainer); + dom.setVisibility(false, tipContainer); + this._codeBlockModelCollection.clear(); this.viewModel = this.instantiationService.createInstance(ChatViewModel, model, this._codeBlockModelCollection, undefined); From c1ecf2f79159b778be3976e07ebb2b95d6eafb50 Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 25 Feb 2026 10:57:45 -0800 Subject: [PATCH 18/30] Fix seti icon build script (#297749) --- extensions/theme-seti/build/update-icon-theme.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/theme-seti/build/update-icon-theme.js b/extensions/theme-seti/build/update-icon-theme.js index 366e7f37dd673..d4da96611081b 100644 --- a/extensions/theme-seti/build/update-icon-theme.js +++ b/extensions/theme-seti/build/update-icon-theme.js @@ -47,7 +47,11 @@ const inheritIconFromLanguage = { "jsonl": 'json', "postcss": 'css', "django-html": 'html', - "blade": 'php' + "blade": 'php', + "prompt": 'markdown', + "instructions": 'markdown', + "chatagent": 'markdown', + "skill": 'markdown' }; const ignoreExtAssociation = { From 3fc0476556624e7e3d80971318687275311db15d Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 25 Feb 2026 10:19:20 -0800 Subject: [PATCH 19/30] skills: bubble plugin skills through the "configure skills" and agent customization view Closes #297249 --- .../browser/aiCustomizationTreeViewViews.ts | 5 ++- .../aiCustomization/aiCustomizationIcons.ts | 5 +++ .../aiCustomizationListWidget.ts | 9 ++++- .../aiCustomizationManagement.contribution.ts | 4 +- .../aiCustomizationManagementEditor.ts | 2 +- .../promptSyntax/pickers/promptFilePickers.ts | 17 +++++++++ .../contrib/chat/common/chatModes.ts | 16 ++++++-- .../common/plugins/agentPluginServiceImpl.ts | 9 ++--- .../config/promptFileLocations.ts | 1 + .../promptSyntax/service/promptsService.ts | 13 ++++++- .../service/promptsServiceImpl.ts | 37 +++++++++++++++---- .../promptFilePickers.fixture.ts | 2 + 12 files changed, 97 insertions(+), 23 deletions(-) diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts index d2e6fcf1933d6..ef3874912b6f7 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts @@ -27,7 +27,7 @@ import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/ import { IViewDescriptorService } from '../../../../workbench/common/views.js'; import { IPromptsService, PromptsStorage, IAgentSkill, IPromptPath } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { agentIcon, extensionIcon, instructionsIcon, promptIcon, skillIcon, userIcon, workspaceIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { agentIcon, extensionIcon, instructionsIcon, pluginIcon, promptIcon, skillIcon, userIcon, workspaceIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; import { AICustomizationItemMenuId } from './aiCustomizationTreeView.js'; import { IAsyncDataSource, ITreeNode, ITreeRenderer, ITreeContextMenuEvent } from '../../../../base/browser/ui/tree/tree.js'; import { FuzzyScore } from '../../../../base/common/filters.js'; @@ -387,18 +387,21 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource = { [PromptsStorage.local]: workspaceIcon, [PromptsStorage.user]: userIcon, [PromptsStorage.extension]: extensionIcon, + [PromptsStorage.plugin]: pluginIcon, }; const storageSuffixes: Record = { [PromptsStorage.local]: 'workspace', [PromptsStorage.user]: 'user', [PromptsStorage.extension]: 'extensions', + [PromptsStorage.plugin]: 'plugins', }; return { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts index f3e775e42a5d8..4d2fd7a57eb50 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts @@ -61,3 +61,8 @@ export const userIcon = registerIcon('ai-customization-user', Codicon.account, l * Icon for extension storage. */ export const extensionIcon = registerIcon('ai-customization-extension', Codicon.extensions, localize('aiCustomizationExtensionIcon', "Icon for extension-contributed items.")); + +/** + * Icon for plugin storage. + */ +export const pluginIcon = registerIcon('ai-customization-plugin', Codicon.plug, localize('aiCustomizationPluginIcon', "Icon for plugin-contributed items.")); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 89a0cd78d0466..aee315caeb730 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -19,7 +19,7 @@ import { WorkbenchList } from '../../../../../platform/list/browser/listService. import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../../../../../base/browser/ui/list/list.js'; import { IPromptsService, PromptsStorage, IPromptPath } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon } from './aiCustomizationIcons.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon, pluginIcon } from './aiCustomizationIcons.js'; import { AICustomizationManagementItemMenuId, AICustomizationManagementSection } from './aiCustomizationManagement.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; @@ -265,6 +265,10 @@ class AICustomizationItemRenderer implements IListRenderer item.storage === PromptsStorage.local); const userItems = allItems.filter(item => item.storage === PromptsStorage.user); const extensionItems = allItems.filter(item => item.storage === PromptsStorage.extension); + const pluginItems = allItems.filter(item => item.storage === PromptsStorage.plugin); const mapToListItem = (item: IPromptPath): IAICustomizationListItem => { const filename = basename(item.uri); @@ -836,6 +841,7 @@ export class AICustomizationListWidget extends Disposable { items.push(...workspaceItems.map(mapToListItem)); items.push(...userItems.map(mapToListItem)); items.push(...extensionItems.map(mapToListItem)); + items.push(...pluginItems.map(mapToListItem)); } // Sort items by name @@ -927,6 +933,7 @@ export class AICustomizationListWidget extends Disposable { { storage: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, { storage: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, { storage: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, + { storage: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, ]; for (const item of matchedItems) { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index faa022a5e4666..92bdd1be270d5 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -196,8 +196,8 @@ registerAction2(class extends Action2 { const fileName = basename(uri); const storage = extractStorage(context); - // Extension files cannot be deleted - if (storage === PromptsStorage.extension) { + // Extension and plugin files cannot be deleted + if (storage === PromptsStorage.extension || storage === PromptsStorage.plugin) { await dialogService.info( localize('cannotDeleteExtension', "Cannot Delete Extension File"), localize('cannotDeleteExtensionDetail', "Files provided by extensions cannot be deleted. You can disable the extension if you no longer want to use this customization.") diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index b5fd79c2fbeab..8793d44e4e400 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -361,7 +361,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.editorDisposables.add(this.listWidget.onDidSelectItem(item => { if (this.workspaceService.preferManualCreation) { const isWorkspaceFile = item.storage === PromptsStorage.local; - const isReadOnly = item.storage === PromptsStorage.extension; + const isReadOnly = item.storage === PromptsStorage.extension || item.storage === PromptsStorage.plugin; this.showEmbeddedEditor(item.uri, item.name, isWorkspaceFile, isReadOnly); } else { this.editorService.openEditor({ resource: item.uri }); diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts index 5113db79b5cfe..3f932d05506c2 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts @@ -27,6 +27,7 @@ import { ILabelService } from '../../../../../../platform/label/common/label.js' import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { PromptFileRewriter } from '../promptFileRewriter.js'; import { isOrganizationPromptFile } from '../../../common/promptSyntax/utils/promptsServiceUtils.js'; +import { assertNever } from '../../../../../../base/common/assert.js'; /** * Options for the {@link askToSelectInstructions} function. @@ -507,6 +508,17 @@ export class PromptFilePickers { result.push({ type: 'separator', label: localize('separator.user', "User Data") }); result.push(...sortByLabel(await Promise.all(users.map(u => this._createPromptPickItem(u, buttons, getVisibility(u), token))))); } + + // Plugin files are read-only so only copy button is available + const plugins = await this._promptsService.listPromptFilesForStorage(options.type, PromptsStorage.plugin, token); + if (plugins.length) { + const pluginButtons: IQuickInputButton[] = []; + if (options.optionCopy !== false) { + pluginButtons.push(COPY_BUTTON); + } + result.push({ type: 'separator', label: localize('separator.plugins', "Plugins") }); + result.push(...sortByLabel(await Promise.all(plugins.map(p => this._createPromptPickItem(p, pluginButtons, getVisibility(p), token))))); + } return result; } @@ -552,6 +564,11 @@ export class PromptFilePickers { case PromptsStorage.user: tooltip = undefined; break; + case PromptsStorage.plugin: + tooltip = promptFile.name; + break; + default: + assertNever(promptFile); } let iconClass: string | undefined; if (visibility === false) { diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index ca181bd19d223..25a9ef1bb2769 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -7,7 +7,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { constObservable, IObservable, ISettableObservable, observableValue, transaction } from '../../../../base/common/observable.js'; -import { URI } from '../../../../base/common/uri.js'; +import { isUriComponents, URI } from '../../../../base/common/uri.js'; import { IOffsetRange } from '../../../../editor/common/core/ranges/offsetRange.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -450,16 +450,20 @@ export class CustomChatMode implements IChatMode { type IChatModeSourceData = | { readonly storage: PromptsStorage.extension; readonly extensionId: string; type?: ExtensionAgentSourceType } - | { readonly storage: PromptsStorage.local | PromptsStorage.user }; + | { readonly storage: PromptsStorage.local | PromptsStorage.user } + | { readonly storage: PromptsStorage.plugin; readonly pluginUri: URI }; function isChatModeSourceData(value: unknown): value is IChatModeSourceData { if (typeof value !== 'object' || value === null) { return false; } - const data = value as { storage?: unknown; extensionId?: unknown }; + const data = value as { storage?: unknown; extensionId?: unknown; pluginUri?: unknown }; if (data.storage === PromptsStorage.extension) { return typeof data.extensionId === 'string'; } + if (data.storage === PromptsStorage.plugin) { + return isUriComponents(data.pluginUri); + } return data.storage === PromptsStorage.local || data.storage === PromptsStorage.user; } @@ -470,6 +474,9 @@ function serializeChatModeSource(source: IAgentSource | undefined): IChatModeSou if (source.storage === PromptsStorage.extension) { return { storage: PromptsStorage.extension, extensionId: source.extensionId.value, type: source.type }; } + if (source.storage === PromptsStorage.plugin) { + return { storage: PromptsStorage.plugin, pluginUri: source.pluginUri }; + } return { storage: source.storage }; } @@ -480,6 +487,9 @@ function reviveChatModeSource(data: IChatModeSourceData | undefined): IAgentSour if (data.storage === PromptsStorage.extension) { return { storage: PromptsStorage.extension, extensionId: new ExtensionIdentifier(data.extensionId), type: data.type ?? ExtensionAgentSourceType.contribution }; } + if (data.storage === PromptsStorage.plugin) { + return { storage: PromptsStorage.plugin, pluginUri: URI.revive(data.pluginUri) }; + } return { storage: data.storage }; } diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 51391adb6e7cd..7b8696d771540 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -562,15 +562,14 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent const skills: IAgentPluginSkill[] = []; for (const child of stat.children) { - if (!child.isFile || extname(child.resource).toLowerCase() !== COMMAND_FILE_SUFFIX) { + const skillMd = URI.joinPath(child.resource, 'SKILL.md'); + if (!(await this._pathExists(skillMd))) { continue; } - const name = basename(child.resource).slice(0, -COMMAND_FILE_SUFFIX.length); - skills.push({ - uri: child.resource, - name, + uri: skillMd, + name: basename(child.resource), }); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index ed04e3aa641e2..7b301db8f2c2e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -111,6 +111,7 @@ export enum PromptFileSource { ConfigPersonal = 'config-personal', ExtensionContribution = 'extension-contribution', ExtensionAPI = 'extension-api', + Plugin = 'plugin', } /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 23113daea69c1..a2ca1942cf04f 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -72,7 +72,8 @@ export const IPromptsService = createDecorator('IPromptsService export enum PromptsStorage { local = 'local', user = 'user', - extension = 'extension' + extension = 'extension', + plugin = 'plugin', } /** @@ -87,7 +88,7 @@ export enum ExtensionAgentSourceType { * Represents a prompt path with its type. * This is used for both prompt files and prompt source folders. */ -export type IPromptPath = IExtensionPromptPath | ILocalPromptPath | IUserPromptPath; +export type IPromptPath = IExtensionPromptPath | ILocalPromptPath | IUserPromptPath | IPluginPromptPath; export interface IPromptPathBase { @@ -131,12 +132,20 @@ export interface IUserPromptPath extends IPromptPathBase { readonly storage: PromptsStorage.user; } +export interface IPluginPromptPath extends IPromptPathBase { + readonly storage: PromptsStorage.plugin; + readonly pluginUri: URI; +} + export type IAgentSource = { readonly storage: PromptsStorage.extension; readonly extensionId: ExtensionIdentifier; readonly type: ExtensionAgentSourceType; } | { readonly storage: PromptsStorage.local | PromptsStorage.user; +} | { + readonly storage: PromptsStorage.plugin; + readonly pluginUri: URI; }; /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index e42b4fde1f921..38866f18fb46f 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -33,7 +33,7 @@ import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAU import { PROMPT_LANGUAGE_ID, PromptsType, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; -import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, Target, IPromptDiscoveryLogEntry } from './promptsService.js'; +import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPluginPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, Target, IPromptDiscoveryLogEntry } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { IChatRequestHooks, IHookCommand, HookType } from '../hookSchema.js'; @@ -44,6 +44,7 @@ import { getTarget, mapClaudeModels, mapClaudeTools } from '../languageProviders import { StopWatch } from '../../../../../../base/common/stopwatch.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { getCanonicalPluginCommandId, IAgentPlugin, IAgentPluginService } from '../../plugins/agentPluginService.js'; +import { assertNever } from '../../../../../../base/common/assert.js'; /** * Error thrown when a skill file is missing the required name attribute. @@ -152,7 +153,7 @@ export class PromptsService extends Disposable implements IPromptsService { private readonly _onDidContributedWhenChange = this._register(new Emitter()); private readonly _onDidPluginPromptFilesChange = this._register(new Emitter()); private readonly _onDidPluginHooksChange = this._register(new Emitter()); - private _pluginPromptFilesByType = new Map(); + private _pluginPromptFilesByType = new Map(); constructor( @ILogService public readonly logger: ILogService, @@ -246,14 +247,15 @@ export class PromptsService extends Disposable implements IPromptsService { ) { return autorun(reader => { const plugins = this.agentPluginService.plugins.read(reader); - const nextFiles: ILocalPromptPath[] = []; + const nextFiles: IPluginPromptPath[] = []; for (const plugin of plugins) { for (const item of getItems(plugin, reader)) { nextFiles.push({ uri: item.uri, - storage: PromptsStorage.local, + storage: PromptsStorage.plugin, type, name: getCanonicalPluginCommandId(plugin, item.name), + pluginUri: plugin.uri, }); } } @@ -476,6 +478,8 @@ export class PromptsService extends Disposable implements IPromptsService { return this.fileLocator.listFiles(type, PromptsStorage.local, token).then(uris => uris.map(uri => ({ uri, storage: PromptsStorage.local, type } satisfies ILocalPromptPath))); case PromptsStorage.user: return this.fileLocator.listFiles(type, PromptsStorage.user, token).then(uris => uris.map(uri => ({ uri, storage: PromptsStorage.user, type } satisfies IUserPromptPath))); + case PromptsStorage.plugin: + return this._pluginPromptFilesByType.get(type) ?? []; default: throw new Error(`[listPromptFilesForStorage] Unsupported prompt storage type: ${storage}`); } @@ -832,7 +836,8 @@ export class PromptsService extends Disposable implements IPromptsService { case PromptsStorage.extension: { return localize('extension.with.id', 'Extension: {0}', promptPath.extension.displayName ?? promptPath.extension.id); } - default: throw new Error('Unknown prompt storage type'); + case PromptsStorage.plugin: return localize('plugin.capitalized', 'Plugin'); + default: assertNever(promptPath, 'Unknown prompt storage type'); } } @@ -1108,6 +1113,7 @@ export class PromptsService extends Disposable implements IPromptsService { configWorkspace: number; extensionContribution: number; extensionAPI: number; + plugin: number; skippedDuplicateName: number; skippedMissingName: number; skippedMissingDescription: number; @@ -1127,6 +1133,7 @@ export class PromptsService extends Disposable implements IPromptsService { configWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of custom configured workspace skills.' }; extensionContribution: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of extension contributed skills.' }; extensionAPI: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of extension API provided skills.' }; + plugin: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of plugin provided skills.' }; skippedDuplicateName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to duplicate names.' }; skippedMissingName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to missing name attribute.' }; skippedMissingDescription: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to missing description attribute.' }; @@ -1148,6 +1155,7 @@ export class PromptsService extends Disposable implements IPromptsService { configPersonal: skillsBySource.get(PromptFileSource.ConfigPersonal) ?? 0, extensionContribution: skillsBySource.get(PromptFileSource.ExtensionContribution) ?? 0, extensionAPI: skillsBySource.get(PromptFileSource.ExtensionAPI) ?? 0, + plugin: skillsBySource.get(PromptFileSource.Plugin) ?? 0, skippedDuplicateName, skippedMissingName, skippedMissingDescription, @@ -1383,10 +1391,15 @@ export class PromptsService extends Disposable implements IPromptsService { const allSkills: Array = []; const discoveredSkills = await this.fileLocator.findAgentSkills(token); const extensionSkills = await this.getExtensionPromptFiles(PromptsType.skill, token); + const pluginSkills = this._pluginPromptFilesByType.get(PromptsType.skill) ?? []; allSkills.push(...discoveredSkills, ...extensionSkills.map((extPath) => ({ fileUri: extPath.uri, storage: extPath.storage, source: extPath.source === ExtensionAgentSourceType.contribution ? PromptFileSource.ExtensionContribution : PromptFileSource.ExtensionAPI + })), ...pluginSkills.map((p) => ({ + fileUri: p.uri, + storage: p.storage, + source: PromptFileSource.Plugin, }))); const getPriority = (skill: IResolvedPromptFile | IExtensionPromptPath): number => { @@ -1396,13 +1409,16 @@ export class PromptsService extends Disposable implements IPromptsService { if (skill.storage === PromptsStorage.user) { return 1; // personal } + if (skill.storage === PromptsStorage.plugin) { + return 2; // plugin + } if (skill.source === PromptFileSource.ExtensionAPI) { - return 2; + return 3; } if (skill.source === PromptFileSource.ExtensionContribution) { - return 3; + return 4; } - return 4; + return 5; }; // Stable sort; we should keep order consistent to the order in the user's configuration object allSkills.sort((a, b) => getPriority(a) - getPriority(b)); @@ -1751,6 +1767,11 @@ namespace IAgentSource { extensionId: promptPath.extension.identifier, type: promptPath.source }; + } else if (promptPath.storage === PromptsStorage.plugin) { + return { + storage: PromptsStorage.plugin, + pluginUri: promptPath.pluginUri + }; } else { return { storage: promptPath.storage diff --git a/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts index 6692adf6bcdfa..444777d13cd54 100644 --- a/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts @@ -115,6 +115,8 @@ async function renderPromptFilePickerFixture({ container, disposableStore, theme return promptsState.userPromptFiles.filter(file => file.type === type); case PromptsStorage.extension: return promptsState.extensionPromptFiles.filter(file => file.type === type); + case PromptsStorage.plugin: + return []; } } From b795f6fb53459245377bfce149594ca3f231ee98 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 25 Feb 2026 19:14:07 +0000 Subject: [PATCH 20/30] Fix quick chat layout (#297737) Fix #297677 --- .../contrib/chat/browser/widget/input/chatInputPart.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 46f466578863d..57a0106718a43 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -3036,7 +3036,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const executeToolbarPadding = (this.executeToolbar.getItemsLength() - 1) * 4; const inputToolbarPadding = this.inputActionsToolbar.getItemsLength() ? (this.inputActionsToolbar.getItemsLength() - 1) * 4 : 0; const contextUsageWidth = dom.getTotalWidth(this.contextUsageWidgetContainer); - return executeToolbarWidth + executeToolbarPadding + contextUsageWidth + (this.options.renderInputToolbarBelowInput ? 0 : inputToolbarWidth + inputToolbarPadding); + const inputToolbarsPadding = 12; // pdading between input toolbar/execute toolbar/contextUsage. + return executeToolbarWidth + executeToolbarPadding + contextUsageWidth + (this.options.renderInputToolbarBelowInput ? 0 : inputToolbarWidth + inputToolbarPadding + inputToolbarsPadding); }; return { From a721e6488d77313de9fe0ca67f2ec0fd4a32cc55 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 25 Feb 2026 20:48:25 +0100 Subject: [PATCH 21/30] update distro (#297769) --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f8a9d07778bbb..f0de36f12a677 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.110.0", - "distro": "1b8e5b3448f8be12228bc9bee56b42a5f95158e7", + "distro": "2d57806cfc4400f602f114b4bfc0fb17ce7e1a32", "author": { "name": "Microsoft Corporation" }, @@ -249,4 +249,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} +} \ No newline at end of file From 1c3c7c4b0951fd5f4358abc2a2f995f8dff7b06e Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 25 Feb 2026 14:01:24 -0600 Subject: [PATCH 22/30] ensure rendering of questions can happen while askQuestions tool call is in flight and chat is moved (#297768) fix #297752 --- .../contrib/chat/browser/widget/chatListWidget.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index 376d670c21d87..d562b874774bc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -521,6 +521,12 @@ export class ChatListWidget extends Disposable { return; } + // Skip refresh when the container is disconnected from the DOM to avoid + // caching incorrect measurements (e.g. 0 heights) for tree elements. + if (!this._container.isConnected) { + return; + } + const items = this._viewModel.getItems(); this._lastItem = items.at(-1); this._lastItemIdContextKey.set(this._lastItem ? [this._lastItem.id] : []); @@ -547,6 +553,9 @@ export class ChatListWidget extends Disposable { // If a response is in the process of progressive rendering, we need to ensure that it will // be re-rendered so progressive rendering is restarted, even if the model wasn't updated. `${isResponseVM(element) && element.renderData ? `_${this._visibleChangeCount}` : ''}` + + // Re-render when new content parts are added to the response (e.g. question carousels + // arriving after progressive rendering caught up and stopped its timer). + (isResponseVM(element) ? `_parts${element.response.value.length}` : '') + // Re-render once content references are loaded (isResponseVM(element) ? `_${element.contentReferences.length}` : '') + // Re-render if element becomes hidden due to undo/redo From 24dd9758283b686eaa54349dfec2639f6ec6a719 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:00:35 -0800 Subject: [PATCH 23/30] chat customization tweaks (#297787) * ai customizations: hide models and extension sources in sessions view - Remove Models section from sessions managementSections, toolbar, and slash commands - Add visibleStorageSources to IAICustomizationWorkspaceService to control which storage groups (workspace/user/extension) appear in the list - Sessions view shows only workspace + user; core VS Code shows all three - Filter extension counts from sessions toolbar badges Fixes #297645 Refs #296944 * ai customizations: simplify editor title, remove menus, mark preview - Set static editor title to 'Chat Customizations' removing dynamic section label updating - Remove EditorTitle and GlobalActivity menu entries - Add (Preview) tag to command name - Add preview tag to chat.customizationsMenu.enabled setting Fixes #297532 Fixes #297689 * update description * update spec * tidy types --- src/vs/sessions/AI_CUSTOMIZATIONS.md | 101 +++++++----------- .../aiCustomizationWorkspaceService.ts | 7 +- .../contrib/chat/browser/slashCommands.ts | 7 -- .../sessions/browser/customizationCounts.ts | 31 ++++-- .../customizationsToolbar.contribution.ts | 14 +-- .../sessions/browser/sessionsViewPane.ts | 4 +- .../aiCustomizationListWidget.ts | 3 +- .../aiCustomizationManagement.contribution.ts | 28 +---- .../aiCustomizationManagementEditor.ts | 13 --- .../aiCustomizationManagementEditorInput.ts | 17 +-- .../aiCustomizationWorkspaceService.ts | 8 ++ .../contrib/chat/browser/chat.contribution.ts | 3 +- .../common/aiCustomizationWorkspaceService.ts | 6 ++ 13 files changed, 99 insertions(+), 143 deletions(-) diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 9dceeac3a2b24..604626bc35ced 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -1,94 +1,73 @@ # AI Customizations – Design Document -This document describes the current AI customization experience in this branch: a management editor and tree view that surface items across worktree, user, and extension storage. +This document describes the AI customization experience: a management editor and tree view that surface customization items (agents, skills, instructions, prompts, hooks, MCP servers) across workspace, user, and extension storage. -## Current Architecture +## Architecture -### File Structure (Agentic) +### File Structure + +The management editor lives in `vs/workbench` (shared between core VS Code and sessions): ``` -src/vs/sessions/contrib/aiCustomizationManagement/browser/ +src/vs/workbench/contrib/chat/browser/aiCustomization/ ├── aiCustomizationManagement.contribution.ts # Commands + context menus ├── aiCustomizationManagement.ts # IDs + context keys ├── aiCustomizationManagementEditor.ts # SplitView list/editor ├── aiCustomizationManagementEditorInput.ts # Singleton input ├── aiCustomizationListWidget.ts # Search + grouped list -├── aiCustomizationOverviewView.ts # Overview view (counts + deep links) +├── aiCustomizationWorkspaceService.ts # Core VS Code workspace service impl ├── customizationCreatorService.ts # AI-guided creation flow ├── mcpListWidget.ts # MCP servers section -├── SPEC.md # Feature specification +├── aiCustomizationIcons.ts # Icons └── media/ └── aiCustomizationManagement.css +src/vs/workbench/contrib/chat/common/ +└── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService interface +``` + +The tree view and overview live in `vs/sessions` (sessions window only): + +``` src/vs/sessions/contrib/aiCustomizationTreeView/browser/ ├── aiCustomizationTreeView.contribution.ts # View + actions ├── aiCustomizationTreeView.ts # IDs + menu IDs ├── aiCustomizationTreeViewViews.ts # Tree data source + view -├── aiCustomizationTreeViewIcons.ts # Icons -├── SPEC.md # Feature specification +├── aiCustomizationOverviewView.ts # Overview view (counts + deep links) └── media/ └── aiCustomizationTreeView.css ``` ---- - -## Service Alignment (Required) - -AI customizations must lean on existing VS Code services with well-defined interfaces. This avoids duplicated parsing logic, keeps discovery consistent across the workbench, and ensures prompt/hook behavior stays authoritative. - -Browser compatibility is required. Do not use Node.js APIs; rely on VS Code services that work in browser contexts. - -Key services to rely on: -- Prompt discovery, parsing, and lifecycle: [src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts](../workbench/contrib/chat/common/promptSyntax/service/promptsService.ts) -- Active session scoping for worktree filtering: [src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts](../workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts) -- MCP servers and tool access: [src/vs/workbench/contrib/mcp/common/mcpService.ts](../workbench/contrib/mcp/common/mcpService.ts) -- MCP management and gallery: [src/vs/platform/mcp/common/mcpManagement.ts](../platform/mcp/common/mcpManagement.ts) -- Chat models and session state: [src/vs/workbench/contrib/chat/common/chatService/chatService.ts](../workbench/contrib/chat/common/chatService/chatService.ts) -- File and model plumbing: [src/vs/platform/files/common/files.ts](../platform/files/common/files.ts), [src/vs/editor/common/services/resolverService.ts](../editor/common/services/resolverService.ts) +Sessions-specific overrides: -The active worktree comes from `IActiveSessionService` and is the source of truth for any workspace/worktree scoping. - -In the agentic workbench, prompt discovery is scoped by an agentic prompt service override that uses the active session root for workspace folders. See [src/vs/sessions/contrib/chat/browser/promptsService.ts](contrib/chat/browser/promptsService.ts). - -## Implemented Experience - -### Management Editor (Current) - -- A singleton editor surfaces Agents, Skills, Instructions, Prompts, Hooks, MCP Servers, and Models. -- Prompts-based sections use a grouped list (Worktree/User/Extensions) with search, context menus, and an embedded editor. -- Embedded editor uses a full `CodeEditorWidget` and auto-commits worktree files on exit (agent session workflow). -- Creation supports manual or AI-guided flows; AI-guided creation opens a new chat with hidden system instructions. - -### Tree View (Current) - -- Unified sidebar tree with Type -> Storage -> File hierarchy. -- Auto-expands categories to reveal storage groups. -- Context menus provide Open and Run Prompt. -- Creation actions are centralized in the management editor. - -### Additional Surfaces (Current) +``` +src/vs/sessions/contrib/chat/browser/ +└── aiCustomizationWorkspaceService.ts # Sessions workspace service override +src/vs/sessions/contrib/sessions/browser/ +├── customizationCounts.ts # Source count utilities +└── customizationsToolbar.contribution.ts # Sidebar customization links +``` -- Overview view provides counts and deep-links into the management editor. -- Management list groups by storage with empty states, git status, and path copy actions. +### IAICustomizationWorkspaceService ---- +The `IAICustomizationWorkspaceService` interface controls per-window behavior: -## AI Feature Gating +| Property | Core VS Code | Sessions Window | +|----------|-------------|----------| +| `managementSections` | All sections except Models | Same | +| `visibleStorageSources` | workspace, user, extension, plugin | workspace, user only | +| `preferManualCreation` | `false` (opens file externally) | `true` (embedded editor) | +| `activeProjectRoot` | First workspace folder | Active session worktree | -All commands and UI must respect `ChatContextKeys.enabled`: +## Key Services -```typescript -All entry points (view contributions, commands) respect `ChatContextKeys.enabled`. -``` +- **Prompt discovery**: `IPromptsService` — parsing, lifecycle, storage enumeration +- **MCP servers**: `IMcpService` — server list, tool access +- **Active worktree**: `IActiveSessionService` — source of truth for workspace scoping (sessions only) +- **File operations**: `IFileService`, `ITextModelService` — file and model plumbing ---- +Browser compatibility is required — no Node.js APIs. -## References +## Feature Gating -- [Settings Editor](../src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts) -- [Keybindings Editor](../src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts) -- [Webview Editor](../src/vs/workbench/contrib/webviewPanel/browser/webviewEditorInput.ts) -- [AI Customization Management (agentic)](../src/vs/sessions/contrib/aiCustomizationManagement/browser/) -- [AI Customization Overview View](../src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationOverviewView.ts) -- [AI Customization Tree View (agentic)](../src/vs/sessions/contrib/aiCustomizationTreeView/browser/) -- [IPromptsService](../src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts) +All commands and UI respect `ChatContextKeys.enabled` and the `chat.customizationsMenu.enabled` setting. diff --git a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts index 32d2236832b09..02b43d147dbbb 100644 --- a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts +++ b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts @@ -6,6 +6,7 @@ import { derived, IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { CustomizationCreatorService } from '../../../../workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.js'; @@ -43,7 +44,11 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization AICustomizationManagementSection.Prompts, AICustomizationManagementSection.Hooks, AICustomizationManagementSection.McpServers, - AICustomizationManagementSection.Models, + ]; + + readonly visibleStorageSources: readonly PromptsStorage[] = [ + PromptsStorage.local, + PromptsStorage.user, ]; readonly preferManualCreation = true; diff --git a/src/vs/sessions/contrib/chat/browser/slashCommands.ts b/src/vs/sessions/contrib/chat/browser/slashCommands.ts index 401961417c0be..361c7e736a330 100644 --- a/src/vs/sessions/contrib/chat/browser/slashCommands.ts +++ b/src/vs/sessions/contrib/chat/browser/slashCommands.ts @@ -126,13 +126,6 @@ export class SlashCommandHandler extends Disposable { executeImmediately: true, execute: openSection(AICustomizationManagementSection.McpServers), }); - this._slashCommands.push({ - command: 'models', - detail: localize('slashCommand.models', "View and manage models"), - sortText: 'z3_models', - executeImmediately: true, - execute: openSection(AICustomizationManagementSection.Models), - }); } private _registerDecorations(): void { diff --git a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts index dd874c3e86c71..5b528d0d4cc1f 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts @@ -8,14 +8,29 @@ import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyn import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; + export interface ISourceCounts { readonly workspace: number; readonly user: number; readonly extension: number; } -export function getSourceCountsTotal(counts: ISourceCounts): number { - return counts.workspace + counts.user + counts.extension; +const storageToCountKey: Partial> = { + [PromptsStorage.local]: 'workspace', + [PromptsStorage.user]: 'user', + [PromptsStorage.extension]: 'extension', +}; + +export function getSourceCountsTotal(counts: ISourceCounts, workspaceService: IAICustomizationWorkspaceService): number { + let total = 0; + for (const storage of workspaceService.visibleStorageSources) { + const key = storageToCountKey[storage]; + if (key) { + total += counts[key]; + } + } + return total; } export async function getPromptSourceCounts(promptsService: IPromptsService, promptType: PromptsType): Promise { @@ -43,7 +58,7 @@ export async function getSkillSourceCounts(promptsService: IPromptsService): Pro }; } -export async function getCustomizationTotalCount(promptsService: IPromptsService, mcpService: IMcpService): Promise { +export async function getCustomizationTotalCount(promptsService: IPromptsService, mcpService: IMcpService, workspaceService: IAICustomizationWorkspaceService): Promise { const [agentCounts, skillCounts, instructionCounts, promptCounts, hookCounts] = await Promise.all([ getPromptSourceCounts(promptsService, PromptsType.agent), getSkillSourceCounts(promptsService), @@ -52,10 +67,10 @@ export async function getCustomizationTotalCount(promptsService: IPromptsService getPromptSourceCounts(promptsService, PromptsType.hook), ]); - return getSourceCountsTotal(agentCounts) - + getSourceCountsTotal(skillCounts) - + getSourceCountsTotal(instructionCounts) - + getSourceCountsTotal(promptCounts) - + getSourceCountsTotal(hookCounts) + return getSourceCountsTotal(agentCounts, workspaceService) + + getSourceCountsTotal(skillCounts, workspaceService) + + getSourceCountsTotal(instructionCounts, workspaceService) + + getSourceCountsTotal(promptCounts, workspaceService) + + getSourceCountsTotal(hookCounts, workspaceService) + mcpService.servers.get().length; } diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts index f2e6d9e45e71b..041463a5977bc 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -21,7 +21,7 @@ import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyn import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { Menus } from '../../../browser/menus.js'; -import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, workspaceIcon, userIcon, extensionIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, workspaceIcon, userIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../../base/common/actions.js'; import { $, append } from '../../../../base/browser/dom.js'; @@ -32,6 +32,7 @@ import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { getPromptSourceCounts, getSkillSourceCounts, getSourceCountsTotal, ISourceCounts } from './customizationCounts.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; interface ICustomizationItemConfig { readonly id: string; @@ -85,13 +86,6 @@ const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ section: AICustomizationManagementSection.McpServers, getCount: (_lm, mcp) => Promise.resolve(mcp.servers.get().length), }, - { - id: 'sessions.customization.models', - label: localize('models', "Models"), - icon: Codicon.vm, - section: AICustomizationManagementSection.Models, - getCount: (lm) => Promise.resolve(lm.getLanguageModelIds().length), - }, ]; /** @@ -113,6 +107,7 @@ class CustomizationLinkViewItem extends ActionViewItem { @IMcpService private readonly _mcpService: IMcpService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly _activeSessionService: ISessionsManagementService, + @IAICustomizationWorkspaceService private readonly _workspaceService: IAICustomizationWorkspaceService, ) { super(undefined, action, { ...options, icon: false, label: false }); this._viewItemDisposables = this._register(new DisposableStore()); @@ -182,7 +177,7 @@ class CustomizationLinkViewItem extends ActionViewItem { private _renderSourceCounts(container: HTMLElement, counts: ISourceCounts): void { container.textContent = ''; - const total = getSourceCountsTotal(counts); + const total = getSourceCountsTotal(counts, this._workspaceService); container.classList.toggle('hidden', total === 0); if (total === 0) { return; @@ -191,7 +186,6 @@ class CustomizationLinkViewItem extends ActionViewItem { const sources: { count: number; icon: ThemeIcon; title: string }[] = [ { count: counts.workspace, icon: workspaceIcon, title: localize('workspaceCount', "{0} from workspace", counts.workspace) }, { count: counts.user, icon: userIcon, title: localize('userCount', "{0} from user", counts.user) }, - { count: counts.extension, icon: extensionIcon, title: localize('extensionCount', "{0} from extensions", counts.extension) }, ]; for (const source of sources) { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 06d4a6c566eea..b8ec6ff4e1dad 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -30,6 +30,7 @@ import { AgentSessionsFilter, AgentSessionsGrouping } from '../../../../workbenc import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ISessionsManagementService } from './sessionsManagementService.js'; import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; @@ -77,6 +78,7 @@ export class AgenticSessionsViewPane extends ViewPane { @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, @IHostService private readonly hostService: IHostService, + @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); } @@ -231,7 +233,7 @@ export class AgenticSessionsViewPane extends ViewPane { let updateCountRequestId = 0; const updateHeaderTotalCount = async () => { const requestId = ++updateCountRequestId; - const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService); + const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService); if (requestId !== updateCountRequestId) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index aee315caeb730..5be1a768214db 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -929,12 +929,13 @@ export class AICustomizationListWidget extends Disposable { this.logService.info(`[AICustomizationListWidget] filterItems: allItems=${this.allItems.length}, matched=${totalBeforeFilter}`); // Group items by storage + const visibleSources = new Set(this.workspaceService.visibleStorageSources); const groups: { storage: PromptsStorage; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = [ { storage: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, { storage: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, { storage: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, { storage: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, - ]; + ].filter(g => visibleSources.has(g.storage)); for (const item of matchedItems) { const group = groups.find(g => g.storage === item.storage); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index 92bdd1be270d5..5e95b733bbbfb 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -5,7 +5,7 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../../nls.js'; -import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { Action2, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; @@ -28,7 +28,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { Codicon } from '../../../../../base/common/codicons.js'; import { URI } from '../../../../../base/common/uri.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { PROMPT_LANGUAGE_ID, INSTRUCTIONS_LANGUAGE_ID, AGENT_LANGUAGE_ID, SKILL_LANGUAGE_ID, PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ChatConfiguration } from '../../common/constants.js'; @@ -37,7 +37,6 @@ import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.j import { basename } from '../../../../../base/common/resources.js'; import { Schemas } from '../../../../../base/common/network.js'; import { isWindows, isMacintosh } from '../../../../../base/common/platform.js'; -import { ResourceContextKey } from '../../../../common/contextkeys.js'; //#region Editor Registration @@ -271,30 +270,11 @@ class AICustomizationManagementActionsContribution extends Disposable implements constructor() { super({ id: AICustomizationManagementCommands.OpenEditor, - title: localize2('openAICustomizations', "Open Chat Customizations"), - shortTitle: localize2('aiCustomizations', "Chat Customizations"), + title: localize2('openAICustomizations', "Open Chat Customizations (Preview)"), + shortTitle: localize2('aiCustomizations', "Chat Customizations (Preview)"), category: CHAT_CATEGORY, precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), f1: true, - menu: [ - { - id: MenuId.GlobalActivity, - when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), - group: '2_configuration', - order: 4, - }, - { - id: MenuId.EditorContent, - when: ContextKeyExpr.and( - ChatContextKeys.enabled, - ContextKeyExpr.or( - ContextKeyExpr.equals(ResourceContextKey.LangId.key, PROMPT_LANGUAGE_ID), - ContextKeyExpr.equals(ResourceContextKey.LangId.key, INSTRUCTIONS_LANGUAGE_ID), - ContextKeyExpr.equals(ResourceContextKey.LangId.key, AGENT_LANGUAGE_ID), - ContextKeyExpr.equals(ResourceContextKey.LangId.key, SKILL_LANGUAGE_ID), - ), - ), - }], }); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 8793d44e4e400..33d40453b83e0 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -453,9 +453,6 @@ export class AICustomizationManagementEditor extends EditorPane { // Persist selection this.storageService.store(AI_CUSTOMIZATION_MANAGEMENT_SELECTED_SECTION_KEY, section, StorageScope.PROFILE, StorageTarget.USER); - // Update editor tab title - this.updateEditorTitle(); - // Update content visibility this.updateContentVisibility(); @@ -465,13 +462,6 @@ export class AICustomizationManagementEditor extends EditorPane { } } - private updateEditorTitle(): void { - const sectionItem = this.sections.find(s => s.id === this.selectedSection); - if (sectionItem && this.input instanceof AICustomizationManagementEditorInput) { - this.input.setSectionLabel(sectionItem.label); - } - } - private updateContentVisibility(): void { const isEditorMode = this.viewMode === 'editor'; const isMcpDetailMode = this.viewMode === 'mcpDetail'; @@ -578,9 +568,6 @@ export class AICustomizationManagementEditor extends EditorPane { await super.setInput(input, options, context, token); - // Set initial editor tab title - this.updateEditorTitle(); - if (this.dimension) { this.layout(this.dimension); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.ts index 1ee4543ddacc1..819107dfcebda 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.ts @@ -22,8 +22,6 @@ export class AICustomizationManagementEditorInput extends EditorInput { private static _instance: AICustomizationManagementEditorInput | undefined; - private _sectionLabel: string | undefined; - /** * Gets or creates the singleton instance of this input. */ @@ -47,20 +45,7 @@ export class AICustomizationManagementEditorInput extends EditorInput { } override getName(): string { - if (this._sectionLabel) { - return localize('aiCustomizationManagementEditorNameWithSection', "Customizations: {0}", this._sectionLabel); - } - return localize('aiCustomizationManagementEditorName', "Customizations"); - } - - /** - * Updates the section label shown in the editor tab title. - */ - setSectionLabel(label: string): void { - if (this._sectionLabel !== label) { - this._sectionLabel = label; - this._onDidChangeLabel.fire(); - } + return localize('aiCustomizationManagementEditorName', "Chat Customizations"); } override getIcon(): ThemeIcon { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts index 450961e960144..8c2cdc5228452 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts @@ -8,6 +8,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; +import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { @@ -52,6 +53,13 @@ class AICustomizationWorkspaceService implements IAICustomizationWorkspaceServic AICustomizationManagementSection.McpServers, ]; + readonly visibleStorageSources: readonly PromptsStorage[] = [ + PromptsStorage.local, + PromptsStorage.user, + PromptsStorage.extension, + PromptsStorage.plugin, + ]; + readonly preferManualCreation = false; async commitFiles(_projectRoot: URI, _fileUris: URI[]): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 97ca2db45d3b1..37f7907162471 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1242,7 +1242,8 @@ configurationRegistry.registerConfiguration({ }, [ChatConfiguration.ChatCustomizationMenuEnabled]: { type: 'boolean', - description: nls.localize('chat.aiCustomizationMenu.enabled', "Controls whether the Chat Customization Menu is shown in the Manage menu and Command Palette. When disabled, the Chat Customizations editor and related commands are hidden."), + tags: ['preview'], + description: nls.localize('chat.aiCustomizationMenu.enabled', "Controls whether the Chat Customizations editor is available in the Command Palette. When disabled, the Chat Customizations editor and related commands are hidden."), default: true, } } diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index b64f5ca45f656..642afcefb4f5c 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -7,6 +7,7 @@ import { IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { PromptsType } from './promptSyntax/promptTypes.js'; +import { PromptsStorage } from './promptSyntax/service/promptsService.js'; export const IAICustomizationWorkspaceService = createDecorator('aiCustomizationWorkspaceService'); @@ -46,6 +47,11 @@ export interface IAICustomizationWorkspaceService { */ readonly managementSections: readonly AICustomizationManagementSection[]; + /** + * The storage sources to show as groups in the customization list. + */ + readonly visibleStorageSources: readonly PromptsStorage[]; + /** * Whether the primary creation action should create a file directly */ From 15fa1cf73b3d66834db752516ba28cee561de4a9 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 25 Feb 2026 15:33:35 -0600 Subject: [PATCH 24/30] disable next/previous tip actions when only one remains (#297747) fix #297391 --- .../contrib/chat/browser/chatTipService.ts | 69 ++++++++++++++++--- .../chatContentParts/chatTipContentPart.ts | 12 +++- .../chat/common/actions/chatContextKeys.ts | 1 + 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 1c82947166de0..7f2559f5895ed 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -117,6 +117,11 @@ export interface IChatTipService { */ navigateToPreviousTip(): IChatTip | undefined; + /** + * Returns whether there are multiple eligible tips for navigation. + */ + hasMultipleTips(): boolean; + /** * Clears all dismissed tips so they can be shown again. */ @@ -976,6 +981,15 @@ export class ChatTipService extends Disposable implements IChatTipService { return this._navigateTip(-1, this._contextKeyService); } + hasMultipleTips(): boolean { + if (!this._contextKeyService) { + return false; + } + + this._createSlashCommandsUsageTracker.syncContextKey(this._contextKeyService); + return this._hasNavigableTip(this._contextKeyService); + } + private _navigateTip(direction: 1 | -1, contextKeyService: IContextKeyService): IChatTip | undefined { this._createSlashCommandsUsageTracker.syncContextKey(contextKeyService); if (!this._shownTip) { @@ -987,20 +1001,57 @@ export class ChatTipService extends Disposable implements IChatTipService { return undefined; } + const candidate = this._getNavigableTip(direction, currentIndex, contextKeyService); + if (candidate) { + this._logTipTelemetry(this._shownTip.id, direction === 1 ? 'navigateNext' : 'navigatePrevious'); + this._shownTip = candidate; + this._tipRequestId = 'welcome'; + this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, candidate.id, StorageScope.APPLICATION, StorageTarget.USER); + this._logTipTelemetry(candidate.id, 'shown'); + this._trackTipCommandClicks(candidate); + const tip = this._createTip(candidate); + this._onDidNavigateTip.fire(tip); + return tip; + } + + return undefined; + } + + private _hasNavigableTip(contextKeyService: IContextKeyService): boolean { + if (!this._shownTip) { + return false; + } + + const currentIndex = TIP_CATALOG.findIndex(t => t.id === this._shownTip!.id); + if (currentIndex === -1) { + return false; + } + + return !!this._getNavigableTip(1, currentIndex, contextKeyService); + } + + private _getNavigableTip(direction: 1 | -1, currentIndex: number, contextKeyService: IContextKeyService): ITipDefinition | undefined { const dismissedIds = new Set(this._getDismissedTipIds()); + + let eligibleTipCount = 0; + for (const tip of TIP_CATALOG) { + if (!dismissedIds.has(tip.id) && this._isEligible(tip, contextKeyService)) { + eligibleTipCount++; + if (eligibleTipCount > 1) { + break; + } + } + } + + if (eligibleTipCount <= 1) { + return undefined; + } + for (let i = 1; i < TIP_CATALOG.length; i++) { const idx = ((currentIndex + direction * i) % TIP_CATALOG.length + TIP_CATALOG.length) % TIP_CATALOG.length; const candidate = TIP_CATALOG[idx]; if (!dismissedIds.has(candidate.id) && this._isEligible(candidate, contextKeyService)) { - this._logTipTelemetry(this._shownTip.id, direction === 1 ? 'navigateNext' : 'navigatePrevious'); - this._shownTip = candidate; - this._tipRequestId = 'welcome'; - this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, candidate.id, StorageScope.APPLICATION, StorageTarget.USER); - this._logTipTelemetry(candidate.id, 'shown'); - this._trackTipCommandClicks(candidate); - const tip = this._createTip(candidate); - this._onDidNavigateTip.fire(tip); - return tip; + return candidate; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index b5fdc3024768b..892ed35e18d20 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -38,6 +38,7 @@ export class ChatTipContentPart extends Disposable { private readonly _toolbar = this._register(new MutableDisposable()); private readonly _inChatTipContextKey: IContextKey; + private readonly _multipleChatTipsContextKey: IContextKey; constructor( tip: IChatTip, @@ -59,10 +60,16 @@ export class ChatTipContentPart extends Disposable { this.domNode.setAttribute('aria-roledescription', localize('chatTipRoleDescription', "tip")); this._inChatTipContextKey = ChatContextKeys.inChatTip.bindTo(this._contextKeyService); + this._multipleChatTipsContextKey = ChatContextKeys.multipleChatTips.bindTo(this._contextKeyService); const focusTracker = this._register(dom.trackFocus(this.domNode)); this._register(focusTracker.onDidFocus(() => this._inChatTipContextKey.set(true))); this._register(focusTracker.onDidBlur(() => this._inChatTipContextKey.set(false))); - this._register({ dispose: () => this._inChatTipContextKey.reset() }); + this._register({ + dispose: () => { + this._inChatTipContextKey.reset(); + this._multipleChatTipsContextKey.reset(); + } + }); this._renderTip(tip); @@ -113,6 +120,7 @@ export class ChatTipContentPart extends Disposable { private _renderTip(tip: IChatTip): void { dom.clearNode(this.domNode); this._toolbar.clear(); + this._multipleChatTipsContextKey.set(this._chatTipService.hasMultipleTips()); const markdownContent = this._renderer.render(tip.content, { actionHandler: (link, md) => { this._handleTipAction(link, md).catch(onUnexpectedError); } @@ -166,6 +174,7 @@ registerAction2(class PreviousTipAction extends Action2 { id: 'workbench.action.chat.previousTip', title: localize2('chatTip.previous', "Previous tip"), icon: Codicon.chevronLeft, + precondition: ChatContextKeys.multipleChatTips, f1: false, menu: [{ id: MenuId.ChatTipToolbar, @@ -187,6 +196,7 @@ registerAction2(class NextTipAction extends Action2 { id: 'workbench.action.chat.nextTip', title: localize2('chatTip.next', "Next tip"), icon: Codicon.chevronRight, + precondition: ChatContextKeys.multipleChatTips, f1: false, menu: [{ id: MenuId.ChatTipToolbar, diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 118eff3f41d14..9ab62c99bbf22 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -43,6 +43,7 @@ export namespace ChatContextKeys { export const inChatEditor = new RawContextKey('inChatEditor', false, { type: 'boolean', description: localize('inChatEditor', "Whether focus is in a chat editor.") }); export const inChatTodoList = new RawContextKey('inChatTodoList', false, { type: 'boolean', description: localize('inChatTodoList', "True when focus is in the chat todo list.") }); export const inChatTip = new RawContextKey('inChatTip', false, { type: 'boolean', description: localize('inChatTip', "True when focus is in a chat tip.") }); + export const multipleChatTips = new RawContextKey('multipleChatTips', false, { type: 'boolean', description: localize('multipleChatTips', "True when there are multiple chat tips available.") }); export const inChatTerminalToolOutput = new RawContextKey('inChatTerminalToolOutput', false, { type: 'boolean', description: localize('inChatTerminalToolOutput', "True when focus is in the chat terminal output region.") }); export const chatModeKind = new RawContextKey('chatAgentKind', ChatModeKind.Ask, { type: 'string', description: localize('agentKind', "The 'kind' of the current agent.") }); export const chatModeName = new RawContextKey('chatModeName', '', { type: 'string', description: localize('chatModeName', "The name of the current chat mode (e.g. 'Plan' for custom modes).") }); From e4cc8ce8b1d15588b5baad26d27a3be28255fae9 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 25 Feb 2026 15:33:45 -0600 Subject: [PATCH 25/30] collapse hidden welcome-tip instead of making it invisible (#297743) fixes #297631 --- .../contrib/chat/browser/widget/media/chatViewWelcome.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatViewWelcome.css b/src/vs/workbench/contrib/chat/browser/widget/media/chatViewWelcome.css index 17c3b7dd1c2de..225d4422d4d8b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chatViewWelcome.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chatViewWelcome.css @@ -22,7 +22,7 @@ .chat-welcome-view .chat-welcome-view-message, .chat-welcome-view .chat-welcome-view-disclaimer, .chat-welcome-view .chat-welcome-view-tips { - visibility: hidden; + display: none; } } From b6558fb4f9619e649cf84e9c88b60a7a88609eca Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 25 Feb 2026 13:34:23 -0800 Subject: [PATCH 26/30] chat: fix plugin hook workspace root resolution and CLAUDE_PLUGIN_ROOT escaping (#297790) * chat: fix plugin hook workspace root resolution and CLAUDE_PLUGIN_ROOT escaping Refactors agent plugin format adapters to use dependency injection and correctly resolve workspace root URIs for hook cwd resolution, matching the pattern from PromptsService. Also uses IInstantiationService for creating adapter instances instead of manual construction. - Converts CopilotPluginFormatAdapter and ClaudePluginFormatAdapter from const objects to proper classes with @IWorkspaceContextService injection - Adds resolveWorkspaceRoot helper that mirrors PromptsService logic to resolve the workspace folder containing the plugin, falling back to the first workspace folder for plugins outside the workspace - Fixes workspace root passed to parseC opilotHooks and parseClaudeHooks (was incorrectly passing pluginUri instead of the workspace folder URI) - Updates _detectPluginFormatAdapter to use _instantiationService .createInstance instead of manual constructor calls (Commit message generated by Copilot) * revert * comments --- .../common/plugins/agentPluginServiceImpl.ts | 162 +++++++++++++++--- .../shellQuotePluginRootInCommand.test.ts | 126 ++++++++++++++ 2 files changed, 266 insertions(+), 22 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/common/plugins/shellQuotePluginRootInCommand.test.ts diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 7b8696d771540..27d1dcc37e16c 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -30,9 +30,12 @@ import { ChatConfiguration } from '../constants.js'; import { parseCopilotHooks } from '../promptSyntax/hookCompatibility.js'; import { parseClaudeHooks } from '../promptSyntax/hookClaudeCompat.js'; import { agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginCommand, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from './agentPluginService.js'; -import { cloneAndChange } from '../../../../../base/common/objects.js'; +import { escapeRegExpCharacters } from '../../../../../base/common/strings.js'; import { IPluginInstallService } from './pluginInstallService.js'; import { IMarketplacePlugin, IPluginMarketplaceService } from './pluginMarketplaceService.js'; +import { Mutable } from '../../../../../base/common/types.js'; +import { IHookCommand } from '../promptSyntax/hookSchema.js'; +import { cloneAndChange } from '../../../../../base/common/objects.js'; const COMMAND_FILE_SUFFIX = '.md'; @@ -53,34 +56,140 @@ function mapParsedHooks(parsed: Map ({ type, hooks, originalId })); } -const copilotPluginFormatAdapter: IAgentPluginFormatAdapter = { - format: AgentPluginFormat.Copilot, - manifestPaths: ['plugin.json'], - hookConfigPaths: ['hooks.json'], - hookWatchPaths: ['hooks.json'], - parseHooks: (json, pluginUri, userHome) => mapParsedHooks(parseCopilotHooks(json, pluginUri, userHome)), -}; - -const claudePluginFormatAdapter: IAgentPluginFormatAdapter = { - format: AgentPluginFormat.Claude, - manifestPaths: ['.claude-plugin/plugin.json'], - hookConfigPaths: ['hooks/hooks.json'], - hookWatchPaths: ['hooks'], - parseHooks: (json, pluginUri, userHome) => { +/** + * Resolves the workspace folder that contains the plugin URI for cwd resolution, + * falling back to the first workspace folder for plugins outside the workspace. + */ +function resolveWorkspaceRoot(pluginUri: URI, workspaceContextService: IWorkspaceContextService): URI | undefined { + const defaultFolder = workspaceContextService.getWorkspace().folders[0]; + const folder = workspaceContextService.getWorkspaceFolder(pluginUri) ?? defaultFolder; + return folder?.uri; +} + +class CopilotPluginFormatAdapter implements IAgentPluginFormatAdapter { + readonly format = AgentPluginFormat.Copilot; + readonly manifestPaths = ['plugin.json']; + readonly hookConfigPaths = ['hooks.json']; + readonly hookWatchPaths = ['hooks.json']; + + constructor( + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + ) { } + + parseHooks(json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { + const workspaceRoot = resolveWorkspaceRoot(pluginUri, this._workspaceContextService); + return mapParsedHooks(parseCopilotHooks(json, workspaceRoot, userHome)); + } +} + +/** + * Characters in a file path that require shell quoting to prevent + * word splitting or interpretation by common shells (bash, zsh, cmd, PowerShell). + */ +const shellUnsafeChars = /[\s&|<>()^;!`"']/; + +/** + * Replaces `${CLAUDE_PLUGIN_ROOT}` in a shell command string with the + * given fsPath. If the path contains characters that would break shell + * parsing (e.g. spaces), occurrences are wrapped in double-quotes. + * + * The token may be followed by additional path segments like + * `${CLAUDE_PLUGIN_ROOT}/scripts/run.sh`; the entire resulting path + * (including suffix) is quoted as one unit. + * + */ +export function shellQuotePluginRootInCommand(command: string, fsPath: string, token: string = '${CLAUDE_PLUGIN_ROOT}'): string { + if (!command.includes(token)) { + return command; + } + + if (!shellUnsafeChars.test(fsPath)) { + // Path is shell-safe; plain replacement is fine. + return command.replaceAll(token, fsPath); + } + + // Replace each token occurrence (plus any trailing path chars that form + // a single filesystem argument) with a properly double-quoted expansion. + const escapedToken = escapeRegExpCharacters(token); + const pattern = new RegExp( + // Capture an optional leading quote so we know if it's already quoted + `(["']?)` + escapedToken + `([\\w./\\\\~:-]*)`, + 'g', + ); + + return command.replace(pattern, (_match, leadingQuote: string, suffix: string) => { + const fullPath = fsPath + suffix; + if (leadingQuote) { + // Already inside quotes — don't add more, just expand. + return leadingQuote + fullPath; + } + // Wrap in double quotes, escaping any embedded double-quote chars. + return '"' + fullPath.replace(/"/g, '\\"') + '"'; + }); +} + +class ClaudePluginFormatAdapter implements IAgentPluginFormatAdapter { + readonly format = AgentPluginFormat.Claude; + readonly manifestPaths = ['.claude-plugin/plugin.json']; + readonly hookConfigPaths = ['hooks/hooks.json']; + readonly hookWatchPaths = ['hooks']; + + constructor( + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + ) { } + + parseHooks(json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { + const token = '${CLAUDE_PLUGIN_ROOT}'; + const fsPath = pluginUri.fsPath; + const typedJson = json as { hooks?: Record }; + + const mutateHookCommand = (hook: Mutable): void => { + for (const field of ['command', 'windows', 'linux', 'osx'] as const) { + if (typeof hook[field] === 'string') { + hook[field] = shellQuotePluginRootInCommand(hook[field], fsPath, token); + } + } + + hook.env ??= {}; + hook.env.CLAUDE_PLUGIN_ROOT = fsPath; + }; + + for (const lifecycle of Object.values(typedJson.hooks ?? {})) { + if (!Array.isArray(lifecycle)) { + continue; + } + + for (const lifecycleEntry of lifecycle) { + if (!lifecycleEntry || typeof lifecycleEntry !== 'object') { + continue; + } + + const entry = lifecycleEntry as { hooks?: Mutable[] } & Mutable; + if (Array.isArray(entry.hooks)) { + for (const hook of entry.hooks) { + mutateHookCommand(hook); + } + } else { + mutateHookCommand(entry); + } + } + } + const replacer = (v: unknown): unknown => { return typeof v === 'string' ? v.replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginUri.fsPath) : undefined; }; - const { hooks, disabledAllHooks } = parseClaudeHooks(cloneAndChange(json, replacer), pluginUri, userHome); + const workspaceRoot = resolveWorkspaceRoot(pluginUri, this._workspaceContextService); + const { hooks, disabledAllHooks } = parseClaudeHooks(cloneAndChange(json, replacer), workspaceRoot, userHome); if (disabledAllHooks) { return []; } return mapParsedHooks(hooks); - }, -}; + } +} export class AgentPluginService extends Disposable implements IAgentPluginService { @@ -164,6 +273,7 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IPathService private readonly _pathService: IPathService, @ILogService private readonly _logService: ILogService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); this._pluginPathsConfig = observableConfigValue>(ChatConfiguration.PluginPaths, {}, _configurationService); @@ -303,10 +413,10 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent private async _detectPluginFormatAdapter(pluginUri: URI): Promise { const isInClaudeDirectory = pluginUri.path.split('/').includes('.claude'); if (isInClaudeDirectory || await this._pathExists(joinPath(pluginUri, '.claude-plugin', 'plugin.json'))) { - return claudePluginFormatAdapter; + return this._instantiationService.createInstance(ClaudePluginFormatAdapter); } - return copilotPluginFormatAdapter; + return this._instantiationService.createInstance(CopilotPluginFormatAdapter); } private async _pathExists(resource: URI): Promise { @@ -521,7 +631,11 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent for (const hooksUri of adapter.hookConfigPaths.map(path => joinPath(pluginUri, path))) { const json = await this._readJsonFile(hooksUri); if (json) { - return adapter.parseHooks(json, pluginUri, userHome); + try { + return adapter.parseHooks(json, pluginUri, userHome); + } catch (e) { + this._logService.info(`[ConfiguredAgentPluginDiscovery] Failed to parse hooks from ${hooksUri.toString()}:`, e); + } } } @@ -530,7 +644,11 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent if (manifest && typeof manifest === 'object') { const hooks = (manifest as Record)['hooks']; if (hooks && typeof hooks === 'object') { - return adapter.parseHooks({ hooks }, pluginUri, userHome); + try { + return adapter.parseHooks({ hooks }, pluginUri, userHome); + } catch (e) { + this._logService.info(`[ConfiguredAgentPluginDiscovery] Failed to parse hooks from manifest ${manifestPath.toString()}:`, e); + } } } } diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/shellQuotePluginRootInCommand.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/shellQuotePluginRootInCommand.test.ts new file mode 100644 index 0000000000000..973c7f28de272 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/plugins/shellQuotePluginRootInCommand.test.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { shellQuotePluginRootInCommand } from '../../../common/plugins/agentPluginServiceImpl.js'; + +suite('shellQuotePluginRootInCommand', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const TOKEN = '${PLUGIN_ROOT}'; + + test('returns command unchanged when token is not present', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('echo hello', '/safe/path', TOKEN), + 'echo hello', + ); + }); + + test('plain replacement when path has no special characters', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}/run.sh', '/safe/path', TOKEN), + '/safe/path/run.sh', + ); + }); + + test('plain replacement for multiple occurrences with safe path', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}/a && ${PLUGIN_ROOT}/b', '/safe', TOKEN), + '/safe/a && /safe/b', + ); + }); + + test('quotes path with spaces', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}/run.sh', '/path with spaces', TOKEN), + '"/path with spaces/run.sh"', + ); + }); + + test('quotes path with ampersand', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}/run.sh', '/path&dir', TOKEN), + '"/path&dir/run.sh"', + ); + }); + + test('quotes multiple occurrences with unsafe path', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}/a && ${PLUGIN_ROOT}/b', '/my dir', TOKEN), + '"/my dir/a" && "/my dir/b"', + ); + }); + + test('does not double-quote when already in double quotes', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('"${PLUGIN_ROOT}/run.sh"', '/my dir', TOKEN), + '"/my dir/run.sh"', + ); + }); + + test('does not double-quote when already in single quotes', () => { + assert.strictEqual( + shellQuotePluginRootInCommand(`'\${PLUGIN_ROOT}/run.sh'`, '/my dir', TOKEN), + `'/my dir/run.sh'`, + ); + }); + + test('escapes embedded double-quote characters in path', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}/run.sh', '/path"with"quotes', TOKEN), + '"/path\\"with\\"quotes/run.sh"', + ); + }); + + test('handles token without trailing path suffix', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('cd ${PLUGIN_ROOT} && run', '/my dir', TOKEN), + 'cd "/my dir" && run', + ); + }); + + test('does not consume shell operators adjacent to token', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('cd ${PLUGIN_ROOT}&& echo ok', '/my dir', TOKEN), + 'cd "/my dir"&& echo ok', + ); + }); + + test('handles token at start, middle and end of command', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}/a ${PLUGIN_ROOT}/b ${PLUGIN_ROOT}/c', '/sp ace', TOKEN), + '"/sp ace/a" "/sp ace/b" "/sp ace/c"', + ); + }); + + test('uses default CLAUDE_PLUGIN_ROOT token when not specified', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${CLAUDE_PLUGIN_ROOT}/run.sh', '/safe/path'), + '/safe/path/run.sh', + ); + }); + + test('uses default CLAUDE_PLUGIN_ROOT token with quoting', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${CLAUDE_PLUGIN_ROOT}/run.sh', '/my dir'), + '"/my dir/run.sh"', + ); + }); + + test('handles Windows-style paths with spaces', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}\\scripts\\run.bat', 'C:\\Program Files\\plugin', TOKEN), + '"C:\\Program Files\\plugin\\scripts\\run.bat"', + ); + }); + + test('handles path with parentheses', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}/run.sh', '/path(1)', TOKEN), + '"/path(1)/run.sh"', + ); + }); +}); From b38621a245a0c5aee6d5e90c3d9b71c992f24b88 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:50:15 -0800 Subject: [PATCH 27/30] sessions window: gate app on login (#297795) * sessions: restore blocking welcome overlay for auth setup Adds a full-screen blocking overlay that prevents using the sessions window until setup is complete. The overlay shows a "Get Started" button that triggers the standard chat setup flow (sign-in, extension install, etc.) via CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID. Shows a spinner while setup is running. After successful setup, the extension host is restarted so the chat extension picks up the auth session cleanly, then the overlay is dismissed. The overlay re-appears if the user later signs out or the extension is uninstalled/disabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * enhance welcome overlay: update card dimensions, improve header layout, and add icon * update welcome overlay: replace agent icon with copilot icon and adjust layout styles * refactor welcome overlay: remove icon from header and clean up styles * update welcome overlay: adjust card padding, center header content, and enhance layout styles * update welcome overlay: adjust card dimensions, padding, and icon size for improved layout * update welcome overlay: enhance card styles, adjust header layout, and improve icon size * update welcome overlay: increase header icon size, adjust header font size, add subtitle for improved clarity * update welcome overlay: change subtitle to clarify sign-in instructions for GitHub account * update chat setup logic: refine conditions for determining chat setup requirements * update welcome overlay: change subtitle to highlight agent-powered development with GitHub Copilot * update welcome overlay: simplify subtitle to focus on agent-powered development * Update src/vs/sessions/contrib/welcome/browser/media/welcomeOverlay.css Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * update welcome overlay: enhance accessibility with ARIA attributes and improve error logging * update welcome overlay: adjust z-index for improved layering --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../welcome/browser/media/welcomeOverlay.css | 103 ++++++++ .../welcome/browser/welcome.contribution.ts | 234 ++++++++++++++++++ src/vs/sessions/sessions.desktop.main.ts | 1 + 3 files changed, 338 insertions(+) create mode 100644 src/vs/sessions/contrib/welcome/browser/media/welcomeOverlay.css create mode 100644 src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts diff --git a/src/vs/sessions/contrib/welcome/browser/media/welcomeOverlay.css b/src/vs/sessions/contrib/welcome/browser/media/welcomeOverlay.css new file mode 100644 index 0000000000000..9883b93456e88 --- /dev/null +++ b/src/vs/sessions/contrib/welcome/browser/media/welcomeOverlay.css @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ---- Welcome Overlay (blocks the sessions window) ---- */ + +.sessions-welcome-overlay { + position: absolute; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + background: var(--vscode-editor-background); + opacity: 1; + transition: opacity 200ms ease-out; +} + +.sessions-welcome-overlay.sessions-welcome-overlay-dismissed { + opacity: 0; + pointer-events: none; +} + +/* ---- Card (borderless, centered content) ---- */ + +.sessions-welcome-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + text-align: center; +} + +/* ---- Header ---- */ + +.sessions-welcome-header { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.sessions-welcome-header .sessions-welcome-icon .codicon { + font-size: 96px; + color: var(--vscode-descriptionForeground); +} + +.sessions-welcome-header h2 { + margin: 0; + font-size: 22px; + font-weight: 600; + color: var(--vscode-foreground); +} + +.sessions-welcome-header .sessions-welcome-subtitle { + margin: 0; + font-size: 14px; + color: var(--vscode-descriptionForeground); +} + +/* ---- Action Area ---- */ + +.sessions-welcome-action-area { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + width: 320px; +} + +.sessions-welcome-action-area .monaco-button { + width: 100%; + padding: 10px 16px; + font-size: 14px; +} + +/* ---- Spinner ---- */ + +.sessions-welcome-spinner { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 13px; + color: var(--vscode-descriptionForeground); +} + +.sessions-welcome-spinner .codicon-loading { + animation: sessions-spin 1.5s linear infinite; +} + +@keyframes sessions-spin { + to { transform: rotate(360deg); } +} + +/* ---- Error ---- */ + +.sessions-welcome-error { + margin: 0; + font-size: 12px; + color: var(--vscode-errorForeground); +} diff --git a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts new file mode 100644 index 0000000000000..2addf1c60a961 --- /dev/null +++ b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/welcomeOverlay.css'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { $, append } from '../../../../base/browser/dom.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; +import { ChatEntitlement, ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +const WELCOME_COMPLETE_KEY = 'workbench.agentsession.welcomeComplete'; + +class SessionsWelcomeOverlay extends Disposable { + + private readonly overlay: HTMLElement; + + constructor( + container: HTMLElement, + @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, + @ICommandService private readonly commandService: ICommandService, + @IExtensionService private readonly extensionService: IExtensionService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + this.overlay = append(container, $('.sessions-welcome-overlay')); + this.overlay.setAttribute('role', 'dialog'); + this.overlay.setAttribute('aria-modal', 'true'); + this.overlay.setAttribute('aria-label', localize('welcomeOverlay.aria', "Sign in to use Sessions")); + this._register(toDisposable(() => this.overlay.remove())); + + const card = append(this.overlay, $('.sessions-welcome-card')); + + // Header — large icon + title, centered + const header = append(card, $('.sessions-welcome-header')); + const iconEl = append(header, $('span.sessions-welcome-icon')); + iconEl.appendChild(renderIcon(Codicon.agent)); + append(header, $('h2', undefined, localize('welcomeTitle', "Sign in to use Sessions"))); + append(header, $('p.sessions-welcome-subtitle', undefined, localize('welcomeSubtitle', "Agent-powered development"))); + + // Action area + const actionArea = append(card, $('.sessions-welcome-action-area')); + const actionButton = this._register(new Button(actionArea, { ...defaultButtonStyles })); + actionButton.label = localize('sessions.getStarted', "Get Started"); + + const spinnerContainer = append(actionArea, $('.sessions-welcome-spinner')); + spinnerContainer.style.display = 'none'; + + const errorContainer = append(actionArea, $('p.sessions-welcome-error')); + errorContainer.style.display = 'none'; + + this._register(actionButton.onDidClick(() => this._runSetup(actionButton, spinnerContainer, errorContainer))); + + // Focus the button so the overlay traps keyboard input + actionButton.focus(); + } + + private async _runSetup(button: Button, spinner: HTMLElement, error: HTMLElement): Promise { + button.enabled = false; + error.style.display = 'none'; + + spinner.textContent = ''; + spinner.appendChild(renderIcon(Codicon.loading)); + append(spinner, $('span', undefined, localize('sessions.settingUp', "Setting up…"))); + spinner.style.display = ''; + + try { + const success = await this.commandService.executeCommand(CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID, { + dialogIcon: Codicon.agent, + dialogTitle: this.chatEntitlementService.anonymous ? + localize('sessions.startUsingSessions', "Start using Sessions") : + localize('sessions.signinRequired', "Sign in to use Sessions") + }); + + if (success) { + spinner.textContent = ''; + spinner.appendChild(renderIcon(Codicon.loading)); + append(spinner, $('span', undefined, localize('sessions.restarting', "Completing setup…"))); + + this.logService.info('[sessions welcome] Restarting extension host after setup completion'); + const stopped = await this.extensionService.stopExtensionHosts( + localize('sessionsWelcome.restart', "Completing sessions setup") + ); + if (stopped) { + await this.extensionService.startExtensionHosts(); + } + } else { + button.enabled = true; + spinner.style.display = 'none'; + } + } catch (err) { + this.logService.error('[sessions welcome] Setup failed:', err); + error.textContent = localize('sessions.setupError', "Something went wrong. Please try again."); + error.style.display = ''; + button.enabled = true; + spinner.style.display = 'none'; + } + } + + dismiss(): void { + this.overlay.classList.add('sessions-welcome-overlay-dismissed'); + const handle = setTimeout(() => this.dispose(), 200); + this._register(toDisposable(() => clearTimeout(handle))); + } +} + +class SessionsWelcomeContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessionsWelcome'; + + private readonly overlayRef = this._register(new MutableDisposable()); + private readonly watcherRef = this._register(new MutableDisposable()); + + constructor( + @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IProductService private readonly productService: IProductService, + @IStorageService private readonly storageService: IStorageService, + ) { + super(); + + if (!this.productService.defaultChatAgent?.chatExtensionId) { + return; + } + + const isFirstLaunch = !this.storageService.getBoolean(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION, false); + if (isFirstLaunch) { + this.showOverlay(); + } else { + this.showOverlayIfNeeded(); + } + } + + private showOverlayIfNeeded(): void { + if (this._needsChatSetup()) { + this.showOverlay(); + } else { + this.watchForRegressions(); + } + } + + private watchForRegressions(): void { + let wasComplete = !this._needsChatSetup(); + this.watcherRef.value = autorun(reader => { + this.chatEntitlementService.sentimentObs.read(reader); + this.chatEntitlementService.entitlementObs.read(reader); + + const needsSetup = this._needsChatSetup(); + if (wasComplete && needsSetup) { + this.showOverlay(); + } + wasComplete = !needsSetup; + }); + } + + private _needsChatSetup(): boolean { + const { sentiment, entitlement } = this.chatEntitlementService; + if ( + !sentiment?.installed || // Extension not installed: run setup to install + sentiment?.disabled || // Extension disabled: run setup to enable + entitlement === ChatEntitlement.Available || // Entitlement available: run setup to sign up + ( + entitlement === ChatEntitlement.Unknown && // Entitlement unknown: run setup to sign in / sign up + !this.chatEntitlementService.anonymous // unless anonymous access is enabled + ) + ) { + return true; + } + + return false; + } + + private showOverlay(): void { + if (this.overlayRef.value) { + return; + } + + this.watcherRef.clear(); + this.overlayRef.value = new DisposableStore(); + + const overlay = this.overlayRef.value.add(this.instantiationService.createInstance( + SessionsWelcomeOverlay, + this.layoutService.mainContainer, + )); + + // When setup completes (observables flip), dismiss and watch again + this.overlayRef.value.add(autorun(reader => { + this.chatEntitlementService.sentimentObs.read(reader); + this.chatEntitlementService.entitlementObs.read(reader); + + if (!this._needsChatSetup()) { + this.storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + overlay.dismiss(); + this.overlayRef.clear(); + this.watchForRegressions(); + } + })); + } +} + +registerWorkbenchContribution2(SessionsWelcomeContribution.ID, SessionsWelcomeContribution, WorkbenchPhase.BlockRestore); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.resetSessionsWelcome', + title: localize2('resetSessionsWelcome', "Reset Sessions Welcome"), + category: Categories.Developer, + f1: true, + }); + } + run(accessor: ServicesAccessor): void { + const storageService = accessor.get(IStorageService); + storageService.remove(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION); + } +}); diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 614af22140066..34cffa0159b21 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -212,6 +212,7 @@ import './contrib/configuration/browser/configuration.contribution.js'; import './contrib/terminal/browser/sessionsTerminalContribution.js'; import './contrib/logs/browser/logs.contribution.js'; +import './contrib/welcome/browser/welcome.contribution.js'; //#endregion From 0f4543145be1d73df27abe45bdcf64dd812227bb Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 25 Feb 2026 15:50:56 -0600 Subject: [PATCH 28/30] ensure question carousel renders in chat sessions hover (#297775) fix #297405 --- .../media/chatQuestionCarousel.css | 123 +++++++++--------- 1 file changed, 62 insertions(+), 61 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 8652ea889d7a2..ac552db4d04ea 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -27,88 +27,89 @@ container-type: inline-size; } -/* container and header */ +/* input part wrapper */ .interactive-session .interactive-input-part > .chat-question-carousel-widget-container { width: 100%; position: relative; +} - .chat-question-carousel-content { - display: flex; - flex-direction: column; - background: var(--vscode-chat-requestBackground); - padding: 8px 16px 10px 16px; - overflow: hidden; +/* container and header */ +.interactive-session .chat-question-carousel-container .chat-question-carousel-content { + display: flex; + flex-direction: column; + background: var(--vscode-chat-requestBackground); + padding: 8px 16px 10px 16px; + overflow: hidden; - .chat-question-header-row { - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; + .chat-question-header-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + min-width: 0; + padding-bottom: 5px; + margin-left: -16px; + margin-right: -16px; + padding-left: 16px; + padding-right: 16px; + border-bottom: 1px solid var(--vscode-chat-requestBorder); + + .chat-question-title { + flex: 1; min-width: 0; - padding-bottom: 5px; - margin-left: -16px; - margin-right: -16px; - padding-left: 16px; - padding-right: 16px; - border-bottom: 1px solid var(--vscode-chat-requestBorder); - - .chat-question-title { - flex: 1; - min-width: 0; - word-wrap: break-word; - overflow-wrap: break-word; - font-weight: 500; - font-size: var(--vscode-chat-font-size-body-s); - margin: 0; - - .rendered-markdown { - a { - color: var(--vscode-textLink-foreground); - } - - a:hover, - a:active { - color: var(--vscode-textLink-activeForeground); - } + word-wrap: break-word; + overflow-wrap: break-word; + font-weight: 500; + font-size: var(--vscode-chat-font-size-body-s); + margin: 0; - p { - margin: 0; - } + .rendered-markdown { + a { + color: var(--vscode-textLink-foreground); } - .chat-question-title-main { - font-weight: 500; + a:hover, + a:active { + color: var(--vscode-textLink-activeForeground); } - .chat-question-title-subtitle { - font-weight: normal; - color: var(--vscode-descriptionForeground); + p { + margin: 0; } } - .chat-question-close-container { - flex-shrink: 0; + .chat-question-title-main { + font-weight: 500; + } - .monaco-button.chat-question-close { - min-width: 22px; - width: 22px; - height: 22px; - padding: 0; - border: none; - background: transparent !important; - color: var(--vscode-foreground) !important; - } + .chat-question-title-subtitle { + font-weight: normal; + color: var(--vscode-descriptionForeground); + } + } - .monaco-button.chat-question-close:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; - } + .chat-question-close-container { + flex-shrink: 0; + + .monaco-button.chat-question-close { + min-width: 22px; + width: 22px; + height: 22px; + padding: 0; + border: none; + background: transparent !important; + color: var(--vscode-foreground) !important; + } + + .monaco-button.chat-question-close:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground) !important; } } } } /* questions list and freeform area */ -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-input-container { +.interactive-session .chat-question-carousel-container .chat-question-input-container { display: flex; flex-direction: column; margin-top: 4px; @@ -294,7 +295,7 @@ } /* footer with step indicator and nav buttons */ -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-footer-row { +.interactive-session .chat-question-carousel-container .chat-question-footer-row { display: flex; justify-content: space-between; align-items: center; From 82256abf7eb0583439fb501516b41f1a1c6e2d32 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 25 Feb 2026 16:15:17 -0600 Subject: [PATCH 29/30] Revert "ensure rendering of questions can happen while askQuestions tool call is in flight and chat is moved" (#297801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "ensure rendering of questions can happen while askQuestions tool call…" This reverts commit 1c3c7c4b0951fd5f4358abc2a2f995f8dff7b06e. --- .../contrib/chat/browser/widget/chatListWidget.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index d562b874774bc..376d670c21d87 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -521,12 +521,6 @@ export class ChatListWidget extends Disposable { return; } - // Skip refresh when the container is disconnected from the DOM to avoid - // caching incorrect measurements (e.g. 0 heights) for tree elements. - if (!this._container.isConnected) { - return; - } - const items = this._viewModel.getItems(); this._lastItem = items.at(-1); this._lastItemIdContextKey.set(this._lastItem ? [this._lastItem.id] : []); @@ -553,9 +547,6 @@ export class ChatListWidget extends Disposable { // If a response is in the process of progressive rendering, we need to ensure that it will // be re-rendered so progressive rendering is restarted, even if the model wasn't updated. `${isResponseVM(element) && element.renderData ? `_${this._visibleChangeCount}` : ''}` + - // Re-render when new content parts are added to the response (e.g. question carousels - // arriving after progressive rendering caught up and stopped its timer). - (isResponseVM(element) ? `_parts${element.response.value.length}` : '') + // Re-render once content references are loaded (isResponseVM(element) ? `_${element.contentReferences.length}` : '') + // Re-render if element becomes hidden due to undo/redo From f3f0d3179d308a0f91e0b64925f32a529edf384d Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:17:51 -0800 Subject: [PATCH 30/30] some yolo mode improvements (#297616) * some yolo mode improvements * escape on close * Update src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/chatSlashCommands.ts | 25 ++++++++++++++++--- .../tools/languageModelToolsService.ts | 1 - .../chatProgressContentPart.ts | 4 +++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 392536b2b8b25..f6d6f20dc9e4c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -162,6 +162,15 @@ export class ChatSlashCommandsContribution extends Disposable { } })); const enableAutoApprove = async (): Promise => { + const inspection = configurationService.inspect(ChatConfiguration.GlobalAutoApprove); + if (inspection.policyValue !== undefined) { + if (inspection.policyValue === true) { + // Global auto-approve is already enabled by policy; nothing more to do. + return true; + } + notificationService.warn(nls.localize('autoApprove.policyManaged', "Global auto-approve is managed by your organization policy. Contact your administrator to change this setting.")); + return false; + } const alreadyOptedIn = storageService.getBoolean('chat.tools.global.autoApprove.optIn', StorageScope.APPLICATION, false); if (!alreadyOptedIn) { const result = await dialogService.prompt({ @@ -172,7 +181,6 @@ export class ChatSlashCommandsContribution extends Disposable { { label: nls.localize('autoApprove.cancel.button', 'Cancel'), run: () => false }, ], custom: { - disableCloseAction: true, markdownDetails: [{ markdown: new MarkdownString(globalAutoApproveDescription.value, { isTrusted: { enabledCommands: ['workbench.action.openSettings'] } }) }], } }); @@ -193,11 +201,22 @@ export class ChatSlashCommandsContribution extends Disposable { if (widget) { widget.acceptInput(trimmed); } + } else { + // Restore the prompt so the user doesn't lose their input + const widget = chatWidgetService.getWidgetBySessionResource(sessionResource); + if (widget) { + widget.setInput(trimmed); + } } } else { // /autoApprove — toggle const isEnabled = configurationService.getValue(ChatConfiguration.GlobalAutoApprove); if (isEnabled) { + const inspection = configurationService.inspect(ChatConfiguration.GlobalAutoApprove); + if (inspection.policyValue !== undefined) { + notificationService.warn(nls.localize('autoApprove.policyManaged', "Global auto-approve is managed by your organization policy. Contact your administrator to change this setting.")); + return; + } await configurationService.updateValue(ChatConfiguration.GlobalAutoApprove, false); notificationService.info(nls.localize('autoApprove.disabled', "Global auto-approve disabled — tools will require approval")); } else { @@ -207,7 +226,7 @@ export class ChatSlashCommandsContribution extends Disposable { }; this._store.add(slashCommandService.registerSlashCommand({ command: 'autoApprove', - detail: nls.localize('autoApprove', "Toggle global auto-approval of all tool calls"), + detail: nls.localize('autoApprove', "Toggle global auto-approval of all tool calls (alias: /yolo)"), sortText: 'z1_autoApprove', executeImmediately: false, silent: true, @@ -215,7 +234,7 @@ export class ChatSlashCommandsContribution extends Disposable { }, handleAutoApprove)); this._store.add(slashCommandService.registerSlashCommand({ command: 'yolo', - detail: nls.localize('yolo', "Toggle global auto-approval of all tool calls"), + detail: nls.localize('yolo', "Toggle global auto-approval of all tool calls (alias: /autoApprove)"), sortText: 'z1_yolo', executeImmediately: false, silent: true, diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index bc2f193bb5f68..93f94e85917b9 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -1107,7 +1107,6 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo ], custom: { icon: Codicon.warning, - disableCloseAction: true, markdownDetails: [{ markdown: new MarkdownString(globalAutoApproveDescription.value, { isTrusted: { enabledCommands: ['workbench.action.openSettings'] } }), }], diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts index 03f160cdd7296..95b0edf8e267b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts @@ -151,6 +151,10 @@ export class ChatProgressSubPart extends Disposable { content: tooltip, style: HoverStyle.Pointer, })); + this._register(hoverService.setupDelayedHover(messageElement, { + content: tooltip, + style: HoverStyle.Pointer, + })); } append(this.domNode, iconElement);