From 4bc179058321baf18b9f7f44905e5ffbe17410b1 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 12 May 2026 13:13:15 +0100 Subject: [PATCH 01/24] refactor: update button styles and colors for improved consistency across themes Co-authored-by: Copilot --- extensions/theme-defaults/themes/2026-dark.json | 3 ++- extensions/theme-defaults/themes/2026-light.json | 3 ++- src/vs/base/browser/ui/button/button.css | 4 ++++ src/vs/platform/theme/browser/defaultStyles.ts | 4 ++-- src/vs/platform/theme/common/colors/inputColors.ts | 2 +- src/vs/sessions/common/theme.ts | 4 ++-- .../sessions/contrib/changes/browser/media/changesView.css | 1 + .../contrib/extensions/browser/extensionsActions.ts | 6 +++--- 8 files changed, 17 insertions(+), 10 deletions(-) diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index 4f772c2d46e67..f1fc997eb150f 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -21,8 +21,9 @@ "button.background": "#297AA0", "button.foreground": "#FFFFFF", "button.hoverBackground": "#2B7DA3", - "button.border": "#333536FF", + "button.border": "#297AA0", "button.secondaryHoverBackground": "#FFFFFF10", + "button.secondaryBorder": "#333536", "checkbox.background": "#242526", "checkbox.border": "#333536", "checkbox.foreground": "#8C8C8C", diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index 66f7e56004480..a094e69c99a47 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -21,10 +21,11 @@ "button.background": "#0069CC", "button.foreground": "#FFFFFF", "button.hoverBackground": "#0063C1", - "button.border": "#EEEEF1", + "button.border": "#0069CC", "button.secondaryBackground": "#EAEAEA", "button.secondaryForeground": "#202020", "button.secondaryHoverBackground": "#F2F3F4", + "button.secondaryBorder": "#EAEAEA", "checkbox.background": "#EAEAEA", "checkbox.border": "#D8D8D8", "checkbox.foreground": "#606060", diff --git a/src/vs/base/browser/ui/button/button.css b/src/vs/base/browser/ui/button/button.css index 0ec4cbb4712dd..ccc3381fc896d 100644 --- a/src/vs/base/browser/ui/button/button.css +++ b/src/vs/base/browser/ui/button/button.css @@ -19,6 +19,10 @@ overflow-wrap: normal; } +.monaco-text-button.secondary { + border-color: var(--vscode-button-secondaryBorder, var(--vscode-button-border, transparent)); +} + .monaco-text-button.small { line-height: 14px; font-size: 11px; diff --git a/src/vs/platform/theme/browser/defaultStyles.ts b/src/vs/platform/theme/browser/defaultStyles.ts index fdc98de1cf6e9..063beb95f610d 100644 --- a/src/vs/platform/theme/browser/defaultStyles.ts +++ b/src/vs/platform/theme/browser/defaultStyles.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IButtonStyles } from '../../../base/browser/ui/button/button.js'; import { IKeybindingLabelStyles } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; -import { ColorIdentifier, keybindingLabelBackground, keybindingLabelBorder, keybindingLabelBottomBorder, keybindingLabelForeground, asCssVariable, widgetShadow, buttonForeground, buttonSeparator, buttonBackground, buttonHoverBackground, buttonSecondaryForeground, buttonSecondaryBackground, buttonSecondaryHoverBackground, buttonBorder, progressBarBackground, inputActiveOptionBorder, inputActiveOptionForeground, inputActiveOptionBackground, editorWidgetBackground, editorWidgetForeground, contrastBorder, checkboxBorder, checkboxBackground, checkboxForeground, problemsErrorIconForeground, problemsWarningIconForeground, problemsInfoIconForeground, inputBackground, inputForeground, inputBorder, textLinkForeground, inputValidationInfoBorder, inputValidationInfoBackground, inputValidationInfoForeground, inputValidationWarningBorder, inputValidationWarningBackground, inputValidationWarningForeground, inputValidationErrorBorder, inputValidationErrorBackground, inputValidationErrorForeground, listFilterWidgetBackground, listFilterWidgetNoMatchesOutline, listFilterWidgetOutline, listFilterWidgetShadow, badgeBackground, badgeForeground, breadcrumbsBackground, breadcrumbsForeground, breadcrumbsFocusForeground, breadcrumbsActiveSelectionForeground, activeContrastBorder, listActiveSelectionBackground, listActiveSelectionForeground, listActiveSelectionIconForeground, listDropOverBackground, listFocusAndSelectionOutline, listFocusBackground, listFocusForeground, listFocusOutline, listHoverBackground, listHoverForeground, listInactiveFocusBackground, listInactiveFocusOutline, listInactiveSelectionBackground, listInactiveSelectionForeground, listInactiveSelectionIconForeground, tableColumnsBorder, tableOddRowsBackgroundColor, treeIndentGuidesStroke, asCssVariableWithDefault, editorWidgetBorder, focusBorder, pickerGroupForeground, quickInputListFocusBackground, quickInputListFocusForeground, quickInputListFocusIconForeground, selectBackground, selectBorder, selectForeground, selectListBackground, treeInactiveIndentGuidesStroke, menuBorder, menuForeground, menuBackground, menuSelectionBorder, menuSeparatorBackground, scrollbarShadow, scrollbarSliderActiveBackground, scrollbarSliderBackground, scrollbarSliderHoverBackground, listDropBetweenBackground, radioActiveBackground, radioActiveForeground, radioInactiveBackground, radioInactiveForeground, radioInactiveBorder, radioInactiveHoverBackground, radioActiveBorder, checkboxDisabledBackground, checkboxDisabledForeground, widgetBorder } from '../common/colorRegistry.js'; +import { ColorIdentifier, keybindingLabelBackground, keybindingLabelBorder, keybindingLabelBottomBorder, keybindingLabelForeground, asCssVariable, widgetShadow, buttonForeground, buttonSeparator, buttonBackground, buttonHoverBackground, buttonSecondaryForeground, buttonSecondaryBackground, buttonSecondaryHoverBackground, buttonBorder, progressBarBackground, inputActiveOptionBorder, inputActiveOptionForeground, inputActiveOptionBackground, editorWidgetBackground, editorWidgetForeground, contrastBorder, checkboxBorder, checkboxBackground, checkboxForeground, problemsErrorIconForeground, problemsWarningIconForeground, problemsInfoIconForeground, inputBackground, inputForeground, inputBorder, textLinkForeground, inputValidationInfoBorder, inputValidationInfoBackground, inputValidationInfoForeground, inputValidationWarningBorder, inputValidationWarningBackground, inputValidationWarningForeground, inputValidationErrorBorder, inputValidationErrorBackground, inputValidationErrorForeground, listFilterWidgetBackground, listFilterWidgetNoMatchesOutline, listFilterWidgetOutline, listFilterWidgetShadow, badgeBackground, badgeForeground, breadcrumbsBackground, breadcrumbsForeground, breadcrumbsFocusForeground, breadcrumbsActiveSelectionForeground, activeContrastBorder, listActiveSelectionBackground, listActiveSelectionForeground, listActiveSelectionIconForeground, listDropOverBackground, listFocusAndSelectionOutline, listFocusBackground, listFocusForeground, listFocusOutline, listHoverBackground, listHoverForeground, listInactiveFocusBackground, listInactiveFocusOutline, listInactiveSelectionBackground, listInactiveSelectionForeground, listInactiveSelectionIconForeground, tableColumnsBorder, tableOddRowsBackgroundColor, treeIndentGuidesStroke, asCssVariableWithDefault, editorWidgetBorder, focusBorder, pickerGroupForeground, quickInputListFocusBackground, quickInputListFocusForeground, quickInputListFocusIconForeground, selectBackground, selectBorder, selectForeground, selectListBackground, treeInactiveIndentGuidesStroke, menuBorder, menuForeground, menuBackground, menuSelectionBorder, menuSeparatorBackground, scrollbarShadow, scrollbarSliderActiveBackground, scrollbarSliderBackground, scrollbarSliderHoverBackground, listDropBetweenBackground, radioActiveBackground, radioActiveForeground, radioInactiveBackground, radioInactiveForeground, radioInactiveBorder, radioInactiveHoverBackground, radioActiveBorder, checkboxDisabledBackground, checkboxDisabledForeground, widgetBorder, buttonSecondaryBorder } from '../common/colorRegistry.js'; import { IProgressBarStyles } from '../../../base/browser/ui/progressbar/progressbar.js'; import { ICheckboxStyles, IToggleStyles } from '../../../base/browser/ui/toggle/toggle.js'; import { IDialogStyles } from '../../../base/browser/ui/dialog/dialog.js'; @@ -51,7 +51,7 @@ export const defaultButtonStyles: IButtonStyles = { buttonSecondaryForeground: asCssVariable(buttonSecondaryForeground), buttonSecondaryBackground: asCssVariable(buttonSecondaryBackground), buttonSecondaryHoverBackground: asCssVariable(buttonSecondaryHoverBackground), - buttonSecondaryBorder: asCssVariable(buttonBorder), + buttonSecondaryBorder: asCssVariable(buttonSecondaryBorder), buttonBorder: asCssVariable(buttonBorder), }; diff --git a/src/vs/platform/theme/common/colors/inputColors.ts b/src/vs/platform/theme/common/colors/inputColors.ts index 9e6e2681d433f..9f7aedf44cb86 100644 --- a/src/vs/platform/theme/common/colors/inputColors.ts +++ b/src/vs/platform/theme/common/colors/inputColors.ts @@ -139,7 +139,7 @@ export const buttonSecondaryBackground = registerColor('button.secondaryBackgrou nls.localize('buttonSecondaryBackground', "Secondary button background color.")); export const buttonSecondaryBorder = registerColor('button.secondaryBorder', - contrastBorder, + { dark: transparent(foreground, 0.15), light: transparent(foreground, 0.15), hcDark: contrastBorder, hcLight: contrastBorder }, nls.localize('buttonSecondaryBorder', "Secondary button border color.")); export const buttonSecondaryHoverBackground = registerColor('button.secondaryHoverBackground', diff --git a/src/vs/sessions/common/theme.ts b/src/vs/sessions/common/theme.ts index 686ca9964feae..a6f4c24ab04ca 100644 --- a/src/vs/sessions/common/theme.ts +++ b/src/vs/sessions/common/theme.ts @@ -15,7 +15,7 @@ import { registerColor, transparent } from '../../platform/theme/common/colorUti import { contrastBorder, focusBorder } from '../../platform/theme/common/colorRegistry.js'; import { editorWidgetBorder, editorBackground, toolbarHoverBackground } from '../../platform/theme/common/colors/editorColors.js'; import { foreground } from '../../platform/theme/common/colors/baseColors.js'; -import { buttonBackground, buttonBorder, inputBackground, inputBorder, inputForeground, inputPlaceholderForeground } from '../../platform/theme/common/colors/inputColors.js'; +import { buttonBackground, buttonSecondaryBorder, inputBackground, inputBorder, inputForeground, inputPlaceholderForeground } from '../../platform/theme/common/colors/inputColors.js'; import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, SIDE_BAR_BACKGROUND, SIDE_BAR_FOREGROUND } from '../../workbench/common/theme.js'; // ============================================================================ @@ -126,7 +126,7 @@ export const agentsNewSessionButtonForeground = registerColor( ); export const agentsNewSessionButtonBorder = registerColor( - 'agentsNewSessionButton.border', buttonBorder, + 'agentsNewSessionButton.border', buttonSecondaryBorder, localize('agentsNewSessionButton.border', 'Border color of the New Session button in the agent sessions sidebar.') ); diff --git a/src/vs/sessions/contrib/changes/browser/media/changesView.css b/src/vs/sessions/contrib/changes/browser/media/changesView.css index d7895de28598d..4c3c7ca6cd524 100644 --- a/src/vs/sessions/contrib/changes/browser/media/changesView.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesView.css @@ -229,6 +229,7 @@ .changes-view-body .chat-editing-session-actions .monaco-button.secondary.monaco-text-button { background-color: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); + border-color: var(--vscode-button-secondaryBorder); } .changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button.secondary.monaco-text-button { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index 37b943542c896..7d1b64a003740 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -29,7 +29,7 @@ import { CommandsRegistry, ICommandService } from '../../../../platform/commands import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { registerThemingParticipant, IColorTheme, ICssStyleCollector } from '../../../../platform/theme/common/themeService.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { buttonBackground, buttonForeground, buttonHoverBackground, buttonSecondaryBackground, buttonSecondaryForeground, buttonSecondaryHoverBackground, registerColor, editorWarningForeground, editorInfoForeground, editorErrorForeground, buttonSeparator, buttonBorder, contrastBorder } from '../../../../platform/theme/common/colorRegistry.js'; +import { buttonBackground, buttonForeground, buttonHoverBackground, buttonSecondaryBackground, buttonSecondaryForeground, buttonSecondaryHoverBackground, registerColor, editorWarningForeground, editorInfoForeground, editorErrorForeground, buttonSeparator, contrastBorder, buttonSecondaryBorder } from '../../../../platform/theme/common/colorRegistry.js'; import { IJSONEditingService } from '../../../services/configuration/common/jsonEditing.js'; import { ITextEditorSelection } from '../../../../platform/editor/common/editor.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; @@ -3441,8 +3441,8 @@ registerColor('extensionButton.hoverBackground', { }, localize('extensionButtonHoverBackground', "Button background hover color for extension actions.")); registerColor('extensionButton.border', { - dark: buttonBorder, - light: buttonBorder, + dark: buttonSecondaryBorder, + light: buttonSecondaryBorder, hcDark: contrastBorder, hcLight: contrastBorder }, localize('extensionButtonBorder', "Button border color for extension actions.")); From b1ffb083e9ad4d37a1b93e32b1aed82066bf5e3c Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 12 May 2026 14:13:35 +0100 Subject: [PATCH 02/24] style: enhance secondary button border colors for dropdowns --- src/vs/base/browser/ui/button/button.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/vs/base/browser/ui/button/button.css b/src/vs/base/browser/ui/button/button.css index ccc3381fc896d..f8f02ab6152b6 100644 --- a/src/vs/base/browser/ui/button/button.css +++ b/src/vs/base/browser/ui/button/button.css @@ -119,6 +119,10 @@ align-items: center; } +.monaco-button-dropdown > .monaco-button.monaco-dropdown-button.secondary { + border-color: var(--vscode-button-secondaryBorder, var(--vscode-button-border, transparent)); +} + .monaco-button-dropdown > .monaco-button.monaco-text-button { border-radius: 4px 0 0 4px; } @@ -181,6 +185,8 @@ .monaco-button-dropdown.default-colors .monaco-button.secondary + .monaco-button-dropdown-separator { background-color: var(--vscode-button-secondaryBackground); + border-top-color: var(--vscode-button-secondaryBorder, var(--vscode-button-border)); + border-bottom-color: var(--vscode-button-secondaryBorder, var(--vscode-button-border)); } .monaco-button-dropdown.default-colors .monaco-button-dropdown-separator > div { From 4a4e105c69b71dc6c1aa38d469a00ab1e8982305 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:42:52 -0400 Subject: [PATCH 03/24] Update chat input send button to be circular (#319506) --- .../workbench/contrib/chat/browser/widget/media/chat.css | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 8a39485d84cf5..824759bce321d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1047,7 +1047,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) { position: relative; - border-radius: 5px; + border-radius: var(--vscode-cornerRadius-circle); } .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) > .action-label.codicon-arrow-up { @@ -1057,9 +1057,10 @@ have to be updated for changes to the rules above, or to support more deeply nes transition: background-color 250ms ease, color 250ms ease; } -/* Optical alignment: nudge arrow glyph 1px left to visually center it. */ +/* Nudge icon position to ensure it looks optically aligned within the button */ .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item > .action-label.codicon-arrow-up::before { display: inline-block; + transform: translateX(-0.5px); } /* Focus indicator drawn on the action-item wrapper so it sits cleanly around @@ -1067,7 +1068,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:focus-visible) { outline: 1px solid var(--vscode-focusBorder); outline-offset: 1px; - border-radius: 5px; + border-radius: var(--vscode-cornerRadius-circle); } .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) > .action-label.codicon-arrow-up:focus, @@ -1079,7 +1080,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up { background: var(--vscode-button-background); color: var(--vscode-button-foreground) !important; - border-radius: 5px; + border-radius: var(--vscode-cornerRadius-circle); transition: background-color 120ms ease; } From 5e68f4429884005f05c5afe82edd16c8e4342851 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:20:54 -0700 Subject: [PATCH 04/24] increase debounce to reduce flickering (#319984) * increase debounce to reduce flickering * clean up --- .../chat/browser/widget/chatListRenderer.ts | 52 +++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index b3b23e58861d4..63c07965e83c2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -114,7 +114,7 @@ import { isAgentHostTarget } from '../agentSessions/agentSessions.js'; const $ = dom.$; const COPILOT_USERNAME = 'GitHub Copilot'; -const WORKING_CAUGHT_UP_DEBOUNCE_MS = 50; +const WORKING_CAUGHT_UP_DEBOUNCE_MS = 750; export interface IChatListItemTemplate { currentElement?: ChatTreeItem; @@ -1152,14 +1152,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer= 0; i--) { - const part = partsToRender[i]; - if (part.kind !== 'markdownContent' || part.content.value.trim().length > 0) { - lastPart = part; - break; - } - } + const lastPart = this.findLastMeaningfulPart(partsToRender); if (showProgressDetails) { // When the thinking section is actively streaming with its own inline @@ -1359,6 +1352,40 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer= WORKING_CAUGHT_UP_DEBOUNCE_MS; } + /** + * Returns the last part that visually contributes to the response, skipping + * empty markdown placeholders. + */ + private findLastMeaningfulPart(partsToRender: readonly IChatRendererContent[]): IChatRendererContent | undefined { + for (let i = partsToRender.length - 1; i >= 0; i--) { + const part = partsToRender[i]; + if (part.kind !== 'markdownContent' || part.content.value.trim().length > 0) { + return part; + } + } + return undefined; + } + + /** + * True while we have caught up to streamed markdown but are still within the + * {@link WORKING_CAUGHT_UP_DEBOUNCE_MS} window before the working indicator + * should appear. The progressive render loop keeps polling in this state so + * the indicator can still surface after a genuine pause, instead of being + * dropped when the loop would otherwise stop (the debounce itself avoids + * flicker during normal token streaming). + */ + private isWorkingProgressDebouncePending(element: IChatResponseViewModel, partsToRender: readonly IChatRendererContent[]): boolean { + if (element.isComplete) { + return false; + } + // The indicator is already showing, so there is nothing pending. + if (partsToRender.some(part => part.kind === 'working')) { + return false; + } + // Only the streamed-markdown "caught up" case is gated behind the debounce. + return this.findLastMeaningfulPart(partsToRender)?.kind === 'markdownContent' && !this.hasBeenCaughtUpLongEnough(element); + } + private getChatFileChangesSummaryPart(element: IChatResponseViewModel): IChatChangesSummaryPart | undefined { if (!this.shouldShowFileChangesSummary(element)) { return undefined; @@ -1649,9 +1676,14 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer Date: Thu, 4 Jun 2026 16:47:58 -0700 Subject: [PATCH 05/24] Make browser toolbar more configurable (#319986) * Make browser toolbar more configurable * clean * test, fixes --- src/vs/base/browser/ui/toolbar/toolbar.ts | 17 +++-- .../test/browser/ui/toolbar/toolbar.test.ts | 68 +++++++++++++++++++ .../browser/menuEntryActionViewItem.ts | 4 +- src/vs/platform/actions/browser/toolbar.ts | 9 ++- .../browserView/common/browserView.ts | 3 +- .../features/browserDataStorageFeatures.ts | 9 ++- .../features/browserDevToolsFeature.ts | 8 +-- .../features/browserEditorChatFeatures.ts | 4 +- .../browserEditorEmulationFeatures.ts | 56 ++++----------- .../features/browserEditorFindFeature.ts | 5 +- .../features/browserEditorZoomFeature.ts | 3 + .../features/browserFavoritesFeature.ts | 51 +++----------- .../features/browserHistoryFeature.ts | 1 + .../features/browserNavigationFeatures.ts | 25 +++++-- .../features/browserTabManagementFeatures.ts | 2 + .../electron-browser/media/browser.css | 3 +- 16 files changed, 160 insertions(+), 108 deletions(-) diff --git a/src/vs/base/browser/ui/toolbar/toolbar.ts b/src/vs/base/browser/ui/toolbar/toolbar.ts index 73e90474f665d..733a6affa5548 100644 --- a/src/vs/base/browser/ui/toolbar/toolbar.ts +++ b/src/vs/base/browser/ui/toolbar/toolbar.ts @@ -30,6 +30,8 @@ export interface IToolBarResponsiveBehaviorOptions { readonly minItems?: number; readonly actionMinWidth?: number; readonly getActionMinWidth?: (action: IAction) => number | undefined; + readonly observedElement?: HTMLElement; + readonly getAvailableWidth?: () => number; } export interface IToolBarOptions { @@ -178,9 +180,9 @@ export class ToolBar extends Disposable { this.element.style.setProperty(ACTION_MIN_WIDTH_VAR, `${this.getConfiguredActionMinWidth()}px`); const observer = new ResizeObserver(() => { - this.updateActions(this.element.getBoundingClientRect().width); + this.updateActions(this.getAvailableWidth()); }); - observer.observe(this.element); + observer.observe(this.options.responsiveBehavior?.observedElement ?? this.element); this._store.add(toDisposable(() => observer.disconnect())); } } @@ -240,7 +242,7 @@ export class ToolBar extends Disposable { */ relayout(): void { if (this.options.responsiveBehavior?.enabled) { - const width = this.element.getBoundingClientRect().width; + const width = this.getAvailableWidth(); this.updateActions(width); } } @@ -301,7 +303,7 @@ export class ToolBar extends Disposable { } // Update toolbar actions to fit with container width - this.updateActions(this.element.getBoundingClientRect().width); + this.updateActions(this.getAvailableWidth()); } } @@ -329,6 +331,13 @@ export class ToolBar extends Disposable { return this.getConfiguredActionMinWidth(action) + ACTION_PADDING; } + private getAvailableWidth(): number { + if (this.options.responsiveBehavior?.getAvailableWidth) { + return this.options.responsiveBehavior.getAvailableWidth(); + } + return this.element.getBoundingClientRect().width; + } + private applyResponsiveActionMinWidths(): void { if (!this.options.responsiveBehavior?.enabled) { return; diff --git a/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts b/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts index 43fcc7a1795a2..a60949abcfad6 100644 --- a/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts +++ b/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts @@ -200,4 +200,72 @@ suite('ToolBar', () => { assert.strictEqual(toolbar.getItemAction(2)?.id, ToggleMenuAction.ID); assert.strictEqual(toolbar.getElement().querySelector('.monaco-action-bar')?.classList.contains('has-overflow'), true); }); + + test('uses getAvailableWidth override instead of the element width', () => { + const widths = new Map([ + ['a', 50], + ['b', 50], + ['c', 50], + [ToggleMenuAction.ID, 22], + ]); + + let availableWidth = 200; + + const toolbar = store.add(new TestToolBar(container, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'last', + minItems: 1, + actionMinWidth: 22, + getAvailableWidth: () => availableWidth, + }, + actionViewItemProvider: action => { + const width = widths.get(action.id); + return typeof width === 'number' ? new FixedWidthActionViewItem(action, width) : undefined; + } + })); + const actionBar = toolbar.actionBarForTest; + const originalGetWidth = actionBar.getWidth.bind(actionBar); + actionBar.getWidth = (index: number) => { + const action = actionBar.getAction(index); + return action ? (widths.get(action.id) ?? originalGetWidth(index)) : originalGetWidth(index); + }; + + // Force the element's bounding rect to a value that would otherwise hide everything + // to prove the toolbar uses the override callback instead. + const originalGetBoundingClientRect = toolbar.getElement().getBoundingClientRect.bind(toolbar.getElement()); + (toolbar.getElement() as HTMLElement & { getBoundingClientRect(): DOMRect }).getBoundingClientRect = () => ({ + ...originalGetBoundingClientRect(), + width: 0, + right: 0, + left: 0, + x: 0, + y: 0, + top: 0, + bottom: 0, + height: 0, + toJSON() { + return {}; + } + }); + + const actions = [ + store.add(new Action('a', 'A')), + store.add(new Action('b', 'B')), + store.add(new Action('c', 'C')), + ]; + + toolbar.setActions(actions); + + // availableWidth = 200 is plenty for all 3 actions; the element's 0 width is ignored + assert.strictEqual(toolbar.getItemsLength(), 3); + assert.strictEqual(toolbar.getElement().querySelector('.monaco-action-bar')?.classList.contains('has-overflow'), false); + + availableWidth = 60; + toolbar.relayout(); + + // availableWidth shrank — actions overflow into the toggle menu + assert.strictEqual(toolbar.getItemAction(toolbar.getItemsLength() - 1)?.id, ToggleMenuAction.ID); + assert.strictEqual(toolbar.getElement().querySelector('.monaco-action-bar')?.classList.contains('has-overflow'), true); + }); }); diff --git a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts index 8f61cd7050578..ab5ff348a9867 100644 --- a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts +++ b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts @@ -464,7 +464,7 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem { defaultAction = submenuAction.actions[0]; } - this._defaultAction = this._defaultActionDisposables.add(this._instaService.createInstance(MenuEntryActionViewItem, defaultAction, { keybinding: this._getDefaultActionKeybindingLabel(defaultAction) })); + this._defaultAction = this._defaultActionDisposables.add(this._instaService.createInstance(MenuEntryActionViewItem, defaultAction, { keybinding: this._getDefaultActionKeybindingLabel(defaultAction), hoverDelegate: options?.hoverDelegate })); const dropdownOptions: IDropdownMenuActionViewItemOptions = { keybindingProvider: action => this._keybindingService.lookupKeybinding(action.id), @@ -494,7 +494,7 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem { } this._defaultActionDisposables.clear(); - this._defaultAction = this._defaultActionDisposables.add(this._instaService.createInstance(MenuEntryActionViewItem, lastAction, { keybinding: this._getDefaultActionKeybindingLabel(lastAction) })); + this._defaultAction = this._defaultActionDisposables.add(this._instaService.createInstance(MenuEntryActionViewItem, lastAction, { keybinding: this._getDefaultActionKeybindingLabel(lastAction), hoverDelegate: this._options?.hoverDelegate })); this._defaultAction.actionRunner = this._defaultActionDisposables.add(new class extends ActionRunner { protected override async runAction(action: IAction, context?: unknown): Promise { await action.run(undefined); diff --git a/src/vs/platform/actions/browser/toolbar.ts b/src/vs/platform/actions/browser/toolbar.ts index e44cdb4eae07e..998276050aa44 100644 --- a/src/vs/platform/actions/browser/toolbar.ts +++ b/src/vs/platform/actions/browser/toolbar.ts @@ -133,6 +133,13 @@ export class WorkbenchToolBar extends ToolBar { if (this._options?.hiddenItemStrategy !== HiddenItemStrategy.NoHide) { for (let i = 0; i < primary.length; i++) { const action = primary[i]; + if (action instanceof Separator) { + // Track group boundaries from `primary` so hidden items keep + // their original groups in the overflow menu (relevant when + // all menu groups are treated as primary). + extraSecondary[i] = action; + continue; + } if (!(action instanceof MenuItemAction) && !(action instanceof SubmenuItemAction)) { // console.warn(`Action ${action.id}/${action.label} is not a MenuItemAction`); continue; @@ -185,7 +192,7 @@ export class WorkbenchToolBar extends ToolBar { coalesceInPlace(primary); coalesceInPlace(extraSecondary); - super.setActions(Separator.clean(primary), Separator.join(extraSecondary, secondary)); + super.setActions(Separator.clean(primary), Separator.join(Separator.clean(extraSecondary), secondary)); // add context menu for toggle and configure keybinding actions if (toggleActions.length > 0 || primary.length > 0) { diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index e713a80a2ef4d..fc83bfb0b8153 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -30,8 +30,7 @@ export enum BrowserViewCommandId { OpenSettings = `${commandPrefix}.openSettings`, // Favorites - AddFavorite = `${commandPrefix}.addFavorite`, - RemoveFavorite = `${commandPrefix}.removeFavorite`, + ToggleFavorite = `${commandPrefix}.toggleFavorite`, // History ShowHistory = `${commandPrefix}.showHistory`, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts index c1650aae0af83..43adb1f5ebf96 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts @@ -55,7 +55,8 @@ class ClearGlobalBrowserStorageAction extends Action2 { id: MenuId.BrowserActionsToolbar, group: BrowserActionGroup.Data, order: 20, - when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Global) + when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Global), + isHiddenByDefault: true, } }); } @@ -80,7 +81,8 @@ class ClearWorkspaceBrowserStorageAction extends Action2 { id: MenuId.BrowserActionsToolbar, group: BrowserActionGroup.Data, order: 20, - when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Workspace) + when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Workspace), + isHiddenByDefault: true, } }); } @@ -106,7 +108,8 @@ class ClearEphemeralBrowserStorageAction extends Action2 { id: MenuId.BrowserActionsToolbar, group: BrowserActionGroup.Data, order: 20, - when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral) + when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral), + isHiddenByDefault: true, } }); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts index 63d9ac587385d..5832512f349af 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts @@ -14,7 +14,7 @@ import { KeybindingWeight } from '../../../../../platform/keybinding/common/keyb import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IBrowserViewModel } from '../../common/browserView.js'; -import { BrowserEditor, BrowserEditorContribution, BROWSER_EDITOR_ACTIVE, BrowserActionCategory, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL } from '../browserEditor.js'; +import { BrowserEditor, BrowserEditorContribution, BROWSER_EDITOR_ACTIVE, BrowserActionCategory, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, BrowserActionGroup } from '../browserEditor.js'; const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view")); @@ -49,7 +49,7 @@ class ToggleDevToolsAction extends Action2 { constructor() { super({ id: ToggleDevToolsAction.ID, - title: localize2('browser.toggleDevToolsAction', 'Toggle Developer Tools'), + title: localize2('browser.toggleDevToolsAction', 'Developer Tools'), category: BrowserActionCategory, icon: Codicon.developerTools, f1: true, @@ -57,8 +57,8 @@ class ToggleDevToolsAction extends Action2 { toggled: ContextKeyExpr.equals(CONTEXT_BROWSER_DEVTOOLS_OPEN.key, true), menu: { id: MenuId.BrowserActionsToolbar, - group: 'actions', - order: 3, + group: BrowserActionGroup.Tools, + order: 2, }, keybinding: { weight: KeybindingWeight.WorkbenchContrib, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index 6a7aac8ad0931..6554b65214964 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -32,7 +32,7 @@ import { BrowserEditorInput } from '../../common/browserEditorInput.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { WorkbenchHoverDelegate } from '../../../../../platform/hover/browser/hover.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; -import { BrowserEditor, BrowserEditorContribution, BrowserWidgetLocation, IBrowserEditorWidget, BrowserActionCategory, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL } from '../browserEditor.js'; +import { BrowserEditor, BrowserEditorContribution, BrowserWidgetLocation, IBrowserEditorWidget, BrowserActionCategory, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, BrowserActionGroup } from '../browserEditor.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { PolicyCategory } from '../../../../../base/common/policy.js'; @@ -729,7 +729,7 @@ MenuRegistry.appendMenuItem(MenuId.BrowserActionsToolbar, { submenu: MenuId.BrowserChatActionsMenu, title: localize2('browser.chatActionsSubmenu', "Add to Chat"), icon: Codicon.inspect, - group: 'actions', + group: BrowserActionGroup.Tools, order: 1, when: ChatContextKeys.enabled, isSplitButton: true diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts index 371e0c4c151d8..62d92236ca0a4 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorEmulationFeatures.ts @@ -24,7 +24,6 @@ import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from ' import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; import { IHoverService, WorkbenchHoverDelegate } from '../../../../../platform/hover/browser/hover.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; -import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; import { defaultInputBoxStyles, defaultSelectBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; @@ -707,69 +706,39 @@ export class BrowserEditorEmulationSupport extends BrowserEditorContribution { BrowserEditor.registerContribution(BrowserEditorEmulationSupport); /** - * Show the emulation toolbar (engages device emulation). Mirrors the - * find-widget pattern: show command on the main action bar / F1, and the - * toolbar is dismissed via its own close button or the Escape keybinding. + * Toggle the emulation toolbar (engages or disables device emulation). */ -class ShowBrowserEmulationToolbarAction extends Action2 { - static readonly ID = 'workbench.action.browser.showEmulationToolbar'; +class ToggleBrowserEmulationAction extends Action2 { + static readonly ID = 'workbench.action.browser.toggleDeviceEmulation'; constructor() { super({ - id: ShowBrowserEmulationToolbarAction.ID, - title: localize2('browser.showEmulationToolbar', 'Show Emulation Toolbar'), + id: ToggleBrowserEmulationAction.ID, + title: localize2('browser.toggleDeviceEmulation', 'Device Emulation'), category: BrowserActionCategory, icon: Codicon.deviceMobile, f1: true, + toggled: CONTEXT_BROWSER_EMULATION_TOOLBAR_VISIBLE, precondition: BROWSER_EDITOR_ACTIVE, menu: { id: MenuId.BrowserActionsToolbar, group: BrowserActionGroup.Tools, - order: 2, + order: 3, + isHiddenByDefault: true, }, }); } override run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): void { if (browserEditor instanceof BrowserEditor) { - browserEditor.getContribution(BrowserEditorEmulationSupport)?.setVisible(true); - } - } -} - -/** - * Hide the emulation toolbar (disables emulation). Available via F1 and bound - * to Escape while the toolbar is visible; also surfaced as the toolbar's - * close button. - */ -class HideBrowserEmulationToolbarAction extends Action2 { - static readonly ID = 'workbench.action.browser.hideEmulationToolbar'; - - constructor() { - super({ - id: HideBrowserEmulationToolbarAction.ID, - title: localize2('browser.hideEmulationToolbar', 'Hide Emulation Toolbar'), - category: BrowserActionCategory, - icon: Codicon.close, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_EMULATION_TOOLBAR_VISIBLE), - keybinding: { - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyCode.Escape, - when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_EMULATION_TOOLBAR_VISIBLE), - }, - }); - } - - override run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): void { - if (browserEditor instanceof BrowserEditor) { - browserEditor.getContribution(BrowserEditorEmulationSupport)?.setVisible(false); + const support = browserEditor.getContribution(BrowserEditorEmulationSupport); + support?.setVisible(!support.isVisible); } } } MenuRegistry.appendMenuItem(MenuId.BrowserEmulationToolbar, { command: { - id: HideBrowserEmulationToolbarAction.ID, + id: ToggleBrowserEmulationAction.ID, title: localize('browser.emulationToolbar.close', "Close"), icon: Codicon.close, }, @@ -950,8 +919,7 @@ MenuRegistry.appendMenuItem(MenuId.BrowserEmulationToolbar, { order: 90, }); -registerAction2(ShowBrowserEmulationToolbarAction); -registerAction2(HideBrowserEmulationToolbarAction); +registerAction2(ToggleBrowserEmulationAction); registerAction2(PickBrowserDevicePresetAction); registerAction2(SetBrowserUserAgentAction); registerAction2(ToggleBrowserMobileEmulationAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts index 5b6776ee5ee86..8f01e766afa23 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts @@ -23,6 +23,7 @@ import { IKeybindingService } from '../../../../../platform/keybinding/common/ke import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { BrowserEditor, BrowserEditorContribution, BrowserWidgetLocation, BROWSER_EDITOR_ACTIVE, BrowserActionCategory, BrowserActionGroup, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, IBrowserEditorWidget } from '../browserEditor.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; const CONTEXT_BROWSER_FIND_WIDGET_VISIBLE = new RawContextKey('browserFindWidgetVisible', false, localize('browser.findWidgetVisible', "Whether the browser find widget is visible")); const CONTEXT_BROWSER_FIND_WIDGET_FOCUSED = new RawContextKey('browserFindWidgetFocused', false, localize('browser.findWidgetFocused', "Whether the browser find widget is focused")); @@ -292,12 +293,14 @@ class ShowBrowserFindAction extends Action2 { id: ShowBrowserFindAction.ID, title: localize2('browser.showFindAction', 'Find in Page'), category: BrowserActionCategory, + icon: Codicon.search, f1: true, precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), menu: { id: MenuId.BrowserActionsToolbar, group: BrowserActionGroup.Tools, - order: 1, + order: 0, + isHiddenByDefault: true, }, keybinding: { weight: KeybindingWeight.EditorContrib, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts index 58724391b515d..a4dc039fa3801 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts @@ -158,6 +158,7 @@ class ZoomInAction extends Action2 { group: BrowserActionGroup.Zoom, order: 1, when: CONTEXT_BROWSER_CAN_ZOOM_IN, + isHiddenByDefault: true, }, keybinding: { when: CONTEXT_BROWSER_FOCUSED, @@ -192,6 +193,7 @@ class ZoomOutAction extends Action2 { group: BrowserActionGroup.Zoom, order: 2, when: CONTEXT_BROWSER_CAN_ZOOM_OUT, + isHiddenByDefault: true, }, keybinding: { when: CONTEXT_BROWSER_FOCUSED, @@ -229,6 +231,7 @@ class ResetZoomAction extends Action2 { id: MenuId.BrowserActionsToolbar, group: BrowserActionGroup.Zoom, order: 3, + isHiddenByDefault: true, }, keybinding: { when: CONTEXT_BROWSER_FOCUSED, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserFavoritesFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserFavoritesFeature.ts index 4118817b1f03f..f5f0c85db6ac1 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserFavoritesFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserFavoritesFeature.ts @@ -81,7 +81,7 @@ class FavoriteIndicator extends Disposable { } private _tooltip(): string { - const kb = this._keybindingService.lookupKeybinding(BrowserViewCommandId.RemoveFavorite)?.getLabel(); + const kb = this._keybindingService.lookupKeybinding(BrowserViewCommandId.ToggleFavorite)?.getLabel(); return kb ? localize('browser.removeFavoriteWithKb', "Remove from Favorites ({0})", kb) : localize('browser.removeFavorite', "Remove from Favorites"); @@ -294,58 +294,30 @@ BrowserEditor.registerContribution(BrowserFavoritesFeature); // -- Actions ---------------------------------------------------------- -class AddFavoriteAction extends Action2 { - static readonly ID = BrowserViewCommandId.AddFavorite; +class ToggleFavoriteAction extends Action2 { + static readonly ID = BrowserViewCommandId.ToggleFavorite; constructor() { super({ - id: AddFavoriteAction.ID, + id: ToggleFavoriteAction.ID, title: localize2('browser.addFavoriteAction', 'Add to Favorites'), category: BrowserActionCategory, icon: Codicon.star, f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_URL_IS_FAVORITED.negate()), - menu: { - id: MenuId.BrowserActionsToolbar, - group: BrowserActionGroup.Data, - order: 5, - when: CONTEXT_BROWSER_URL_IS_FAVORITED.negate(), + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL), + toggled: { + condition: CONTEXT_BROWSER_URL_IS_FAVORITED, + icon: Codicon.starFull, }, - keybinding: { - weight: KeybindingWeight.WorkbenchContrib, - when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_URL_IS_FAVORITED.negate()), - primary: KeyMod.CtrlCmd | KeyCode.KeyD, - } - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - browserEditor.getContribution(BrowserFavoritesFeature)?.toggleCurrent(); - } - } -} - -class RemoveFavoriteAction extends Action2 { - static readonly ID = BrowserViewCommandId.RemoveFavorite; - - constructor() { - super({ - id: RemoveFavoriteAction.ID, - title: localize2('browser.removeFavoriteAction', 'Remove from Favorites'), - category: BrowserActionCategory, - icon: Codicon.starFull, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_URL_IS_FAVORITED), menu: { id: MenuId.BrowserActionsToolbar, group: BrowserActionGroup.Data, order: 5, - when: CONTEXT_BROWSER_URL_IS_FAVORITED, + isHiddenByDefault: true, }, keybinding: { weight: KeybindingWeight.WorkbenchContrib, - when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_URL_IS_FAVORITED), + when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL), primary: KeyMod.CtrlCmd | KeyCode.KeyD, } }); @@ -358,5 +330,4 @@ class RemoveFavoriteAction extends Action2 { } } -registerAction2(AddFavoriteAction); -registerAction2(RemoveFavoriteAction); +registerAction2(ToggleFavoriteAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserHistoryFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserHistoryFeature.ts index 044ac49f3feb3..37f4d69eaa80c 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserHistoryFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserHistoryFeature.ts @@ -359,6 +359,7 @@ class ShowBrowserHistoryAction extends Action2 { group: BrowserActionGroup.Data, order: 1, when, + isHiddenByDefault: true, }, keybinding: { primary: KeyMod.CtrlCmd | KeyCode.KeyH, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserNavigationFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserNavigationFeatures.ts index f63b406dcf72c..fe1dafde561c9 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserNavigationFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserNavigationFeatures.ts @@ -100,8 +100,23 @@ class BrowserNavigationBar extends Disposable { { hoverDelegate, highlightToggledItems: true, - toolbarOptions: { primaryGroup: (group) => group.startsWith('actions'), useSeparatorsInPrimaryActions: true }, - menuOptions: { shouldForwardArgs: true } + toolbarOptions: { primaryGroup: () => true, useSeparatorsInPrimaryActions: true }, + menuOptions: { shouldForwardArgs: true }, + responsiveBehavior: { + enabled: true, + kind: 'last', + minItems: 0, + + // The URL bar is the flexible element, so the actions toolbar's own + // element width does not reflect the room it could occupy. + // So we pass manual calculations based on the navbar's overall width and the URL bar's width. + observedElement: this.element, + getAvailableWidth: () => { + const toolbarBounds = this.element.getBoundingClientRect(); + const urlBarBounds = this._urlBar.element.getBoundingClientRect(); + return Math.max(0, toolbarBounds.right - urlBarBounds.left - 220 - 24 /* 220px min width, 8px padding on both sides plus 8px gap */); + } + }, } )); actionsToolbar.context = editor; @@ -376,7 +391,8 @@ class OpenInExternalBrowserAction extends Action2 { menu: { id: MenuId.BrowserActionsToolbar, group: BrowserActionGroup.Tools, - order: 3 + order: 10, + isHiddenByDefault: true, } }); } @@ -410,7 +426,8 @@ class OpenBrowserSettingsAction extends Action2 { menu: { id: MenuId.BrowserActionsToolbar, group: BrowserActionGroup.Settings, - order: 2 + order: 2, + isHiddenByDefault: true, } }); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts index 524c352be18d7..f62c0a1a9bcae 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -393,12 +393,14 @@ class NewTabAction extends Action2 { id: BrowserViewCommandId.NewTab, title: localize2('browser.newTabAction', "New Tab"), category: BrowserActionCategory, + icon: Codicon.add, f1: true, precondition: BROWSER_EDITOR_ACTIVE, menu: { id: MenuId.BrowserActionsToolbar, group: BrowserActionGroup.Tabs, order: 1, + isHiddenByDefault: true, }, // When already in a browser, Ctrl/Cmd + T opens a new tab keybinding: { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index a557eaad0c0ef..f1d731a7339a8 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -59,7 +59,8 @@ flex: 1; display: flex; align-items: center; - min-width: 0; + box-sizing: border-box; + min-width: 220px; background-color: var(--vscode-input-background); border: 1px solid var(--vscode-input-border); border-radius: var(--vscode-cornerRadius-medium); From d56ae73e325a75640200963f726df6a822c8238a Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:03:06 -0700 Subject: [PATCH 06/24] address more image attachment optimizations + caching (#319966) address more image attachment optimizations + cache so we are not rerendering --- .../attachments/chatAttachmentWidgets.ts | 18 ++--- .../contrib/chat/browser/chatImageUtils.ts | 77 +++++++++++++++++-- 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts index 8d91914a96743..5ba5b75827314 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts @@ -76,7 +76,7 @@ import { ILanguageModelToolsService, isToolSet } from '../../common/tools/langua import { getCleanPromptName } from '../../common/promptSyntax/config/promptFileLocations.js'; import { IChatContextService } from '../contextContrib/chatContextService.js'; import { IChatImageCarouselService } from '../chatImageCarouselService.js'; -import { createImageThumbnail } from '../chatImageUtils.js'; +import { getOrCreateImageThumbnail } from '../chatImageUtils.js'; const commonHoverOptions: Partial = { style: HoverStyle.Pointer, @@ -525,7 +525,7 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { const currentLanguageModelName = this.currentLanguageModel ? this.languageModelsService.lookupLanguageModel(this.currentLanguageModel.identifier)?.name ?? this.currentLanguageModel.identifier : 'Current model'; const fullName = resource ? this.labelService.getUriLabel(resource) : (attachment.fullName || attachment.name); - this._register(createImageElements(resource, attachment.name, fullName, this.element, imageData ?? (attachment.value as Uint8Array), this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, attachment.omittedState, this.chatEntitlementService.previewFeaturesDisabled)); + this._register(createImageElements(resource, attachment.name, fullName, this.element, imageData ?? (attachment.value as Uint8Array), attachment.id, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, attachment.omittedState, this.chatEntitlementService.previewFeaturesDisabled)); this.attachSaveButton(resource, imageData, attachment.name, options.supportsDeletion); this.element.ariaLabel = this.appendDeletionHint(ariaLabel); @@ -597,6 +597,7 @@ const IMAGE_HOVER_THUMBNAIL_MAX_SIZE = 768; function createImageElements(resource: URI | undefined, name: string, fullName: string, element: HTMLElement, buffer: ArrayBuffer | Uint8Array, + cacheKey: string, hoverService: IHoverService, ariaLabel: string, currentLanguageModelName: string | undefined, clickHandler: () => void, @@ -661,10 +662,6 @@ function createImageElements(resource: URI | undefined, name: string, fullName: style: HoverStyle.Pointer, })); - const blob = new Blob([buffer as Uint8Array]); - - // Keep the full-resolution image available as a fallback for the hover - // preview, but only decode it lazily if the downscaled thumbnail fails. const hoverImage = dom.$('img.chat-attached-context-image', { alt: '' }); const imageContainer = dom.$('div.chat-attached-context-image-container', {}, hoverImage); hoverElement.appendChild(imageContainer); @@ -689,12 +686,13 @@ function createImageElements(resource: URI | undefined, name: string, fullName: // only once, and the UI keeps a small object URL for steady-state rendering. const previewImageUrl = disposable.add(new MutableDisposable()); const renderPreviewImage = async () => { - const thumbnail = await createImageThumbnail(data, undefined, IMAGE_HOVER_THUMBNAIL_MAX_SIZE); + const thumbnail = await getOrCreateImageThumbnail(cacheKey, data, IMAGE_HOVER_THUMBNAIL_MAX_SIZE, dom.getWindow(element)); if (disposable.isDisposed) { return; } - // Fall back to the full-resolution image if downscaling failed. - const source = thumbnail ?? blob; + // Fall back to the full-resolution image only if downscaling failed, so + // the larger original bytes aren't copied into a Blob in the common case. + const source = thumbnail ?? new Blob([data as Uint8Array]); const url = URL.createObjectURL(source); previewImageUrl.value = toDisposable(() => URL.revokeObjectURL(url)); if (thumbnail) { @@ -1102,7 +1100,7 @@ export class NotebookCellOutputChatAttachmentWidget extends AbstractChatAttachme const clickHandler = async () => await this.openResource(resource, { editorOptions: { preserveFocus: true } }, false, undefined); const currentLanguageModelName = this.currentLanguageModel ? this.languageModelsService.lookupLanguageModel(this.currentLanguageModel.identifier)?.name ?? this.currentLanguageModel.identifier : undefined; const buffer = this.getOutputItem(resource, attachment)?.data.buffer ?? new Uint8Array(); - this._register(createImageElements(resource, attachment.name, attachment.name, this.element, buffer, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, attachment.omittedState, this.chatEntitlementService.previewFeaturesDisabled)); + this._register(createImageElements(resource, attachment.name, attachment.name, this.element, buffer, attachment.id, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, attachment.omittedState, this.chatEntitlementService.previewFeaturesDisabled)); this.element.ariaLabel = this.appendDeletionHint(ariaLabel); } diff --git a/src/vs/workbench/contrib/chat/browser/chatImageUtils.ts b/src/vs/workbench/contrib/chat/browser/chatImageUtils.ts index 61cdee7023b8f..ffb77ed8c9146 100644 --- a/src/vs/workbench/contrib/chat/browser/chatImageUtils.ts +++ b/src/vs/workbench/contrib/chat/browser/chatImageUtils.ts @@ -3,7 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { raceTimeout } from '../../../../base/common/async.js'; import { decodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; +import { LRUCache } from '../../../../base/common/map.js'; import { joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { IFileService } from '../../../../platform/files/common/files.js'; @@ -87,15 +89,20 @@ export async function resizeImage(data: Uint8Array | string, mimeType?: string): * Creates a small downscaled thumbnail of an image. Useful for compact previews * (e.g. attachment pills) where the UI should retain a small rendered image * instead of a full-resolution object URL. + * + * The thumbnail is re-encoded as JPEG when the source is a JPEG and as PNG + * otherwise, so that photographic images don't balloon in size from being + * re-encoded as PNG. * @param data The image bytes. - * @param mimeType The image mime type. * @param maxSize The maximum width or height of the thumbnail, in pixels. - * @returns A promise that resolves to a PNG {@link Blob} of the thumbnail, or `undefined` on failure. + * @param targetWindow The window whose document is used to decode and resize the + * image, so the work happens in the same window that renders the thumbnail. + * @returns A promise that resolves to a {@link Blob} of the thumbnail, or `undefined` on failure. */ -export function createImageThumbnail(data: Uint8Array, mimeType: string | undefined, maxSize: number): Promise { +function createImageThumbnail(data: Uint8Array, maxSize: number, targetWindow: Window): Promise { return new Promise((resolve) => { - const blob = new Blob([data as Uint8Array], { type: mimeType }); - const img = new Image(); + const blob = new Blob([data as Uint8Array]); + const img = targetWindow.document.createElement('img'); const url = URL.createObjectURL(blob); img.src = url; @@ -106,7 +113,7 @@ export function createImageThumbnail(data: Uint8Array, mimeType: string | undefi const targetWidth = Math.max(1, Math.round(width * scaleFactor)); const targetHeight = Math.max(1, Math.round(height * scaleFactor)); - const canvas = document.createElement('canvas'); + const canvas = targetWindow.document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight; const ctx = canvas.getContext('2d'); @@ -116,7 +123,9 @@ export function createImageThumbnail(data: Uint8Array, mimeType: string | undefi } ctx.drawImage(img, 0, 0, targetWidth, targetHeight); - canvas.toBlob(thumbnail => resolve(thumbnail ?? undefined), 'image/png'); + // JPEG (FF D8 FF) re-encodes far smaller than PNG for photos, so keep it as JPEG. + const outputMimeType = data.length >= 3 && data[0] === 0xFF && data[1] === 0xD8 && data[2] === 0xFF ? 'image/jpeg' : 'image/png'; + canvas.toBlob(thumbnail => resolve(thumbnail ?? undefined), outputMimeType); }; img.onerror = () => { URL.revokeObjectURL(url); @@ -125,6 +134,60 @@ export function createImageThumbnail(data: Uint8Array, mimeType: string | undefi }); } +/** + * Bounded cache of generated image thumbnails, keyed by a caller-provided stable + * identifier (e.g. an attachment id). Attachment widgets are torn down and + * recreated whenever the attachment list re-renders, so memoizing the downscaled + * blob avoids re-decoding and re-resizing the full-resolution image every time. + * + * Only the small thumbnail bytes are retained, and the cache is bounded so memory + * stays predictable. The cached value is a `Promise` so concurrent renders of the + * same image share a single in-flight decode. + */ +const thumbnailCache = new LRUCache>(50); + +/** + * Safety net so a cached decode always settles. {@link createImageThumbnail} + * only resolves via the image element's load/error events, which may never fire + * if the {@link Window} it decodes in is torn down mid-decode. Without this, the + * cached (pending) promise would never resolve, blocking every later render of + * that image. The value is far larger than any real decode of an already-resized + * image, so it never trips in the normal path. The timer is cleared the instant + * the decode settles, so this adds negligible cost. + */ +const THUMBNAIL_DECODE_TIMEOUT_MS = 10_000; + +/** + * Memoized variant of {@link createImageThumbnail}. Repeated calls with the same + * {@link cacheKey} (and matching size/byte length) reuse the previously generated + * thumbnail instead of decoding and resizing the original image again. + * @param cacheKey A stable identifier for the source image (e.g. the attachment id). + * @param data The image bytes. + * @param maxSize The maximum width or height of the thumbnail, in pixels. + * @param targetWindow The window whose document is used to decode and resize the image. + * @returns A promise that resolves to a {@link Blob} of the thumbnail, or `undefined` on failure. + */ +export function getOrCreateImageThumbnail(cacheKey: string, data: Uint8Array, maxSize: number, targetWindow: Window): Promise { + // Include the size and byte length so a reused id with different content or a + // different target size doesn't return a stale thumbnail. + const key = `${cacheKey}:${maxSize}:${data.byteLength}`; + const cached = thumbnailCache.get(key); + if (cached) { + return cached; + } + + const thumbnail: Promise = raceTimeout(createImageThumbnail(data, maxSize, targetWindow), THUMBNAIL_DECODE_TIMEOUT_MS).then(blob => { + // Don't keep failures cached so a later render can retry. Only evict our own + // entry in case LRU eviction already replaced it with a newer decode. + if (!blob && thumbnailCache.peek(key) === thumbnail) { + thumbnailCache.delete(key); + } + return blob; + }); + thumbnailCache.set(key, thumbnail); + return thumbnail; +} + export function convertStringToUInt8Array(data: string): Uint8Array { const base64Data = data.includes(',') ? data.split(',')[1] : data; if (isValidBase64(base64Data)) { From 1100b7d8c29ced2179da378e36b1bc829d986d3e Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:21:11 -0700 Subject: [PATCH 07/24] make sure input switches when editing prev requests (#320024) make sure input switches --- src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 55f75663e0cb9..8a8fbcccfd456 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1790,6 +1790,10 @@ export class ChatWidget extends Disposable implements IChatWidget { if (!isInput) { this.inputPart.setChatMode(this.input.currentModeObs.get().id); this.inputPart.setPermissionLevel(this.input.currentModeInfo.permissionLevel ?? ChatPermissionLevel.Default); + const editModelId = this.input.currentLanguageModel; + if (editModelId) { + this.inputPart.switchModelByIdentifier(editModelId); + } this.inputPart?.toggleChatInputOverlay(false); try { From fe9d0dd094691e72d1266e5f22207acdde9411aa Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:30:05 -0700 Subject: [PATCH 08/24] Fix overlapping inline actions in quick inputs (#320025) --- src/vs/base/browser/ui/findinput/findInput.ts | 13 ++++++------- src/vs/base/browser/ui/inputbox/inputBox.ts | 4 ++++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/vs/base/browser/ui/findinput/findInput.ts b/src/vs/base/browser/ui/findinput/findInput.ts index aefd0b69b1ef4..db42e9055ecc6 100644 --- a/src/vs/base/browser/ui/findinput/findInput.ts +++ b/src/vs/base/browser/ui/findinput/findInput.ts @@ -334,16 +334,15 @@ export class FindInput extends Widget { public setActions(actions: ReadonlyArray | undefined, actionViewItemProvider?: IActionViewItemProvider): void { this.inputBox.setActions(actions, actionViewItemProvider); + this.updateInputBoxPadding(); } private updateInputBoxPadding(controlsHidden = false) { - if (controlsHidden) { - this.inputBox.paddingRight = 0; - } else { - this.inputBox.paddingRight = - ((this.caseSensitive?.width() ?? 0) + (this.wholeWords?.width() ?? 0) + (this.regex?.width() ?? 0)) - + this.additionalToggles.reduce((r, t) => r + t.width(), 0); - } + const togglesWidth = controlsHidden + ? 0 + : ((this.caseSensitive?.width() ?? 0) + (this.wholeWords?.width() ?? 0) + (this.regex?.width() ?? 0)) + + this.additionalToggles.reduce((r, t) => r + t.width(), 0); + this.inputBox.paddingRight = togglesWidth + this.inputBox.actionsWidth; } public clear(): void { diff --git a/src/vs/base/browser/ui/inputbox/inputBox.ts b/src/vs/base/browser/ui/inputbox/inputBox.ts index 9b55cd2ec683c..d8c041c95ac93 100644 --- a/src/vs/base/browser/ui/inputbox/inputBox.ts +++ b/src/vs/base/browser/ui/inputbox/inputBox.ts @@ -231,6 +231,10 @@ export class InputBox extends Widget { } } + public get actionsWidth(): number { + return this.actionbar?.getContainer().offsetWidth ?? 0; + } + protected onBlur(): void { this._hideMessage(); if (this.options.showPlaceholderOnFocus) { From 4046533c48ece86fdee0142c600f487ae85fd48b Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:33:43 -0700 Subject: [PATCH 09/24] move more progress parts to shimmer (#320020) --- .../chatProgressContentPart.ts | 53 +++++++++++++- .../chatInputOutputMarkdownProgressPart.ts | 2 +- .../chatSimpleToolProgressPart.ts | 2 +- .../chatToolPartUtilities.ts | 21 ++++-- .../chatToolProgressPart.ts | 10 ++- .../chat/browser/widget/media/chat.css | 3 +- .../chatToolProgressPart.test.ts | 72 +++++++++++++++++-- 7 files changed, 145 insertions(+), 18 deletions(-) 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 1608bbda8f195..a4da04834ab0e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, append } from '../../../../../../base/browser/dom.js'; +import { $, append, isHTMLElement } from '../../../../../../base/browser/dom.js'; import { IRenderedMarkdown, renderAsPlaintext } from '../../../../../../base/browser/markdownRenderer.js'; import { alert } from '../../../../../../base/browser/ui/aria/aria.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; @@ -19,7 +19,7 @@ import { IChatRendererContent, IChatWorkingProgress, IChatWorkingProgressState, import { ChatTreeItem } from '../../chat.js'; import { renderFileWidgets } from './chatInlineAnchorWidget.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; -import { getToolApprovalMessage } from './toolInvocationParts/chatToolPartUtilities.js'; +import { getToolApprovalMessage, isAskQuestionsToolInvocation } from './toolInvocationParts/chatToolPartUtilities.js'; import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { AccessibilityWorkbenchSettingId } from '../../../../accessibility/browser/accessibilityConfiguration.js'; @@ -75,6 +75,9 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP const result = this.chatContentMarkdownRenderer.render(progress.content); result.element.classList.add('progress-step'); renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._fileWidgetStore); + if (useShimmer) { + this.applyPartialShimmer(result.element); + } const tooltip: IMarkdownString | undefined = this.createApprovalMessage(); const progressPart = this._register(instantiationService.createInstance(ChatProgressSubPart, result.element, codicon, tooltip)); @@ -85,6 +88,52 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP this.renderedMessage.value = result; } + private applyPartialShimmer(element: HTMLElement): void { + if (!this.toolInvocation || !isAskQuestionsToolInvocation(this.toolInvocation)) { + return; + } + + const firstChild = element.firstElementChild; + const messageElement = isHTMLElement(firstChild) && firstChild.tagName === 'P' ? firstChild : element; + const message = messageElement.textContent; + const suffixOffset = message?.indexOf(' (') ?? -1; + if (suffixOffset <= 0) { + return; + } + + element.classList.add('chat-progress-partial-shimmer'); + this.wrapLeadingText(messageElement, suffixOffset); + } + + private wrapLeadingText(element: HTMLElement, length: number): void { + let remaining = length; + const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT); + while (remaining > 0) { + const node = walker.nextNode(); + if (!node) { + return; + } + + const text = node.nodeValue ?? ''; + if (!text) { + continue; + } + + const shimmerText = text.slice(0, remaining); + const suffixText = text.slice(remaining); + const span = element.ownerDocument.createElement('span'); + span.classList.add('chat-progress-shimmer-text'); + span.textContent = shimmerText; + node.parentNode?.insertBefore(span, node); + if (suffixText) { + node.nodeValue = suffixText; + } else { + node.parentNode?.removeChild(node); + } + remaining -= shimmerText.length; + } + } + updateMessage(content: IMarkdownString): void { if (this.isHidden) { return; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts index 724922571dca1..1fa25d9fad6bb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts @@ -116,7 +116,7 @@ export class ChatInputOutputMarkdownProgressPart extends BaseChatToolInvocationS // otherwise use the stored expanded state (defaulting to false) (isError && configurationService.getValue(ChatConfiguration.AutoExpandToolFailures)) || (ChatInputOutputMarkdownProgressPart._expandedByDefault.get(toolInvocation) ?? false), - shouldShimmerForTool(toolInvocation), + shouldShimmerForTool(toolInvocation, message), )); this._register(toDisposable(() => ChatInputOutputMarkdownProgressPart._expandedByDefault.set(toolInvocation, collapsibleListPart.expanded))); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatSimpleToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatSimpleToolProgressPart.ts index fd87efc42cd72..12857f0a703df 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatSimpleToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatSimpleToolProgressPart.ts @@ -84,7 +84,7 @@ export class ChatSimpleToolProgressPart extends BaseChatToolInvocationSubPart { // otherwise use the stored expanded state (defaulting to false) (isError && configurationService.getValue(ChatConfiguration.AutoExpandToolFailures)) || (ChatSimpleToolProgressPart._expandedByDefault.get(toolInvocation) ?? false), - shouldShimmerForTool(toolInvocation), + shouldShimmerForTool(toolInvocation, message), )); this._register(toDisposable(() => ChatSimpleToolProgressPart._expandedByDefault.set(toolInvocation, collapsibleListPart.expanded))); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts index 9ba9846de1a9f..8840232b7803b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts @@ -11,18 +11,25 @@ export function isMcpToolInvocation(toolInvocation: IChatToolInvocation | IChatT return toolInvocation.source?.type === 'mcp' || toolInvocation.toolId.toLowerCase().includes('mcp'); } +export function isAskQuestionsToolInvocation(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): boolean { + return toolInvocation.toolId === 'copilot_askQuestions' || toolInvocation.toolId === 'vscode_askQuestions'; +} + /** * Determines whether a tool invocation's progress text should shimmer. - * MCP tools shimmer; askQuestions defers to the caller's default; all others opt out. */ -export function shouldShimmerForTool(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): boolean { - if (isMcpToolInvocation(toolInvocation)) { - return !IChatToolInvocation.isComplete(toolInvocation); - } - if (toolInvocation.toolId === 'copilot_askQuestions' || toolInvocation.toolId === 'vscode_askQuestions') { +export function shouldShimmerForTool(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, content: string | IMarkdownString | undefined): boolean { + if (!isAskQuestionsToolInvocation(toolInvocation) || IChatToolInvocation.isComplete(toolInvocation)) { return false; } - return false; + + return getMarkdownValue(content) === getMarkdownValue(toolInvocation.invocationMessage); +} + +function getMarkdownValue(content: string | IMarkdownString | undefined): string | undefined { + return (typeof content === 'string' ? content : content?.value) + ?.replaceAll(' ', ' ') + .replace(/\\[\\`*_{}\[\]()#+\-!~]/g, escaped => escaped.slice(1)); } /** diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts index 6add7ab1ea1a1..080ed052c5b69 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts @@ -6,9 +6,11 @@ import * as dom from '../../../../../../../base/browser/dom.js'; import { renderAsPlaintext } from '../../../../../../../base/browser/markdownRenderer.js'; import { status } from '../../../../../../../base/browser/ui/aria/aria.js'; +import { Codicon } from '../../../../../../../base/common/codicons.js'; import { IMarkdownString, MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { stripIcons } from '../../../../../../../base/common/iconLabels.js'; import { autorun } from '../../../../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../../../../base/common/themables.js'; import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; @@ -106,7 +108,13 @@ export class ChatToolProgressSubPart extends BaseChatToolInvocationSubPart { this.provideScreenReaderStatus(content); } - return this.instantiationService.createInstance(ChatProgressContentPart, progressMessage, this.renderer, this.context, undefined, true, this.getIcon(), this.toolInvocation, shouldShimmerForTool(this.toolInvocation)); + const shouldShimmer = shouldShimmerForTool(this.toolInvocation, content); + return this.instantiationService.createInstance(ChatProgressContentPart, progressMessage, this.renderer, this.context, shouldShimmer ? true : undefined, true, this.getProgressIcon(), this.toolInvocation, shouldShimmer); + } + + private getProgressIcon(): ThemeIcon { + const icon = this.getIcon(); + return ThemeIcon.isEqual(icon, ThemeIcon.modify(Codicon.loading, 'spin')) ? Codicon.check : icon; } private getAnnouncementKey(kind: 'progress' | 'complete'): string { diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 824759bce321d..b6aa71ad43c05 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -3070,7 +3070,8 @@ have to be updated for changes to the rules above, or to support more deeply nes } /* shimmer animation for shimmer progress */ - &.shimmer-progress .rendered-markdown.progress-step > p { + &.shimmer-progress .rendered-markdown.progress-step:not(.chat-progress-partial-shimmer) > p, + &.shimmer-progress .rendered-markdown.progress-step.chat-progress-partial-shimmer .chat-progress-shimmer-text { background: linear-gradient(90deg, var(--vscode-descriptionForeground) 0%, var(--vscode-descriptionForeground) 30%, diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts index 026a009dbe37e..a4e13f0b327aa 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { Event } from '../../../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../../base/common/observable.js'; -import { IRenderedMarkdown, MarkdownRenderOptions } from '../../../../../../../base/browser/markdownRenderer.js'; +import { IRenderedMarkdown, MarkdownRenderOptions, renderAsPlaintext } from '../../../../../../../base/browser/markdownRenderer.js'; import { IMarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; @@ -88,6 +88,7 @@ suite('ChatToolProgressSubPart', () => { source?: ToolDataSourceType; toolId?: string; invocationMessage?: string; + progressMessage?: string; } = {}): IChatToolInvocation { const source = options.source ?? ToolDataSource.Internal; const toolId = options.toolId ?? 'test_tool'; @@ -104,7 +105,7 @@ suite('ChatToolProgressSubPart', () => { type: IChatToolInvocation.StateKind.Executing, parameters: undefined, confirmed: { type: ToolConfirmKind.ConfirmationNotNeeded }, - progress: observableValue('progress', { message: undefined, progress: undefined }) + progress: observableValue('progress', { message: options.progressMessage, progress: undefined }) }), toolSpecificDataKind: observableValue('test', undefined), isAttachedToThinking: false, @@ -123,7 +124,7 @@ suite('ChatToolProgressSubPart', () => { mockMarkdownRenderer = { render: (markdown: IMarkdownString, _options?: MarkdownRenderOptions, outElement?: HTMLElement): IRenderedMarkdown => { const element = outElement ?? mainWindow.document.createElement('div'); - const content = typeof markdown === 'string' ? markdown : (markdown.value ?? ''); + const content = typeof markdown === 'string' ? markdown : renderAsPlaintext(markdown); element.textContent = content; return { element, @@ -178,7 +179,7 @@ suite('ChatToolProgressSubPart', () => { assert.deepStrictEqual(cases, [true, true, false]); }); - test('adds shimmer styling for active MCP tool progress', () => { + test('does not add shimmer styling for active MCP tool progress', () => { const mcpTool = createToolInvocation({ source: { type: 'mcp', @@ -199,7 +200,68 @@ suite('ChatToolProgressSubPart', () => { new Set() )); - assert.ok(part.domNode.querySelector('.shimmer-progress')); + assert.strictEqual(part.domNode.querySelector('.shimmer-progress'), null); + }); + + test('adds shimmer styling only for active ask questions invocation progress', () => { + const askQuestionsTool = disposables.add(instantiationService.createInstance( + ChatToolProgressSubPart, + createToolInvocation({ + toolId: 'vscode_askQuestions', + invocationMessage: 'Asking a question (Target)' + }), + createRenderContext(false), + mockMarkdownRenderer, + new Set() + )); + const askMultipleQuestionsTool = disposables.add(instantiationService.createInstance( + ChatToolProgressSubPart, + createToolInvocation({ + toolId: 'vscode_askQuestions', + invocationMessage: 'Asking 3 questions (What should we work on?, Preferred area, How hands-on?)' + }), + createRenderContext(false), + mockMarkdownRenderer, + new Set() + )); + const analyzingAnswersTool = disposables.add(instantiationService.createInstance( + ChatToolProgressSubPart, + createToolInvocation({ + toolId: 'vscode_askQuestions', + invocationMessage: 'Asking a question (Target)', + progressMessage: 'Analyzing your answers...' + }), + createRenderContext(false), + mockMarkdownRenderer, + new Set() + )); + + assert.deepStrictEqual([ + !!askQuestionsTool.domNode.querySelector('.shimmer-progress'), + askQuestionsTool.domNode.querySelector('.chat-progress-shimmer-text')?.textContent, + askQuestionsTool.domNode.textContent, + askMultipleQuestionsTool.domNode.querySelector('.chat-progress-shimmer-text')?.textContent, + askMultipleQuestionsTool.domNode.textContent, + !!analyzingAnswersTool.domNode.querySelector('.shimmer-progress'), + analyzingAnswersTool.domNode.querySelector('.chat-progress-shimmer-text')?.textContent + ], [true, 'Asking a question', 'Asking a question (Target)', 'Asking 3 questions', 'Asking 3 questions (What should we work on?, Preferred area, How hands-on?)', false, undefined]); + }); + + test('does not render a loading icon for run playwright code progress', () => { + const tool = createToolInvocation({ + toolId: 'run_playwright_code', + invocationMessage: 'Running Playwright code...' + }); + + const part = disposables.add(instantiationService.createInstance( + ChatToolProgressSubPart, + tool, + createRenderContext(false), + mockMarkdownRenderer, + new Set() + )); + + assert.strictEqual(part.domNode.querySelector('.codicon-loading'), null); }); test('does not add shimmer styling for non-MCP tool progress', () => { From c5e7f266cd47233e3614bc29ec8732993813c3df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaqu=C3=ADn=20Ruales?= <1588988+jruales@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:26:44 -0700 Subject: [PATCH 10/24] Upgrade Playwright to 1.61.0-alpha-2026-06-04 (#319067) --- package-lock.json | 8 +-- package.json | 2 +- .../browserView/node/playwrightService.ts | 50 +++++++++++-------- 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index b4376b5d8c576..38d50ffa4de4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "node-addon-api": "^6.0.0", "node-pty": "^1.2.0-beta.13", "open": "^10.1.2", - "playwright-core": "1.59.1", + "playwright-core": "1.61.0-alpha-2026-06-04", "ssh2": "^1.16.0", "tas-client": "0.3.1", "undici": "^7.24.0", @@ -15415,9 +15415,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.61.0-alpha-2026-06-04", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0-alpha-2026-06-04.tgz", + "integrity": "sha512-OCdgxfcyRfuc79OcWc0Y019YoVvTqZWvtEYZxyXtwO+CpAln5s8hQH1bmgqOJd5vccKO6yOzfYYz0Vs2Zr8eIg==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/package.json b/package.json index e1446b3571c89..e292bc1a6d4cc 100644 --- a/package.json +++ b/package.json @@ -143,7 +143,7 @@ "node-addon-api": "^6.0.0", "node-pty": "^1.2.0-beta.13", "open": "^10.1.2", - "playwright-core": "1.59.1", + "playwright-core": "1.61.0-alpha-2026-06-04", "ssh2": "^1.16.0", "tas-client": "0.3.1", "undici": "^7.24.0", diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index 8dcd26786cf8a..3ccaabe3279be 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -13,18 +13,11 @@ import { IInvokeFunctionResult, IPlaywrightService } from '../common/playwrightS import { IBrowserViewGroupRemoteService } from '../node/browserViewGroupRemoteService.js'; import { IBrowserViewGroup } from '../common/browserViewGroup.js'; import { PlaywrightTab, DialogInterruptedError } from './playwrightTab.js'; -import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; +import { CDPRequest, CDPResponse } from '../common/cdp/types.js'; import { generateUuid } from '../../../base/common/uuid.js'; // eslint-disable-next-line local/code-import-patterns -import type { Browser, BrowserContext, Page } from 'playwright-core'; - -interface PlaywrightTransport { - send(s: CDPRequest): void; - close(): void; // Note: calling close is expected to issue onclose at some point. - onmessage?: (message: CDPResponse | CDPEvent) => void; - onclose?: (reason?: string) => void; -} +import type { Browser, BrowserContext, ConnectOverCDPTransport, Page } from 'playwright-core'; /** * Tracks whether a caller-initiated Playwright action is currently in flight. @@ -33,15 +26,26 @@ export interface IPlaywrightActionScope { activeCalls: number; } -declare module 'playwright-core' { - interface BrowserType { - _connectOverCDPTransport(transport: PlaywrightTransport): Promise; - } -} - const DEFERRED_RESULT_CLEANUP_MS = 5 * 60_000; // 5 minutes const SESSION_INACTIVITY_MS = 30 * 60_000; // 30 minutes +/** + * Narrow a raw Playwright transport payload to a {@link CDPRequest}. + * + * Playwright types the `send` payload as `object` but passes structured CDP + * messages (not JSON strings) for a caller-supplied transport, so this guard + * is expected to always hold. It exists to fail loudly (the caller throws) + * should a future Playwright version change the wire format, rather than + * silently forwarding malformed messages. + */ +function isCDPRequest(message: object): message is CDPRequest { + const candidate = message as Partial; + return typeof candidate.id === 'number' + && typeof candidate.method === 'string' + && (candidate.sessionId === undefined || typeof candidate.sessionId === 'string'); +} + + /** * Shared-process implementation of {@link IPlaywrightService}. @@ -122,12 +126,18 @@ export class PlaywrightService extends Disposable implements IPlaywrightService try { const playwright = await import('playwright-core'); const sub = group.onCDPMessage(msg => transport.onmessage?.(msg)); - const transport: PlaywrightTransport = { + const transport: ConnectOverCDPTransport = { close() { sub.dispose(); this.onclose?.(); }, - send(message) { + send: (rawMessage) => { + if (!isCDPRequest(rawMessage)) { + // Fail loudly: returning silently would leave Playwright + // waiting for a response and surface later as an opaque hang. + throw new Error(`[PlaywrightService] Unexpected CDP transport payload for session ${sessionId} (type: ${typeof rawMessage})`); + } + const message = rawMessage; // Block Playwright's automatic / default emulation traffic. We // only forward `Emulation.*` to the view while a caller-initiated // action is running (see IPlaywrightActionScope) so the workbench @@ -135,16 +145,16 @@ export class PlaywrightService extends Disposable implements IPlaywrightService // setup Playwright issues on its own when connecting or creating // pages — is acknowledged with a synthetic success response and // never hits the view. - if (actionScope.activeCalls === 0 && typeof message.method === 'string' && message.method.startsWith('Emulation.')) { + if (actionScope.activeCalls === 0 && message.method.startsWith('Emulation.')) { setTimeout(() => { - transport.onmessage?.({ id: message.id, result: {}, sessionId: message.sessionId }); + transport.onmessage?.({ id: message.id, result: {}, sessionId: message.sessionId } satisfies CDPResponse); }, 1); return; } void group.sendCDPMessage(message); } }; - browser = await playwright.chromium._connectOverCDPTransport(transport); + browser = await playwright.chromium.connectOverCDP(transport); } catch (e) { group.dispose(); throw e; From 7b00c19f5a6d9af0438fd546b64778d2c0beb3b8 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:26:52 -0700 Subject: [PATCH 11/24] Prevent call-after-dispose when browser tabs are closed (#320021) --- .../browserView/electron-browser/browserEditor.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 208fad0eb0c5a..f37f1db9b7c13 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -567,7 +567,10 @@ export class BrowserEditor extends EditorPane { // When closing a tab, the model gets disposed before the editor input is cleared. // So we make sure we don't keep a reference to the disposed model. this._inputDisposables.add(this._model.onWillDispose(() => { - this._model = undefined; + if (this._model === model) { + this._model = undefined; + this._onDidChangeModel.fire({ model: undefined, isNew: false }); + } })); this._inputDisposables.add(this._model.onWillNavigate(() => { @@ -744,8 +747,10 @@ export class BrowserEditor extends EditorPane { override clearInput(): void { this._inputDisposables.clear(); - this._model = undefined; - this._onDidChangeModel.fire({ model: undefined, isNew: false }); + if (this._model) { + this._model = undefined; + this._onDidChangeModel.fire({ model: undefined, isNew: false }); + } this._hasUrlContext.reset(); this._hasErrorContext.reset(); From 8c026b1b91ccfe0152d22e7117c63c38620a4b9b Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Thu, 4 Jun 2026 19:22:53 -0700 Subject: [PATCH 12/24] Remove Content-Length header from MCP requests since it's disallowed by fetch (#320034) Don't include content-length header for MCP requests --- src/vs/workbench/api/common/extHostMcp.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/api/common/extHostMcp.ts b/src/vs/workbench/api/common/extHostMcp.ts index 52e0ec06853d7..65e87a5ebec5c 100644 --- a/src/vs/workbench/api/common/extHostMcp.ts +++ b/src/vs/workbench/api/common/extHostMcp.ts @@ -307,6 +307,19 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService } } +function stringifyError(err: unknown): string { + if (!(err instanceof Error)) { + return String(err); + } + let msg = String(err); + let cause: unknown = err.cause; + for (let depth = 0; cause !== undefined && depth < 5; depth++) { + msg += `: ${cause instanceof Error ? (cause.message || String(cause)) : String(cause)}`; + cause = cause instanceof Error ? cause.cause : undefined; + } + return msg; +} + const enum HttpMode { Unknown, Http, @@ -361,7 +374,7 @@ export class McpHTTPHandle extends Disposable { await this._send(message); } } catch (err) { - const msg = `Error sending message to ${this._launch.uri}: ${String(err)}`; + const msg = `Error sending message to ${this._launch.uri}: ${stringifyError(err)}`; this._proxy.$onDidChangeState(this._id, { state: McpConnectionState.Kind.Error, message: msg }); } } @@ -422,7 +435,6 @@ export class McpHTTPHandle extends Disposable { const headers: Record = { ...Object.fromEntries(this._launch.headers), 'Content-Type': 'application/json', - 'Content-Length': String(asBytes.length), Accept: 'text/event-stream, application/json', }; if (sessionId) { @@ -512,7 +524,7 @@ export class McpHTTPHandle extends Disposable { try { await this._doSSE(parser, res); } catch (err) { - this._log(LogLevel.Warning, `Error reading SSE stream: ${String(err)}`); + this._log(LogLevel.Warning, `Error reading SSE stream: ${stringifyError(err)}`); } } else if (contentType.startsWith('application/json')) { this._proxy.$onDidReceiveMessage(this._id, await res.text()); @@ -642,7 +654,7 @@ export class McpHTTPHandle extends Disposable { this._register(toDisposable(() => postEndpoint.cancel())); this._doSSE(parser, res).catch(err => { - this._proxy.$onDidChangeState(this._id, { state: McpConnectionState.Kind.Error, message: `Error reading SSE stream: ${String(err)}` }); + this._proxy.$onDidChangeState(this._id, { state: McpConnectionState.Kind.Error, message: `Error reading SSE stream: ${stringifyError(err)}` }); }); return postEndpoint.p; @@ -657,7 +669,6 @@ export class McpHTTPHandle extends Disposable { const headers: Record = { ...Object.fromEntries(this._launch.headers), 'Content-Type': 'application/json', - 'Content-Length': String(asBytes.length), }; await this._addAuthHeader(headers); const res = await this._fetch(url, { From 2ff28e038db4c2667cdf6887475bdd04d2b43384 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 5 Jun 2026 12:58:12 +1000 Subject: [PATCH 13/24] feat: add skill file parsing and enhance completion item with skill description (#320023) * feat: add skill file parsing and enhance completion item with skill description * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../platform/agentHost/node/copilot/copilotAgent.ts | 5 ++++- src/vs/platform/agentPlugins/common/pluginParsers.ts | 11 +++++++++++ .../contrib/chat/browser/agentHostInputCompletions.ts | 3 ++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index aa8bd0a6d5922..2e4ae828a3b07 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -20,7 +20,7 @@ import { basename as resourceBasename, dirname as resourceDirname } from '../../ import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; -import { IParsedPlugin, parseAgentFile, parsePlugin } from '../../../agentPlugins/common/pluginParsers.js'; +import { IParsedPlugin, parseAgentFile, parsePlugin, parseSkillFile } from '../../../agentPlugins/common/pluginParsers.js'; import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; @@ -2067,14 +2067,17 @@ async function toDiscoveredChildCustomization(file: URI, type: DiscoveredType, f id, uri, name: agentInfo.name, + ...(agentInfo.description ? { description: agentInfo.description } : {}), }; } if (type === DiscoveredType.Skill) { + const skillInfo = await parseSkillFile(file, fileService); return { type: CustomizationType.Skill, id, uri, name: resourceBasename(resourceDirname(file)), + ...(skillInfo.description ? { description: skillInfo.description } : {}), }; } if (type === DiscoveredType.Instruction) { diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index 6f27a16be942d..0ac02639c2636 100644 --- a/src/vs/platform/agentPlugins/common/pluginParsers.ts +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -959,6 +959,17 @@ export async function parseAgentFile(uri: URI, fileService: IFileService): Promi } } +export async function parseSkillFile(uri: URI, fileService: IFileService): Promise<{ description?: string }> { + try { + const content = await fileService.readFile(uri); + const frontmatter = parseFrontMatter(content.value.toString()); + const description = frontmatter?.getStringValue('description')?.trim(); + return { ...(description ? { description } : {}) }; + } catch { + return {}; + } +} + async function readHooks( pluginUri: URI, paths: readonly URI[], diff --git a/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts b/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts index 238ff194c866f..0ae8604956e99 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts @@ -163,10 +163,11 @@ export class AgentHostInputCompletionHandler extends AgentHostInputCompletionsBa } case 'skill': { return { - label: item.insertText, + label: { label: item.insertText, description: attachment.description }, insertText: item.insertText, filterText: item.insertText, range: replaceRange, + documentation: attachment.description, kind: CompletionItemKind.Text, }; } From abb632599935b45d825e977cca05cd335522e340 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 5 Jun 2026 12:58:16 +1000 Subject: [PATCH 14/24] fix: improve connection registration logic in startAgentHost (#320032) --- src/vs/platform/agentHost/node/agentHostMain.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 6a63c707bc86f..59c5257d573f5 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -206,7 +206,10 @@ async function startAgentHost(): Promise { // `AGENT_HOST_CLIENT_RESOURCE_CHANNEL` for filesystem reads. if (server instanceof UtilityProcessServer) { const authorityRegistrations = new Map(); - disposables.add(server.onDidAddConnection(connection => { + const registerConnection = (connection: (typeof server.connections)[number]) => { + if (authorityRegistrations.has(connection)) { + return; + } const clientId = connection.ctx; if (typeof clientId !== 'string' || !clientId) { return; @@ -214,7 +217,8 @@ async function startAgentHost(): Promise { const channel = server.getChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, c => c.ctx === clientId); const fsConnection = createAgentHostClientResourceConnection(channel); authorityRegistrations.set(connection, clientFileSystemProvider.registerAuthority(clientId, fsConnection)); - })); + }; + disposables.add(server.onDidAddConnection(registerConnection)); disposables.add(server.onDidRemoveConnection(connection => { const reg = authorityRegistrations.get(connection); if (reg) { @@ -222,6 +226,9 @@ async function startAgentHost(): Promise { authorityRegistrations.delete(connection); } })); + for (const connection of server.connections) { + registerConnection(connection); + } } // Expose the WebSocket client connection count to the parent process via IPC. From 5fd263f7d0c47ded4a90fcab6f608707dcfb3310 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:34:47 -0700 Subject: [PATCH 15/24] Chronicle: consolidate /chronicle standup into the skill workflow (#320031) * Chronicle: consolidate /chronicle standup into the skill workflow * fix test --- .../prompts/chronicle-standup.prompt.md | 4 +- .../assets/prompts/skills/chronicle/SKILL.md | 43 +++- extensions/copilot/package.json | 5 +- .../chronicle/common/standupPrompt.ts | 161 ------------ .../common/test/standupPrompt.spec.ts | 193 --------------- .../tools/node/sessionStoreSqlTool.ts | 229 +----------------- .../node/test/sessionStoreSqlTool.spec.ts | 20 -- 7 files changed, 41 insertions(+), 614 deletions(-) delete mode 100644 extensions/copilot/src/extension/chronicle/common/standupPrompt.ts delete mode 100644 extensions/copilot/src/extension/chronicle/common/test/standupPrompt.spec.ts diff --git a/extensions/copilot/assets/prompts/chronicle-standup.prompt.md b/extensions/copilot/assets/prompts/chronicle-standup.prompt.md index c910988d6f869..51413174e1987 100644 --- a/extensions/copilot/assets/prompts/chronicle-standup.prompt.md +++ b/extensions/copilot/assets/prompts/chronicle-standup.prompt.md @@ -2,6 +2,6 @@ name: chronicle:standup description: Generate a standup report from recent chat sessions --- -Generate a standup report from my recent coding sessions. Use the **chronicle** skill — it documents the `copilot_sessionStoreSql` tool and the Standup workflow (call with `action: "standup"` to pre-fetch the last 24h of sessions, turns, files, and refs). +Generate a standup report from my recent coding sessions. Use the **chronicle** skill — it documents the `copilot_sessionStoreSql` tool, the session-store schema, and the Standup workflow for summarizing the last 24h of activity from `sessions`, `session_refs`, `turns`, and `session_files`. -When you invoke `copilot_sessionStoreSql`, set `subcommand: "standup"`. +When you invoke `copilot_sessionStoreSql`, set `subcommand: "standup"` on every call. diff --git a/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md b/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md index 206485047d639..738ecb7810943 100644 --- a/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md +++ b/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md @@ -13,11 +13,10 @@ Sessions may be stored locally (SQLite) and optionally synced to the cloud for c ## Available Tool Actions -The `copilot_sessionStoreSql` tool supports three actions: +The `copilot_sessionStoreSql` tool supports two actions: | Action | Purpose | `query` param | |--------|---------|---------------| -| `standup` | Pre-fetch last 24h sessions, turns, files, refs | Not needed | | `query` | Execute a read-only SQL query | Required | | `reindex` | Rebuild local session index + cloud sync | Not needed | @@ -25,13 +24,30 @@ The `copilot_sessionStoreSql` tool supports three actions: ### Standup -When the user asks for a standup, daily summary, or "what did I do": +When the user asks for a standup, daily summary, or "what did I do" (e.g. `/chronicle standup`): -1. Call `copilot_sessionStoreSql` with `action: "standup"` and `description: "Generate standup"`. -2. The tool returns pre-fetched session data (sessions, turns, files, refs from the last 24 hours). -3. If the result is empty, tell the user no sessions were found in the last 24h, suggest `/chronicle reindex`, and stop — do not fabricate a standup. -4. For any PR references in the data, check their current status (open, merged, draft) if possible. -5. Format the returned data as a standup report grouped by work stream (branch/feature): +**Step 1: Gather the last 24h of activity** + +Use `copilot_sessionStoreSql` with `action: "query"` and follow the SQL dialect shown in the tool description (SQLite locally, DuckDB on cloud — see the **Database Schema** and **Query Guidelines** sections below). + +Query the `sessions` table for rows where `updated_at` falls within the last 24 hours, ordered by `updated_at` descending. Recent-window predicate by backend: + +- **Local SQLite**: `WHERE updated_at >= datetime('now', '-1 day')` +- **Cloud DuckDB**: `WHERE updated_at >= now() - INTERVAL '1 day'` + +Then, for those session ids, pull related references from `session_refs` (PRs, issues, commits). If you need more detail on a particular session, query `turns` (and `session_files`, or `checkpoints` on cloud) further — don't dump every turn for every session up front. + +If no sessions are found in the last 24 hours, tell the user there's no recent activity to report, suggest a longer window or `/chronicle reindex`, and stop. Do not fabricate a standup. + +**Step 2: Include PR-less work** + +Treat every recent session as a candidate work item, even when it has no PR, issue, or commit reference. PRs are supporting evidence, not the source of truth. Do not omit a session or branch solely because it has no PR — use session summaries and turn content to decide what to include. + +**Step 3: Check PR status and format** + +For any PR references found, use the GitHub CLI or MCP tools to check current status (open, merged, draft, closed). For each work item, include either a PR status line or a "No PR found" line — never invent a PR. + +Format the result grouped by work stream (branch/feature). Use exactly this structure: ``` Standup for : @@ -41,26 +57,27 @@ Standup for : **Feature name** (`branch-name` branch, `repo-name`) - 3-7 words describing the status - Key files: 2-3 most important files changed - - Merged: [#123](link) - - Session: `session-id` + - Merged: [#123](https://github.com/owner/repo/pull/123) or No PR found + - Session: `full-session-id` **🚧 In Progress** **Feature name** (`branch-name` branch, `repo-name`) - 3-7 words describing the current state of work - Key files: 2-3 most important files being worked on - - Draft: [#789](link) - - Session: `session-id` + - Draft: [#789](https://github.com/owner/repo/pull/789) or No PR found + - Session: `full-session-id` ``` Rules: - Keep it concise and succinct — the user can always ask follow-up questions - Use turn data (user messages AND assistant responses) to understand WHAT was done -- Use file paths to identify which components/areas were affected +- Use file paths from `session_files` to identify which components/areas were affected - Group related sessions on the same branch into one entry - For sessions, only show the most recent session per feature/branch - Link PRs and issues using markdown link syntax - Classify as Done if work appears complete, In Progress otherwise +- If a session has no branch or repo, include it under an "Other" section ### Tips diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 64135f3db12f0..2e9c4e7b79fa0 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1236,7 +1236,7 @@ "toolReferenceName": "sessionStoreSql", "when": "github.copilot.sessionSearch.enabled", "userDescription": "Query your Copilot session history using SQL", - "modelDescription": "Query the local session store containing history from past coding sessions. Uses SQLite syntax (NOT DuckDB or Postgres). SQL queries are read-only — only SELECT and WITH are allowed. Use `datetime('now', '-1 day')` for date math (NOT `now() - INTERVAL '1 day'`), FTS5 `MATCH` for text search.\n\nTables: `sessions`, `turns`, `session_files`, `session_refs`, `checkpoints`, `search_index`. For column details and query patterns, use the **chronicle** skill.\n\nActions: 'query' (execute SQL — supports JOINs, FTS5 MATCH, aggregations), 'standup' (pre-fetch last 24h data), 'reindex' (rebuild index from debug logs).", + "modelDescription": "Query the local session store containing history from past coding sessions. Uses SQLite syntax (NOT DuckDB or Postgres). SQL queries are read-only — only SELECT and WITH are allowed. Use `datetime('now', '-1 day')` for date math (NOT `now() - INTERVAL '1 day'`), FTS5 `MATCH` for text search.\n\nTables: `sessions`, `turns`, `session_files`, `session_refs`, `checkpoints`, `search_index`. For column details and query patterns, use the **chronicle** skill.\n\nActions: 'query' (execute SQL — supports JOINs, FTS5 MATCH, aggregations), 'reindex' (rebuild index from debug logs).", "tags": [], "canBeReferencedInPrompt": false, "inputSchema": { @@ -1246,10 +1246,9 @@ "type": "string", "enum": [ "query", - "standup", "reindex" ], - "description": "The action to perform. 'query' (default) executes a SQL query. 'standup' pre-fetches last 24h session data for standup reports. 'reindex' rebuilds the local session index and syncs to cloud if enabled." + "description": "The action to perform. 'query' (default) executes a SQL query. 'reindex' rebuilds the local session index and syncs to cloud if enabled." }, "query": { "type": "string", diff --git a/extensions/copilot/src/extension/chronicle/common/standupPrompt.ts b/extensions/copilot/src/extension/chronicle/common/standupPrompt.ts deleted file mode 100644 index 3b66f0e8ff2af..0000000000000 --- a/extensions/copilot/src/extension/chronicle/common/standupPrompt.ts +++ /dev/null @@ -1,161 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type { RefRow, SessionRow } from '../../../platform/chronicle/common/sessionStore'; - -/** A session row annotated with its source. */ -export interface AnnotatedSession extends SessionRow { - /** Where this session came from: 'vscode', 'cli', or 'cloud'. */ - source: 'vscode' | 'cli' | 'cloud'; -} - -/** A ref row annotated with its source. */ -export interface AnnotatedRef extends RefRow { - source: 'vscode' | 'cli' | 'cloud'; -} - -/** Sessions query — SQLite dialect, last 24 hours */ -export const SESSIONS_QUERY_SQLITE = `SELECT * - FROM sessions - WHERE updated_at >= datetime('now', '-1 day') - ORDER BY updated_at DESC`; - -/** Build refs query for a list of session IDs */ -export function buildRefsQuery(sessionIds: string[]): string { - const ids = sessionIds.map(s => `'${s.replace(/'/g, '\'\'')}'`).join(','); - return `SELECT session_id, ref_type, ref_value FROM session_refs WHERE session_id IN (${ids})`; -} - -/** Build files query for a list of session IDs */ -export function buildFilesQuery(sessionIds: string[]): string { - const ids = sessionIds.map(s => `'${s.replace(/'/g, '\'\'')}'`).join(','); - return `SELECT session_id, file_path, tool_name FROM session_files WHERE session_id IN (${ids})`; -} - -/** Build turns query for a list of session IDs (user messages + assistant response summaries, truncated) */ -export function buildTurnsQuery(sessionIds: string[]): string { - const ids = sessionIds.map(s => `'${s.replace(/'/g, '\'\'')}'`).join(','); - return `SELECT session_id, turn_index, substr(user_message, 1, 120) as user_message, substr(assistant_response, 1, 200) as assistant_response FROM turns WHERE session_id IN (${ids}) AND (user_message IS NOT NULL OR assistant_response IS NOT NULL) ORDER BY session_id, turn_index`; -} - -/** A file row from the session_files table. */ -export interface SessionFileInfo { - session_id: string; - file_path: string; - tool_name?: string; -} - -/** A turn summary from the turns table. */ -export interface SessionTurnInfo { - session_id: string; - turn_index: number; - user_message?: string; - assistant_response?: string; -} - -/** - * Build a standup prompt from pre-fetched session and ref data. - */ -export function buildStandupPrompt( - sessions: AnnotatedSession[], - refs: AnnotatedRef[], - turns: SessionTurnInfo[], - files: SessionFileInfo[], - extra?: string, -): string { - if (sessions.length === 0) { - return 'The user ran /standup but no sessions were found. Let them know there\'s no recent activity to report.'; - } - - const sessionLines = sessions.map(s => { - const branch = s.branch ?? 'unknown'; - const repo = s.repository ?? 'unknown'; - const agent = s.agent_name ?? s.source; - - // Include turn summaries for this session (first few user messages + assistant responses) - const sessionTurns = turns.filter(t => t.session_id === s.id).slice(0, 5); - - // Use first turn's user_message as summary when sessions.summary is empty - const firstTurnMessage = sessionTurns[0]?.user_message; - const summary = s.summary || firstTurnMessage || 'No summary'; - - const turnLines = sessionTurns - .filter(t => t.user_message || t.assistant_response) - .map(t => { - const parts: string[] = []; - if (t.user_message) { parts.push(`User: ${t.user_message}`); } - if (t.assistant_response) { parts.push(`Assistant: ${t.assistant_response}`); } - return ` - ${parts.join(' → ')}`; - }); - - // Include files touched in this session (capped to avoid noise) - const sessionFiles = files.filter(f => f.session_id === s.id); - const uniqueFiles = [...new Set(sessionFiles.map(f => f.file_path))]; - const shownFiles = uniqueFiles.slice(0, 5); - const fileLines = shownFiles.length > 0 - ? [` - Files (${uniqueFiles.length} total): ${shownFiles.join(', ')}${uniqueFiles.length > 5 ? `, +${uniqueFiles.length - 5} more` : ''}`] - : []; - - return [ - `- ${s.id} | ${repo} (${branch}) | ${agent} | ${summary} | updated ${s.updated_at}`, - ...turnLines, - ...fileLines, - ].join('\n'); - }); - - const refLines = refs.map(r => `- ${r.session_id} | ${r.ref_type}: ${r.ref_value}`); - - let prompt = `The user ran /chronicle standup. Generate a concise standup update from the pre-fetched data below. - -## Pre-fetched Session Data (last 24 hours) - -### Sessions (${sessions.length}) -${sessionLines.join('\n')} - -### References (PRs, Issues, Commits) -${refLines.length > 0 ? refLines.join('\n') : 'No references found.'} - -## Instructions - -1. Analyze the turn data (user messages and assistant responses) to understand the actual work done in each session. -2. Use file paths to identify which components, modules, or areas of the codebase were affected. -3. For any PR/issue references, mention them with links. -4. If a session has no turns or summary, note it briefly but don't skip it entirely. - -## Output Format - -Format the update grouped by work stream (branch/feature). Use this structure: - -Standup for : - -**✅ Done** - -**Feature name** (\`branch-name\` branch, \`repo-name\`) - - Summary of what was accomplished (1-2 sentences grounded in the user messages and assistant responses) - - Key files: list 2-3 most important files changed - - Tools used: mention key tools if visible (e.g., apply_patch, run_in_terminal, search) - - PR: [#123](link) — merged/closed (if applicable) - -**🚧 In Progress** - -**Feature name** (\`branch-name\` branch, \`repo-name\`) - - Summary of current work (1-2 sentences based on turn content) - - Key files: list 2-3 most important files being worked on - - PR: [#789](link) — draft/open (if applicable) - -Formatting rules: -- Use the turn data (user messages AND assistant responses) to understand WHAT was done, not just that something happened -- Use file paths to identify which components/areas were affected -- Group related sessions on the same branch into one entry -- Link PRs and issues using markdown link syntax -- Classify as Done if the session has no recent activity or the work appears complete, In Progress otherwise -- If a session has no branch or repo, still include it under an "Other" section`; - - if (extra) { - prompt += `\n\nAdditional context: ${extra}`; - } - - return prompt; -} diff --git a/extensions/copilot/src/extension/chronicle/common/test/standupPrompt.spec.ts b/extensions/copilot/src/extension/chronicle/common/test/standupPrompt.spec.ts deleted file mode 100644 index 2f2c37e4df027..0000000000000 --- a/extensions/copilot/src/extension/chronicle/common/test/standupPrompt.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { describe, expect, it } from 'vitest'; -import { extractFilePath, extractRefsFromMcpTool, extractRefsFromTerminal, extractRepoFromMcpTool, isGitHubMcpTool } from '../sessionStoreTracking'; -import { type AnnotatedRef, type AnnotatedSession, buildRefsQuery, buildStandupPrompt } from '../standupPrompt'; - -describe('buildStandupPrompt', () => { - it('returns no-activity message when no sessions', () => { - const result = buildStandupPrompt([], [], [], []); - expect(result).toContain('no sessions were found'); - }); - - it('includes session data in prompt', () => { - const sessions: AnnotatedSession[] = [ - { id: 'sess-1', branch: 'feature/auth', repository: 'owner/repo', summary: 'Added OAuth', updated_at: '2026-04-06T10:00:00Z', source: 'cloud' }, - ]; - const result = buildStandupPrompt(sessions, [], [], []); - expect(result).toContain('sess-1'); - expect(result).toContain('feature/auth'); - expect(result).toContain('owner/repo'); - expect(result).toContain('Added OAuth'); - }); - - it('includes refs in prompt', () => { - const sessions: AnnotatedSession[] = [ - { id: 'sess-1', branch: 'main', summary: 'Fix', source: 'cloud' }, - ]; - const refs: AnnotatedRef[] = [ - { session_id: 'sess-1', ref_type: 'pr', ref_value: '42', source: 'cloud' }, - { session_id: 'sess-1', ref_type: 'commit', ref_value: 'abc123', source: 'cloud' }, - ]; - const result = buildStandupPrompt(sessions, refs, [], []); - expect(result).toContain('commit: abc123'); - }); - - it('shows "No references found" when refs empty', () => { - const sessions: AnnotatedSession[] = [{ id: 'sess-1', branch: 'main', summary: 'Fix', source: 'cloud' }]; - const result = buildStandupPrompt(sessions, [], [], []); - expect(result).toContain('No references found.'); - }); - - it('includes extra context when provided', () => { - const sessions: AnnotatedSession[] = [{ id: 'sess-1', summary: 'Work', source: 'cloud' }]; - const result = buildStandupPrompt(sessions, [], [], [], 'Focus on backend changes'); - expect(result).toContain('Additional context: Focus on backend changes'); - }); - - it('shows "unknown" for missing branch and repo', () => { - const sessions: AnnotatedSession[] = [{ id: 'sess-1', summary: 'Work', source: 'cloud' }]; - const result = buildStandupPrompt(sessions, [], [], []); - expect(result).toContain('unknown (unknown)'); - }); - - it('handles multiple sessions from different branches', () => { - const sessions: AnnotatedSession[] = [ - { id: 'sess-1', branch: 'feature/a', repository: 'org/repo', summary: 'Feature A', source: 'cloud' }, - { id: 'sess-2', branch: 'feature/b', repository: 'org/repo', summary: 'Feature B', source: 'cloud' }, - ]; - const result = buildStandupPrompt(sessions, [], [], []); - expect(result).toContain('feature/a'); - expect(result).toContain('feature/b'); - }); - - it('shows source tags for cloud sessions', () => { - const sessions: AnnotatedSession[] = [ - { id: 'cloud-1', branch: 'main', summary: 'Cloud work 1', source: 'cloud' }, - { id: 'cloud-2', branch: 'feature/x', summary: 'Cloud work 2', source: 'cloud' }, - ]; - const refs: AnnotatedRef[] = [ - { session_id: 'cloud-2', ref_type: 'pr', ref_value: '99', source: 'cloud' }, - ]; - const result = buildStandupPrompt(sessions, refs, [], []); - expect(result).toContain('cloud-2 | pr: 99'); - }); -}); - -describe('buildRefsQuery', () => { - it('builds IN clause with escaped IDs', () => { - const query = buildRefsQuery(['sess-1', 'sess-2']); - expect(query).toContain('\'sess-1\''); - expect(query).toContain('\'sess-2\''); - expect(query).toContain('session_refs'); - }); - - it('escapes single quotes in session IDs', () => { - const query = buildRefsQuery(['it\'s']); - expect(query).toContain('\'it\'\'s\''); - }); -}); - -describe('extractFilePath', () => { - it('extracts filePath from apply_patch args', () => { - expect(extractFilePath('apply_patch', { filePath: '/src/index.ts' })).toBe('/src/index.ts'); - }); - - it('extracts path from create tool args', () => { - expect(extractFilePath('create', { path: '/src/new.ts' })).toBe('/src/new.ts'); - }); - - it('extracts filePath from read_file args', () => { - expect(extractFilePath('read_file', { filePath: '/src/index.ts' })).toBe('/src/index.ts'); - }); - - it('returns undefined for null args', () => { - expect(extractFilePath('apply_patch', null)).toBeUndefined(); - }); - - it('returns undefined when no path field exists', () => { - expect(extractFilePath('apply_patch', { content: 'hello' })).toBeUndefined(); - }); -}); - -describe('extractRefsFromMcpTool', () => { - it('extracts PR number from pull_request tool', () => { - const refs = extractRefsFromMcpTool('github-mcp-server-pull_request_read', { pullNumber: 42 }); - expect(refs).toEqual([{ ref_type: 'pr', ref_value: '42' }]); - }); - - it('extracts issue number from issue tool', () => { - const refs = extractRefsFromMcpTool('github-mcp-server-issue_read', { issue_number: 99 }); - expect(refs).toEqual([{ ref_type: 'issue', ref_value: '99' }]); - }); - - it('extracts commit SHA from commit tool', () => { - const refs = extractRefsFromMcpTool('github-mcp-server-get_commit', { sha: 'abc123' }); - expect(refs).toEqual([{ ref_type: 'commit', ref_value: 'abc123' }]); - }); - - it('returns empty for unrecognized tool', () => { - expect(extractRefsFromMcpTool('github-mcp-server-list_repos', {})).toEqual([]); - }); -}); - -describe('extractRefsFromTerminal', () => { - it('extracts PR URL from gh pr create output', () => { - const refs = extractRefsFromTerminal( - { command: 'gh pr create --title "feat"' }, - 'https://github.com/owner/repo/pull/123' - ); - expect(refs).toEqual([{ ref_type: 'pr', ref_value: '123' }]); - }); - - it('extracts issue URL from gh issue create output', () => { - const refs = extractRefsFromTerminal( - { command: 'gh issue create --title "bug"' }, - 'https://github.com/owner/repo/issues/456' - ); - expect(refs).toEqual([{ ref_type: 'issue', ref_value: '456' }]); - }); - - it('extracts commit SHA from git commit output', () => { - const refs = extractRefsFromTerminal( - { command: 'git commit -m "fix"' }, - '[main abc1234] fix' - ); - expect(refs).toEqual([{ ref_type: 'commit', ref_value: 'abc1234' }]); - }); - - it('returns empty for missing command', () => { - expect(extractRefsFromTerminal({}, undefined)).toEqual([]); - }); - - it('returns empty for unrecognized command', () => { - expect(extractRefsFromTerminal({ command: 'npm install' }, 'done')).toEqual([]); - }); -}); - -describe('extractRepoFromMcpTool', () => { - it('extracts owner/repo', () => { - expect(extractRepoFromMcpTool({ owner: 'microsoft', repo: 'vscode' })).toBe('microsoft/vscode'); - }); - - it('returns undefined when owner missing', () => { - expect(extractRepoFromMcpTool({ repo: 'vscode' })).toBeUndefined(); - }); - - it('returns undefined when repo missing', () => { - expect(extractRepoFromMcpTool({ owner: 'microsoft' })).toBeUndefined(); - }); -}); - -describe('isGitHubMcpTool', () => { - it('returns true for github-mcp-server-* tools', () => { - expect(isGitHubMcpTool('github-mcp-server-pull_request_read')).toBe(true); - }); - - it('returns false for other tools', () => { - expect(isGitHubMcpTool('apply_patch')).toBe(false); - }); -}); diff --git a/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts b/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts index 66c83fa826e96..19fbaaf009585 100644 --- a/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts +++ b/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts @@ -8,10 +8,9 @@ import type * as vscode from 'vscode'; import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { ICopilotTokenManager } from '../../../platform/authentication/common/copilotTokenManager'; import { IChatDebugFileLoggerService } from '../../../platform/chat/common/chatDebugFileLoggerService'; -import { type SessionRow, type RefRow, ISessionStore } from '../../../platform/chronicle/common/sessionStore'; +import { ISessionStore } from '../../../platform/chronicle/common/sessionStore'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { LanguageModelTextPart, LanguageModelToolResult } from '../../../vscodeTypes'; -import { type AnnotatedSession, type AnnotatedRef, type SessionFileInfo, type SessionTurnInfo, SESSIONS_QUERY_SQLITE, buildRefsQuery, buildFilesQuery, buildTurnsQuery, buildStandupPrompt } from '../../chronicle/common/standupPrompt'; import { SessionIndexingPreference } from '../../chronicle/common/sessionIndexingPreference'; import { CloudSessionStoreClient } from '../../chronicle/node/cloudSessionStoreClient'; import { reindexSessions } from '../../chronicle/node/sessionReindexer'; @@ -55,7 +54,7 @@ function stripLeadingCommentsAndWhitespace(sql: string): string { } export interface SessionStoreSqlParams { - readonly action?: 'query' | 'standup' | 'reindex'; + readonly action?: 'query' | 'reindex'; readonly query?: string; readonly force?: boolean; readonly description: string; @@ -63,19 +62,12 @@ export interface SessionStoreSqlParams { readonly subcommand?: 'standup' | 'tips' | 'cost-tips' | 'search' | 'improve' | 'reindex'; } -/** Cloud SQL dialect sessions query. */ -const SESSIONS_QUERY_CLOUD = `SELECT * - FROM sessions - WHERE updated_at >= now() - INTERVAL '1 day' - ORDER BY updated_at DESC - LIMIT 100`; - /** Model description when cloud sync is enabled — uses DuckDB SQL syntax. */ const CLOUD_MODEL_DESCRIPTION = `Query the cloud session store containing ALL past coding sessions across devices and agents. Uses DuckDB syntax (NOT SQLite). SQL queries are read-only — only SELECT and WITH are allowed. Use \`now() - INTERVAL '1 day'\` for date math (NOT \`datetime('now', '-1 day')\` — that's SQLite-only), \`ILIKE\` for text search (no FTS5/MATCH). Tables: \`sessions\`, \`turns\`, \`session_files\`, \`session_refs\`, \`checkpoints\`, \`events\`, \`tool_requests\`. For column details and query patterns, use the **chronicle** skill. -Actions: 'query' (execute DuckDB SQL), 'standup' (pre-fetch last 24h data), 'reindex' (rebuild index + cloud sync).`; +Actions: 'query' (execute DuckDB SQL), 'reindex' (rebuild index + cloud sync).`; class SessionStoreSqlTool implements ICopilotTool { public static readonly toolName = ToolName.SessionStoreSql; @@ -104,8 +96,6 @@ class SessionStoreSqlTool implements ICopilotTool { const subcommand = options.input.subcommand; switch (action) { - case 'standup': - return this._invokeStandup(subcommand ?? 'standup', token); case 'reindex': return this._invokeReindex(options.input.force ?? false, subcommand ?? 'reindex', token); default: @@ -211,103 +201,6 @@ class SessionStoreSqlTool implements ICopilotTool { return this._sessionStore.executeReadOnly(sql); } - /** - * Standup action: pre-fetch last 24h sessions + turns + files + refs, - * merge local/cloud, dedup, and return formatted data for the model to summarise. - */ - private async _invokeStandup(subcommand: NonNullable, _token: CancellationToken): Promise { - const startTime = Date.now(); - const hadCloudConsent = this._indexingPreference.hasCloudConsent(); - const target: 'local' | 'cloud' = hadCloudConsent ? 'cloud' : 'local'; - - try { - // Always query local SQLite (has current machine's sessions) - const localSessions = this._queryLocalStore(); - - // Query cloud if user has cloud consent - let cloudSessions: { sessions: AnnotatedSession[]; refs: AnnotatedRef[] } = { sessions: [], refs: [] }; - if (hadCloudConsent) { - cloudSessions = await this._queryCloudStore(); - } - - // Merge and dedup by session ID (cloud wins on conflict) - const seenIds = new Set(); - const sessions: AnnotatedSession[] = []; - const refs: AnnotatedRef[] = []; - - for (const s of cloudSessions.sessions) { - if (!seenIds.has(s.id)) { - seenIds.add(s.id); - sessions.push(s); - } - } - for (const s of localSessions.sessions) { - if (!seenIds.has(s.id)) { - seenIds.add(s.id); - sessions.push(s); - } - } - - const seenRefs = new Set(); - for (const r of [...cloudSessions.refs, ...localSessions.refs]) { - const key = `${r.session_id}:${r.ref_type}:${r.ref_value}`; - if (!seenRefs.has(key)) { - seenRefs.add(key); - refs.push(r); - } - } - - // Sort by updated_at descending, cap to 20 - sessions.sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? '')); - const capped = sessions.slice(0, 20); - const cappedIds = new Set(capped.map(s => s.id)); - const cappedRefs = refs.filter(r => cappedIds.has(r.session_id)); - - // Fetch turns and files for capped sessions - let cappedTurns: SessionTurnInfo[] = []; - let cappedFiles: SessionFileInfo[] = []; - if (capped.length > 0) { - const ids = capped.map(s => s.id); - try { - cappedTurns = this._sessionStore.executeReadOnlyFallback(buildTurnsQuery(ids)) as unknown as SessionTurnInfo[]; - } catch { /* non-fatal */ } - try { - cappedFiles = this._sessionStore.executeReadOnlyFallback(buildFilesQuery(ids)) as unknown as SessionFileInfo[]; - } catch { /* non-fatal */ } - - if (this._indexingPreference.hasCloudConsent()) { - const cloudDetail = await this._queryCloudTurnsAndFiles(ids); - - if (cloudDetail.turns.length > 0) { - const seenTurns = new Set(cappedTurns.map(t => `${t.session_id}:${t.turn_index}`)); - for (const t of cloudDetail.turns) { - if (!seenTurns.has(`${t.session_id}:${t.turn_index}`)) { - cappedTurns.push(t); - } - } - } - - if (cloudDetail.files.length > 0) { - const seenFiles = new Set(cappedFiles.map(f => `${f.session_id}:${f.file_path}`)); - for (const f of cloudDetail.files) { - if (!seenFiles.has(`${f.session_id}:${f.file_path}`)) { - cappedFiles.push(f); - } - } - } - } - } - - const prompt = buildStandupPrompt(capped, cappedRefs, cappedTurns, cappedFiles); - this._sendTelemetry({ command: 'standup', subcommand, target, rowCount: capped.length, durationMs: Date.now() - startTime, success: true }); - return new LanguageModelToolResult([new LanguageModelTextPart(prompt)]); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this._sendTelemetry({ command: 'standup', subcommand, target, rowCount: 0, durationMs: Date.now() - startTime, success: false, error: message.substring(0, 100) }); - return new LanguageModelToolResult([new LanguageModelTextPart(`Error fetching standup data: ${message}`)]); - } - } - /** * Reindex action: rebuild the local session store from debug logs, * then trigger cloud sync if enabled. @@ -372,111 +265,8 @@ class SessionStoreSqlTool implements ICopilotTool { } } - /** - * Query the local SQLite session store for sessions and refs. - */ - private _queryLocalStore(): { sessions: AnnotatedSession[]; refs: AnnotatedRef[] } { - try { - const rawSessions = this._sessionStore.executeReadOnlyFallback(SESSIONS_QUERY_SQLITE) as unknown as SessionRow[]; - const sessions: AnnotatedSession[] = rawSessions.map(s => ({ ...s, source: 'vscode' as const })); - - let refs: AnnotatedRef[] = []; - if (sessions.length > 0) { - const ids = sessions.map(s => s.id); - const rawRefs = this._sessionStore.executeReadOnlyFallback(buildRefsQuery(ids)) as unknown as RefRow[]; - refs = rawRefs.map(r => ({ ...r, source: 'vscode' as const })); - } - - return { sessions, refs }; - } catch { - return { sessions: [], refs: [] }; - } - } - - private async _queryCloudStore(): Promise<{ sessions: AnnotatedSession[]; refs: AnnotatedRef[] }> { - const empty = { sessions: [] as AnnotatedSession[], refs: [] as AnnotatedRef[] }; - try { - const client = new CloudSessionStoreClient(this._tokenManager, this._authService, this._fetcherService); - - const sessionsResult = await client.executeQuery(SESSIONS_QUERY_CLOUD); - if (!sessionsResult || 'error' in sessionsResult || sessionsResult.rows.length === 0) { - return empty; - } - - const sessions: AnnotatedSession[] = sessionsResult.rows.map(r => ({ - id: r.id as string, - summary: r.summary as string | undefined, - branch: r.branch as string | undefined, - repository: r.repository as string | undefined, - agent_name: r.agent_name as string | undefined, - agent_description: r.agent_description as string | undefined, - created_at: r.created_at as string | undefined, - updated_at: r.updated_at as string | undefined, - source: 'cloud' as const, - })); - - const ids = sessions.map(s => s.id); - let refs: AnnotatedRef[] = []; - try { - const refsQuery = `SELECT session_id, ref_type, ref_value FROM session_refs WHERE session_id IN (${ids.map(s => `'${s.replace(/'/g, '\'\'')}'`).join(',')})`; - const refsResult = await client.executeQuery(refsQuery); - if (refsResult && !('error' in refsResult) && refsResult.rows.length > 0) { - refs = refsResult.rows.map(r => ({ - session_id: r.session_id as string, - ref_type: r.ref_type as 'commit' | 'pr' | 'issue', - ref_value: r.ref_value as string, - source: 'cloud' as const, - })); - } - } catch { /* non-fatal */ } - - return { sessions, refs }; - } catch { - return empty; - } - } - - private async _queryCloudTurnsAndFiles(sessionIds: string[]): Promise<{ turns: SessionTurnInfo[]; files: SessionFileInfo[] }> { - const empty = { turns: [] as SessionTurnInfo[], files: [] as SessionFileInfo[] }; - try { - const client = new CloudSessionStoreClient(this._tokenManager, this._authService, this._fetcherService); - const inClause = sessionIds.map(s => `'${s.replace(/'/g, '\'\'')}'`).join(','); - - let turns: SessionTurnInfo[] = []; - try { - const turnsQuery = `SELECT session_id, turn_index, substring(user_message, 1, 120) as user_message, substring(assistant_response, 1, 200) as assistant_response FROM turns WHERE session_id IN (${inClause}) AND (user_message IS NOT NULL OR assistant_response IS NOT NULL) ORDER BY session_id, turn_index LIMIT 200`; - const turnsResult = await client.executeQuery(turnsQuery); - if (turnsResult && !('error' in turnsResult) && turnsResult.rows.length > 0) { - turns = turnsResult.rows.map(r => ({ - session_id: r.session_id as string, - turn_index: r.turn_index as number, - user_message: r.user_message as string | undefined, - assistant_response: r.assistant_response as string | undefined, - })); - } - } catch { /* non-fatal */ } - - let files: SessionFileInfo[] = []; - try { - const filesQuery = `SELECT session_id, file_path, tool_name FROM session_files WHERE session_id IN (${inClause}) LIMIT 200`; - const filesResult = await client.executeQuery(filesQuery); - if (filesResult && !('error' in filesResult) && filesResult.rows.length > 0) { - files = filesResult.rows.map(r => ({ - session_id: r.session_id as string, - file_path: r.file_path as string, - tool_name: r.tool_name as string | undefined, - })); - } - } catch { /* non-fatal */ } - - return { turns, files }; - } catch { - return empty; - } - } - private _sendTelemetry(args: { - command: 'query' | 'standup' | 'reindex'; + command: 'query' | 'reindex'; subcommand?: SessionStoreSqlParams['subcommand']; target: 'local' | 'cloud'; blocked?: boolean; @@ -507,11 +297,11 @@ class SessionStoreSqlTool implements ICopilotTool { /* __GDPR__ "chronicle.sqlQuery" : { "owner": "vijayu", -"comment": "Tracks chronicle session-store tool invocations (query/standup/reindex) and outcomes", -"command": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Tool action invoked: query, standup, or reindex." }, +"comment": "Tracks chronicle session-store tool invocations (query/reindex) and outcomes", +"command": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Tool action invoked: query or reindex." }, "subcommand": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Originating /chronicle slash command (standup, tips, cost-tips, search, improve, reindex) or 'unknown' for ad-hoc model calls." }, "target": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the invocation primarily targeted the local SQLite store or the cloud session store." }, -"source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Fine-grained source: local, cloud, local_fallback, blocked, standup, or reindex (kept for back-compat)." }, +"source": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Fine-grained source: local, cloud, local_fallback, blocked, or reindex (kept for back-compat)." }, "success": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the invocation succeeded (true/false)." }, "error": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Truncated error message." }, "rowCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of rows returned." }, @@ -534,11 +324,6 @@ class SessionStoreSqlTool implements ICopilotTool { ) { const action = options.input.action ?? 'query'; switch (action) { - case 'standup': - return { - invocationMessage: l10n.t('Fetching standup data'), - pastTenseMessage: l10n.t('Fetched standup data'), - }; case 'reindex': return { invocationMessage: l10n.t('Reindexing session store'), diff --git a/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts b/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts index 2405e9c4fa241..f58bfc9c48efe 100644 --- a/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts +++ b/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts @@ -168,20 +168,6 @@ describe('SessionStoreSqlTool', () => { expect(extractText(result)).toContain('Results:'); }); - it('routes standup action correctly', async () => { - const { tool } = createToolInstance(); - const cts = new CancellationTokenSource(); - - const result = await tool.invoke( - makeOptions({ action: 'standup', description: 'Generate standup' }), - cts.token, - ); - - const text = extractText(result); - // Standup should return either session data or an error — not a SQL result - expect(text).not.toContain('Blocked SQL'); - }); - it('routes reindex action correctly', async () => { const { tool, debugLogService } = createToolInstance(); (debugLogService.listSessionIds as any).mockResolvedValue([]); @@ -416,12 +402,6 @@ describe('SessionStoreSqlTool', () => { const { tool } = createToolInstance(); const cts = new CancellationTokenSource(); - const standup = tool.prepareInvocation( - { input: { action: 'standup', description: 'test' } } as any, - cts.token, - ); - expect(standup.invocationMessage).toContain('standup'); - const reindex = tool.prepareInvocation( { input: { action: 'reindex', description: 'test' } } as any, cts.token, From ea1e491f1dd2dcaa31cc51b40988336d87ba5800 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:54:19 -0400 Subject: [PATCH 16/24] Update chat session icons and order (#319507) Align chat session list action button icons and order with agents window --- .../chat/browser/agentSessions/agentSessionsActions.ts | 8 ++++---- .../chat/browser/agentSessions/agentSessionsPicker.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index d874a49c4a9ef..2630082d200ff 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -234,7 +234,7 @@ export class ArchiveAgentSessionSectionAction extends Action2 { super({ id: 'agentSessionSection.archive', title: localize2('archiveSection', "Archive All"), - icon: Codicon.archive, + icon: Codicon.checkAll, menu: [{ id: MenuId.AgentSessionSectionToolbar, group: 'navigation', @@ -475,7 +475,7 @@ export class ArchiveAgentSessionAction extends BaseAgentSessionAction { super({ id: 'agentSession.archive', title: localize2('archive', "Archive"), - icon: Codicon.archive, + icon: Codicon.check, keybinding: { primary: KeyCode.Delete, mac: { primary: KeyMod.CtrlCmd | KeyCode.Backspace }, @@ -568,7 +568,7 @@ export class PinAgentSessionAction extends BaseAgentSessionAction { menu: [{ id: MenuId.AgentSessionItemToolbar, group: 'navigation', - order: 0, + order: 2, when: ContextKeyExpr.and( ChatContextKeys.isPinnedAgentSession.negate(), ChatContextKeys.isArchivedAgentSession.negate() @@ -602,7 +602,7 @@ export class UnpinAgentSessionAction extends BaseAgentSessionAction { menu: [{ id: MenuId.AgentSessionItemToolbar, group: 'navigation', - order: 0, + order: 2, when: ContextKeyExpr.and( ChatContextKeys.isPinnedAgentSession, ChatContextKeys.isArchivedAgentSession.negate() diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index abf4fc4f26d7a..53dc9f50f565c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -23,7 +23,7 @@ interface ISessionPickItem extends IQuickPickItem { } export const archiveButton: IQuickInputButton = { - iconClass: ThemeIcon.asClassName(Codicon.archive), + iconClass: ThemeIcon.asClassName(Codicon.check), tooltip: localize('archiveSession', "Archive") }; From df25ea51086e62112afa6eba48503b8837e04869 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 5 Jun 2026 14:10:16 +1000 Subject: [PATCH 17/24] feat: enhance session customization handling with throttling and cancellation support (#320038) * feat: enhance session customization handling with throttling and cancellation support * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * test: enhance session customization tests with improved assertions and timeout handling * feat: add cancellation support to session plugin bundler and related tests --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../agentHost/node/copilot/copilotAgent.ts | 71 ++++++++++++++++--- .../node/shared/sessionPluginBundler.ts | 68 +++++++++++++----- .../agentHost/test/node/copilotAgent.test.ts | 38 ++++++++++ .../sessionCustomizationDiscovery.test.ts | 65 ++++++++++++++++- 4 files changed, 214 insertions(+), 28 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 2e4ae828a3b07..3961a09755a5e 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -5,7 +5,8 @@ import { CopilotClient, ResumeSessionConfig, RuntimeConnection, type CopilotClientOptions, type SessionConfig } from '@github/copilot-sdk'; import * as fs from 'fs/promises'; -import { Limiter, SequencerByKey } from '../../../../base/common/async.js'; +import { Limiter, SequencerByKey, Throttler } from '../../../../base/common/async.js'; +import { CancellationTokenSource, type CancellationToken } from '../../../../base/common/cancellation.js'; import { rgDiskPath } from '../../../../base/node/ripgrep.js'; import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; @@ -1972,6 +1973,8 @@ class SessionDiscoveredEntry extends Disposable { private readonly _discovery: SessionCustomizationDiscovery; private readonly _bundler: SessionPluginBundler; + private readonly _refreshThrottler = this._register(new Throttler()); + private _refreshCancellationSource: CancellationTokenSource | undefined; private _customizations: readonly DirectoryCustomization[] = []; private _plugin: IParsedPlugin | undefined; @@ -1990,12 +1993,18 @@ class SessionDiscoveredEntry extends Disposable { this._discovery = this._register(instantiationService.createInstance(SessionCustomizationDiscovery, workingDirectory, userHome)); this._bundler = this._register(instantiationService.createInstance(SessionPluginBundler, workingDirectory)); this._fileService = instantiationService.invokeFunction(accessor => accessor.get(IFileService)); - this._settled = this._refresh(); + this._settled = this._queueRefresh(false); this._register(this._discovery.onDidChange(() => { - this._settled = this._refresh().finally(() => this._onDidRefresh()); + this._settled = this._queueRefresh(true); })); } + override dispose(): void { + this._refreshCancellationSource?.dispose(true); + this._refreshCancellationSource = undefined; + super.dispose(); + } + whenSettled(): Promise { return this._settled; } @@ -2008,22 +2017,64 @@ class SessionDiscoveredEntry extends Disposable { return this._plugin; } - private async _refresh(): Promise { + private _queueRefresh(notify: boolean): Promise { + this._refreshCancellationSource?.cancel(); + return this._refreshThrottler.queue(async throttlerToken => { + const refreshCancellationSource = new CancellationTokenSource(throttlerToken); + this._refreshCancellationSource = refreshCancellationSource; + try { + const didRefresh = await this._refresh(refreshCancellationSource.token); + if (didRefresh && notify && !refreshCancellationSource.token.isCancellationRequested) { + this._onDidRefresh(); + } + } finally { + if (this._refreshCancellationSource === refreshCancellationSource) { + this._refreshCancellationSource = undefined; + } + refreshCancellationSource.dispose(); + } + }); + } + + private async _refresh(token: CancellationToken): Promise { try { const directories = await this._discovery.directories(); - this._customizations = await toDiscoveredDirectoryCustomizations(directories, this._fileService); - this._plugin = undefined; + if (token.isCancellationRequested) { + return false; + } + + const customizations = await toDiscoveredDirectoryCustomizations(directories, this._fileService); + if (token.isCancellationRequested) { + return false; + } + + const bundleResult = await this._bundler.bundle(directories, token); + if (token.isCancellationRequested) { + return false; + } - const bundleResult = await this._bundler.bundle(directories); + // Don't update `_customizations` / `_plugin` when cancelled. + // Otherwise a cancelled refresh could temporarily clear them and cause callers to see empty customizations. if (!bundleResult) { - return; + this._customizations = customizations; + this._plugin = undefined; + } else { + const pluginDir = URI.parse(bundleResult.ref.uri); + const plugin = await this._resolvePlugin(pluginDir); + this._customizations = customizations; + this._plugin = plugin; } - const pluginDir = URI.parse(bundleResult.ref.uri); - this._plugin = await this._resolvePlugin(pluginDir); + return true; } catch (err) { + // Don't update `_customizations` / `_plugin` when cancelled. + // Otherwise a cancelled refresh could temporarily clear them and cause callers to see empty customizations. + if (token.isCancellationRequested) { + return false; + } this._logService.warn(`[Copilot:SessionDiscoveredEntry] Discovery/bundle failed: ${err instanceof Error ? err.message : String(err)}`); this._customizations = []; this._plugin = undefined; + return true; } } } diff --git a/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts b/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts index a864776042964..1990ba9259b89 100644 --- a/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts +++ b/src/vs/platform/agentHost/node/shared/sessionPluginBundler.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { VSBuffer } from '../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { hash } from '../../../../base/common/hash.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { basename, dirname } from '../../../../base/common/resources.js'; @@ -82,23 +83,16 @@ export class SessionPluginBundler extends Disposable { * * Overwrites any previous bundle for this working directory. Returns a * {@link ClientPluginCustomization} pointing at the on-disk plugin root - * with a content-based nonce, or `undefined` when there are no files. + * with a content-based nonce, or `undefined` when there are no files or + * cancellation was requested. */ - async bundle(directories: readonly IDiscoveredDirectory[]): Promise { - if (directories.length === 0) { + async bundle(directories: readonly IDiscoveredDirectory[], token: CancellationToken = CancellationToken.None): Promise { + if (directories.length === 0 || token.isCancellationRequested) { return undefined; } - try { - await this._fileService.del(this._rootUri, { recursive: true }); - } catch { - // Directory may not exist on first bundle. - } - - const manifestUri = URI.joinPath(this._rootUri, '.plugin', 'plugin.json'); - await this._fileService.writeFile(manifestUri, VSBuffer.fromString(MANIFEST_CONTENT)); - const hashParts: string[] = []; + const files: { readonly destUri: URI; readonly content: VSBuffer }[] = []; for (const discoveredDirectory of directories) { const dir = pluginDirForType(discoveredDirectory.type); @@ -122,17 +116,22 @@ export class SessionPluginBundler extends Disposable { } const content = await this._fileService.readFile(file); - await this._fileService.writeFile(destUri, content.value); + if (token.isCancellationRequested) { + return undefined; + } + files.push({ destUri, content: content.value }); hashParts.push(`${hashKey}:${content.value.toString()}`); } } + if (token.isCancellationRequested) { + return undefined; + } hashParts.sort(); const nonce = String(hash(hashParts.join('\n'))); - this._lastNonce = nonce; const rootUriString = this._rootUri.toString() as ProtocolURI; - return { + const result = { ref: { type: CustomizationType.Plugin, id: customizationId(rootUriString), @@ -141,6 +140,43 @@ export class SessionPluginBundler extends Disposable { enabled: true, nonce, }, - }; + } satisfies IBundleResult; + + if (this._lastNonce === nonce) { + return result; + } + + try { + await this._fileService.del(this._rootUri, { recursive: true }); + } catch { + // Directory may not exist on first bundle. + } + if (token.isCancellationRequested) { + return undefined; + } + + const manifestUri = URI.joinPath(this._rootUri, '.plugin', 'plugin.json'); + await this._fileService.createFolder(dirname(manifestUri)); + if (token.isCancellationRequested) { + return undefined; + } + await this._fileService.writeFile(manifestUri, VSBuffer.fromString(MANIFEST_CONTENT)); + if (token.isCancellationRequested) { + return undefined; + } + + for (const file of files) { + await this._fileService.createFolder(dirname(file.destUri)); + if (token.isCancellationRequested) { + return undefined; + } + await this._fileService.writeFile(file.destUri, file.content); + if (token.isCancellationRequested) { + return undefined; + } + } + + this._lastNonce = nonce; + return result; } } diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 7c047a3140cbb..5b15e53336c0e 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -871,6 +871,44 @@ suite('CopilotAgent', () => { await disposeAgent(agent); } }); + + test('getSessionCustomizations clears discovered files when the root disappears', async () => { + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + + const workspace = URI.from({ scheme: Schemas.inMemory, path: '/workspace' }); + const agentsRoot = URI.joinPath(workspace, '.github', 'agents'); + await fileService.createFolder(agentsRoot); + await fileService.writeFile(URI.joinPath(agentsRoot, 'helper.agent.md'), VSBuffer.fromString('agent body')); + + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([]); + const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, fileService }); + + try { + await agent.authenticate('https://api.github.com', 'token'); + + const session = AgentSession.uri('copilotcli', 'session-discovery-cleared'); + await agent.createSession({ + session, + workingDirectory: workspace, + }); + + const before = await agent.getSessionCustomizations(session); + assert.deepStrictEqual(before.filter(customization => customization.type === CustomizationType.Directory).map(customization => customization.uri), [agentsRoot.toString()]); + + await fileService.del(agentsRoot, { recursive: true }); + + let after = await agent.getSessionCustomizations(session); + for (let i = 0; i < 20 && after.filter(customization => customization.type === CustomizationType.Directory).length > 0; i++) { + await new Promise(resolve => setTimeout(resolve, 50)); + after = await agent.getSessionCustomizations(session); + } + assert.deepStrictEqual(after.filter(customization => customization.type === CustomizationType.Directory).map(customization => customization.uri), []); + } finally { + await disposeAgent(agent); + } + }); }); suite('provisional sessions', () => { diff --git a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts index c988bf53cb324..292771067f87b 100644 --- a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { VSBuffer } from '../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; @@ -263,9 +264,69 @@ suite('SessionCustomizationDiscovery + SessionPluginBundler', () => { const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); const first = await bundler.bundle(await discovery.directories()); + + let writeCalls = 0; + let deleteCalls = 0; + const originalWriteFile = fileService.writeFile.bind(fileService); + const originalDel = fileService.del.bind(fileService); + disposables.add({ + dispose: () => { + fileService.writeFile = originalWriteFile as typeof fileService.writeFile; + fileService.del = originalDel as typeof fileService.del; + } + }); + fileService.writeFile = ((...args: Parameters) => { + writeCalls++; + return originalWriteFile(...args); + }) as typeof fileService.writeFile; + fileService.del = ((...args: Parameters) => { + deleteCalls++; + return originalDel(...args); + }) as typeof fileService.del; + const second = await bundler.bundle(await discovery.directories()); - assert.ok(first && second); - assert.strictEqual(first.ref.nonce, second.ref.nonce); + assert.ok(first); + assert.ok(second); + assert.deepStrictEqual({ + firstNonce: first.ref.nonce, + secondNonce: second.ref.nonce, + writeCalls, + deleteCalls, + }, { + firstNonce: first.ref.nonce, + secondNonce: first.ref.nonce, + writeCalls: 0, + deleteCalls: 0, + }); + }); + + test('returns undefined without rewriting when cancelled', async () => { + await seed('/workspace/.github/agents/foo.agent.md', 'agent body'); + + const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome)); + const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace)); + + let writeCalls = 0; + let deleteCalls = 0; + const originalWriteFile = fileService.writeFile.bind(fileService); + const originalDel = fileService.del.bind(fileService); + disposables.add({ + dispose: () => { + fileService.writeFile = originalWriteFile as typeof fileService.writeFile; + fileService.del = originalDel as typeof fileService.del; + } + }); + fileService.writeFile = ((...args: Parameters) => { + writeCalls++; + return originalWriteFile(...args); + }) as typeof fileService.writeFile; + fileService.del = ((...args: Parameters) => { + deleteCalls++; + return originalDel(...args); + }) as typeof fileService.del; + + const result = await bundler.bundle(await discovery.directories(), CancellationToken.Cancelled); + assert.deepStrictEqual({ result, writeCalls, deleteCalls }, { result: undefined, writeCalls: 0, deleteCalls: 0 }); }); test('different working directories produce different bundle authorities', async () => { From 516eb0c8a17d5be4216078ae737345f9d08f77d9 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Thu, 4 Jun 2026 21:13:27 -0700 Subject: [PATCH 18/24] Remove agent-host branch and isolation pickers (#320014) * Remove agent-host branch and isolation pickers (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clarify agent-host config chip filtering (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostChatInputPicker.contribution.ts | 54 +------------------ .../agentHost/agentHostChatInputPicker.ts | 38 ++++++------- .../media/agentHostChatInputPicker.css | 26 --------- .../browser/widget/input/chatInputPart.ts | 22 +++----- 4 files changed, 23 insertions(+), 117 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts index fc9919805c2d3..cfe6336e4e2a0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.ts @@ -5,12 +5,10 @@ import { localize2 } from '../../../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr } from '../../../../../../platform/contextkey/common/contextkey.js'; -import { IsSessionsWindowContext } from '../../../../../common/contextkeys.js'; import { ChatContextKeys, ChatContextKeyExprs } from '../../../common/actions/chatContextKeys.js'; /** - * All three agent-host pickers live under `MenuId.ChatInputSecondary` group + * Agent-host pickers live under `MenuId.ChatInputSecondary` group * `'navigation'` because non-navigation groups are routed to the overflow * menu by VS Code's menu/toolbar convention. * @@ -22,11 +20,6 @@ import { ChatContextKeys, ChatContextKeyExprs } from '../../../common/actions/ch * 0.8 OpenAgentHostAutoApprovePickerAction (NEW — Auto-Approve) * 0.9 OpenAgentHostPermissionModePickerAction (NEW — Claude Approvals) * 1 OpenPermissionPickerAction (Default Approvals) - * 100 OpenAgentHostBranchPickerAction (NEW — Branch) - * 101 OpenAgentHostIsolationPickerAction (NEW — Isolation) - * - * Branch + Isolation are pushed to the right of the row by CSS - * (`margin-left: auto` on the first of the two; see picker CSS). */ export class OpenAgentHostModePickerAction extends Action2 { @@ -86,51 +79,6 @@ export class OpenAgentHostPermissionModePickerAction extends Action2 { override async run(): Promise { /* the action view item handles interaction */ } } -export class OpenAgentHostBranchPickerAction extends Action2 { - static readonly ID = 'workbench.action.chat.openAgentHostBranchPicker'; - constructor() { - super({ - id: OpenAgentHostBranchPickerAction.ID, - title: localize2('agentHost.branchPicker', "Branch"), - f1: false, - precondition: ChatContextKeys.enabled, - menu: [{ - id: MenuId.ChatInputSecondary, - group: 'navigation', - // Large order so Branch always sorts after the existing - // secondary chips (SessionTarget, Mode, Approvals, ...). - order: 100, - // Workbench is locked to `isolation: 'folder'` (no worktrees / - // branch picking yet); only expose this chip in the dedicated - // agent sessions window. - when: ContextKeyExpr.and(ChatContextKeyExprs.isAgentHostSession, IsSessionsWindowContext), - }], - }); - } - override async run(): Promise { /* the action view item handles interaction */ } -} - -export class OpenAgentHostIsolationPickerAction extends Action2 { - static readonly ID = 'workbench.action.chat.openAgentHostIsolationPicker'; - constructor() { - super({ - id: OpenAgentHostIsolationPickerAction.ID, - title: localize2('agentHost.isolationPicker', "Isolation"), - f1: false, - precondition: ChatContextKeys.enabled, - menu: [{ - id: MenuId.ChatInputSecondary, - group: 'navigation', - order: 101, - when: ContextKeyExpr.and(ChatContextKeyExprs.isAgentHostSession, IsSessionsWindowContext), - }], - }); - } - override async run(): Promise { /* the action view item handles interaction */ } -} - registerAction2(OpenAgentHostModePickerAction); registerAction2(OpenAgentHostAutoApprovePickerAction); registerAction2(OpenAgentHostPermissionModePickerAction); -registerAction2(OpenAgentHostBranchPickerAction); -registerAction2(OpenAgentHostIsolationPickerAction); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index a6e93e75d05d5..f05b460df7f5f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -48,14 +48,6 @@ interface IConfigPickerItem { } function getConfigIcon(property: string, value: unknown | undefined): ThemeIcon | undefined { - if (property === SessionConfigKey.Isolation) { - if (value === 'folder') { return Codicon.folder; } - if (value === 'worktree') { return Codicon.worktree; } - return undefined; - } - if (property === SessionConfigKey.Branch) { - return Codicon.gitBranch; - } if (property === SessionConfigKey.Mode) { switch (value) { case 'plan': return Codicon.checklist; @@ -149,11 +141,10 @@ export function isWellKnownAutoApproveSchema(schema: SessionConfigPropertySchema } /** - * The set of well-known session-config property names that have a dedicated - * picker chip in the secondary toolbar (registered as `MenuId.ChatInputSecondary` - * actions). The generic-fallback chip lane filters these out so unknown - * properties advertised by an agent get their own chip without duplicating - * the dedicated ones. + * The set of well-known session-config property names that are either handled + * by dedicated UI or intentionally hidden from the workbench chat-input chip + * lane. The generic-fallback chip lane filters these out so unknown properties + * advertised by an agent get their own chip. * * `Permissions` has no chip — it is surfaced through other UI — but is * included so the generic lane does not invent a chip for it. @@ -161,19 +152,21 @@ export function isWellKnownAutoApproveSchema(schema: SessionConfigPropertySchema export const WELL_KNOWN_PICKER_PROPERTIES: ReadonlySet = new Set([ SessionConfigKey.Mode, SessionConfigKey.AutoApprove, + SessionConfigKey.Isolation, + SessionConfigKey.Branch, SessionConfigKey.Permissions, ClaudeSessionConfigKey.PermissionMode, ]); /** - * Whether the given `(property, schema)` pair will be rendered by a dedicated - * chip widget on the secondary toolbar. Used by the generic-fallback chip - * lane to decide whether to render a chip for `property`. + * Whether the given `(property, schema)` pair is handled outside the + * generic-fallback chip lane. This includes properties rendered by dedicated + * chip widgets and properties intentionally hidden from workbench chat. * - * For most well-known keys this is purely a property-name check. AutoApprove - * is special: only well-known schema shapes are claimed by the dedicated - * picker; non-conforming schemas (e.g. Claude's approval mode) fall through - * to the generic lane. + * For most well-known keys this is purely a property-name check. AutoApprove is + * special: only well-known schema shapes are claimed by the dedicated picker; + * non-conforming schemas (e.g. Claude's approval mode) fall through to the + * generic lane. */ export function isClaimedByDedicatedPicker(property: string, schema: SessionConfigPropertySchema): boolean { if (property === SessionConfigKey.AutoApprove) { @@ -185,9 +178,8 @@ export function isClaimedByDedicatedPicker(property: string, schema: SessionConf /** * One workbench chat-input chip bound to a single agent-host session-config * property. Used both for dedicated well-known property chips - * (`SessionConfigKey.Mode`, `.Isolation`, `.Branch`, `.AutoApprove`) and for - * generic per-property chips advertised by an agent's config schema but not - * known to VS Code. + * (`SessionConfigKey.Mode`, `.AutoApprove`) and for generic per-property chips + * advertised by an agent's config schema but not known to VS Code. */ export class AgentHostChatInputPicker extends Disposable { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css index 6d64760130670..94a108aafa296 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css @@ -141,29 +141,3 @@ text-overflow: ellipsis; white-space: nowrap; } - -/* - * Right-align the Branch + Isolation chips within the secondary chat-input - * toolbar. The picker tags its host element with a per-property modifier - * class (`agent-host-chat-input-picker-host-branch`) so these selectors can - * target just the right-aligned pair without affecting Mode. Branch (when - * present) takes the leading `margin-left: auto`; Isolation otherwise. With - * both present the Isolation rule is suppressed by the general-sibling - * selector below that detects a preceding (visible) Branch chip. - */ -.chat-secondary-input-toolbar .monaco-action-bar .actions-container > .action-item.agent-host-chat-input-picker-host-branch { - margin-left: auto; -} - -.chat-secondary-input-toolbar .monaco-action-bar .actions-container > .action-item.agent-host-chat-input-picker-host-isolation { - margin-left: auto; -} - -/* - * Suppress Isolation's leading-margin only when a *visible* Branch chip - * precedes it. Hidden chips keep the `agent-host-chat-input-picker-host-hidden` - * marker so this selector can ignore them. - */ -.chat-secondary-input-toolbar .monaco-action-bar .actions-container > .action-item.agent-host-chat-input-picker-host-branch:not(.agent-host-chat-input-picker-host-hidden) ~ .action-item.agent-host-chat-input-picker-host-isolation { - margin-left: 0; -} 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 6582ef8c8bcdb..cfbda9284e03a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -107,7 +107,7 @@ import { ChatEditingShowChangesAction, ViewPreviousEditsAction } from '../../cha import { resizeImage } from '../../chatImageUtils.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../chatSessions/chatSessionPickerActionItem.js'; import { AgentHostChatInputPicker, AgentHostChatInputPickerActionViewItem } from '../../agentSessions/agentHost/agentHostChatInputPicker.js'; -import { OpenAgentHostAutoApprovePickerAction, OpenAgentHostBranchPickerAction, OpenAgentHostIsolationPickerAction, OpenAgentHostModePickerAction, OpenAgentHostPermissionModePickerAction } from '../../agentSessions/agentHost/agentHostChatInputPicker.contribution.js'; +import { OpenAgentHostAutoApprovePickerAction, OpenAgentHostModePickerAction, OpenAgentHostPermissionModePickerAction } from '../../agentSessions/agentHost/agentHostChatInputPicker.contribution.js'; import { AgentHostGenericConfigChips } from '../../agentSessions/agentHost/agentHostGenericConfigChips.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { ClaudeSessionConfigKey } from '../../../../../../platform/agentHost/common/claudeSessionConfigKeys.js'; @@ -2760,8 +2760,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge [OpenAgentHostModePickerAction.ID, 22], [OpenAgentHostAutoApprovePickerAction.ID, 22], [OpenAgentHostPermissionModePickerAction.ID, 22], - [OpenAgentHostBranchPickerAction.ID, 22], - [OpenAgentHostIsolationPickerAction.ID, 22], ['sessions.tunnelHost.toggleSharing', 16], ]); // Direct-rendered chip lane for agent-host config properties that @@ -2871,23 +2869,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } else if ( (action.id === OpenAgentHostModePickerAction.ID || action.id === OpenAgentHostAutoApprovePickerAction.ID - || action.id === OpenAgentHostPermissionModePickerAction.ID - || action.id === OpenAgentHostBranchPickerAction.ID - || action.id === OpenAgentHostIsolationPickerAction.ID) + || action.id === OpenAgentHostPermissionModePickerAction.ID) && action instanceof MenuItemAction ) { if (this.options.isSessionsWindow) { return new HiddenActionViewItem(action); } - const property = action.id === OpenAgentHostBranchPickerAction.ID - ? SessionConfigKey.Branch - : action.id === OpenAgentHostIsolationPickerAction.ID - ? SessionConfigKey.Isolation - : action.id === OpenAgentHostAutoApprovePickerAction.ID - ? SessionConfigKey.AutoApprove - : action.id === OpenAgentHostPermissionModePickerAction.ID - ? ClaudeSessionConfigKey.PermissionMode - : SessionConfigKey.Mode; + const property = action.id === OpenAgentHostAutoApprovePickerAction.ID + ? SessionConfigKey.AutoApprove + : action.id === OpenAgentHostPermissionModePickerAction.ID + ? ClaudeSessionConfigKey.PermissionMode + : SessionConfigKey.Mode; const picker = this.instantiationService.createInstance(AgentHostChatInputPicker, widget, property); return new AgentHostChatInputPickerActionViewItem(action, picker); } else if (action.id === ChatSessionPrimaryPickerAction.ID && action instanceof MenuItemAction) { From db81a24a9eabe181928830b72567387ef1bf81b5 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:36:52 -0400 Subject: [PATCH 19/24] Agents: Indent chat session list items (#320006) Indent session list items by an additional 6px --- src/vs/sessions/contrib/sessions/browser/media/sessionsList.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 14ac0fbde7e2a..ac331b0153f6e 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -91,7 +91,7 @@ flex-direction: row; height: 100%; box-sizing: border-box; - padding: 8px 6px; + padding: 8px 6px 8px 12px; &.archived { color: var(--vscode-descriptionForeground); From 38046b5590ffe0423f67110e27c3157878b82979 Mon Sep 17 00:00:00 2001 From: Vritant Bhardwaj Date: Thu, 4 Jun 2026 22:33:26 -0700 Subject: [PATCH 20/24] Add better guards for Specialized Subagents and BG Todo Agent (#319978) * Run BG Todo Agent only if github copilot is signed in * move away from incorrectly using the `ownsAuthorization` property * add tests * fix type errors * address PR comments * fix tests --- .../src/extension/intents/node/agentIntent.ts | 42 +++++--- .../extension/intents/node/askAgentIntent.ts | 4 +- .../extension/intents/node/editCodeIntent2.ts | 4 +- .../intents/node/notebookEditorIntent.ts | 4 +- .../test/backgroundTodoEnablement.spec.ts | 101 ++++++++++++++---- .../endpoint/vscode-node/extChatEndpoint.ts | 2 - .../platform/networking/common/networking.ts | 24 ++++- .../networking/test/node/networking.spec.ts | 29 ++++- 8 files changed, 168 insertions(+), 42 deletions(-) diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index 166658b9c460f..b9ae19c55c7c0 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -19,7 +19,7 @@ import { IEnvService } from '../../../platform/env/common/envService'; import { ILogService } from '../../../platform/log/common/logService'; import { IEditLogService } from '../../../platform/multiFileEdit/common/editLogService'; import { CUSTOM_TOOL_SEARCH_NAME, isAnthropicContextEditingEnabled } from '../../../platform/networking/common/anthropic'; -import { IChatEndpoint } from '../../../platform/networking/common/networking'; +import { IChatEndpoint, isCAPIEndpoint } from '../../../platform/networking/common/networking'; import { modelsWithoutResponsesContextManagement } from '../../../platform/networking/common/openai'; import { INotebookService } from '../../../platform/notebook/common/notebookService'; import { GenAiMetrics } from '../../../platform/otel/common/genAiMetrics'; @@ -69,6 +69,7 @@ import { getAgentMaxRequests } from '../common/agentConfig'; import { addCacheBreakpoints } from './cacheBreakpoints'; import { EditCodeIntent, EditCodeIntentInvocation, EditCodeIntentInvocationOptions, mergeMetadata, toNewChatReferences } from './editCodeIntent'; import { ToolCallingLoop } from './toolCallingLoop'; +import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; function isResponsesCompactionContextManagementEnabled(endpoint: IChatEndpoint, configurationService: IConfigurationService, experimentationService: IExperimentationService): boolean { return endpoint.apiType === 'responses' @@ -99,8 +100,17 @@ export function isTodoToolExplicitlyEnabled(request: vscode.ChatRequest): boolea * * @internal - exported for testing */ -export function isBackgroundTodoAgentEnabled(configurationService: IConfigurationService, experimentationService: IExperimentationService, request: vscode.ChatRequest): boolean { - return configurationService.getExperimentBasedConfig(ConfigKey.Advanced.BackgroundTodoAgentEnabled, experimentationService) +export function isBackgroundTodoAgentEnabled( + endpoint: IChatEndpoint, + configurationService: IConfigurationService, + experimentationService: IExperimentationService, + authenticationService: IAuthenticationService, + request: vscode.ChatRequest): boolean { + const token = authenticationService.copilotToken; + + // Only enable for a signed in no-free plan user talking to the CAPI endpoint. + const isEnabledForToken = token !== undefined && !token.isFreeUser && !token.isNoAuthUser && isCAPIEndpoint(endpoint); + return isEnabledForToken && configurationService.getExperimentBasedConfig(ConfigKey.Advanced.BackgroundTodoAgentEnabled, experimentationService) && !isTodoToolExplicitlyEnabled(request); } @@ -148,6 +158,8 @@ export const getAgentTools = async (accessor: ServicesAccessor, request: vscode. const experimentationService = accessor.get(IExperimentationService); const endpointProvider = accessor.get(IEndpointProvider); const editToolLearningService = accessor.get(IEditToolLearningService); + const authenticationService = accessor.get(IAuthenticationService); + model ??= await endpointProvider.getChatEndpoint(request); const allowTools: Record = {}; @@ -180,10 +192,9 @@ export const getAgentTools = async (accessor: ServicesAccessor, request: vscode. allowTools[ToolName.CoreRunTest] = await testService.hasAnyTests(); allowTools[ToolName.CoreRunTask] = tasksService.getTasks().length > 0; - // BYOK endpoints that own their `Authorization` have no Copilot token for the - // agentic proxy or override models the subagents rely on, so disable them - // entirely and skip the (otherwise unnecessary) config and endpoint lookups. - if (model.ownsAuthorization) { + // The specialized subagents must only run when + // the main agent is on CAPI. + if (!isCAPIEndpoint(model)) { allowTools[ToolName.SearchSubagent] = false; allowTools[ToolName.ExploreSubagent] = false; allowTools[ToolName.ExecutionSubagent] = false; @@ -222,7 +233,7 @@ export const getAgentTools = async (accessor: ServicesAccessor, request: vscode. allowTools[ToolName.CoreManageTodoList] = false; } - if (isBackgroundTodoAgentEnabled(configurationService, experimentationService, request)) { + if (isBackgroundTodoAgentEnabled(model, configurationService, experimentationService, authenticationService, request)) { allowTools[ToolName.CoreManageTodoList] = false; } @@ -383,12 +394,12 @@ export class AgentIntent extends EditCodeIntent { // Do NOT pass the request `token` as parentToken — it may be cancelled // by the framework after the turn ends, which would immediately abort // the background pass even on a normal completion. - if (isBackgroundTodoAgentEnabled(this.configurationService, this.expService, request)) { - const todoProcessor = this._backgroundTodoProcessors.get(conversation.sessionId); + const todoProcessor = this._backgroundTodoProcessors.get(conversation.sessionId); + if (todoProcessor !== undefined) { const currentTurn = conversation.getLatestTurn(); const invocation = currentTurn.getMetadata(IntentInvocationMetadata)?.value; const executionContext = invocation instanceof AgentIntentInvocation ? invocation.getBackgroundTodoExecutionContext() : undefined; - if (todoProcessor && executionContext) { + if (executionContext) { todoProcessor.requestFinalReview(currentTurn.id, executionContext); await todoProcessor.waitForCompletion(); } @@ -567,6 +578,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I @IAutomodeService private readonly automodeService: IAutomodeService, @IOTelService protected override readonly otelService: IOTelService, @ISessionTranscriptService private readonly sessionTranscriptService: ISessionTranscriptService, + @IAuthenticationService private readonly authenticationService: IAuthenticationService, ) { super(intent, location, endpoint, request, intentOptions, instantiationService, codeMapperService, envService, promptPathRepresentationService, endpointProvider, workspaceService, toolsService, configurationService, editLogService, commandService, telemetryService, notebookService, otelService); } @@ -928,7 +940,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I } // ── Background todo processing ────────────────────────────────── - this._maybeStartBackgroundTodoPass(promptContext, token); + this._maybeStartBackgroundTodoPass(endpoint, promptContext, token); const lastMessage = result.messages.at(-1); if (lastMessage?.role === Raw.ChatRole.User) { @@ -1349,6 +1361,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I * Kick off a background todo pass if the policy says to run. */ private _maybeStartBackgroundTodoPass( + endpoint: IChatEndpoint, promptContext: IBuildPromptContext, token: vscode.CancellationToken, ): void { @@ -1373,10 +1386,9 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I telemetryService: this.telemetryService, promptContext, }; - this._backgroundTodoExecutionContext = executionContext; const { decision, reason, delta } = processor.shouldRun({ - backgroundTodoAgentEnabled: isBackgroundTodoAgentEnabled(this.configurationService, this.expService, this.request), + backgroundTodoAgentEnabled: isBackgroundTodoAgentEnabled(endpoint, this.configurationService, this.expService, this.authenticationService, this.request), todoToolExplicitlyEnabled: isTodoToolExplicitlyEnabled(this.request), isAgentPrompt: this.prompt === AgentPrompt, promptContext, @@ -1387,6 +1399,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I if (decision === BackgroundTodoDecision.Wait && reason === 'processorInProgress' && delta) { // Coalesce into the queue so the latest context is not lost. + this._backgroundTodoExecutionContext = executionContext; processor.requestRegularPass(delta, executionContext, token, turnId); return; } @@ -1395,6 +1408,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I return; } + this._backgroundTodoExecutionContext = executionContext; processor.requestRegularPass(delta, executionContext, token, turnId); } diff --git a/extensions/copilot/src/extension/intents/node/askAgentIntent.ts b/extensions/copilot/src/extension/intents/node/askAgentIntent.ts index 8a8cd2ecdbd72..3a7e1e1a8bcdc 100644 --- a/extensions/copilot/src/extension/intents/node/askAgentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/askAgentIntent.ts @@ -34,6 +34,7 @@ import { ICodeMapperService } from '../../prompts/node/codeMapper/codeMapperServ import { IToolsService } from '../../tools/common/toolsService'; import { getAgentMaxRequests } from '../common/agentConfig'; import { AgentIntentInvocation } from './agentIntent'; +import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; const getTools = (instaService: IInstantiationService, request: vscode.ChatRequest): Promise => @@ -130,8 +131,9 @@ export class AskAgentIntentInvocation extends AgentIntentInvocation { @IAutomodeService automodeService: IAutomodeService, @IOTelService otelService: IOTelService, @ISessionTranscriptService sessionTranscriptService: ISessionTranscriptService, + @IAuthenticationService authenticationService: IAuthenticationService, ) { - super(intent, location, endpoint, request, { processCodeblocks: true }, instantiationService, codeMapperService, envService, promptPathRepresentationService, endpointProvider, workspaceService, toolsService, configurationService, editLogService, commandService, telemetryService, notebookService, logService, expService, automodeService, otelService, sessionTranscriptService); + super(intent, location, endpoint, request, { processCodeblocks: true }, instantiationService, codeMapperService, envService, promptPathRepresentationService, endpointProvider, workspaceService, toolsService, configurationService, editLogService, commandService, telemetryService, notebookService, logService, expService, automodeService, otelService, sessionTranscriptService, authenticationService); } public override async getAvailableTools(): Promise { diff --git a/extensions/copilot/src/extension/intents/node/editCodeIntent2.ts b/extensions/copilot/src/extension/intents/node/editCodeIntent2.ts index 1c9063e066b74..9faff95436a9f 100644 --- a/extensions/copilot/src/extension/intents/node/editCodeIntent2.ts +++ b/extensions/copilot/src/extension/intents/node/editCodeIntent2.ts @@ -31,6 +31,7 @@ import { ToolName } from '../../tools/common/toolNames'; import { IToolsService } from '../../tools/common/toolsService'; import { AgentIntentInvocation } from './agentIntent'; import { EditCodeIntentOptions } from './editCodeIntent'; +import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; const getTools = (instaService: IInstantiationService, request: vscode.ChatRequest): Promise => instaService.invokeFunction(async accessor => { @@ -91,8 +92,9 @@ export class EditCode2IntentInvocation extends AgentIntentInvocation { @IAutomodeService automodeService: IAutomodeService, @IOTelService otelService: IOTelService, @ISessionTranscriptService sessionTranscriptService: ISessionTranscriptService, + @IAuthenticationService authenticationService: IAuthenticationService, ) { - super(intent, location, endpoint, request, intentOptions, instantiationService, codeMapperService, envService, promptPathRepresentationService, endpointProvider, workspaceService, toolsService, configurationService, editLogService, commandService, telemetryService, notebookService, logService, expService, automodeService, otelService, sessionTranscriptService); + super(intent, location, endpoint, request, intentOptions, instantiationService, codeMapperService, envService, promptPathRepresentationService, endpointProvider, workspaceService, toolsService, configurationService, editLogService, commandService, telemetryService, notebookService, logService, expService, automodeService, otelService, sessionTranscriptService, authenticationService); } public override async getAvailableTools(): Promise { diff --git a/extensions/copilot/src/extension/intents/node/notebookEditorIntent.ts b/extensions/copilot/src/extension/intents/node/notebookEditorIntent.ts index a0e71c9757101..b20d95ad7b378 100644 --- a/extensions/copilot/src/extension/intents/node/notebookEditorIntent.ts +++ b/extensions/copilot/src/extension/intents/node/notebookEditorIntent.ts @@ -38,6 +38,7 @@ import { IToolsService } from '../../tools/common/toolsService'; import { getAgentMaxRequests } from '../common/agentConfig'; import { EditCodeIntent, EditCodeIntentOptions } from './editCodeIntent'; import { EditCode2IntentInvocation } from './editCodeIntent2'; +import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; const getTools = (instaService: IInstantiationService, request: vscode.ChatRequest): Promise => instaService.invokeFunction(async accessor => { @@ -109,8 +110,9 @@ export class NotebookEditorIntentInvocation extends EditCode2IntentInvocation { @IAutomodeService automodeService: IAutomodeService, @IOTelService otelService: IOTelService, @ISessionTranscriptService sessionTranscriptService: ISessionTranscriptService, + @IAuthenticationService authenticationService: IAuthenticationService, ) { - super(intent, location, endpoint, request, intentOptions, instantiationService, codeMapperService, envService, promptPathRepresentationService, endpointProvider, workspaceService, toolsService, configurationService, editLogService, commandService, telemetryService, notebookService, logService, expService, automodeService, otelService, sessionTranscriptService); + super(intent, location, endpoint, request, intentOptions, instantiationService, codeMapperService, envService, promptPathRepresentationService, endpointProvider, workspaceService, toolsService, configurationService, editLogService, commandService, telemetryService, notebookService, logService, expService, automodeService, otelService, sessionTranscriptService, authenticationService); } protected override prompt = NotebookInlinePrompt; diff --git a/extensions/copilot/src/extension/intents/node/test/backgroundTodoEnablement.spec.ts b/extensions/copilot/src/extension/intents/node/test/backgroundTodoEnablement.spec.ts index 6bf701fe64427..cad2bd40d8006 100644 --- a/extensions/copilot/src/extension/intents/node/test/backgroundTodoEnablement.spec.ts +++ b/extensions/copilot/src/extension/intents/node/test/backgroundTodoEnablement.spec.ts @@ -3,7 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { RequestType, type RequestMetadata } from '@vscode/copilot-api'; import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest'; +import { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; +import { CopilotToken, createTestExtendedTokenInfo } from '../../../../platform/authentication/common/copilotToken'; +import { setCopilotToken } from '../../../../platform/authentication/common/staticGitHubAuthenticationService'; import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { MockEndpoint } from '../../../../platform/endpoint/test/node/mockEndpoint'; import { IChatEndpoint } from '../../../../platform/networking/common/networking'; @@ -63,13 +67,74 @@ describe('isTodoToolExplicitlyEnabled', () => { }); }); +// ─── isBackgroundTodoAgentEnabled unit tests ───────────────────── + +// The gate only opens for a signed-in, paid user whose request is routed +// through CAPI. Each test flips exactly one of those factors away from an +// otherwise-enabled baseline to confirm it is sufficient to close the gate. + +describe('isBackgroundTodoAgentEnabled', () => { + + // CAPI endpoints carry a `RequestMetadata` object; BYOK/custom endpoints are + // fetched from a literal URL string. + const capiEndpoint = { urlOrRequestMetadata: { type: RequestType.ChatCompletions } } as unknown as IChatEndpoint; + const byokEndpoint = { urlOrRequestMetadata: 'https://api.example.com/v1/chat' } as unknown as IChatEndpoint; + + const paidToken = new CopilotToken(createTestExtendedTokenInfo({ sku: 'copilot_individual', copilot_plan: 'individual' })); + const freeToken = new CopilotToken(createTestExtendedTokenInfo({ sku: 'free_limited_copilot' })); + const noAuthToken = new CopilotToken(createTestExtendedTokenInfo({ sku: 'no_auth_limited_copilot' })); + + function auth(copilotToken: CopilotToken | undefined): IAuthenticationService { + return { copilotToken } as unknown as IAuthenticationService; + } + + function config(experimentEnabled: boolean): IConfigurationService { + return { getExperimentBasedConfig: () => experimentEnabled } as unknown as IConfigurationService; + } + + const expService = {} as IExperimentationService; + + function isEnabled(endpoint: IChatEndpoint, copilotToken: CopilotToken | undefined, experimentEnabled: boolean, request: TestChatRequest = new TestChatRequest('fix the bug')): boolean { + return isBackgroundTodoAgentEnabled(endpoint, config(experimentEnabled), expService, auth(copilotToken), request); + } + + test('enabled for a signed-in paid user on a CAPI endpoint with the experiment on', () => { + expect(isEnabled(capiEndpoint, paidToken, true)).toBe(true); + }); + + test('disabled when the experiment is off', () => { + expect(isEnabled(capiEndpoint, paidToken, false)).toBe(false); + }); + + test('disabled when there is no Copilot token (signed out)', () => { + expect(isEnabled(capiEndpoint, undefined, true)).toBe(false); + }); + + test('disabled for a free-plan user', () => { + expect(isEnabled(capiEndpoint, freeToken, true)).toBe(false); + }); + + test('disabled for a no-auth user', () => { + expect(isEnabled(capiEndpoint, noAuthToken, true)).toBe(false); + }); + + test('disabled on a non-CAPI (BYOK) endpoint', () => { + expect(isEnabled(byokEndpoint, paidToken, true)).toBe(false); + }); + + test('disabled when #todo is explicitly referenced', () => { + const request = new TestChatRequest('fix the bug'); + (request as any).toolReferences = [{ name: 'todo' }]; + expect(isEnabled(capiEndpoint, paidToken, true, request)).toBe(false); + }); +}); + // ─── getAgentTools integration tests for background todo gate ──── describe('getAgentTools background todo enablement', () => { let accessor: ITestingServicesAccessor; let instantiationService: IInstantiationService; let configService: IConfigurationService; - let experimentationService: IExperimentationService; let mockEndpoint: IChatEndpoint; beforeAll(() => { @@ -85,8 +150,14 @@ describe('getAgentTools background todo enablement', () => { accessor = services.createTestingAccessor(); instantiationService = accessor.get(IInstantiationService); configService = accessor.get(IConfigurationService); - experimentationService = accessor.get(IExperimentationService); + + // The background-todo gate only opens for a signed-in paid user whose + // request is routed through CAPI, so set up both for this harness. + const paidToken = new CopilotToken(createTestExtendedTokenInfo({ sku: 'copilot_individual', copilot_plan: 'individual' })); + setCopilotToken(accessor.get(IAuthenticationService), paidToken); + mockEndpoint = instantiationService.createInstance(MockEndpoint, undefined); + (mockEndpoint as unknown as { urlOrRequestMetadata: string | RequestMetadata }).urlOrRequestMetadata = { type: RequestType.ChatCompletions }; }); afterAll(() => { @@ -102,18 +173,6 @@ describe('getAgentTools background todo enablement', () => { return tools.some(t => t.name === ToolName.CoreManageTodoList); } - test('background todo agent is enabled only when experiment is on and todo is not explicit', () => { - const request = new TestChatRequest('fix the bug'); - configService.setConfig(ConfigKey.Advanced.BackgroundTodoAgentEnabled, false); - expect(isBackgroundTodoAgentEnabled(configService, experimentationService, request)).toBe(false); - - configService.setConfig(ConfigKey.Advanced.BackgroundTodoAgentEnabled, true); - expect(isBackgroundTodoAgentEnabled(configService, experimentationService, request)).toBe(true); - - (request as any).toolReferences = [{ name: 'todo' }]; - expect(isBackgroundTodoAgentEnabled(configService, experimentationService, request)).toBe(false); - }); - test('todo tool is not in enabled tools when experiment is on', async () => { configService.setConfig(ConfigKey.Advanced.BackgroundTodoAgentEnabled, true); const request = new TestChatRequest('fix the bug'); @@ -149,10 +208,14 @@ describe('getAgentTools background todo enablement', () => { describe('AgentIntentInvocation._maybeStartBackgroundTodoPass subagent guard', () => { - function getMethod(): (this: unknown, promptContext: unknown, token: unknown) => void { - return (AgentIntentInvocation.prototype as unknown as { _maybeStartBackgroundTodoPass: (this: unknown, promptContext: unknown, token: unknown) => void })._maybeStartBackgroundTodoPass; + function getMethod(): (this: unknown, endpoint: unknown, promptContext: unknown, token: unknown) => void { + return (AgentIntentInvocation.prototype as unknown as { _maybeStartBackgroundTodoPass: (this: unknown, endpoint: unknown, promptContext: unknown, token: unknown) => void })._maybeStartBackgroundTodoPass; } + // The guard short-circuits on `request.subAgentInvocationId` before the + // endpoint is inspected, so a placeholder endpoint suffices for these tests. + const endpoint = {} as unknown as IChatEndpoint; + function makeStub(request: TestChatRequest, processorLookup: () => unknown) { return { request, @@ -176,7 +239,7 @@ describe('AgentIntentInvocation._maybeStartBackgroundTodoPass subagent guard', ( return undefined; }); - getMethod().call(stub, { conversation: { sessionId: 'sess-1' } }, {}); + getMethod().call(stub, endpoint, { conversation: { sessionId: 'sess-1' } }, {}); expect(processorLookups).toBe(0); }); @@ -190,7 +253,7 @@ describe('AgentIntentInvocation._maybeStartBackgroundTodoPass subagent guard', ( return undefined; }); - getMethod().call(stub, { conversation: { sessionId: 'sess-1' } }, {}); + getMethod().call(stub, endpoint, { conversation: { sessionId: 'sess-1' } }, {}); expect(processorLookups).toBe(1); }); @@ -205,7 +268,7 @@ describe('AgentIntentInvocation._maybeStartBackgroundTodoPass subagent guard', ( return undefined; }); - getMethod().call(stub, { conversation: { sessionId: 'sess-1' } }, {}); + getMethod().call(stub, endpoint, { conversation: { sessionId: 'sess-1' } }, {}); expect(processorLookups).toBe(1); }); diff --git a/extensions/copilot/src/platform/endpoint/vscode-node/extChatEndpoint.ts b/extensions/copilot/src/platform/endpoint/vscode-node/extChatEndpoint.ts index 4cf8728a51ea3..eab63306a2127 100644 --- a/extensions/copilot/src/platform/endpoint/vscode-node/extChatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/vscode-node/extChatEndpoint.ts @@ -45,8 +45,6 @@ export class ExtensionContributedChatEndpoint implements IChatEndpoint { public readonly multiplier: number | undefined = undefined; public readonly isExtensionContributed = true; public readonly supportedEditTools?: readonly EndpointEditToolName[] | undefined; - // Extension-contributed endpoints are not backed by the CAPI Copilot token; treat them as owning auth to opt out of token fallback and related tool gating. - public readonly ownsAuthorization = true; constructor( private readonly languageModel: vscode.LanguageModelChat, diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index 398ea915b1d40..fe01f95ee555e 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -150,6 +150,26 @@ export function stringifyUrlOrRequestMetadata(urlOrRequestMetadata: string | Req return JSON.stringify(urlOrRequestMetadata); } +/** + * Whether the given value is {@link RequestMetadata} (routed through CAPI) rather + * than a literal URL string (fetched directly, e.g. BYOK / custom endpoints). + * + * This is the exact discriminant used by `networkRequest`: a `RequestMetadata` + * object is dispatched via {@link ICAPIClientService.makeRequest}, whereas a + * `string` URL is sent straight to {@link IFetcherService.fetch}. + */ +export function isCAPIRequestMetadata(urlOrRequestMetadata: string | RequestMetadata): urlOrRequestMetadata is RequestMetadata { + return typeof urlOrRequestMetadata !== 'string'; +} + +/** + * Whether requests for this endpoint are routed through CAPI (the Copilot proxy) + * rather than fetched directly from a literal URL (BYOK / custom endpoints). + */ +export function isCAPIEndpoint(endpoint: IEndpoint): boolean { + return isCAPIRequestMetadata(endpoint.urlOrRequestMetadata); +} + export interface IEmbeddingsEndpoint extends IEndpoint { readonly maxBatchSize: number; } @@ -504,7 +524,7 @@ function networkRequest( // pass the controller abort signal to the request request.signal = abort.signal; } - if (typeof endpoint.urlOrRequestMetadata === 'string') { + if (!isCAPIRequestMetadata(endpoint.urlOrRequestMetadata)) { const requestPromise = fetcher.fetch(endpoint.urlOrRequestMetadata, request).catch(reason => { if (canRetryOnce && canRetryOnceNetworkError(reason)) { // disconnect and retry the request once if the connection was reset @@ -520,7 +540,7 @@ function networkRequest( }); return requestPromise; } else { - return capiClientService.makeRequest(request, endpoint.urlOrRequestMetadata as RequestMetadata); + return capiClientService.makeRequest(request, endpoint.urlOrRequestMetadata); } } diff --git a/extensions/copilot/src/platform/networking/test/node/networking.spec.ts b/extensions/copilot/src/platform/networking/test/node/networking.spec.ts index 044901f9d7821..de022dd7b233c 100644 --- a/extensions/copilot/src/platform/networking/test/node/networking.spec.ts +++ b/extensions/copilot/src/platform/networking/test/node/networking.spec.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { RequestType } from '@vscode/copilot-api'; +import { RequestType, type RequestMetadata } from '@vscode/copilot-api'; import assert from 'assert'; import { suite, test } from 'vitest'; import { Event } from '../../../../util/vs/base/common/event'; @@ -11,7 +11,7 @@ import { IInstantiationService } from '../../../../util/vs/platform/instantiatio import { createFakeResponse } from '../../../test/node/fetcher'; import { createPlatformServices } from '../../../test/node/services'; import { FetchOptions, IAbortController, IFetcherService, PaginationOptions, Response, WebSocketConnection } from '../../common/fetcherService'; -import { postRequest } from '../../common/networking'; +import { IEndpoint, isCAPIEndpoint, isCAPIRequestMetadata, postRequest } from '../../common/networking'; suite('Networking test Suite', function () { @@ -102,3 +102,28 @@ suite('Networking test Suite', function () { assert.strictEqual('Authorization' in headerBuffer!, false); }); }); + +suite('isCAPIRequestMetadata / isCAPIEndpoint', function () { + + const capiMetadata: RequestMetadata = { type: RequestType.ChatCompletions }; + + function endpointWith(urlOrRequestMetadata: string | RequestMetadata): IEndpoint { + return { urlOrRequestMetadata } as unknown as IEndpoint; + } + + test('isCAPIRequestMetadata is false for a literal URL string', function () { + assert.strictEqual(isCAPIRequestMetadata('https://api.example.com/v1/chat'), false); + }); + + test('isCAPIRequestMetadata is true for RequestMetadata routed through CAPI', function () { + assert.strictEqual(isCAPIRequestMetadata(capiMetadata), true); + }); + + test('isCAPIEndpoint is false for an endpoint fetched from a literal URL (BYOK)', function () { + assert.strictEqual(isCAPIEndpoint(endpointWith('https://api.example.com/v1/chat')), false); + }); + + test('isCAPIEndpoint is true for an endpoint routed through CAPI', function () { + assert.strictEqual(isCAPIEndpoint(endpointWith(capiMetadata)), true); + }); +}); From 557cfb020af518f47c7ea912cb09fc91e6b49c84 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Thu, 4 Jun 2026 23:27:41 -0700 Subject: [PATCH 21/24] chat: stop agent host config update loop across windows (#320015) * chat: stop agent host config update loop across windows The local agent host's root config is shared across all VS Code windows. AgentHostTerminalContribution re-pushed its own managed values (defaultShell, disableCustomTerminalTool) on every rootState.onDidChange, including value changes made by *other* windows. Two windows with different terminal settings would therefore ping-pong RootConfigChanged actions forever (~8000 dispatches in 25s in the captured logs). Fix: only re-push when a managed key's *schema* first appears (host hydration), not on value-only changes from other windows. Also refactor the managed keys into a data-driven table so the schema gate, value-equality guard, dispatch, and hydration-retry are shared - adding a new managed key is now a single entry with no copy/pasted logic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chat: rename disableCustomTerminalTool to enableCustomTerminalTool Flip the agent host root-config key polarity so the custom terminal tool is disabled by default in a natural way. `enableCustomTerminalTool` defaults to false, and since `getRootValue` returns undefined when unset and consumers compare against `true`, "unset -> disabled" now falls out naturally without touching getRootValue. The workbench-side push also drops its negation and maps 1:1 to the `chat.agentHost.customTerminalTool.enabled` setting. Note: this is a host-side schema key (not part of the versioned wire protocol), so no protocol bump. A stale `disableCustomTerminalTool` value in a persisted agent-host-config.json is simply ignored; the workbench re-pushes the new key on connect. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Re-run schema gate after async resolve in _push Addresses review feedback: after awaiting computeValue(), re-check _schemaHasKey() rather than just rootState.config existence, so a host restart / schema refresh that retracts the key can't defeat the schema gate for older / 3rd-party hosts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/agentHostCustomizationConfig.ts | 10 +- .../agentHost/node/copilot/copilotAgent.ts | 4 +- .../node/copilot/copilotAgentSession.ts | 5 +- .../agentHost/test/node/copilotAgent.test.ts | 4 +- .../agentHostTerminalContribution.ts | 225 +++++++++++++----- .../agentHostTerminalContribution.test.ts | 85 ++++++- 6 files changed, 244 insertions(+), 89 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts b/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts index 24c945d31f597..775abcbc8b5b4 100644 --- a/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts +++ b/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts @@ -20,8 +20,8 @@ export const enum AgentHostConfigKey { * TODO: revisit magic key in config; refine into a dedicated typed channel. https://github.com/microsoft/vscode/issues/313812 */ DefaultShell = 'defaultShell', - /** When true, Copilot SDK sessions use the SDK's default terminal behavior instead of Agent Host's terminal tool override. */ - DisableCustomTerminalTool = 'disableCustomTerminalTool', + /** When true, Copilot SDK sessions use Agent Host's custom terminal tool override instead of the SDK's default terminal behavior. Disabled by default. */ + EnableCustomTerminalTool = 'enableCustomTerminalTool', /** When true, Copilot SDK sessions enable the rubber duck critic subagent. */ RubberDuck = 'rubberDuck', } @@ -70,10 +70,10 @@ export const agentHostCustomizationConfigSchema = createSchema({ title: localize('agentHost.config.defaultShell.title', "Default Shell"), description: localize('agentHost.config.defaultShell.description', "Absolute path to the shell executable used by host-managed terminals. Normally pushed by the connected VS Code client from `terminal.integrated.agentHostProfile.` (falling back to `terminal.integrated.defaultProfile.`); when unset, the agent host falls back to the system shell. Only the path is supported; `args` and `env` from the workbench profile are not piped through yet. The workbench only pushes this for the local agent host — remote agent host operators should set this directly in the remote machine's `agent-host-config.json`."), }), - [AgentHostConfigKey.DisableCustomTerminalTool]: schemaProperty({ + [AgentHostConfigKey.EnableCustomTerminalTool]: schemaProperty({ type: 'boolean', - title: localize('agentHost.config.disableCustomTerminalTool.title', "Use SDK Terminal Tool"), - description: localize('agentHost.config.disableCustomTerminalTool.description', "When enabled, Copilot SDK sessions use the SDK's default terminal behavior instead of Agent Host's terminal tool override."), + title: localize('agentHost.config.enableCustomTerminalTool.title', "Use Agent Host Terminal Tool"), + description: localize('agentHost.config.enableCustomTerminalTool.description', "When enabled, Copilot SDK sessions use Agent Host's terminal tool override instead of the SDK's default terminal behavior."), default: false, }), [AgentHostConfigKey.RubberDuck]: schemaProperty({ diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 3961a09755a5e..69cc370dcc00e 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1564,8 +1564,8 @@ export class CopilotAgent extends Disposable implements IAgent { const plugins = snapshot.plugins; return async (callbacks: Parameters[0]) => { - const disableCustomTerminalTool = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.DisableCustomTerminalTool) === true; - const shellTools = disableCustomTerminalTool ? [] : await createShellTools(shellManager, this._terminalManager, this._logService, callbacks.requestUnsandboxedCommandConfirmation); + const enableCustomTerminalTool = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.EnableCustomTerminalTool) === true; + const shellTools = enableCustomTerminalTool ? await createShellTools(shellManager, this._terminalManager, this._logService, callbacks.requestUnsandboxedCommandConfirmation) : []; // Rely on SDK to find all agents/skills & the like from the plugins instead of us feeding them. // Else we could end up with duplicates or the like. const pluginsWithoutDirs = plugins.filter(p => !p.pluginDir || p.pluginDir.scheme !== Schemas.file); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 29e3e9c62d4c9..136ca7ec43aa8 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -1191,14 +1191,15 @@ export class CopilotAgentSession extends Disposable { * SDK's pre-call permission prompt is redundant in that case. * * Returns false when shell tools are not registered (the SDK's built-in - * terminal runs unsandboxed via `AgentHostConfigKey.DisableCustomTerminalTool`) + * terminal runs unsandboxed unless `AgentHostConfigKey.EnableCustomTerminalTool` + * is set) * so the standard confirmation flow is preserved. */ private async _isShellSandboxedByDefault(): Promise { if (!this._shellManager) { return false; } - if (this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.DisableCustomTerminalTool) === true) { + if (this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.EnableCustomTerminalTool) !== true) { return false; } return this._shellManager.getOrCreateSandboxEngine().isEnabled(); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 5b15e53336c0e..93b6c7e786c03 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -511,7 +511,7 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); await agent.listSessions(); - configurationService.updateRootConfig({ [AgentHostConfigKey.DisableCustomTerminalTool]: true }); + configurationService.updateRootConfig({ [AgentHostConfigKey.EnableCustomTerminalTool]: true }); await Promise.resolve(); assert.strictEqual(client.stopCount, 0); @@ -1048,7 +1048,7 @@ suite('CopilotAgent', () => { const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client }); try { await agent.authenticate('https://api.github.com', 'token'); - configurationService.updateRootConfig({ [AgentHostConfigKey.DisableCustomTerminalTool]: true }); + configurationService.updateRootConfig({ [AgentHostConfigKey.EnableCustomTerminalTool]: false }); const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'sdk-terminal-defaults'), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts index c2bf3db0c1cb1..19da2678097c6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts @@ -29,6 +29,31 @@ const AGENT_HOST_SHELL_DEPENDENT_SETTINGS = [ TerminalSettingId.ProfilesWindows, ]; +/** + * A single agent-host root-config key managed by this contribution. + * + * The shared machinery in {@link AgentHostTerminalContribution} handles the + * schema gate, the value-equality guard, the dispatch, and the + * hydration-retry. A descriptor only has to say *what* its value is and *when* + * to recompute it - adding a new managed key is one entry, no copy/paste. + */ +interface IManagedRootConfigKey { + /** The root-config key this descriptor owns. */ + readonly key: AgentHostConfigKey; + + /** + * Compute the desired value for {@link key}. Return `undefined` to skip the + * push (e.g. the value can't be resolved yet). May be async. + */ + computeValue(): unknown | Promise; + + /** + * Wire up the events / settings whose change should re-push this key. + * Disposables go in `store`; call `push` to trigger a re-push. + */ + registerTriggers(store: DisposableStore, push: () => void): void; +} + /** * Registers local agent host terminal entries with * {@link IAgentHostTerminalService} so they appear in the terminal dropdown. @@ -41,6 +66,16 @@ export class AgentHostTerminalContribution extends Disposable implements IWorkbe private readonly _localEntry = this._register(new MutableDisposable()); private readonly _conditionalListeners = this._register(new MutableDisposable()); + /** Declarative table of the root-config keys we manage. */ + private readonly _managedKeys: readonly IManagedRootConfigKey[]; + + /** + * Managed keys whose schema the host has already advertised. Used to re-push + * only when a key's schema *first* appears (hydration) rather than on every + * root-state change - see {@link _onRootStateChanged}. + */ + private readonly _schemaSeen = new Set(); + constructor( @IAgentHostService private readonly _agentHostService: IAgentHostService, @IAgentHostTerminalService private readonly _agentHostTerminalService: IAgentHostTerminalService, @@ -50,6 +85,32 @@ export class AgentHostTerminalContribution extends Disposable implements IWorkbe ) { super(); + this._managedKeys = [ + { + key: AgentHostConfigKey.DefaultShell, + computeValue: () => this._resolveDefaultShell(), + registerTriggers: (store, push) => { + store.add(this._configurationService.onDidChangeConfiguration(e => { + if (AGENT_HOST_SHELL_DEPENDENT_SETTINGS.some(s => e.affectsConfiguration(s))) { + push(); + } + })); + store.add(this._terminalProfileService.onDidChangeAvailableProfiles(() => push())); + }, + }, + { + key: AgentHostConfigKey.EnableCustomTerminalTool, + computeValue: () => this._configurationService.getValue(AgentHostCustomTerminalToolEnabledSettingId) === true, + registerTriggers: (store, push) => { + store.add(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(AgentHostCustomTerminalToolEnabledSettingId)) { + push(); + } + })); + }, + }, + ]; + this._register(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(AgentHostEnabledSettingId)) { this._updateEnabled(); @@ -64,27 +125,34 @@ export class AgentHostTerminalContribution extends Disposable implements IWorkbe if (!this._conditionalListeners.value) { const store = new DisposableStore(); store.add(this._agentHostService.onAgentHostStart(() => this._reconcile())); - store.add(this._configurationService.onDidChangeConfiguration(e => { - if (AGENT_HOST_SHELL_DEPENDENT_SETTINGS.some(s => e.affectsConfiguration(s))) { - this._pushDefaultShell(); - } - if (e.affectsConfiguration(AgentHostCustomTerminalToolEnabledSettingId)) { - this._pushCustomTerminalToolEnabled(); + for (const entry of this._managedKeys) { + entry.registerTriggers(store, () => this._push(entry)); + } + // Re-push only when the host's root-config *schema* first + // advertises a key we manage. The initial push from + // `_reconcile()` may race an undefined `rootState.value` (schema + // not yet hydrated); this retries once the schema arrives. + // + // We deliberately do NOT re-push on every root-state change: the + // local agent host's root config is shared across windows, so + // reacting to *value* changes pushed by another window would + // start an infinite update war - each window forcing its own + // value back over the other's. See #314385 follow-up. + store.add(this._agentHostService.rootState.onDidChange(() => this._onRootStateChanged())); + // Seed the schema-seen set from the current state so the + // immediate `_reconcile()` below counts as the initial push for + // whatever keys are already advertised. + this._schemaSeen.clear(); + for (const entry of this._managedKeys) { + if (this._schemaHasKey(entry.key)) { + this._schemaSeen.add(entry.key); } - })); - store.add(this._terminalProfileService.onDidChangeAvailableProfiles(() => this._pushDefaultShell())); - // Retry the push when the host's root state hydrates or its schema - // changes - the initial push from `_reconcile()` may have raced an - // undefined `rootState.value`, in which case the schema gate below - // in `_pushDefaultShell` returned early. - store.add(this._agentHostService.rootState.onDidChange(() => { - this._pushDefaultShell(); - this._pushCustomTerminalToolEnabled(); - })); + } this._conditionalListeners.value = store; this._reconcile(); } } else { + this._schemaSeen.clear(); this._conditionalListeners.value = undefined; this._localEntry.value = undefined; } @@ -98,84 +166,109 @@ export class AgentHostTerminalContribution extends Disposable implements IWorkbe getConnection: () => this._agentHostService, }); } - this._pushDefaultShell(); - this._pushCustomTerminalToolEnabled(); + for (const entry of this._managedKeys) { + this._push(entry); + } } /** - * Resolve the agent host terminal profile (with `defaultProfile.` - * fallback) and push the shell path into the agent host's root config so - * its host-managed shells inherit the user's preferred terminal binary. + * Push managed values only for keys whose schema has just transitioned from + * absent to present (host root-config hydration). Value-only changes - e.g. + * another window writing a different value into the shared root config - are + * intentionally ignored so multiple windows don't fight in an infinite loop. + */ + private _onRootStateChanged(): void { + for (const entry of this._managedKeys) { + if (this._schemaHasKey(entry.key)) { + if (!this._schemaSeen.has(entry.key)) { + this._schemaSeen.add(entry.key); + this._push(entry); + } + } else { + this._schemaSeen.delete(entry.key); + } + } + } + + private _schemaHasKey(key: AgentHostConfigKey): boolean { + const rootState = this._agentHostService.rootState.value; + if (!rootState || rootState instanceof Error) { + return false; + } + return !!rootState.config?.schema.properties[key]; + } + + /** + * Shared push pipeline for a managed root-config key: * - * No-ops if the host's root-config schema doesn't advertise - * `AgentHostConfigKey.DefaultShell` - protects older / third-party - * agent hosts from receiving keys they don't understand. The push is - * retried automatically when `rootState` hydrates (see `_updateEnabled`). + * 1. No-op if the host's root-config schema doesn't advertise the key - + * protects older / third-party agent hosts from receiving keys they + * don't understand. The push is retried automatically when `rootState` + * hydrates (see {@link _onRootStateChanged}). + * 2. Compute the desired value (may be async); `undefined` skips the push. + * 3. Skip if the host already holds that value (avoids redundant dispatches + * and, critically, breaks cross-window update loops - see #314385). * * Local agent host only. Remote agent hosts (via * `IRemoteAgentHostService.connections`) are intentionally not fanned out - * to: the resolved path is local-machine-shaped (e.g. a Windows path) and - * not necessarily valid on the remote machine. Remote operators should - * configure the shell server-side via the remote's `agent-host-config.json`. - * See https://github.com/microsoft/vscode/issues/313160 follow-ups. + * to: e.g. the resolved shell path is local-machine-shaped (a Windows path) + * and not necessarily valid on the remote machine. Remote operators should + * configure such values server-side via the remote's + * `agent-host-config.json`. See + * https://github.com/microsoft/vscode/issues/313160 follow-ups. */ - private async _pushDefaultShell(): Promise { - const rootState = this._agentHostService.rootState.value; - if (!rootState || rootState instanceof Error) { - return; - } - if (!rootState.config?.schema.properties[AgentHostConfigKey.DefaultShell]) { + private async _push(entry: IManagedRootConfigKey): Promise { + if (!this._schemaHasKey(entry.key)) { return; } - let profile; + let value: unknown; try { - profile = await this._terminalProfileResolverService.getDefaultProfile({ - remoteAuthority: undefined, - os: OS, - allowAgentHostShell: true, - }); + value = await entry.computeValue(); } catch { return; } - - if (!profile.path) { + if (value === undefined) { return; } - const currentRootState = this._agentHostService.rootState.value; - if (!currentRootState || currentRootState instanceof Error) { + // Re-check after the await: a host restart / schema refresh may have + // landed while we resolved. Re-run the schema gate (not just a config + // existence check) so we never dispatch a key the *current* schema no + // longer advertises - protects older / 3rd-party hosts. + if (!this._schemaHasKey(entry.key)) { return; } - - // Fix #314385 - if (rootState.config.values[AgentHostConfigKey.DefaultShell] === profile.path) { - return; - } - - this._agentHostService.dispatch(ROOT_STATE_URI, { - type: ActionType.RootConfigChanged, - config: { [AgentHostConfigKey.DefaultShell]: profile.path }, - }); - } - - private _pushCustomTerminalToolEnabled(): void { const rootState = this._agentHostService.rootState.value; - if (!rootState || rootState instanceof Error) { + if (!rootState || rootState instanceof Error || !rootState.config) { return; } - if (!rootState.config?.schema.properties[AgentHostConfigKey.DisableCustomTerminalTool]) { - return; - } - - const disableCustomTerminalTool = !this._configurationService.getValue(AgentHostCustomTerminalToolEnabledSettingId); - if (rootState.config.values[AgentHostConfigKey.DisableCustomTerminalTool] === disableCustomTerminalTool) { + if (rootState.config.values[entry.key] === value) { return; } this._agentHostService.dispatch(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, - config: { [AgentHostConfigKey.DisableCustomTerminalTool]: disableCustomTerminalTool }, + config: { [entry.key]: value }, }); } + + /** + * Resolve the agent host terminal profile (with `defaultProfile.` + * fallback) so its host-managed shells inherit the user's preferred terminal + * binary. Returns `undefined` when no usable path can be resolved. + */ + private async _resolveDefaultShell(): Promise { + let profile; + try { + profile = await this._terminalProfileResolverService.getDefaultProfile({ + remoteAuthority: undefined, + os: OS, + allowAgentHostShell: true, + }); + } catch { + return undefined; + } + return profile.path || undefined; + } } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts index 51873fecd7714..a0ecca9a490cd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts @@ -91,8 +91,12 @@ class MockTerminalProfileResolverService extends mock void) | undefined; + override async getDefaultProfile(options: IShellLaunchConfigResolveOptions): Promise { this.lastOptions = options; + this.onResolve?.(); if (this.profile instanceof Error) { throw this.profile; } @@ -143,9 +147,9 @@ function rootStateWithoutDefaultShellKey(): RootState { }); } -function rootStateWithDisableCustomTerminalToolKey(): RootState { +function rootStateWithEnableCustomTerminalToolKey(): RootState { return makeRootStateWithSchema({ - [AgentHostConfigKey.DisableCustomTerminalTool]: { type: 'boolean', title: 'Use SDK Terminal Tool' }, + [AgentHostConfigKey.EnableCustomTerminalTool]: { type: 'boolean', title: 'Use Agent Host Terminal Tool' }, }); } @@ -324,6 +328,23 @@ suite('AgentHostTerminalContribution', () => { assert.deepStrictEqual(agentHostService.dispatchedActions, []); }); + test('skips dispatch when the schema retracts the key while resolving', async () => { + const { agentHostService, resolver } = setup(disposables); + resolver.profile = { profileName: 'Bash', path: '/usr/bin/bash', args: [], isDefault: true }; + + // While getDefaultProfile is in flight (e.g. a host restart / schema + // refresh lands), swap to a schema that no longer advertises + // defaultShell. The post-await schema gate must catch this and bail. + resolver.onResolve = () => { + agentHostService.setRootState(rootStateWithoutDefaultShellKey()); + }; + + agentHostService.setRootState(rootStateWithDefaultShellKey()); + await flush(); + + assert.deepStrictEqual(agentHostService.dispatchedActions, []); + }); + test('uses the local OS when resolving the profile', async () => { const { agentHostService, resolver } = setup(disposables); agentHostService.setRootState(rootStateWithDefaultShellKey()); @@ -333,35 +354,35 @@ suite('AgentHostTerminalContribution', () => { assert.strictEqual(resolver.lastOptions?.remoteAuthority, undefined); }); - test('dispatches inverted disableCustomTerminalTool from the VS Code setting', async () => { + test('dispatches enableCustomTerminalTool from the VS Code setting', async () => { const { agentHostService, configurationService } = setup(disposables); configurationService.setUserConfiguration(AgentHostCustomTerminalToolEnabledSettingId, false); - agentHostService.setRootState(rootStateWithDisableCustomTerminalToolKey()); + agentHostService.setRootState(rootStateWithEnableCustomTerminalToolKey()); await flush(); assert.strictEqual(agentHostService.dispatchedActions.length, 1); assert.deepStrictEqual((agentHostService.dispatchedActions[0].action as IRootConfigChangedAction).config, { - [AgentHostConfigKey.DisableCustomTerminalTool]: true, + [AgentHostConfigKey.EnableCustomTerminalTool]: false, }); }); - test('dispatches disableCustomTerminalTool false by default', async () => { + test('dispatches enableCustomTerminalTool true when the setting is enabled', async () => { const { agentHostService } = setup(disposables); - agentHostService.setRootState(rootStateWithDisableCustomTerminalToolKey()); + agentHostService.setRootState(rootStateWithEnableCustomTerminalToolKey()); await flush(); assert.strictEqual(agentHostService.dispatchedActions.length, 1); assert.deepStrictEqual((agentHostService.dispatchedActions[0].action as IRootConfigChangedAction).config, { - [AgentHostConfigKey.DisableCustomTerminalTool]: false, + [AgentHostConfigKey.EnableCustomTerminalTool]: true, }); }); - test('re-dispatches disableCustomTerminalTool when the enabled setting changes', async () => { + test('re-dispatches enableCustomTerminalTool when the enabled setting changes', async () => { const { agentHostService, configurationService } = setup(disposables); - const rootState = rootStateWithDisableCustomTerminalToolKey(); - rootState.config!.values[AgentHostConfigKey.DisableCustomTerminalTool] = false; + const rootState = rootStateWithEnableCustomTerminalToolKey(); + rootState.config!.values[AgentHostConfigKey.EnableCustomTerminalTool] = true; agentHostService.setRootState(rootState); await flush(); assert.deepStrictEqual(agentHostService.dispatchedActions as readonly unknown[], []); @@ -373,11 +394,51 @@ suite('AgentHostTerminalContribution', () => { source: 1, // ConfigurationTarget.USER change: { keys: [AgentHostCustomTerminalToolEnabledSettingId], overrides: [] }, }); + await flush(); assert.strictEqual(agentHostService.dispatchedActions.length, 1); assert.deepStrictEqual((agentHostService.dispatchedActions[0].action as IRootConfigChangedAction).config, { - [AgentHostConfigKey.DisableCustomTerminalTool]: true, + [AgentHostConfigKey.EnableCustomTerminalTool]: false, }); }); + + test('does not re-dispatch when another window changes the shared root config value (no schema change)', async () => { + const { agentHostService } = setup(disposables); + + // Schema hydrates → initial push for defaultShell. + agentHostService.setRootState(rootStateWithDefaultShellKey()); + await flush(); + assert.strictEqual(agentHostService.dispatchedActions.length, 1); + + // Another window writes a *different* value into the shared root config. + // The schema is unchanged - only the value differs. This must NOT trigger + // a re-push, otherwise two windows with different settings ping-pong + // forever (the loop this guards against). + const updated = rootStateWithDefaultShellKey(); + updated.config!.values[AgentHostConfigKey.DefaultShell] = 'C:/other/window/shell.exe'; + agentHostService.setRootState(updated); + await flush(); + + assert.strictEqual(agentHostService.dispatchedActions.length, 1); + }); + + test('does not re-dispatch enableCustomTerminalTool on a value-only root-state change', async () => { + const { agentHostService } = setup(disposables); + + // Schema hydrates with our preferred value already present → no push. + const rootState = rootStateWithEnableCustomTerminalToolKey(); + rootState.config!.values[AgentHostConfigKey.EnableCustomTerminalTool] = true; + agentHostService.setRootState(rootState); + await flush(); + assert.deepStrictEqual(agentHostService.dispatchedActions as readonly unknown[], []); + + // Another window flips the shared value. Schema unchanged → no fight. + const updated = rootStateWithEnableCustomTerminalToolKey(); + updated.config!.values[AgentHostConfigKey.EnableCustomTerminalTool] = false; + agentHostService.setRootState(updated); + await flush(); + + assert.deepStrictEqual(agentHostService.dispatchedActions as readonly unknown[], []); + }); }); From 628f6fe5e46f1436cde1fb1eb1d37c1906f997c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaqu=C3=ADn=20Ruales?= <1588988+jruales@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:28:10 -0700 Subject: [PATCH 22/24] Browser: Match ExP value for setting `workbench.browser.enableChatTools` (#319465) --- build/lib/policies/policyData.jsonc | 2 +- .../features/browserEditorChatFeatures.ts | 2 +- .../contrib/chat/browser/chatTipCatalog.ts | 19 ------------------- .../chat/test/browser/chatTipService.test.ts | 2 -- 4 files changed, 2 insertions(+), 23 deletions(-) diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 180f1329ae5a5..1eaa84bfaa14d 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -556,7 +556,7 @@ } }, "type": "boolean", - "default": false, + "default": true, "included": true }, { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index 6554b65214964..f4131a0eee40d 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -740,7 +740,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis properties: { 'workbench.browser.enableChatTools': { type: 'boolean', - default: false, + default: true, experiment: { mode: 'startup' }, tags: ['experimental'], markdownDescription: localize( diff --git a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts index 60420df2f0efb..78ac248fc8ef6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts @@ -329,25 +329,6 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ TipTrackingCommands.ForkConversationUsed, ], }, - { - id: 'tip.agenticBrowser', - tier: ChatTipTier.Qol, - buildMessage() { - return new MarkdownString( - localize( - 'tip.agenticBrowser', - "Enable [{0}](command:workbench.action.openSettings?%5B%22workbench.browser.enableChatTools%22%5D \"Open Settings\") to let the agent open and interact with pages in the Integrated Browser.", - 'agentic browser integration' - ) - ); - }, - when: ContextKeyExpr.and( - ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - ContextKeyExpr.notEquals('config.workbench.browser.enableChatTools', true), - ), - excludeWhenSettingsChanged: ['workbench.browser.enableChatTools'], - dismissWhenCommandsClicked: ['workbench.action.openSettings'], - }, { id: 'tip.mermaid', tier: ChatTipTier.Qol, diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index b3fed0d43ee80..624bbfeb9d7a7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -1445,7 +1445,6 @@ suite('ChatTipService', () => { for (const { tipId, settingKey } of [ { tipId: 'tip.thinkingPhrases', settingKey: 'chat.agent.thinking.phrases' }, - { tipId: 'tip.agenticBrowser', settingKey: 'workbench.browser.enableChatTools' }, ]) { test(`shows ${tipId} with correct setting link when setting is at default`, async () => { const service = createService(); @@ -1470,7 +1469,6 @@ suite('ChatTipService', () => { for (const tipId of [ 'tip.thinkingPhrases', - 'tip.agenticBrowser', ]) { test(`dismisses ${tipId} after clicking its settings link`, async () => { const service = createService(); From c1c20ad0c19f58c5e51664e87d54faa880b25fc5 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 4 Jun 2026 23:52:24 -0700 Subject: [PATCH 23/24] agentHost: support attaching virtual resources (untitled, notebook cells) (#320040) * agentHost: support attaching virtual resources (untitled, notebook cells) Consolidates the agent host's permission and virtual-resource services into a single `IAgentHostResourceService` that owns the gated FS surface (`list`/`read`/`write`/`del`/`move`/`copy`/`resolve`/`mkdir`), the permission policy, and an `ITextModelService`-backed fallback for content that isn't on disk. Both the in-process local channel and the remote protocol client's reverse-RPC handler now reduce to a thin wire adapter that dispatches frames to the same service. - Adds a single `IAgentHostResourceService` (platform decorator + types, workbench implementation) replacing `IAgentHostPermissionService` and `IAgentHostVirtualResourceProvider`. - `read`, `write`, and `resolve` (stat) transparently fall back to `ITextModelService` when `IFileService` cannot satisfy the request, so attached untitled documents and notebook cells round-trip end to end. - `AgentHostClientResourceChannel` (local in-process) and `RemoteAgentHostProtocolClient._handleReverseRequest` (remote) shrink to thin adapters that translate JSON-RPC frames into service calls and surface `AgentHostResourcePermissionError` as `PermissionDenied` frames so the host's standard `resourceRequest` -> retry loop still works. - Local agent host short-circuits the permission gate (sentinel address `'local'`): the utility process already has the renderer's FS access, so gating it adds no security and would just produce unprompted denials. - `createAgentHostClientResourceConnection` now exposes `resourceRequest`, completing the local prompt/retry loop. - `agentClientUri` preserves `query` and `fragment` across the round trip and uses a `!` scheme-slot marker to faithfully round-trip opaque-path URIs such as `untitled:Untitled-1`. Fixes https://github.com/microsoft/vscode/issues/319802 (Commit message generated by Copilot) * Address Copilot review and update tests for virtual-resource attachments --- .../browser/remoteAgentHostProtocolClient.ts | 267 ++++++----------- .../agentHost/common/agentClientUri.ts | 34 ++- .../common/agentHostClientResourceChannel.ts | 197 ++++++------ .../common/agentHostPermissionService.ts | 102 ------- .../common/agentHostResourceService.ts | 168 +++++++++++ .../electron-browser/localAgentHostService.ts | 8 +- .../platform/agentHost/node/agentService.ts | 8 +- .../test/common/agentClientUri.test.ts | 33 ++ .../agentHostClientResourceChannel.test.ts | 138 +++++++++ .../localAhpJsonlLogging.test.ts | 32 -- .../remoteAgentHostProtocolClient.test.ts | 95 +++--- .../browser/remoteAgentHost.contribution.ts | 2 +- src/vs/sessions/sessions.common.main.ts | 2 +- .../agentHostPermissionUiContribution.ts | 14 +- .../agentHost/agentHostSessionHandler.ts | 32 +- .../agentHostChatContribution.test.ts | 7 +- .../agentHostPermissionUiContribution.test.ts | 16 +- ...Service.ts => agentHostResourceService.ts} | 281 ++++++++++++++---- .../electron-browser/agentHostService.ts | 2 +- ...st.ts => agentHostResourceService.test.ts} | 20 +- src/vs/workbench/workbench.common.main.ts | 2 +- 21 files changed, 898 insertions(+), 562 deletions(-) delete mode 100644 src/vs/platform/agentHost/common/agentHostPermissionService.ts create mode 100644 src/vs/platform/agentHost/common/agentHostResourceService.ts create mode 100644 src/vs/platform/agentHost/test/common/agentHostClientResourceChannel.test.ts rename src/vs/workbench/services/agentHost/common/{agentHostPermissionService.ts => agentHostResourceService.ts} (57%) rename src/vs/workbench/services/agentHost/test/common/{agentHostPermissionService.test.ts => agentHostResourceService.test.ts} (96%) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 8a43ae5f3c9f6..6b362b674f2cb 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -16,13 +16,13 @@ import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; -import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js'; +import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../../files/common/files.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agentService.js'; import { createRemoteWatchHandle, type IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; import { AgentSubscriptionManager, type IAgentSubscription } from '../common/state/agentSubscription.js'; import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.js'; -import { AgentHostPermissionMode, IAgentHostPermissionService } from '../common/agentHostPermissionService.js'; +import { AgentHostResourcePermissionError, IAgentHostResourceService } from '../common/agentHostResourceService.js'; import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest } from '../common/state/protocol/messages.js'; import { ActionType, type ActionEnvelope, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import { SessionSummary, SessionStatus, ROOT_STATE_URI, StateComponents, isAhpRootChannel, type ClientPluginCustomization, type RootState } from '../common/state/sessionState.js'; @@ -33,7 +33,7 @@ import { isClientTransport, type IProtocolTransport } from '../common/state/sess import { AhpErrorCodes } from '../common/state/protocol/errors.js'; import { ContentEncoding, ResourceRequestParams, type CompletionsParams, type CompletionsResult, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; -import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; +import { encodeBase64 } from '../../../base/common/buffer.js'; import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ipc.net.js'; import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; @@ -286,8 +286,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC transportOrFactory: IProtocolTransport | (() => IProtocolTransport), loadEstimator: ILoadEstimator | undefined, @ILogService private readonly _logService: ILogService, - @IFileService private readonly _fileService: IFileService, - @IAgentHostPermissionService private readonly _permissionService: IAgentHostPermissionService, + @IAgentHostResourceService private readonly _resourceService: IAgentHostResourceService, @IConfigurationService private readonly _configurationService: IConfigurationService, ) { super(); @@ -893,9 +892,9 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC continue; } this._grantedCustomizationUris.add(key); - // Disposable is owned by the permission service; cleared on + // Disposable is owned by the resource service; cleared on // connectionClosed. - this._permissionService.grantImplicitRead(this._address, grantUri); + this._resourceService.grantImplicitRead(this._address, grantUri); } } @@ -1060,7 +1059,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC // transition below. } this._rejectPendingRequests(error); - this._permissionService.connectionClosed(this._address); + this._resourceService.connectionClosed(this._address); this._grantedCustomizationUris.clear(); this._transitionTo({ kind: AgentHostClientState.Closed, error }); this._onDidClose.fire(); @@ -1085,13 +1084,10 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC /** * Handles reverse RPC requests from the server (e.g. resourceList, - * resourceRead). Reads from the local file service and sends a response. - * - * Filesystem-mutating reverse requests are gated through - * {@link IAgentHostPermissionService} — denied operations return a typed - * `PermissionDenied` error advertising a `resourceRequest` payload that, - * if granted, would unlock the operation. Hosts SHOULD then issue a - * `resourceRequest` and retry. + * resourceRead). Thin wire adapter — dispatches each frame to + * {@link IAgentHostResourceService} (which owns gating, virtual reads, + * and the user-prompt flow) and translates results / errors back into + * JSON-RPC frames. */ private _handleReverseRequest(id: number, method: string, params: unknown): void { // Capture the transport at request-entry so async handlers (permission @@ -1104,6 +1100,18 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC transport.send({ jsonrpc: '2.0', id, result }); }; const sendError = (err: unknown) => { + if (err instanceof AgentHostResourcePermissionError) { + transport.send({ + jsonrpc: '2.0', + id, + error: { + code: AhpErrorCodes.PermissionDenied, + message: err.message, + data: err.request ? { request: err.request } : undefined, + }, + }); + return; + } const fsCode = toFileSystemProviderErrorCode(err instanceof Error ? err : undefined); let code = -32000; switch (fsCode) { @@ -1113,177 +1121,80 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } transport.send({ jsonrpc: '2.0', id, error: { code, message: err instanceof Error ? err.message : String(err) } }); }; - const sendPermissionDenied = (request: ResourceRequestParams | undefined) => { - transport.send({ - jsonrpc: '2.0', - id, - error: { - code: AhpErrorCodes.PermissionDenied, - message: request - ? `Access to ${request.uri} is not granted.` - : 'Access to the requested resource is not granted.', - data: request ? { request } : undefined, - }, - }); - }; - /** - * Runs `fn` if the permission service grants access for `(uri, mode)`. - * Otherwise replies with `PermissionDenied` advertising the request - * that, if granted, would unlock the operation. Errors thrown from - * `fn` are reported via `sendError`. - */ - const gateAndHandle = async ( - uri: URI, - mode: AgentHostPermissionMode, - deniedRequest: ResourceRequestParams, - fn: () => Promise, - ): Promise => { + const p = (params ?? {}) as Record; + const addr = this._address; + void (async () => { try { - if (!await this._permissionService.check(this._address, uri, mode)) { - sendPermissionDenied(deniedRequest); - return; - } - sendResult(await fn()); - } catch (err) { - sendError(err); - } - }; - - const p = params as Record; - switch (method) { - case 'resourceList': { - if (!p.uri) { sendError(new Error('Missing uri')); return; } - const uri = URI.parse(p.uri as string); - return void gateAndHandle(uri, AgentHostPermissionMode.Read, { channel: ROOT_STATE_URI, uri: uri.toString(), read: true }, async () => { - const stat = await this._fileService.resolve(uri); - return { entries: (stat.children ?? []).map(c => ({ name: c.name, type: c.isDirectory ? 'directory' as const : 'file' as const })) }; - }); - } - case 'resourceRead': { - if (!p.uri) { sendError(new Error('Missing uri')); return; } - const uri = URI.parse(p.uri as string); - return void gateAndHandle(uri, AgentHostPermissionMode.Read, { channel: ROOT_STATE_URI, uri: uri.toString(), read: true }, async () => { - const content = await this._fileService.readFile(uri); - return { data: encodeBase64(content.value), encoding: ContentEncoding.Base64 }; - }); - } - case 'resourceWrite': { - if (!p.uri || !p.data) { sendError(new Error('Missing uri or data')); return; } - const writeUri = URI.parse(p.uri as string); - return void gateAndHandle(writeUri, AgentHostPermissionMode.Write, { channel: ROOT_STATE_URI, uri: writeUri.toString(), write: true }, async () => { - const buf = p.encoding === ContentEncoding.Base64 - ? decodeBase64(p.data as string) - : VSBuffer.fromString(p.data as string); - if (p.createOnly) { - await this._fileService.createFile(writeUri, buf, { overwrite: false }); - } else { - await this._fileService.writeFile(writeUri, buf); + switch (method) { + case 'resourceList': { + if (!p.uri) { throw new Error('Missing uri'); } + const result = await this._resourceService.list(addr, URI.parse(p.uri as string)); + sendResult({ entries: result.entries }); + return; } - return {}; - }); - } - case 'resourceDelete': { - if (!p.uri) { sendError(new Error('Missing uri')); return; } - const deleteUri = URI.parse(p.uri as string); - return void gateAndHandle(deleteUri, AgentHostPermissionMode.Write, { channel: ROOT_STATE_URI, uri: deleteUri.toString(), write: true }, () => - this._fileService.del(deleteUri, { recursive: !!p.recursive }).then(() => ({}))); - } - case 'resourceMove': { - if (!p.source || !p.destination) { sendError(new Error('Missing source or destination')); return; } - const sourceUri = URI.parse(p.source as string); - const destUri = URI.parse(p.destination as string); - return void (async () => { - try { - const [sourceOk, destOk] = await Promise.all([ - this._permissionService.check(this._address, sourceUri, AgentHostPermissionMode.Write), - this._permissionService.check(this._address, destUri, AgentHostPermissionMode.Write), - ]); - if (!sourceOk) { - sendPermissionDenied({ channel: ROOT_STATE_URI, uri: sourceUri.toString(), write: true }); - return; - } - if (!destOk) { - sendPermissionDenied({ channel: ROOT_STATE_URI, uri: destUri.toString(), write: true }); - return; - } - await this._fileService.move(sourceUri, destUri, !p.failIfExists); + case 'resourceRead': { + if (!p.uri) { throw new Error('Missing uri'); } + const result = await this._resourceService.read(addr, URI.parse(p.uri as string)); + sendResult({ data: encodeBase64(result.bytes), encoding: ContentEncoding.Base64 }); + return; + } + case 'resourceWrite': { + if (!p.uri || p.data === undefined) { throw new Error('Missing uri or data'); } + await this._resourceService.write(addr, p as unknown as Parameters[1]); sendResult({}); - } catch (err) { - sendError(err); + return; } - })(); - } - case 'resourceRequest': { - const requestParams = p as unknown as ResourceRequestParams; - this._permissionService.request(this._address, requestParams) - .then(() => sendResult({})) - .catch(err => { - if (err instanceof CancellationError) { - sendPermissionDenied(undefined); - } else { - sendError(err); - } - }); - return; - } - case 'resourceResolve': { - if (!p.uri) { sendError(new Error('Missing uri')); return; } - const resolveUri = URI.parse(p.uri as string); - return void gateAndHandle(resolveUri, AgentHostPermissionMode.Read, { channel: ROOT_STATE_URI, uri: resolveUri.toString(), read: true }, async () => { - const stat = await this._fileService.stat(resolveUri); - const type = stat.isSymbolicLink && p.followSymlinks === false ? 'symlink' as const - : stat.isDirectory ? 'directory' as const - : 'file' as const; - return { - uri: resolveUri.toString(), - type, - ...(stat.size !== undefined ? { size: stat.size } : {}), - ...(stat.mtime !== undefined ? { mtime: new Date(stat.mtime).toISOString() } : {}), - ...(stat.ctime !== undefined ? { ctime: new Date(stat.ctime).toISOString() } : {}), - ...(stat.etag ? { etag: stat.etag } : {}), - }; - }); - } - case 'resourceMkdir': { - if (!p.uri) { sendError(new Error('Missing uri')); return; } - const mkdirUri = URI.parse(p.uri as string); - return void gateAndHandle(mkdirUri, AgentHostPermissionMode.Write, { channel: ROOT_STATE_URI, uri: mkdirUri.toString(), write: true }, async () => { - await this._fileService.createFolder(mkdirUri); - return {}; - }); - } - case 'resourceCopy': { - if (!p.source) { sendError(new Error('Missing source')); return; } - if (!p.destination) { sendError(new Error('Missing destination')); return; } - const sourceUri = URI.parse(p.source as string); - const destinationUri = URI.parse(p.destination as string); - return void (async () => { - try { - // Gate both source (read) and destination (write). - const [sourceOk, destOk] = await Promise.all([ - this._permissionService.check(this._address, sourceUri, AgentHostPermissionMode.Read), - this._permissionService.check(this._address, destinationUri, AgentHostPermissionMode.Write), - ]); - if (!sourceOk) { - sendPermissionDenied({ channel: ROOT_STATE_URI, uri: sourceUri.toString(), read: true }); - return; - } - if (!destOk) { - sendPermissionDenied({ channel: ROOT_STATE_URI, uri: destinationUri.toString(), write: true }); - return; - } - await this._fileService.copy(sourceUri, destinationUri, !p.failIfExists); + case 'resourceDelete': { + if (!p.uri) { throw new Error('Missing uri'); } + await this._resourceService.del(addr, p as unknown as Parameters[1]); + sendResult({}); + return; + } + case 'resourceMove': { + if (!p.source || !p.destination) { throw new Error('Missing source or destination'); } + await this._resourceService.move(addr, p as unknown as Parameters[1]); sendResult({}); - } catch (err) { - sendError(err); + return; } - })(); + case 'resourceCopy': { + if (!p.source || !p.destination) { throw new Error('Missing source or destination'); } + await this._resourceService.copy(addr, p as unknown as Parameters[1]); + sendResult({}); + return; + } + case 'resourceResolve': { + if (!p.uri) { throw new Error('Missing uri'); } + const result = await this._resourceService.resolve(addr, p as unknown as Parameters[1]); + sendResult(result); + return; + } + case 'resourceMkdir': { + if (!p.uri) { throw new Error('Missing uri'); } + await this._resourceService.mkdir(addr, p as unknown as Parameters[1]); + sendResult({}); + return; + } + case 'resourceRequest': { + try { + await this._resourceService.request(addr, p as unknown as ResourceRequestParams); + sendResult({}); + } catch (err) { + if (err instanceof CancellationError) { + throw new AgentHostResourcePermissionError(undefined); + } + throw err; + } + return; + } + default: + this._logService.warn(`[RemoteAgentHostProtocol] Unhandled reverse request: ${method}`); + throw new Error(`Unknown method: ${method}`); + } + } catch (err) { + sendError(err); } - default: - this._logService.warn(`[RemoteAgentHostProtocol] Unhandled reverse request: ${method}`); - sendError(new Error(`Unknown method: ${method}`)); - } + })(); } /** Send a typed JSON-RPC notification for a protocol-defined method. */ diff --git a/src/vs/platform/agentHost/common/agentClientUri.ts b/src/vs/platform/agentHost/common/agentClientUri.ts index 852cec896c58d..31561c08e7e2b 100644 --- a/src/vs/platform/agentHost/common/agentClientUri.ts +++ b/src/vs/platform/agentHost/common/agentClientUri.ts @@ -26,15 +26,27 @@ export const AGENT_CLIENT_SCHEME = 'vscode-agent-client'; * Wraps a client-side URI into a {@link AGENT_CLIENT_SCHEME} URI that * can be resolved through the agent host's client filesystem provider. * + * Opaque-path URIs (e.g. `untitled:Untitled-1`, where `path` has no + * leading `/`) are marked with a `!` suffix on the encoded scheme slot + * so the decoder can restore them faithfully. Scheme names are + * alphanumeric + `+.-` per RFC 3986, so `!` cannot collide. + * * @param originalUri The URI on the client (e.g. `file:///path`) * @param clientId The client identifier (from the protocol `clientId`) */ export function toAgentClientUri(originalUri: URI, clientId: string): URI { const originalAuthority = originalUri.authority || '-'; + const isOpaque = originalUri.path.length > 0 && !originalUri.path.startsWith('/'); + const schemeSlot = isOpaque ? `${originalUri.scheme}!` : originalUri.scheme; + // Insert a synthetic '/' for opaque paths so the encoded URI is well-formed. + // The decoder strips it after detecting the opaque marker on the scheme slot. + const pathBody = isOpaque ? `/${originalUri.path}` : originalUri.path; return URI.from({ scheme: AGENT_CLIENT_SCHEME, authority: clientId, - path: `/${originalUri.scheme}/${originalAuthority}${originalUri.path}`, + path: `/${schemeSlot}/${originalAuthority}${pathBody}`, + query: originalUri.query || undefined, + fragment: originalUri.fragment || undefined, }); } @@ -45,18 +57,24 @@ export function toAgentClientUri(originalUri: URI, clientId: string): URI { */ export function fromAgentClientUri(agentClientUri: URI): URI { const path = agentClientUri.path; + const query = agentClientUri.query || undefined; + const fragment = agentClientUri.fragment || undefined; const schemeEnd = path.indexOf('/', 1); if (schemeEnd === -1) { - return URI.from({ scheme: 'file', path }); + return URI.from({ scheme: 'file', path, query, fragment }); } - const originalScheme = path.substring(1, schemeEnd); + let originalScheme = path.substring(1, schemeEnd); + const isOpaque = originalScheme.endsWith('!'); + if (isOpaque) { + originalScheme = originalScheme.substring(0, originalScheme.length - 1); + } const authorityEnd = path.indexOf('/', schemeEnd + 1); if (authorityEnd === -1) { const originalAuthority = path.substring(schemeEnd + 1); - return URI.from({ scheme: originalScheme, authority: originalAuthority === '-' ? '' : originalAuthority, path: '/' }); + return URI.from({ scheme: originalScheme, authority: originalAuthority === '-' ? '' : originalAuthority, path: '/', query, fragment }); } let originalAuthority = path.substring(schemeEnd + 1, authorityEnd); @@ -64,11 +82,17 @@ export function fromAgentClientUri(agentClientUri: URI): URI { originalAuthority = ''; } - const originalPath = path.substring(authorityEnd); + let originalPath = path.substring(authorityEnd); + if (isOpaque) { + // Drop the synthetic leading '/' we inserted in toAgentClientUri. + originalPath = originalPath.substring(1); + } return URI.from({ scheme: originalScheme, authority: originalAuthority || undefined, path: originalPath, + query, + fragment, }); } diff --git a/src/vs/platform/agentHost/common/agentHostClientResourceChannel.ts b/src/vs/platform/agentHost/common/agentHostClientResourceChannel.ts index eccbb865b8617..0bf21513e38de 100644 --- a/src/vs/platform/agentHost/common/agentHostClientResourceChannel.ts +++ b/src/vs/platform/agentHost/common/agentHostClientResourceChannel.ts @@ -3,17 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; +import { encodeBase64 } from '../../../base/common/buffer.js'; +import { CancellationError } from '../../../base/common/errors.js'; import { Event } from '../../../base/common/event.js'; import { URI } from '../../../base/common/uri.js'; import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { IFileService } from '../../files/common/files.js'; -import { AhpJsonlLogger } from './ahpJsonlLogger.js'; +import { + AgentHostResourcePermissionError, + IAgentHostResourceService, + LOCAL_AGENT_HOST_ADDRESS, +} from './agentHostResourceService.js'; import { IRemoteFilesystemConnection } from './agentHostFileSystemProvider.js'; +import { AhpJsonlLogger } from './ahpJsonlLogger.js'; +import { AhpErrorCodes } from './state/protocol/errors.js'; import { - ContentEncoding, ResourceType, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, + ContentEncoding, + type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, - type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWriteParams, type ResourceWriteResult, + type ResourceReadResult, type ResourceRequestParams, type ResourceRequestResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWriteParams, type ResourceWriteResult, } from './state/protocol/commands.js'; /** @@ -23,19 +30,44 @@ import { * `connection.channelClient.getChannel(name)` on its `UtilityProcessServer`. * * Mirrors the WebSocket reverse-RPC handlers in - * {@link RemoteAgentHostProtocolClient._handleReverseRequest}. + * `RemoteAgentHostProtocolClient._handleReverseRequest`. */ export const AGENT_HOST_CLIENT_RESOURCE_CHANNEL = 'agentHostClientResource'; /** - * Server-side channel implementation handling resource RPCs from the - * agent host. Backed by the local {@link IFileService} on the renderer. + * Wraps an {@link IChannel} (typically obtained from the agent host's + * `UtilityProcessServer.getChannel`) into an + * {@link IRemoteFilesystemConnection} suitable for registering with + * `AgentHostClientFileSystemProvider.registerAuthority`. + */ +export function createAgentHostClientResourceConnection(channel: IChannel): IRemoteFilesystemConnection { + return { + resourceList: (uri) => channel.call('resourceList', { uri: uri.toString() }) as Promise, + resourceRead: (uri) => channel.call('resourceRead', { uri: uri.toString() }) as Promise, + resourceWrite: (params) => channel.call('resourceWrite', { ...params, uri: params.uri.toString() }) as Promise, + resourceCopy: (params) => channel.call('resourceCopy', { ...params, source: params.source.toString(), destination: params.destination.toString() }) as Promise, + resourceDelete: (params) => channel.call('resourceDelete', { ...params, uri: params.uri.toString() }) as Promise, + resourceMove: (params) => channel.call('resourceMove', { ...params, source: params.source.toString(), destination: params.destination.toString() }) as Promise, + resourceResolve: (params) => channel.call('resourceResolve', { ...params, uri: params.uri.toString() }) as Promise, + resourceMkdir: (params) => channel.call('resourceMkdir', { ...params, uri: params.uri.toString() }) as Promise, + resourceRequest: (params) => channel.call('resourceRequest', { ...params, uri: params.uri.toString() }) as Promise, + }; +} + +/** + * Server-side channel for in-process reverse FS RPCs from the local agent + * host. Thin adapter — translates JSON-RPC frames into + * {@link IAgentHostResourceService} calls, keyed on + * {@link LOCAL_AGENT_HOST_ADDRESS} so permission policy is shared with + * remote agent hosts. Permission denials are surfaced as + * `PermissionDenied` wire frames carrying the suggested + * `resourceRequest` so the host can run the standard request → retry loop. */ export class AgentHostClientResourceChannel implements IServerChannel { constructor( - private readonly _fileService: IFileService, - private readonly _ahpLogger?: AhpJsonlLogger, + private readonly _ahpLogger: AhpJsonlLogger | undefined, + @IAgentHostResourceService private readonly _resourceService: IAgentHostResourceService, ) { } listen(_ctx: unknown, event: string): Event { @@ -46,19 +78,29 @@ export class AgentHostClientResourceChannel implements IServerChannel { const requestFrame = { jsonrpc: '2.0' as const, method: command, params: arg }; this._logReverseFrame(requestFrame, 's2c'); try { - const result = await this._call(ctx, command, arg); + const result = await this._call(ctx, command, arg); const responseFrame = { jsonrpc: '2.0' as const, method: command, result: result ?? null }; this._logReverseFrame(responseFrame, 'c2s'); - return result; + return result as T; } catch (err) { - const errorFrame = { - jsonrpc: '2.0' as const, - method: command, - error: { - code: -32603, - message: err instanceof Error ? err.message : String(err), - }, - }; + const errorFrame = err instanceof AgentHostResourcePermissionError + ? { + jsonrpc: '2.0' as const, + method: command, + error: { + code: AhpErrorCodes.PermissionDenied, + message: err.message, + data: err.request ? { request: err.request } : undefined, + }, + } + : { + jsonrpc: '2.0' as const, + method: command, + error: { + code: -32603, + message: err instanceof Error ? err.message : String(err), + }, + }; this._logReverseFrame(errorFrame, 'c2s'); throw err; } @@ -68,114 +110,53 @@ export class AgentHostClientResourceChannel implements IServerChannel { this._ahpLogger?.log(frame, dir); } - private async _call(_ctx: unknown, command: string, arg?: unknown): Promise { + private async _call(_ctx: unknown, command: string, arg?: unknown): Promise { const a = (arg ?? {}) as Record; + const addr = LOCAL_AGENT_HOST_ADDRESS; switch (command) { case 'resourceList': { - const stat = await this._fileService.resolve(URI.parse(a.uri as string)); - if (!stat.isDirectory) { - throw new Error(`Resource is not a directory: ${a.uri}`); - } - const entries: DirectoryEntry[] = (stat.children ?? []).map(c => ({ - name: c.name, - type: c.isDirectory ? 'directory' : 'file', - })); - const result: ResourceListResult = { entries }; - return result as T; + const result = await this._resourceService.list(addr, URI.parse(a.uri as string)); + return { entries: result.entries }; } case 'resourceRead': { - const content = await this._fileService.readFile(URI.parse(a.uri as string)); - const result: ResourceReadResult = { - data: encodeBase64(content.value), - encoding: ContentEncoding.Base64, - }; - return result as T; + const result = await this._resourceService.read(addr, URI.parse(a.uri as string)); + return { data: encodeBase64(result.bytes), encoding: ContentEncoding.Base64 }; } case 'resourceWrite': { - const params = a as unknown as ResourceWriteParams; - const writeUri = URI.parse(params.uri); - const buf = params.encoding === ContentEncoding.Base64 - ? decodeBase64(params.data) - : VSBuffer.fromString(params.data); - if (params.createOnly) { - await this._fileService.createFile(writeUri, buf, { overwrite: false }); - } else { - await this._fileService.writeFile(writeUri, buf); - } - const result: ResourceWriteResult = {}; - return result as T; + await this._resourceService.write(addr, a as unknown as ResourceWriteParams); + return {}; } case 'resourceDelete': { - const params = a as unknown as ResourceDeleteParams; - await this._fileService.del(URI.parse(params.uri), { recursive: !!params.recursive }); - const result: ResourceDeleteResult = {}; - return result as T; + await this._resourceService.del(addr, a as unknown as ResourceDeleteParams); + return {}; } case 'resourceMove': { - const params = a as unknown as ResourceMoveParams; - await this._fileService.move(URI.parse(params.source), URI.parse(params.destination), !params.failIfExists); - const result: ResourceMoveResult = {}; - return result as T; + await this._resourceService.move(addr, a as unknown as ResourceMoveParams); + return {}; } case 'resourceCopy': { - const params = a as unknown as ResourceCopyParams; - await this._fileService.copy(URI.parse(params.source), URI.parse(params.destination), !params.failIfExists); - const result: ResourceCopyResult = {}; - return result as T; + await this._resourceService.copy(addr, a as unknown as ResourceCopyParams); + return {}; } case 'resourceResolve': { - const params = a as unknown as ResourceResolveParams; - const uri = URI.parse(params.uri); - const stat = await this._fileService.stat(uri); - let type: ResourceType; - if (stat.isSymbolicLink && params.followSymlinks === false) { - type = ResourceType.Symlink; - } else if (stat.isDirectory) { - type = ResourceType.Directory; - } else { - type = ResourceType.File; - } - const result: ResourceResolveResult = { - uri: uri.toString(), - type, - ...(stat.size !== undefined ? { size: stat.size } : {}), - ...(stat.mtime !== undefined ? { mtime: new Date(stat.mtime).toISOString() } : {}), - ...(stat.ctime !== undefined ? { ctime: new Date(stat.ctime).toISOString() } : {}), - ...(stat.etag ? { etag: stat.etag } : {}), - }; - return result as T; + return this._resourceService.resolve(addr, a as unknown as ResourceResolveParams); } case 'resourceMkdir': { - const params = a as unknown as ResourceMkdirParams; - const uri = URI.parse(params.uri); - const existing = await this._fileService.stat(uri).catch(() => undefined); - if (existing && !existing.isDirectory) { - throw new Error(`Path exists and is not a directory: ${uri.toString()}`); + await this._resourceService.mkdir(addr, a as unknown as ResourceMkdirParams); + return {}; + } + case 'resourceRequest': { + try { + await this._resourceService.request(addr, a as unknown as ResourceRequestParams); + return {}; + } catch (err) { + if (err instanceof CancellationError) { + throw new AgentHostResourcePermissionError(undefined); + } + throw err; } - await this._fileService.createFolder(uri); - const result: ResourceMkdirResult = {}; - return result as T; } } throw new Error(`Unknown command '${command}' on AgentHostClientResourceChannel`); } } - -/** - * Wraps an {@link IChannel} (typically obtained from the agent host's - * `UtilityProcessServer.getChannel`) into an - * {@link IRemoteFilesystemConnection} suitable for registering with - * {@link AgentHostClientFileSystemProvider.registerAuthority}. - */ -export function createAgentHostClientResourceConnection(channel: IChannel): IRemoteFilesystemConnection { - return { - resourceList: (uri) => channel.call('resourceList', { uri: uri.toString() }) as Promise, - resourceRead: (uri) => channel.call('resourceRead', { uri: uri.toString() }) as Promise, - resourceWrite: (params) => channel.call('resourceWrite', { ...params, uri: params.uri.toString() }) as Promise, - resourceCopy: (params) => channel.call('resourceCopy', { ...params, source: params.source.toString(), destination: params.destination.toString() }) as Promise, - resourceDelete: (params) => channel.call('resourceDelete', { ...params, uri: params.uri.toString() }) as Promise, - resourceMove: (params) => channel.call('resourceMove', { ...params, source: params.source.toString(), destination: params.destination.toString() }) as Promise, - resourceResolve: (params) => channel.call('resourceResolve', { ...params, uri: params.uri.toString() }) as Promise, - resourceMkdir: (params) => channel.call('resourceMkdir', { ...params, uri: params.uri.toString() }) as Promise, - }; -} diff --git a/src/vs/platform/agentHost/common/agentHostPermissionService.ts b/src/vs/platform/agentHost/common/agentHostPermissionService.ts deleted file mode 100644 index 853cce65a33f5..0000000000000 --- a/src/vs/platform/agentHost/common/agentHostPermissionService.ts +++ /dev/null @@ -1,102 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IDisposable } from '../../../base/common/lifecycle.js'; -import { IObservable } from '../../../base/common/observable.js'; -import { URI } from '../../../base/common/uri.js'; -import { createDecorator } from '../../instantiation/common/instantiation.js'; -import { ResourceRequestParams } from './state/protocol/commands.js'; - -/** Configuration key for persisted per-host filesystem grants. */ -export const AgentHostLocalFilePermissionsSettingId = 'chat.agentHost.localFilePermissions'; - -/** Persisted access mode for a granted URI. */ -export const enum AgentHostAccessMode { - Read = 'r', - ReadWrite = 'rw', -} - -/** - * Persisted shape of {@link AgentHostLocalFilePermissionsSettingId}: - * `{ [normalizedAddress]: { [uriString]: 'r' | 'rw' } }`. - */ -export type AgentHostPermissionsSetting = Record>; - -/** - * Capability a request needs from the user. The protocol-level `read` and - * `write` flags are split into one or two of these requests. - */ -export const enum AgentHostPermissionMode { - Read = 'read', - Write = 'write', -} - -/** A single pending permission request awaiting user input. */ -export interface IPendingResourceRequest { - readonly id: string; - readonly address: string; - readonly uri: URI; - readonly mode: AgentHostPermissionMode; - /** Approve and remember the grant in user settings. */ - allowAlways(): void; - /** - * Approve the request and remember it in memory for the lifetime of the - * connection (cleared on connection close or window reload). - */ - allow(): void; - /** Reject this request. */ - deny(): void; -} - -export const IAgentHostPermissionService = createDecorator('agentHostPermissionService'); - -export interface IAgentHostPermissionService { - readonly _serviceBrand: undefined; - - /** - * Returns whether {@link uri} is already granted for {@link mode} on - * {@link address}, considering implicit read grants, in-memory session - * grants, and persisted permissions. The URI is canonicalized through - * the file service (realpath) before comparison so symlinks and `..` - * traversal cannot bypass a grant. - */ - check(address: string, uri: URI, mode: AgentHostPermissionMode): Promise; - - /** - * Handle an inbound `resourceRequest` from a host. Resolves once access - * is granted (immediately, if {@link check} already covers the request); - * rejects if the user denies or the connection closes. - */ - request(address: string, params: ResourceRequestParams): Promise; - - /** Per-address observable of pending requests for UI surfaces. */ - pendingFor(address: string): IObservable; - - /** - * Observable of all pending requests across every address. Useful for - * surfaces that aren't scoped to a single session/connection. - */ - readonly allPending: IObservable; - - /** - * Find a pending request by id, across all addresses. Returns - * `undefined` once the request has been resolved or rejected. - */ - findPending(id: string): IPendingResourceRequest | undefined; - - /** - * Register an implicit read grant for {@link uri} (and descendants) on - * {@link address}. Used by call sites that are about to send a URI to a - * host and therefore expect that host to read it back. The returned - * disposable revokes the grant. - */ - grantImplicitRead(address: string, uri: URI): IDisposable; - - /** - * Notify that the connection at {@link address} has closed. Drops all - * implicit grants and rejects any outstanding pending requests. - */ - connectionClosed(address: string): void; -} diff --git a/src/vs/platform/agentHost/common/agentHostResourceService.ts b/src/vs/platform/agentHost/common/agentHostResourceService.ts new file mode 100644 index 0000000000000..bfd25969137f1 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostResourceService.ts @@ -0,0 +1,168 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../base/common/buffer.js'; +import { IDisposable } from '../../../base/common/lifecycle.js'; +import { IObservable } from '../../../base/common/observable.js'; +import { URI } from '../../../base/common/uri.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { + DirectoryEntry, + ResourceCopyParams, ResourceDeleteParams, ResourceMkdirParams, ResourceMoveParams, + ResourceRequestParams, ResourceResolveParams, ResourceResolveResult, ResourceWriteParams, +} from './state/protocol/commands.js'; + +/** + * Stable sentinel address used for the in-process local agent host. Keyed + * persisted grants in user settings live under this name so that "Always + * allow" survives window reloads. + */ +export const LOCAL_AGENT_HOST_ADDRESS = 'local'; + +/** Configuration key for persisted per-host filesystem grants. */ +export const AgentHostLocalFilePermissionsSettingId = 'chat.agentHost.localFilePermissions'; + +/** Persisted access mode for a granted URI. */ +export const enum AgentHostAccessMode { + Read = 'r', + ReadWrite = 'rw', +} + +/** + * Persisted shape of {@link AgentHostLocalFilePermissionsSettingId}: + * `{ [normalizedAddress]: { [uriString]: 'r' | 'rw' } }`. + */ +export type AgentHostPermissionsSetting = Record>; + +/** + * Capability a request needs from the user. The protocol-level `read` and + * `write` flags are split into one or two of these requests. + */ +export const enum AgentHostPermissionMode { + Read = 'read', + Write = 'write', +} + +/** A single pending permission request awaiting user input. */ +export interface IPendingResourceRequest { + readonly id: string; + readonly address: string; + readonly uri: URI; + readonly mode: AgentHostPermissionMode; + /** Approve and remember the grant in user settings. */ + allowAlways(): void; + /** + * Approve the request and remember it in memory for the lifetime of the + * connection (cleared on connection close or window reload). + */ + allow(): void; + /** Reject this request. */ + deny(): void; +} + +/** + * Thrown by gated FS operations on {@link IAgentHostResourceService} when + * the calling address lacks the required permission. Carries the + * {@link ResourceRequestParams} that, if approved, would unlock the + * operation, so wire adapters can echo it back to the agent host inside a + * `PermissionDenied` frame and let the host run the standard + * `resourceRequest` → retry loop. + */ +export class AgentHostResourcePermissionError extends Error { + constructor(public readonly request: ResourceRequestParams | undefined) { + super(request + ? `Access to ${request.uri} is not granted.` + : 'Access to the requested resource is not granted.'); + this.name = 'AgentHostResourcePermissionError'; + } +} + +export interface IResourceReadResult { + readonly bytes: VSBuffer; +} + +export interface IResourceListResult { + readonly entries: readonly DirectoryEntry[]; +} + +export const IAgentHostResourceService = createDecorator('agentHostResourceService'); + +/** + * Single owner of agent-host-facing filesystem operations and the + * permission policy that gates them. Combines what were previously two + * services (`IAgentHostPermissionService` + `IAgentHostVirtualResourceProvider`) + * into one consistent interface used by both the in-process local channel + * and the remote protocol client. + * + * Each FS method is gated by a permission check keyed on `address`: a + * normalized network host for remote agent hosts, or + * {@link LOCAL_AGENT_HOST_ADDRESS} for the local utility-process host. + * Denied operations throw {@link AgentHostResourcePermissionError} carrying + * the {@link ResourceRequestParams} that, if granted, would unlock the + * operation. + * + * Read operations transparently fall back to virtual content (untitled + * documents, notebook cells, ...) when the local file service cannot + * resolve the URI. + */ +export interface IAgentHostResourceService { + readonly _serviceBrand: undefined; + + // ---- Gated filesystem operations --------------------------------------- + + list(address: string, uri: URI): Promise; + read(address: string, uri: URI): Promise; + write(address: string, params: ResourceWriteParams): Promise; + del(address: string, params: ResourceDeleteParams): Promise; + move(address: string, params: ResourceMoveParams): Promise; + copy(address: string, params: ResourceCopyParams): Promise; + resolve(address: string, params: ResourceResolveParams): Promise; + mkdir(address: string, params: ResourceMkdirParams): Promise; + + // ---- Permission requests / observables (UI) ---------------------------- + + /** + * Returns whether {@link uri} is already granted for {@link mode} on + * {@link address}. Useful as a pre-check before sending data to a host + * that will read it back. The same gating runs implicitly inside every + * FS method on this service. + */ + check(address: string, uri: URI, mode: AgentHostPermissionMode): Promise; + + /** + * Handle an inbound `resourceRequest` from a host. Resolves once access + * is granted (immediately, if already covered); rejects with a + * `CancellationError` if the user denies or the connection closes. + */ + request(address: string, params: ResourceRequestParams): Promise; + + /** Per-address observable of pending requests for UI surfaces. */ + pendingFor(address: string): IObservable; + + /** Observable of all pending requests across every address. */ + readonly allPending: IObservable; + + /** + * Find a pending request by id, across all addresses. Returns + * `undefined` once the request has been resolved or rejected. + */ + findPending(id: string): IPendingResourceRequest | undefined; + + // ---- Implicit grants and lifecycle ------------------------------------- + + /** + * Register an implicit read grant for {@link uri} (and descendants) on + * {@link address}. Used by call sites that are about to send a URI to a + * host and therefore expect that host to read it back. The returned + * disposable revokes the grant. + */ + grantImplicitRead(address: string, uri: URI): IDisposable; + + /** + * Notify that the connection at {@link address} has closed. Drops all + * implicit grants and rejects any outstanding pending requests. + */ + connectionClosed(address: string): void; +} diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 5295eb4d5d547..13dd749b888c1 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -27,7 +27,6 @@ import type { CreateResourceWatchParams, CreateResourceWatchResult, ResourceCopy import { StateComponents, ROOT_STATE_URI, type RootState } from '../common/state/sessionState.js'; import { revive } from '../../../base/common/marshalling.js'; import { URI } from '../../../base/common/uri.js'; -import { IFileService } from '../../files/common/files.js'; import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AgentHostClientResourceChannel } from '../common/agentHostClientResourceChannel.js'; import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; @@ -82,9 +81,8 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos constructor( @ILogService private readonly _logService: ILogService, @IConfigurationService private readonly _configurationService: IConfigurationService, - @IFileService private readonly _fileService: IFileService, @IEnvironmentService environmentService: IEnvironmentService, - @IInstantiationService instantiationService: IInstantiationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); @@ -98,7 +96,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos // frames for every request/response/notification on the in-process MessagePort // channel, mirroring the AHP transport JSONL logs produced by remote agent hosts. this._ahpLogger = this._configurationService.getValue(AgentHostAhpJsonlLoggingSettingId) - ? this._register(instantiationService.createInstance(AhpJsonlLogger, { + ? this._register(this._instantiationService.createInstance(AhpJsonlLogger, { logsHome: environmentService.logsHome, connectionId: this.clientId, transport: 'local', @@ -145,7 +143,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos // Serve filesystem reverse-RPCs from the local file service. The // agent host registers an authority on its // AgentHostClientFileSystemProvider that calls back through this channel. - client.registerChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, new AgentHostClientResourceChannel(this._fileService, this._ahpLogger)); + client.registerChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, this._instantiationService.createInstance(AgentHostClientResourceChannel, this._ahpLogger)); this._clientEventually.complete(client); this._updateTelemetryLevel(); this._updateSessionSyncEnabled(); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 01d764352f619..0e082bb5c1993 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -1169,8 +1169,12 @@ export class AgentService extends Disposable implements IAgentService { return contents.value.buffer; } catch (err) { if (proxiedUri !== originalUri) { - const contents = await this._fileService.readFile(originalUri); - return contents.value.buffer; + try { + const contents = await this._fileService.readFile(originalUri); + return contents.value.buffer; + } catch { + // ignore + } } throw err; } diff --git a/src/vs/platform/agentHost/test/common/agentClientUri.test.ts b/src/vs/platform/agentHost/test/common/agentClientUri.test.ts index f276a58a8dead..8df245b2de003 100644 --- a/src/vs/platform/agentHost/test/common/agentClientUri.test.ts +++ b/src/vs/platform/agentHost/test/common/agentClientUri.test.ts @@ -51,4 +51,37 @@ suite('Agent Client URI transform', () => { const wrapped = toAgentClientUri(URI.file('/foo'), 'my-client'); assert.strictEqual(wrapped.authority, 'my-client'); }); + + test('preserves query and fragment across round trip', () => { + const original = URI.from({ + scheme: 'vscode-notebook-cell', + authority: '', + path: '/Users/me/notebook.ipynb', + query: 'cellHandle=3', + fragment: 'L10-L20', + }); + const wrapped = toAgentClientUri(original, 'client-q'); + assert.strictEqual(wrapped.query, 'cellHandle=3'); + assert.strictEqual(wrapped.fragment, 'L10-L20'); + + const decoded = fromAgentClientUri(wrapped); + assert.strictEqual(decoded.scheme, 'vscode-notebook-cell'); + assert.strictEqual(decoded.path, '/Users/me/notebook.ipynb'); + assert.strictEqual(decoded.query, 'cellHandle=3'); + assert.strictEqual(decoded.fragment, 'L10-L20'); + }); + + test('preserves opaque paths (e.g. untitled:Untitled-1)', () => { + const original = URI.parse('untitled:Untitled-1'); + assert.strictEqual(original.path, 'Untitled-1'); + assert.strictEqual(original.authority, ''); + + const wrapped = toAgentClientUri(original, 'client-u'); + const decoded = fromAgentClientUri(wrapped); + + assert.strictEqual(decoded.scheme, 'untitled'); + assert.strictEqual(decoded.authority, ''); + assert.strictEqual(decoded.path, 'Untitled-1'); + assert.strictEqual(decoded.toString(), 'untitled:Untitled-1'); + }); }); diff --git a/src/vs/platform/agentHost/test/common/agentHostClientResourceChannel.test.ts b/src/vs/platform/agentHost/test/common/agentHostClientResourceChannel.test.ts new file mode 100644 index 0000000000000..a72585771ea8b --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostClientResourceChannel.test.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { decodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AgentHostClientResourceChannel } from '../../common/agentHostClientResourceChannel.js'; +import { AhpJsonlLogger } from '../../common/ahpJsonlLogger.js'; +import { + AgentHostResourcePermissionError, + IAgentHostResourceService, + LOCAL_AGENT_HOST_ADDRESS, +} from '../../common/agentHostResourceService.js'; +import { ContentEncoding } from '../../common/state/protocol/commands.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../log/common/log.js'; + +interface IStubOpts { + files?: Map; + virtual?: Map; + denyRead?: boolean; +} + +/** + * Hand-rolled {@link IAgentHostResourceService} stub. The channel only + * exercises a couple of FS methods; everything else is a placeholder. + */ +function createResourceStub(opts: IStubOpts): IAgentHostResourceService { + const files = opts.files ?? new Map(); + const virtual = opts.virtual ?? new Map(); + const empty = observableValue('test', []); + return { + _serviceBrand: undefined, + check: async () => !opts.denyRead, + async list() { throw new Error('not implemented'); }, + async read(_addr, uri) { + if (opts.denyRead) { + throw new AgentHostResourcePermissionError({ channel: 'ahp-root://', uri: uri.toString(), read: true }); + } + const key = uri.toString(); + const real = files.get(key); + if (real) { + return { bytes: real }; + } + const virtualBuf = virtual.get(key); + if (virtualBuf) { + return { bytes: virtualBuf }; + } + throw new Error(`No such file: ${key}`); + }, + async write() { /* */ }, + async del() { /* */ }, + async move() { /* */ }, + async copy() { /* */ }, + async resolve() { throw new Error('not implemented'); }, + async mkdir() { /* */ }, + async request() { /* */ }, + pendingFor: () => empty, + allPending: empty, + findPending: () => undefined, + grantImplicitRead: () => Disposable.None, + connectionClosed: () => { }, + }; +} + +suite('AgentHostClientResourceChannel', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function makeChannel(opts: IStubOpts = {}) { + const fileService = store.add(new FileService(new NullLogService())); + store.add(fileService.registerProvider('file', store.add(new InMemoryFileSystemProvider()))); + const logger = store.add(new AhpJsonlLogger( + { logsHome: URI.file('/logs'), connectionId: 'local-1', transport: 'local' }, + fileService, + new NullLogService(), + )); + const service = createResourceStub(opts); + const channel = new AgentHostClientResourceChannel(logger, service); + return { fileService, logger, channel }; + } + + async function readEntries(fileService: FileService, logger: AhpJsonlLogger) { + await logger.flush(); + const content = (await fileService.readFile(logger.resource)).value.toString(); + return content.split('\n').filter(Boolean).map(line => JSON.parse(line)); + } + + test('uses LOCAL_AGENT_HOST_ADDRESS for gating', async () => { + const seenAddresses: string[] = []; + const baseStub = createResourceStub({ files: new Map([[URI.file('/x.txt').toString(), VSBuffer.fromString('x')]]) }); + const channel = new AgentHostClientResourceChannel(undefined, { + ...baseStub, + read: async (addr, uri) => { + seenAddresses.push(addr); + return baseStub.read(addr, uri); + }, + }); + await channel.call(undefined, 'resourceRead', { uri: URI.file('/x.txt').toString() }); + assert.deepStrictEqual(seenAddresses, [LOCAL_AGENT_HOST_ADDRESS]); + }); + + test('reads existing files via the resource service', async () => { + const uri = URI.file('/hello.txt'); + const { channel } = makeChannel({ files: new Map([[uri.toString(), VSBuffer.fromString('hi')]]) }); + const result = await channel.call<{ data: string; encoding: ContentEncoding }>(undefined, 'resourceRead', { uri: uri.toString() }); + assert.strictEqual(result.encoding, ContentEncoding.Base64); + assert.strictEqual(decodeBase64(result.data).toString(), 'hi'); + }); + + test('falls back to virtual content for unknown URIs', async () => { + const uri = URI.parse('untitled:/Untitled-1'); + const { channel } = makeChannel({ virtual: new Map([[uri.toString(), VSBuffer.fromString('virtual content')]]) }); + const result = await channel.call<{ data: string; encoding: ContentEncoding }>(undefined, 'resourceRead', { uri: uri.toString() }); + assert.strictEqual(result.encoding, ContentEncoding.Base64); + assert.strictEqual(decodeBase64(result.data).toString(), 'virtual content'); + }); + + test('translates permission denial to a logged PermissionDenied wire frame', async () => { + const { fileService, logger, channel } = makeChannel({ denyRead: true }); + const uri = URI.file('/secret.txt'); + await channel.call(undefined, 'resourceRead', { uri: uri.toString() }).then( + () => assert.fail('expected error'), + err => assert.ok(err instanceof AgentHostResourcePermissionError), + ); + + const entries = await readEntries(fileService, logger); + const errorFrame = entries.find(e => e.error); + assert.ok(errorFrame, 'expected logged error frame'); + assert.strictEqual(errorFrame.error.data?.request?.uri, uri.toString()); + }); +}); diff --git a/src/vs/platform/agentHost/test/electron-browser/localAhpJsonlLogging.test.ts b/src/vs/platform/agentHost/test/electron-browser/localAhpJsonlLogging.test.ts index 166af750d43d3..3311aef8cfa4f 100644 --- a/src/vs/platform/agentHost/test/electron-browser/localAhpJsonlLogging.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/localAhpJsonlLogging.test.ts @@ -10,10 +10,8 @@ import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { AhpJsonlLogger } from '../../common/ahpJsonlLogger.js'; -import { AgentHostClientResourceChannel } from '../../common/agentHostClientResourceChannel.js'; import { wrapAgentServiceWithAhpLogging } from '../../electron-browser/localAhpJsonlLogging.js'; import type { IAgentCreateSessionConfig, IAgentService } from '../../common/agentService.js'; -import { ContentEncoding } from '../../common/state/protocol/commands.js'; suite('localAhpJsonlLogging', () => { @@ -105,34 +103,4 @@ suite('localAhpJsonlLogging', () => { assert.strictEqual(entries[5].result, session.toString()); assert.strictEqual(entries[7].result, null); }); - - test('logs reverse resource channel requests and responses', async () => { - const { fileService, logger } = makeLogger(); - const channel = new AgentHostClientResourceChannel(fileService, logger); - const uri = URI.file('/from-client.txt').toString(); - - await channel.call(undefined, 'resourceWrite', { uri, data: 'hello', encoding: ContentEncoding.Utf8, createOnly: true }); - await channel.call(undefined, 'resourceRead', { uri }); - await assert.rejects(() => channel.call(undefined, 'resourceRead', { uri: URI.file('/missing.txt').toString() })); - - const entries = await readEntries(fileService, logger); - const summary = entries.map(e => ({ - dir: e._ahpLog.dir, - id: e.id, - method: e.method, - hasResult: Object.hasOwn(e, 'result'), - hasError: Object.hasOwn(e, 'error'), - })); - - assert.deepStrictEqual(summary, [ - { dir: 's2c', id: undefined, method: 'resourceWrite', hasResult: false, hasError: false }, - { dir: 'c2s', id: undefined, method: 'resourceWrite', hasResult: true, hasError: false }, - { dir: 's2c', id: undefined, method: 'resourceRead', hasResult: false, hasError: false }, - { dir: 'c2s', id: undefined, method: 'resourceRead', hasResult: true, hasError: false }, - { dir: 's2c', id: undefined, method: 'resourceRead', hasResult: false, hasError: false }, - { dir: 'c2s', id: undefined, method: 'resourceRead', hasResult: false, hasError: true }, - ]); - assert.deepStrictEqual(entries[0].params, { uri, data: 'hello', encoding: ContentEncoding.Utf8, createOnly: true }); - assert.deepStrictEqual(entries[1].result, {}); - }); }); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index be186d1798319..146bd573fa5af 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -12,10 +12,9 @@ import { observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { FileService } from '../../../files/common/fileService.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentHostClientState, RemoteAgentHostProtocolClient } from '../../browser/remoteAgentHostProtocolClient.js'; -import { IAgentHostPermissionService } from '../../common/agentHostPermissionService.js'; +import { AgentHostPermissionMode, AgentHostResourcePermissionError, IAgentHostResourceService } from '../../common/agentHostResourceService.js'; import { ContentEncoding, ReconnectResultType } from '../../common/state/protocol/commands.js'; import { AhpErrorCodes } from '../../common/state/protocol/errors.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; @@ -75,23 +74,56 @@ class CloseOnDisposeProtocolTransport extends TestProtocolTransport { suite('RemoteAgentHostProtocolClient', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createPermissionService(allow = true): IAgentHostPermissionService { + function createPermissionService(allow = true): IAgentHostResourceService { + return createResourceServiceStub({ granted: () => allow }); + } + + interface IResourceServiceStubOpts { + granted?: (address: string, uri: URI, mode: AgentHostPermissionMode) => boolean; + onRequest?: (address: string, params: { uri: string; read?: boolean; write?: boolean }) => Promise; + onGrantImplicitRead?: (address: string, uri: URI) => void; + } + + /** + * Stub for {@link IAgentHostResourceService}: each FS method runs the + * `granted` predicate and either throws {@link AgentHostResourcePermissionError} + * (carrying the same `resourceRequest` payload the real service would + * advertise) or resolves with a minimal placeholder result. Sufficient to + * drive the protocol client's reverse-RPC permission-gating paths. + */ + function createResourceServiceStub(opts: IResourceServiceStubOpts = {}): IAgentHostResourceService { + const grant = opts.granted ?? (() => true); const empty = observableValue('test', []); + const denyRead = (uri: string) => new AgentHostResourcePermissionError({ channel: 'ahp-root://', uri, read: true }); + const denyWrite = (uri: string) => new AgentHostResourcePermissionError({ channel: 'ahp-root://', uri, write: true }); + const gateRead = async (addr: string, uri: URI) => { + if (!grant(addr, uri, AgentHostPermissionMode.Read)) { throw denyRead(uri.toString()); } + }; + const gateWrite = async (addr: string, uri: URI) => { + if (!grant(addr, uri, AgentHostPermissionMode.Write)) { throw denyWrite(uri.toString()); } + }; return { _serviceBrand: undefined, - check: async () => allow, - request: async () => { /* auto-allow */ }, + check: async (addr, uri, mode) => grant(addr, uri, mode), + async list(addr, uri) { await gateRead(addr, uri); return { entries: [] }; }, + async read(addr, uri) { await gateRead(addr, uri); throw new Error('Not implemented in stub'); }, + async write(addr, params) { await gateWrite(addr, URI.parse(params.uri)); }, + async del(addr, params) { await gateWrite(addr, URI.parse(params.uri)); }, + async move(addr, params) { await gateWrite(addr, URI.parse(params.source)); await gateWrite(addr, URI.parse(params.destination)); }, + async copy(addr, params) { await gateRead(addr, URI.parse(params.source)); await gateWrite(addr, URI.parse(params.destination)); }, + async resolve(addr, params) { await gateRead(addr, URI.parse(params.uri)); throw new Error('Not implemented in stub'); }, + async mkdir(addr, params) { await gateWrite(addr, URI.parse(params.uri)); }, + request: async (addr, params) => opts.onRequest ? opts.onRequest(addr, params) : undefined, pendingFor: () => empty, allPending: empty, findPending: () => undefined, - grantImplicitRead: () => Disposable.None, + grantImplicitRead: (address, uri) => { opts.onGrantImplicitRead?.(address, uri); return Disposable.None; }, connectionClosed: () => { }, }; } function createClient(transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }): { client: RemoteAgentHostProtocolClient; transport: TestProtocolTransport } { - const fileService = disposables.add(new FileService(new NullLogService())); - const client = disposables.add(new RemoteAgentHostProtocolClient('test.example:1234', transport, loadEstimator, new NullLogService(), fileService, permissionService, new TestConfigurationService())); + const client = disposables.add(new RemoteAgentHostProtocolClient('test.example:1234', transport, loadEstimator, new NullLogService(), permissionService, new TestConfigurationService())); return { client, transport }; } @@ -548,10 +580,9 @@ suite('RemoteAgentHostProtocolClient', () => { test('resourceMove is denied when destination lacks write access', async () => { const sourceUri = URI.file('/grant/foo').toString(); const destUri = URI.file('/no-grant/bar').toString(); - const stub: ReturnType = { - ...createPermissionService(false), - check: async (_addr, uri) => uri.toString() === sourceUri, - }; + const stub = createResourceServiceStub({ + granted: (_addr, uri) => uri.toString() === sourceUri, + }); const { transport } = createClient(undefined, stub); transport.fireMessage({ jsonrpc: '2.0', id: 9, method: 'resourceMove', params: { channel: 'ahp-root://', source: sourceUri, destination: destUri } }); @@ -569,11 +600,11 @@ suite('RemoteAgentHostProtocolClient', () => { }); test('reverse resourceRequest delegates to permission service and replies with empty result', async () => { - let lastRequest: { address: string; params: { channel: 'ahp-root://'; uri: string; read?: boolean; write?: boolean } } | undefined; - const stub: ReturnType = { - ...createPermissionService(false), - request: async (address, params) => { lastRequest = { address, params }; }, - }; + let lastRequest: { address: string; params: { uri: string; read?: boolean; write?: boolean } } | undefined; + const stub = createResourceServiceStub({ + granted: () => false, + onRequest: async (address, params) => { lastRequest = { address, params }; }, + }); const { transport } = createClient(undefined, stub); const uri = URI.file('/etc/foo').toString(); @@ -587,10 +618,10 @@ suite('RemoteAgentHostProtocolClient', () => { }); test('reverse resourceRequest replies with PermissionDenied on cancellation', async () => { - const stub: ReturnType = { - ...createPermissionService(false), - request: async () => { throw new CancellationError(); }, - }; + const stub = createResourceServiceStub({ + granted: () => false, + onRequest: async () => { throw new CancellationError(); }, + }); const { transport } = createClient(undefined, stub); const uri = URI.file('/etc/foo').toString(); @@ -612,22 +643,11 @@ suite('RemoteAgentHostProtocolClient', () => { suite('implicit grants for outgoing customization actions', () => { - function createCapturingPermissionService(): { service: IAgentHostPermissionService; calls: { address: string; uri: URI }[] } { - const empty = observableValue('test', []); + function createCapturingPermissionService(): { service: IAgentHostResourceService; calls: { address: string; uri: URI }[] } { const calls: { address: string; uri: URI }[] = []; - const service: IAgentHostPermissionService = { - _serviceBrand: undefined, - check: async () => true, - request: async () => { /* auto-allow */ }, - pendingFor: () => empty, - allPending: empty, - findPending: () => undefined, - grantImplicitRead: (address, uri) => { - calls.push({ address, uri }); - return Disposable.None; - }, - connectionClosed: () => { }, - }; + const service = createResourceServiceStub({ + onGrantImplicitRead: (address, uri) => calls.push({ address, uri }), + }); return { service, calls }; } @@ -814,9 +834,8 @@ suite('RemoteAgentHostProtocolClient', () => { transports.push(t); return t; }; - const fileService = disposables.add(new FileService(new NullLogService())); const client = disposables.add(new RemoteAgentHostProtocolClient( - 'test.example:1234', factory, undefined, new NullLogService(), fileService, createPermissionService(), new TestConfigurationService(), + 'test.example:1234', factory, undefined, new NullLogService(), createPermissionService(), new TestConfigurationService(), )); return { client, transports }; } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 32a00ca20b3de..f525e275384bb 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -16,7 +16,7 @@ import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostEntry, IRemoteAgentHost import { IWSLRemoteAgentHostService } from '../../../../../platform/agentHost/common/wslRemoteAgentHost.js'; import { TunnelAgentHostsSettingId } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; import { PROTOCOL_VERSION } from '../../../../../platform/agentHost/common/state/protocol/version/registry.js'; -import { AgentHostLocalFilePermissionsSettingId } from '../../../../../platform/agentHost/common/agentHostPermissionService.js'; +import { AgentHostLocalFilePermissionsSettingId } from '../../../../../platform/agentHost/common/agentHostResourceService.js'; import { type ProtectedResourceMetadata } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { type AgentInfo, type RootState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 5f04b98c6f235..32bedd0b9e82d 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -141,7 +141,7 @@ import '../workbench/services/dataChannel/browser/dataChannelService.js'; import '../workbench/services/inlineCompletions/common/inlineCompletionsUnification.js'; import '../workbench/services/chat/common/chatEntitlementService.js'; import '../workbench/services/log/common/defaultLogLevels.js'; -import '../workbench/services/agentHost/common/agentHostPermissionService.js'; +import '../workbench/services/agentHost/common/agentHostResourceService.js'; import './services/agentHost/browser/agentHostCustomizationService.js'; import { InstantiationType, registerSingleton } from '../platform/instantiation/common/extensions.js'; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostPermissionUiContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostPermissionUiContribution.ts index b284beef5b878..baab38c923979 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostPermissionUiContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostPermissionUiContribution.ts @@ -10,9 +10,9 @@ import { autorun } from '../../../../../../base/common/observable.js'; import { localize } from '../../../../../../nls.js'; import { AgentHostPermissionMode, - IAgentHostPermissionService, + IAgentHostResourceService, IPendingResourceRequest, -} from '../../../../../../platform/agentHost/common/agentHostPermissionService.js'; +} from '../../../../../../platform/agentHost/common/agentHostResourceService.js'; import { AGENT_HOST_SCHEME, agentHostAuthority } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; @@ -29,19 +29,19 @@ const ALLOW_ALWAYS_COMMAND = '_agentHost.permission.allowAlways'; const DENY_COMMAND = '_agentHost.permission.deny'; CommandsRegistry.registerCommand(ALLOW_COMMAND, (accessor: ServicesAccessor, requestId: string) => { - accessor.get(IAgentHostPermissionService).findPending(requestId)?.allow(); + accessor.get(IAgentHostResourceService).findPending(requestId)?.allow(); }); CommandsRegistry.registerCommand(ALLOW_ALWAYS_COMMAND, (accessor: ServicesAccessor, requestId: string) => { - accessor.get(IAgentHostPermissionService).findPending(requestId)?.allowAlways(); + accessor.get(IAgentHostResourceService).findPending(requestId)?.allowAlways(); }); CommandsRegistry.registerCommand(DENY_COMMAND, (accessor: ServicesAccessor, requestId: string) => { - accessor.get(IAgentHostPermissionService).findPending(requestId)?.deny(); + accessor.get(IAgentHostResourceService).findPending(requestId)?.deny(); }); /** - * Bridges {@link IAgentHostPermissionService} to the chat input notification + * Bridges {@link IAgentHostResourceService} to the chat input notification * banner. While there are pending permission requests, the oldest one is * shown above the chat input with three actions: * @@ -60,7 +60,7 @@ export class AgentHostPermissionUiContribution extends Disposable implements IWo private _lastRequestId: string | undefined; constructor( - @IAgentHostPermissionService private readonly _permissionService: IAgentHostPermissionService, + @IAgentHostResourceService private readonly _permissionService: IAgentHostResourceService, @IChatInputNotificationService private readonly _chatInputNotificationService: IChatInputNotificationService, @ILabelService private readonly _labelService: ILabelService, ) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 9b3caafbc9e0a..6340400b49451 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -793,7 +793,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // open with hydrated state. Use the unmanaged accessor to peek // without taking a fresh subscription, which would trigger a // duplicate snapshot fetch and (in tests) unrelated mock behaviour. - const existingState = this._readEagerlyCreatedSessionState(resolvedSession); + const existingState = await this._readEagerlyCreatedSessionState(resolvedSession, cancellationToken); if (!existingState) { // Eager-create did not produce server-side state (e.g. no @@ -842,12 +842,34 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * subscription (which would issue a duplicate snapshot fetch on the wire, * and in tests would synthesise placeholder state via the mock's auto- * hydration path). + * + * If the eager subscription exists but hasn't received its first snapshot + * yet (creation in flight), waits for it to hydrate or error before + * returning. This closes a race where the chat request arrives between + * `createSession` resolving and the snapshot landing. */ - private _readEagerlyCreatedSessionState(resolvedSession: URI): SessionState | undefined { + private async _readEagerlyCreatedSessionState(resolvedSession: URI, token: CancellationToken): Promise { const sub = this._config.connection.getSubscriptionUnmanaged(StateComponents.Session, resolvedSession); if (!sub) { return undefined; } + if (sub.value === undefined) { + // Snapshot is in flight. Attach the listener before re-checking + // to close a race where the snapshot lands between the value + // read and the listener attachment. + await new Promise(resolve => { + const store = new DisposableStore(); + const settle = () => { + store.dispose(); + resolve(); + }; + store.add(sub.onDidChange(settle)); + store.add(token.onCancellationRequested(settle)); + if (sub.value !== undefined || token.isCancellationRequested) { + settle(); + } + }); + } const value = sub.value; return (value && !(value instanceof Error)) ? value : undefined; } @@ -2792,9 +2814,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } private _toResourceAttachment(uri: URI, label: string, displayKind: string, sessionResource: URI, _meta: Record | undefined): MessageAttachment | undefined { - if (uri.scheme !== 'file') { - return undefined; - } const attachmentUri = this._rebaseAttachmentUri(uri, sessionResource); const attachment: MessageAttachment = { type: MessageAttachmentKind.Resource, uri: attachmentUri.toString(), label, displayKind }; if (_meta) { @@ -2804,9 +2823,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } private _toSelectionAttachment(location: Location, label: string, displayKind: string, sessionResource: URI, _meta: Record | undefined): MessageAttachment | undefined { - if (location.uri.scheme !== 'file') { - return undefined; - } const attachmentUri = this._rebaseAttachmentUri(location.uri, sessionResource); const attachment: MessageAttachment = { type: MessageAttachmentKind.Resource, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 0824aa44b587b..b27837d5804cb 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -3110,7 +3110,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(turnAction.message.attachments, undefined); })); - test('non-file URI variables are skipped', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test('non-file URI variables (e.g. untitled documents) are forwarded as attachments', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const uri = URI.from({ scheme: 'untitled', path: '/foo' }); @@ -3127,7 +3127,9 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.strictEqual(turnAction.message.attachments, undefined); + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.Resource, uri: uri.toString(), label: 'untitled', displayKind: 'document' }, + ]); })); test('tool variables are skipped', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -3171,6 +3173,7 @@ suite('AgentHostChatContribution', () => { assert.deepStrictEqual(turnAction.message.attachments, [ { type: MessageAttachmentKind.Resource, uri: URI.file('/workspace/a.ts').toString(), label: 'a.ts', displayKind: 'document' }, { type: MessageAttachmentKind.Resource, uri: URI.file('/workspace/lib').toString(), label: 'lib', displayKind: 'directory' }, + { type: MessageAttachmentKind.Resource, uri: 'vscode-remote:/remote/file.ts', label: 'remote.ts', displayKind: 'document' }, ]); })); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPermissionUiContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPermissionUiContribution.test.ts index c437243fb303d..087fac8c787e7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPermissionUiContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPermissionUiContribution.test.ts @@ -11,9 +11,9 @@ import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AgentHostPermissionMode, - IAgentHostPermissionService, + IAgentHostResourceService, IPendingResourceRequest, -} from '../../../../../../platform/agentHost/common/agentHostPermissionService.js'; +} from '../../../../../../platform/agentHost/common/agentHostResourceService.js'; import { AGENT_HOST_SCHEME, agentHostAuthority } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -25,11 +25,19 @@ import { IChatInputNotificationService, } from '../../../browser/widget/input/chatInputNotificationService.js'; -class FakePermissionService extends Disposable implements IAgentHostPermissionService { +class FakePermissionService extends Disposable implements IAgentHostResourceService { declare readonly _serviceBrand: undefined; readonly pending: ISettableObservable = observableValue('pending', []); readonly allPending: IObservable = this.pending; + list = async () => { throw new Error('not implemented'); }; + read = async () => { throw new Error('not implemented'); }; + write = async () => { throw new Error('not implemented'); }; + del = async () => { throw new Error('not implemented'); }; + move = async () => { throw new Error('not implemented'); }; + copy = async () => { throw new Error('not implemented'); }; + resolve = async () => { throw new Error('not implemented'); }; + mkdir = async () => { throw new Error('not implemented'); }; check = async () => true; request = async () => { /* */ }; pendingFor = () => this.pending; @@ -109,7 +117,7 @@ suite('AgentHostPermissionUiContribution', () => { function createContribution(): AgentHostPermissionUiContribution { const instantiationService = disposables.add(new TestInstantiationService()); - instantiationService.stub(IAgentHostPermissionService, permissionService); + instantiationService.stub(IAgentHostResourceService, permissionService); instantiationService.stub(IChatInputNotificationService, notificationService); instantiationService.stub(ILabelService, labelService); const contribution = instantiationService.createInstance(AgentHostPermissionUiContribution); diff --git a/src/vs/workbench/services/agentHost/common/agentHostPermissionService.ts b/src/vs/workbench/services/agentHost/common/agentHostResourceService.ts similarity index 57% rename from src/vs/workbench/services/agentHost/common/agentHostPermissionService.ts rename to src/vs/workbench/services/agentHost/common/agentHostResourceService.ts index 64ce17a9daeb9..1546f2688b7e2 100644 --- a/src/vs/workbench/services/agentHost/common/agentHostPermissionService.ts +++ b/src/vs/workbench/services/agentHost/common/agentHostResourceService.ts @@ -4,24 +4,35 @@ *--------------------------------------------------------------------------------------------*/ import { DeferredPromise } from '../../../../base/common/async.js'; +import { VSBuffer, decodeBase64 } from '../../../../base/common/buffer.js'; import { CancellationError } from '../../../../base/common/errors.js'; import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable, derived, observableValue } from '../../../../base/common/observable.js'; import { extUri } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; -import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; -import { normalizeRemoteAgentHostAddress } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; import { AgentHostAccessMode, + AgentHostLocalFilePermissionsSettingId, AgentHostPermissionMode, AgentHostPermissionsSetting, - IAgentHostPermissionService, + AgentHostResourcePermissionError, + IAgentHostResourceService, IPendingResourceRequest, - AgentHostLocalFilePermissionsSettingId, -} from '../../../../platform/agentHost/common/agentHostPermissionService.js'; -import { ResourceRequestParams } from '../../../../platform/agentHost/common/state/protocol/commands.js'; + IResourceListResult, + IResourceReadResult, + LOCAL_AGENT_HOST_ADDRESS, +} from '../../../../platform/agentHost/common/agentHostResourceService.js'; +import { normalizeRemoteAgentHostAddress } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { + ContentEncoding, + ResourceCopyParams, ResourceDeleteParams, ResourceMkdirParams, ResourceMoveParams, + ResourceRequestParams, ResourceResolveParams, ResourceResolveResult, ResourceType, ResourceWriteParams, +} from '../../../../platform/agentHost/common/state/protocol/commands.js'; +import { ROOT_STATE_URI } from '../../../../platform/agentHost/common/state/sessionState.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -43,7 +54,11 @@ interface IInMemoryGrant { } /** - * Default implementation of {@link IAgentHostPermissionService}. + * Default implementation of {@link IAgentHostResourceService} — the unified + * owner of agent-host-facing filesystem operations and the permission + * policy that gates them. Reads transparently fall back to + * {@link ITextModelService} so virtual resources (untitled documents, + * notebook cells, ...) work without the host having to know about them. * * Permission storage shape (in user settings): * @@ -52,44 +67,148 @@ interface IInMemoryGrant { * "localhost:3000": { * "file:///Users/me/.gitconfig": "r", * "file:///Users/me/.agentConfig": "rw" - * } + * }, + * "local": { ... } * } * ``` * - * - Keys are addresses normalized via {@link normalizeRemoteAgentHostAddress}. + * - Keys are addresses normalized via {@link normalizeRemoteAgentHostAddress}, + * with the in-process local agent host keyed under `'local'`. * - Values are URI strings → `r` | `rw`. Descendant URIs are covered by a - * parent grant (e.g. a grant for `.config/` covers `.config/foo.json`). + * parent grant. */ -export class AgentHostPermissionService extends Disposable implements IAgentHostPermissionService { +export class AgentHostResourceService extends Disposable implements IAgentHostResourceService { declare readonly _serviceBrand: undefined; - /** - * In-memory grants. Two kinds, both stored here so they share the - * `connectionClosed` cleanup pass: - * - * - **Implicit reads** added by `grantImplicitRead` (read-only, kept alive - * by an explicit disposable revocation handle from the caller). - * - **Session grants** from the user clicking "Allow" in the prompt - * (read or write, cleared when the connection closes or the window - * reloads). These have no caller-held disposable. - * - * Keyed by an opaque handle so callers can revoke independently. - */ private readonly _inMemoryGrants = new Map(); - - /** All pending requests across every connection. */ - private readonly _pending = observableValue('agentHostPermissions.pending', []); + private readonly _pending = observableValue('agentHostResources.pending', []); readonly allPending: IObservable = this._pending; constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @IFileService private readonly _fileService: IFileService, + @ITextModelService private readonly _textModelService: ITextModelService, @ILogService private readonly _logService: ILogService, ) { super(); } + // ---- Gated FS operations ------------------------------------------------ + + async list(address: string, uri: URI): Promise { + await this._gate(address, uri, AgentHostPermissionMode.Read, { channel: ROOT_STATE_URI, uri: uri.toString(), read: true }); + const stat = await this._fileService.resolve(uri); + if (!stat.isDirectory) { + throw new Error(`Resource is not a directory: ${uri.toString()}`); + } + return { + entries: (stat.children ?? []).map(c => ({ + name: c.name, + type: c.isDirectory ? 'directory' : 'file', + })), + }; + } + + async read(address: string, uri: URI): Promise { + await this._gate(address, uri, AgentHostPermissionMode.Read, { channel: ROOT_STATE_URI, uri: uri.toString(), read: true }); + try { + const content = await this._fileService.readFile(uri); + return { bytes: content.value }; + } catch (err) { + const virtual = await this._readVirtual(uri); + if (virtual) { + return { bytes: virtual }; + } + throw err; + } + } + + async write(address: string, params: ResourceWriteParams): Promise { + const uri = URI.parse(params.uri); + await this._gate(address, uri, AgentHostPermissionMode.Write, { channel: ROOT_STATE_URI, uri: uri.toString(), write: true }); + const buf = params.encoding === ContentEncoding.Base64 + ? decodeBase64(params.data) + : VSBuffer.fromString(params.data); + try { + if (params.createOnly) { + await this._fileService.createFile(uri, buf, { overwrite: false }); + } else { + await this._fileService.writeFile(uri, buf); + } + } catch (err) { + if (await this._writeVirtual(uri, buf)) { + return; + } + throw err; + } + } + + async del(address: string, params: ResourceDeleteParams): Promise { + const uri = URI.parse(params.uri); + await this._gate(address, uri, AgentHostPermissionMode.Write, { channel: ROOT_STATE_URI, uri: uri.toString(), write: true }); + await this._fileService.del(uri, { recursive: !!params.recursive }); + } + + async move(address: string, params: ResourceMoveParams): Promise { + const source = URI.parse(params.source); + const destination = URI.parse(params.destination); + await this._gate(address, source, AgentHostPermissionMode.Write, { channel: ROOT_STATE_URI, uri: source.toString(), write: true }); + await this._gate(address, destination, AgentHostPermissionMode.Write, { channel: ROOT_STATE_URI, uri: destination.toString(), write: true }); + await this._fileService.move(source, destination, !params.failIfExists); + } + + async copy(address: string, params: ResourceCopyParams): Promise { + const source = URI.parse(params.source); + const destination = URI.parse(params.destination); + await this._gate(address, source, AgentHostPermissionMode.Read, { channel: ROOT_STATE_URI, uri: source.toString(), read: true }); + await this._gate(address, destination, AgentHostPermissionMode.Write, { channel: ROOT_STATE_URI, uri: destination.toString(), write: true }); + await this._fileService.copy(source, destination, !params.failIfExists); + } + + async resolve(address: string, params: ResourceResolveParams): Promise { + const uri = URI.parse(params.uri); + await this._gate(address, uri, AgentHostPermissionMode.Read, { channel: ROOT_STATE_URI, uri: uri.toString(), read: true }); + let stat; + try { + stat = await this._fileService.stat(uri); + } catch (err) { + const virtual = await this._statVirtual(uri); + if (virtual) { + return virtual; + } + throw err; + } + let type: ResourceType; + if (stat.isSymbolicLink && params.followSymlinks === false) { + type = ResourceType.Symlink; + } else if (stat.isDirectory) { + type = ResourceType.Directory; + } else { + type = ResourceType.File; + } + return { + uri: uri.toString(), + type, + ...(stat.size !== undefined ? { size: stat.size } : {}), + ...(stat.mtime !== undefined ? { mtime: new Date(stat.mtime).toISOString() } : {}), + ...(stat.ctime !== undefined ? { ctime: new Date(stat.ctime).toISOString() } : {}), + ...(stat.etag ? { etag: stat.etag } : {}), + }; + } + + async mkdir(address: string, params: ResourceMkdirParams): Promise { + const uri = URI.parse(params.uri); + await this._gate(address, uri, AgentHostPermissionMode.Write, { channel: ROOT_STATE_URI, uri: uri.toString(), write: true }); + const existing = await this._fileService.stat(uri).catch(() => undefined); + if (existing && !existing.isDirectory) { + throw new Error(`Path exists and is not a directory: ${uri.toString()}`); + } + await this._fileService.createFolder(uri); + } + + // ---- Permission requests / observables --------------------------------- + async check(address: string, uri: URI, mode: AgentHostPermissionMode): Promise { const normalized = normalizeRemoteAgentHostAddress(address); const canonical = await this._canonicalize(uri); @@ -99,7 +218,6 @@ export class AgentHostPermissionService extends Disposable implements IAgentHost async request(address: string, params: ResourceRequestParams): Promise { const normalized = normalizeRemoteAgentHostAddress(address); const canonical = await this._canonicalize(URI.parse(params.uri)); - // Per AHP: a request with neither flag set is treated as read. const wantsWrite = params.write === true; const wantsRead = params.read === true || !wantsWrite; @@ -122,10 +240,6 @@ export class AgentHostPermissionService extends Disposable implements IAgentHost grantImplicitRead(address: string, uri: URI): IDisposable { const handle = generateUuid(); - // Implicit grants are usually for paths that exist (e.g. plugin - // directories on disk). Kick off realpath in the background; consumers - // await this promise before comparing so a symlinked grant root still - // covers descendant requests that resolve through the symlink. const lexical = extUri.normalizePath(uri); const realpath = this._fileService.realpath(lexical).then( real => real ?? lexical, @@ -164,6 +278,76 @@ export class AgentHostPermissionService extends Disposable implements IAgentHost // ---- internals --------------------------------------------------------- + private async _gate( + address: string, + uri: URI, + mode: AgentHostPermissionMode, + deniedRequest: ResourceRequestParams, + ): Promise { + if (!await this.check(address, uri, mode)) { + throw new AgentHostResourcePermissionError(deniedRequest); + } + } + + private async _readVirtual(uri: URI): Promise { + try { + const ref = await this._textModelService.createModelReference(uri); + try { + return VSBuffer.fromString(ref.object.textEditorModel.getValue()); + } finally { + ref.dispose(); + } + } catch { + return undefined; + } + } + + /** + * Write {@link bytes} as text into the resolved text model for {@link uri}, + * if one can be resolved and is writable. Returns `true` when the model was + * updated, `false` otherwise (no provider, readonly, decode failure). + */ + private async _writeVirtual(uri: URI, bytes: VSBuffer): Promise { + try { + const ref = await this._textModelService.createModelReference(uri); + try { + if (ref.object.isReadonly()) { + return false; + } + ref.object.textEditorModel.setValue(bytes.toString()); + return true; + } finally { + ref.dispose(); + } + } catch { + return false; + } + } + + /** + * Resolve {@link uri} via {@link ITextModelService} and synthesize a + * {@link ResourceResolveResult} so virtual resources stat as `File` with + * a size matching their text content. Returns `undefined` if no model + * can be resolved. + */ + private async _statVirtual(uri: URI): Promise { + try { + const ref = await this._textModelService.createModelReference(uri); + try { + const size = VSBuffer.fromString(ref.object.textEditorModel.getValue()).byteLength; + return { + uri: uri.toString(), + type: ResourceType.File, + size, + }; + } finally { + ref.dispose(); + } + } catch { + return undefined; + } + } + /** * Resolve {@link uri} against the local filesystem, collapsing `..` * segments and following symlinks so the policy check sees the same @@ -177,8 +361,6 @@ export class AgentHostPermissionService extends Disposable implements IAgentHost if (real) { return real; } - // File doesn't exist (yet). Realpath the parent so symlinks in the - // directory chain are still resolved. const parent = extUri.dirname(normalized); if (extUri.isEqual(parent, normalized)) { return normalized; @@ -189,16 +371,12 @@ export class AgentHostPermissionService extends Disposable implements IAgentHost : normalized; } - /** - * Policy check against in-memory + persisted grants. Asynchronous - * because in-memory grants from {@link grantImplicitRead} carry an - * unresolved realpath promise — see {@link IInMemoryGrant.realpath}. - */ private async _isCovered(address: string, canonicalUri: URI, mode: AgentHostPermissionMode): Promise { + if (address === LOCAL_AGENT_HOST_ADDRESS) { + return true; + } const requireWrite = mode === AgentHostPermissionMode.Write; - // Persisted grants are synchronous; check them first to short-circuit - // without awaiting any in-memory realpath promises. for (const grant of this._readPersistedGrants(address)) { if (requireWrite && grant.mode !== AgentHostAccessMode.ReadWrite) { continue; @@ -208,8 +386,6 @@ export class AgentHostPermissionService extends Disposable implements IAgentHost } } - // In-memory grants — await each candidate's realpath so symlinked - // grant roots compare against the canonicalized request URI. const candidates: Promise[] = []; for (const grant of this._inMemoryGrants.values()) { if (grant.address !== address) { @@ -254,11 +430,6 @@ export class AgentHostPermissionService extends Disposable implements IAgentHost ? AgentHostAccessMode.ReadWrite : AgentHostAccessMode.Read; - // Always add an in-memory grant so the host's retry of the original - // operation hits a covered check synchronously. For "persist", the - // settings write is fire-and-forget; the in-memory cover hides any - // latency in the configuration service propagating the update. - // `request.uri` is already canonical (canonicalized in `request()`). this._inMemoryGrants.set(generateUuid(), { address: request.address, realpath: Promise.resolve(request.uri), @@ -267,7 +438,7 @@ export class AgentHostPermissionService extends Disposable implements IAgentHost if (scope === 'persist') { void this._persistGrant(request.address, request.uri, request.mode).catch(err => { - this._logService.warn('[AgentHostPermissionService] Failed to persist grant', err); + this._logService.warn('[AgentHostResourceService] Failed to persist grant', err); }); } @@ -305,7 +476,6 @@ export class AgentHostPermissionService extends Disposable implements IAgentHost ? AgentHostAccessMode.ReadWrite : AgentHostAccessMode.Read; - // If a covering ancestor already grants enough, do nothing. for (const grant of this._readPersistedGrants(address)) { const covers = grant.mode === AgentHostAccessMode.ReadWrite || requested === AgentHostAccessMode.Read; if (covers && extUri.isEqualOrParent(uri, grant.uri)) { @@ -317,7 +487,7 @@ export class AgentHostPermissionService extends Disposable implements IAgentHost const forAddress: Record = { ...(value[address] ?? {}) }; const uriKey = uri.toString(); if (forAddress[uriKey] === AgentHostAccessMode.ReadWrite) { - return; // Already at the strongest level. + return; } forAddress[uriKey] = requested; @@ -328,13 +498,6 @@ export class AgentHostPermissionService extends Disposable implements IAgentHost ); } - /** - * Inspect the setting and pick the scope to write back to. The setting - * is registered with `ConfigurationScope.APPLICATION`, so APPLICATION is - * the canonical home; we still honour pre-existing values in the - * user-* scopes so a hand-edited entry isn't silently relocated, but - * fresh writes default to APPLICATION. - */ private _inspectScopedSetting(): { target: ConfigurationTarget; value: AgentHostPermissionsSetting } { const inspected = this._configurationService.inspect(AgentHostLocalFilePermissionsSettingId); if (inspected.applicationValue !== undefined) { @@ -353,4 +516,4 @@ export class AgentHostPermissionService extends Disposable implements IAgentHost } } -registerSingleton(IAgentHostPermissionService, AgentHostPermissionService, InstantiationType.Delayed); +registerSingleton(IAgentHostResourceService, AgentHostResourceService, InstantiationType.Delayed); diff --git a/src/vs/workbench/services/agentHost/electron-browser/agentHostService.ts b/src/vs/workbench/services/agentHost/electron-browser/agentHostService.ts index 299a4b0e5fab9..dfa1cb6ea1fe9 100644 --- a/src/vs/workbench/services/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/workbench/services/agentHost/electron-browser/agentHostService.ts @@ -5,7 +5,7 @@ // Registers `IAgentHostService` for the desktop workbench. When the window // is attached to a remote authority, the renderer talks to the agent host -// running on the remote (via `VSCodeRemoteAgentHostServiceClient`); +// running on the remote (via `EditorRemoteAgentHostServiceClient`); // otherwise it uses the local utility-process agent host // (`LocalAgentHostServiceClient`). diff --git a/src/vs/workbench/services/agentHost/test/common/agentHostPermissionService.test.ts b/src/vs/workbench/services/agentHost/test/common/agentHostResourceService.test.ts similarity index 96% rename from src/vs/workbench/services/agentHost/test/common/agentHostPermissionService.test.ts rename to src/vs/workbench/services/agentHost/test/common/agentHostResourceService.test.ts index 4062127dbf978..f913dbed3d679 100644 --- a/src/vs/workbench/services/agentHost/test/common/agentHostPermissionService.test.ts +++ b/src/vs/workbench/services/agentHost/test/common/agentHostResourceService.test.ts @@ -16,8 +16,11 @@ import { AgentHostPermissionMode, AgentHostPermissionsSetting, AgentHostLocalFilePermissionsSettingId, -} from '../../../../../platform/agentHost/common/agentHostPermissionService.js'; -import { AgentHostPermissionService } from '../../common/agentHostPermissionService.js'; +} from '../../../../../platform/agentHost/common/agentHostResourceService.js'; +import { AgentHostResourceService } from '../../common/agentHostResourceService.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; + +const stubTextModelService = {} as unknown as ITextModelService; class CapturingConfigurationService extends TestConfigurationService { override async updateValue(key: string, value: unknown, arg3?: ConfigurationTarget | unknown): Promise { @@ -47,15 +50,15 @@ function createStubFileService(opts?: { } as unknown as IFileService; } -suite('AgentHostPermissionService', () => { +suite('AgentHostResourceService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createService(initial?: AgentHostPermissionsSetting, fileService = createStubFileService()): { service: AgentHostPermissionService; config: CapturingConfigurationService } { + function createService(initial?: AgentHostPermissionsSetting, fileService = createStubFileService()): { service: AgentHostResourceService; config: CapturingConfigurationService } { const config = new CapturingConfigurationService(); if (initial) { void config.setUserConfiguration(AgentHostLocalFilePermissionsSettingId, initial); } - const service = disposables.add(new AgentHostPermissionService(config, fileService, new NullLogService())); + const service = disposables.add(new AgentHostResourceService(config, fileService, stubTextModelService, new NullLogService())); return { service, config }; } @@ -262,7 +265,7 @@ suite('AgentHostPermissionService', () => { await super.updateValue(key, value, target as ConfigurationTarget); } })(); - const service = disposables.add(new AgentHostPermissionService(config, createStubFileService(), new NullLogService())); + const service = disposables.add(new AgentHostResourceService(config, createStubFileService(), stubTextModelService, new NullLogService())); const uri = URI.file('/etc/foo'); const promise = service.request('host', { channel: 'ahp-root://', uri: uri.toString(), read: true }); @@ -494,7 +497,7 @@ suite('AgentHostPermissionService', () => { } return originalInspect(key); }; - const service = disposables.add(new AgentHostPermissionService(config, createStubFileService(), new NullLogService())); + const service = disposables.add(new AgentHostResourceService(config, createStubFileService(), stubTextModelService, new NullLogService())); const promise = service.request('host', { channel: 'ahp-root://', uri: URI.file('/etc/foo').toString(), read: true }); await new Promise(resolve => setTimeout(resolve, 0)); @@ -525,7 +528,7 @@ suite('AgentHostPermissionService', () => { } return originalInspect(key); }; - const service = disposables.add(new AgentHostPermissionService(config, createStubFileService(), new NullLogService())); + const service = disposables.add(new AgentHostResourceService(config, createStubFileService(), stubTextModelService, new NullLogService())); const promise = service.request('host', { channel: 'ahp-root://', uri: URI.file('/etc/foo').toString(), read: true }); await new Promise(resolve => setTimeout(resolve, 0)); @@ -551,3 +554,4 @@ suite('AgentHostPermissionService', () => { assert.strictEqual(await service.check('host', URI.file('/etc/good'), AgentHostPermissionMode.Read), true); }); }); + diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 532c20d1cf1db..5eb62e5e261ca 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -143,7 +143,7 @@ import './services/editor/common/customEditorLabelService.js'; import './services/dataChannel/browser/dataChannelService.js'; import './services/inlineCompletions/common/inlineCompletionsUnification.js'; import './services/chat/common/chatEntitlementService.js'; -import './services/agentHost/common/agentHostPermissionService.js'; +import './services/agentHost/common/agentHostResourceService.js'; import './services/log/common/defaultLogLevels.js'; import { InstantiationType, registerSingleton } from '../platform/instantiation/common/extensions.js'; From 248c024ac767332d219fc365fd31ee480bb2894d Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 5 Jun 2026 11:04:37 +0100 Subject: [PATCH 24/24] Refactor border-radius styles for consistency (#320065) * Refactor CSS border-radius properties to use variable values - Updated various CSS files to replace hardcoded border-radius values with corresponding CSS variables for consistency and maintainability. - Adjusted border-radius for elements in openInVSCode.css, sidebarActionButton.css, style.css, auxiliaryBarPart.css, chatCompositeBar.css, panelPart.css, projectBarPart.css, mobile styles, agentFeedback styles, changes view styles, and more. - Ensured that all instances of border-radius now utilize the defined variables such as --vscode-cornerRadius-small, --vscode-cornerRadius-medium, --vscode-cornerRadius-large, etc. Co-authored-by: Copilot * style: unify border-radius for action bar items across components Co-authored-by: Copilot * style: update border-radius to use small variable for titlebar and session button * style: update border-radius for chat input area in phone layout --------- Co-authored-by: mrleemurray Co-authored-by: Copilot --- .../sessions/browser/media/openInVSCode.css | 2 +- .../browser/media/sidebarActionButton.css | 2 +- src/vs/sessions/browser/media/style.css | 35 ++++++++++++------- .../browser/parts/media/auxiliaryBarPart.css | 4 +-- .../browser/parts/media/chatCompositeBar.css | 4 +-- .../browser/parts/media/panelPart.css | 4 +-- .../browser/parts/media/projectBarPart.css | 12 +++---- .../browser/parts/media/sidebarPart.css | 2 +- .../browser/parts/media/titlebarpart.css | 2 +- .../media/mobileMultiDiffView.css | 2 +- .../media/mobileOverlayViews.css | 12 +++---- .../parts/mobile/media/mobilePickerSheet.css | 8 ++--- .../mobile/media/mobileSessionFilterChips.css | 2 +- .../mobile/media/mobileSortGroupSheet.css | 2 +- .../browser/parts/mobile/mobileChatShell.css | 10 +++--- .../browser/media/accountTitleBarWidget.css | 2 +- .../media/agentFeedbackEditorInput.css | 4 +-- .../media/agentFeedbackEditorOverlay.css | 4 +-- .../media/agentFeedbackEditorWidget.css | 16 ++++----- .../changes/browser/media/changesView.css | 8 ++--- .../browser/media/changesViewActions.css | 2 +- .../changes/browser/media/checksWidget.css | 4 +-- .../contrib/chat/browser/media/chatInput.css | 10 +++--- .../contrib/chat/browser/media/chatWidget.css | 4 +-- .../browser/media/noAgentHostEmptyState.css | 2 +- .../chat/browser/media/runScriptAction.css | 14 ++++---- .../browser/media/sessionsPolicyBlocked.css | 4 +-- .../browser/media/hostFilter.css | 4 +-- .../browser/media/hostPickerDropdown.css | 2 +- .../browser/media/hostPickerSheet.css | 2 +- .../sessions/browser/media/sessionsList.css | 12 ++++--- .../browser/media/sessionsTitleBarWidget.css | 2 +- .../browser/media/sessionsViewPane.css | 4 +-- .../electron-browser/media/tunnelHost.css | 2 +- 34 files changed, 109 insertions(+), 96 deletions(-) diff --git a/src/vs/sessions/browser/media/openInVSCode.css b/src/vs/sessions/browser/media/openInVSCode.css index 03119964326ca..f558bcab22ab1 100644 --- a/src/vs/sessions/browser/media/openInVSCode.css +++ b/src/vs/sessions/browser/media/openInVSCode.css @@ -10,7 +10,7 @@ height: 22px; padding: 0 4px; margin: 0 4px 0 2px; - border-radius: 5px; + border-radius: var(--vscode-cornerRadius-small); cursor: pointer; color: var(--vscode-titleBar-activeForeground); -webkit-app-region: no-drag; diff --git a/src/vs/sessions/browser/media/sidebarActionButton.css b/src/vs/sessions/browser/media/sidebarActionButton.css index 973477830032b..bbf51987dc08f 100644 --- a/src/vs/sessions/browser/media/sidebarActionButton.css +++ b/src/vs/sessions/browser/media/sidebarActionButton.css @@ -26,7 +26,7 @@ text-align: left; justify-content: flex-start; text-decoration: none; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); cursor: pointer; gap: 6px; display: flex; diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index b03918772de53..4cc8f58c36594 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -3,6 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* ---- Action Bar Items ---- */ + +/* Default rounded hover/active background for all action bar items in the + * agents window. Components that need a different shape (e.g. circular avatars + * or the workspace profile icon) override this with higher-specificity rules. */ +.agent-sessions-workbench .monaco-action-bar .action-label { + border-radius: var(--vscode-cornerRadius-small); +} + /* ---- Sidebar & Auxiliary Bar Card Appearance ---- */ .agent-sessions-workbench .part.sidebar { @@ -144,7 +153,7 @@ margin: 0 5px 5px 10px; background: var(--part-background); border: 1px solid var(--part-border-color, transparent); - border-radius: 8px; + border-radius: var(--vscode-cornerRadius-large); box-sizing: border-box; } @@ -221,7 +230,7 @@ } .agent-sessions-workbench .part.editor .multiDiffEntry .header-content { - border-radius: 6px; + border-radius: var(--vscode-cornerRadius-medium); border: none; background: var(--vscode-sideBarSectionHeader-background); margin: 0; @@ -260,7 +269,7 @@ } .agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a { - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); } .agent-sessions-workbench .part.editor .multiDiffEntry .editorParent { @@ -275,13 +284,13 @@ /* The hidden-lines bar is split into two halves (original / modified) sitting * side by side. Round only the outer corners so the pair forms a single pill. */ .agent-sessions-workbench .part.editor .editor.original .diff-hidden-lines .center { - border-top-left-radius: 6px; - border-bottom-left-radius: 6px; + border-top-left-radius: var(--vscode-cornerRadius-medium); + border-bottom-left-radius: var(--vscode-cornerRadius-medium); } .agent-sessions-workbench .part.editor .editor.modified .diff-hidden-lines .center { - border-top-right-radius: 6px; - border-bottom-right-radius: 6px; + border-top-right-radius: var(--vscode-cornerRadius-medium); + border-bottom-right-radius: var(--vscode-cornerRadius-medium); } .agent-sessions-workbench .part.editor .multiDiffEntry .header-content .status { @@ -316,7 +325,7 @@ .agent-sessions-workbench .part.editor .tabs-container > .tab { background-color: transparent !important; border-right: none !important; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); font-size: 12px; font-weight: 500; box-shadow: none !important; @@ -417,7 +426,7 @@ margin: 5px 0 0 10px; background: var(--part-background); border: 1px solid var(--part-border-color, transparent); - border-radius: 8px; + border-radius: var(--vscode-cornerRadius-large); box-sizing: border-box; } @@ -449,7 +458,7 @@ content: ''; position: absolute; inset: 1px; - border-radius: 999px; + border-radius: var(--vscode-cornerRadius-circle); background: var(--vscode-scrollbarSlider-background); } @@ -768,7 +777,7 @@ /* Badge */ .agent-sessions-workbench .badge > .badge-content { - border-radius: 4px !important; + border-radius: var(--vscode-cornerRadius-small) !important; } /* Phone-layout rules for parts, sashes, max-width constraints, and grid @@ -885,7 +894,7 @@ } .agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast .notification-toast-container { - border-radius: 12px; + border-radius: var(--vscode-cornerRadius-xLarge); } /* ---- Phone Layout: Hover Cards ---- */ @@ -991,7 +1000,7 @@ height: 5px; background: var(--vscode-foreground); opacity: 0.3; - border-radius: 3px; + border-radius: var(--vscode-cornerRadius-small); margin: 8px auto 4px auto; } diff --git a/src/vs/sessions/browser/parts/media/auxiliaryBarPart.css b/src/vs/sessions/browser/parts/media/auxiliaryBarPart.css index f9875f2027562..c662ff3ad17f1 100644 --- a/src/vs/sessions/browser/parts/media/auxiliaryBarPart.css +++ b/src/vs/sessions/browser/parts/media/auxiliaryBarPart.css @@ -9,7 +9,7 @@ .agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon) .action-label { text-transform: capitalize; font-weight: 500; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); padding: 0px 8px; font-size: 12px; line-height: 22px; @@ -42,7 +42,7 @@ /* Active/checked state: background container instead of underline */ .agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked { background-color: color-mix(in srgb, var(--vscode-agentsPanel-foreground, var(--vscode-foreground)) 5%, transparent) !important; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); } /* Override base workbench monaco editor background in auxiliary bar content */ diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 5cbcf3a273252..6023b6556a5e9 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -263,7 +263,7 @@ font-size: 12px; line-height: 22px; height: 26px; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); user-select: none; flex-shrink: 0; max-width: min(200px, 40cqi); @@ -355,7 +355,7 @@ .chat-composite-bar-tab-actions .action-item .action-label { width: 16px; height: 16px; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); color: var(--chat-tab-inactive-foreground, currentColor); } diff --git a/src/vs/sessions/browser/parts/media/panelPart.css b/src/vs/sessions/browser/parts/media/panelPart.css index 810e10b19c63f..57e58bf872de7 100644 --- a/src/vs/sessions/browser/parts/media/panelPart.css +++ b/src/vs/sessions/browser/parts/media/panelPart.css @@ -14,7 +14,7 @@ .agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon) .action-label { text-transform: capitalize; font-weight: 500; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); padding: 0px 8px; font-size: 12px; line-height: 22px; @@ -47,7 +47,7 @@ /* Active/checked state: background container instead of underline */ .agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked { background-color: color-mix(in srgb, var(--vscode-agentsPanel-foreground, var(--vscode-foreground)) 5%, transparent) !important; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); } /* Override base workbench panel content background for terminal */ diff --git a/src/vs/sessions/browser/parts/media/projectBarPart.css b/src/vs/sessions/browser/parts/media/projectBarPart.css index 70576594a7a3f..62aebfb80ca11 100644 --- a/src/vs/sessions/browser/parts/media/projectBarPart.css +++ b/src/vs/sessions/browser/parts/media/projectBarPart.css @@ -59,7 +59,7 @@ position: absolute; inset: 6px; border: 1px solid var(--vscode-focusBorder); - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); pointer-events: none; } @@ -70,7 +70,7 @@ width: 24px; height: 24px; font-size: 16px; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); color: var(--vscode-activityBar-inactiveForeground); } @@ -94,7 +94,7 @@ text-transform: uppercase; background-color: var(--vscode-activityBar-inactiveForeground); color: var(--vscode-activityBar-background); - border-radius: 6px; + border-radius: var(--vscode-cornerRadius-medium); } .monaco-workbench .projectbar .action-item.workspace-entry:hover .action-label.workspace-icon { @@ -134,7 +134,7 @@ width: 2px; height: 24px; background-color: transparent; - border-radius: 0 2px 2px 0; + border-radius: 0 var(--vscode-cornerRadius-xSmall) var(--vscode-cornerRadius-xSmall) 0; } .monaco-workbench .projectbar .action-item.workspace-entry.checked .active-item-indicator { @@ -238,7 +238,7 @@ height: 16px; line-height: 16px; padding: 0 4px; - border-radius: 20px; + border-radius: var(--vscode-cornerRadius-xLarge); text-align: center; } @@ -250,7 +250,7 @@ top: 24px; right: 6px; padding: 2px 3px; - border-radius: 7px; + border-radius: var(--vscode-cornerRadius-large); background-color: var(--vscode-profileBadge-background); color: var(--vscode-profileBadge-foreground); border: 2px solid var(--vscode-activityBar-background); diff --git a/src/vs/sessions/browser/parts/media/sidebarPart.css b/src/vs/sessions/browser/parts/media/sidebarPart.css index 28f63b4d66872..a2efe1dfdccfd 100644 --- a/src/vs/sessions/browser/parts/media/sidebarPart.css +++ b/src/vs/sessions/browser/parts/media/sidebarPart.css @@ -31,7 +31,7 @@ /* Toggled state for the sidebar toggle button in the sidebar title area */ .agent-sessions-workbench .part.sidebar > .composite.title > .global-actions-left .action-label.checked { background: var(--vscode-toolbar-activeBackground); - border-radius: var(--vscode-cornerRadius-medium); + border-radius: var(--vscode-cornerRadius-small); } /* Preserve toggled background when the toggle button is hovered or focused */ diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css index 532ed2516bda3..8b40db4e828d8 100644 --- a/src/vs/sessions/browser/parts/media/titlebarpart.css +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -120,7 +120,7 @@ /* Toggled action buttons in session actions toolbar */ .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container .action-label.checked { background: var(--vscode-toolbar-activeBackground); - border-radius: var(--vscode-cornerRadius-medium); + border-radius: var(--vscode-cornerRadius-small); } /* Secondary sidebar toggle uses icon variants for toggle state — no background needed */ diff --git a/src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css b/src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css index bc174c4977b23..91f137e1ac696 100644 --- a/src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css +++ b/src/vs/sessions/browser/parts/mobile/contributions/media/mobileMultiDiffView.css @@ -84,7 +84,7 @@ flex-shrink: 0; color: var(--vscode-descriptionForeground); padding: 4px; - border-radius: 3px; + border-radius: var(--vscode-cornerRadius-small); cursor: pointer; } diff --git a/src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css b/src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css index 2230c9b275021..72783c69a75c0 100644 --- a/src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css +++ b/src/vs/sessions/browser/parts/mobile/contributions/media/mobileOverlayViews.css @@ -45,7 +45,7 @@ background: none; color: var(--vscode-foreground); cursor: pointer; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); font-size: 14px; white-space: nowrap; touch-action: manipulation; @@ -96,7 +96,7 @@ align-items: center; gap: 3px; padding: 1px 6px; - border-radius: 10px; + border-radius: var(--vscode-cornerRadius-circle); font-size: 11px; font-weight: 500; white-space: nowrap; @@ -155,7 +155,7 @@ gap: 6px; height: 36px; padding: 0 14px; - border-radius: 6px; + border-radius: var(--vscode-cornerRadius-medium); border: 1px solid var(--vscode-widget-border, color-mix(in srgb, var(--vscode-foreground) 15%, transparent)); background: transparent; color: var(--vscode-foreground); @@ -196,7 +196,7 @@ right: calc(16px + env(safe-area-inset-right)); height: 36px; padding: 0 14px; - border-radius: 18px; + border-radius: var(--vscode-cornerRadius-circle); border: 1px solid var(--vscode-widget-border, transparent); background: var(--vscode-editor-background); color: var(--vscode-foreground); @@ -407,7 +407,7 @@ background: none; color: var(--vscode-foreground); cursor: pointer; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); touch-action: manipulation; } @@ -544,7 +544,7 @@ justify-content: center; width: 22px; height: 22px; - border-radius: 11px; + border-radius: var(--vscode-cornerRadius-circle); font-size: 11px; font-weight: 600; font-family: var(--monaco-monospace-font, 'SF Mono', Menlo, Monaco, Consolas, monospace); diff --git a/src/vs/sessions/browser/parts/mobile/media/mobilePickerSheet.css b/src/vs/sessions/browser/parts/mobile/media/mobilePickerSheet.css index fe18ef68f580d..7c66f51eb9462 100644 --- a/src/vs/sessions/browser/parts/mobile/media/mobilePickerSheet.css +++ b/src/vs/sessions/browser/parts/mobile/media/mobilePickerSheet.css @@ -64,7 +64,7 @@ width: 36px; height: 5px; margin: 6px auto 2px; - border-radius: 3px; + border-radius: var(--vscode-cornerRadius-small); background: color-mix(in srgb, var(--vscode-foreground) 28%, transparent); flex-shrink: 0; } @@ -178,7 +178,7 @@ margin: 4px 16px 10px; padding: 0 10px; height: 36px; - border-radius: 18px; + border-radius: var(--vscode-cornerRadius-circle); background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); flex-shrink: 0; } @@ -276,7 +276,7 @@ padding: 8px 12px; margin: 2px 0; border: none; - border-radius: 12px; + border-radius: var(--vscode-cornerRadius-xLarge); background: transparent; color: inherit; font-family: inherit; @@ -326,7 +326,7 @@ justify-content: center; width: 36px; height: 36px; - border-radius: 8px; + border-radius: var(--vscode-cornerRadius-large); background: color-mix(in srgb, var(--vscode-foreground) 8%, transparent); flex-shrink: 0; color: var(--vscode-descriptionForeground); diff --git a/src/vs/sessions/browser/parts/mobile/media/mobileSessionFilterChips.css b/src/vs/sessions/browser/parts/mobile/media/mobileSessionFilterChips.css index e067ce36852a3..030e3d57eda04 100644 --- a/src/vs/sessions/browser/parts/mobile/media/mobileSessionFilterChips.css +++ b/src/vs/sessions/browser/parts/mobile/media/mobileSessionFilterChips.css @@ -41,7 +41,7 @@ align-items: center; gap: 4px; padding: 4px 10px; - border-radius: 14px; + border-radius: var(--vscode-cornerRadius-circle); font-size: 12px; line-height: 18px; white-space: nowrap; diff --git a/src/vs/sessions/browser/parts/mobile/media/mobileSortGroupSheet.css b/src/vs/sessions/browser/parts/mobile/media/mobileSortGroupSheet.css index 42a14513c9ae6..356fcbf30f44e 100644 --- a/src/vs/sessions/browser/parts/mobile/media/mobileSortGroupSheet.css +++ b/src/vs/sessions/browser/parts/mobile/media/mobileSortGroupSheet.css @@ -59,7 +59,7 @@ width: 36px; height: 4px; margin: 8px auto 4px; - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-xSmall); background: color-mix(in srgb, var(--vscode-foreground) 30%, transparent); flex-shrink: 0; } diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css index 722ab8c9b82cf..46c95557c94f7 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css +++ b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css @@ -73,7 +73,7 @@ width: auto; height: 28px; padding: 0 10px; - border-radius: 14px; + border-radius: var(--vscode-cornerRadius-circle); gap: 4px; font-size: 12px; line-height: 18px; @@ -415,7 +415,7 @@ min-height: 36px; padding: 2px 6px; font-size: 17px; - border-radius: 999px; + border-radius: var(--vscode-cornerRadius-circle); background-color: var(--vscode-toolbar-hoverBackground, transparent); border: 1px solid var(--vscode-commandCenter-inactiveBorder, var(--vscode-commandCenter-border, transparent)); gap: 4px; @@ -591,7 +591,7 @@ min-height: 30px; min-width: 0; padding: 2px 6px; - border-radius: 999px; + border-radius: var(--vscode-cornerRadius-circle); background-color: var(--vscode-toolbar-hoverBackground, transparent); border: 1px solid transparent; color: var(--vscode-foreground); @@ -844,7 +844,7 @@ min-height: 52px; padding: 0 4px; border: none; - border-radius: 12px; + border-radius: var(--vscode-cornerRadius-xLarge); background: none; color: var(--vscode-foreground); font-size: 16px; @@ -955,7 +955,7 @@ .agent-sessions-find-widget-container > .monaco-tree-type-filter .monaco-tree-type-filter-input > .monaco-findInput > .monaco-inputbox { min-height: 36px; - border-radius: 18px; + border-radius: var(--vscode-cornerRadius-circle); padding: 0 12px; font-size: 14px; width: 100%; diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css index dca6578d1c455..6c708c0b7976d 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css @@ -85,7 +85,7 @@ min-width: 18px; height: 16px; padding: 0 5px; - border-radius: 8px; + border-radius: var(--vscode-cornerRadius-large); background: var(--vscode-badge-background); color: var(--vscode-badge-foreground); font-size: 10px; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css index 82ed568e03679..d9c001b40fc21 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css @@ -9,7 +9,7 @@ background-color: var(--vscode-panel-background); border: 1px solid var(--vscode-agentFeedbackInputWidget-border, var(--vscode-editorWidget-border, var(--vscode-contrastBorder))); box-shadow: var(--vscode-shadow-lg); - border-radius: 8px; + border-radius: var(--vscode-cornerRadius-large); padding: 4px; display: flex; flex-direction: row; @@ -39,7 +39,7 @@ border: none; color: var(--vscode-input-foreground); font: inherit; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); padding: 0 0 0 6px; outline: none; min-width: 150px; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css index 766e481b9eb51..928157658e76d 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css @@ -7,7 +7,7 @@ padding: 2px 4px; color: var(--vscode-foreground); background-color: var(--vscode-editorWidget-background); - border-radius: 6px; + border-radius: var(--vscode-cornerRadius-medium); border: 1px solid var(--vscode-editorHoverWidget-border); display: flex; align-items: center; @@ -22,7 +22,7 @@ padding: 4px 6px; font-size: 11px; line-height: 14px; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); } .agent-feedback-editor-overlay-widget .monaco-action-bar .actions-container { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css index 7d6c8eec37609..46141ef991e49 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css @@ -10,7 +10,7 @@ min-width: 180px; background-color: var(--vscode-editorWidget-background); border: 1px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); - border-radius: 8px; + border-radius: var(--vscode-cornerRadius-large); box-shadow: var(--vscode-shadow-lg); font-size: 12px; line-height: 1.4; @@ -71,7 +71,7 @@ align-items: center; padding: 4px 4px 4px 8px; border-bottom: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border)); - border-radius: 8px 8px 0 0; + border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) 0 0; overflow: hidden; cursor: pointer; gap: 6px; @@ -79,7 +79,7 @@ .agent-feedback-widget.collapsed .agent-feedback-widget-header { border-bottom: none; - border-radius: 8px 8px 8px 8px; + border-radius: var(--vscode-cornerRadius-large); } .agent-feedback-widget-header:hover { @@ -193,7 +193,7 @@ display: inline-flex; align-items: center; padding: 1px 6px; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); font-size: 10px; font-weight: 600; letter-spacing: 0.2px; @@ -237,7 +237,7 @@ font-family: var(--monaco-monospace-font); font-size: 11px; padding: 1px 4px; - border-radius: 3px; + border-radius: var(--vscode-cornerRadius-small); background: color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 25%, transparent); } @@ -274,7 +274,7 @@ .agent-feedback-widget-suggestion-text { margin: 0; padding: 6px 8px; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); overflow-x: auto; white-space: pre-wrap; font-family: var(--monaco-monospace-font); @@ -303,7 +303,7 @@ min-height: 22px; padding: 4px 6px; border: 1px solid var(--vscode-focusBorder); - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); background: var(--vscode-input-background); color: var(--vscode-input-foreground); font: inherit; @@ -347,7 +347,7 @@ font-family: var(--monaco-monospace-font); font-size: 11px; padding: 1px 4px; - border-radius: 3px; + border-radius: var(--vscode-cornerRadius-small); background: color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 25%, transparent); } diff --git a/src/vs/sessions/contrib/changes/browser/media/changesView.css b/src/vs/sessions/contrib/changes/browser/media/changesView.css index 4c3c7ca6cd524..b868d12f1d11f 100644 --- a/src/vs/sessions/contrib/changes/browser/media/changesView.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesView.css @@ -205,7 +205,7 @@ padding: 4px; width: auto; min-width: 0; - border-radius: 0px 4px 4px 0px; + border-radius: 0px var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0px; } .changes-view-body .chat-editing-session-actions.outside-card .monaco-button.secondary.monaco-text-button.codicon { @@ -222,7 +222,7 @@ .changes-view-body .chat-editing-session-actions .monaco-button.secondary.monaco-text-button.codicon { cursor: pointer; padding: 2px; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); display: inline-flex; } @@ -233,7 +233,7 @@ } .changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button.secondary.monaco-text-button { - border-radius: 4px 0px 0px 4px; + border-radius: var(--vscode-cornerRadius-small) 0px 0px var(--vscode-cornerRadius-small); } .changes-view-body .chat-editing-session-actions .monaco-button.secondary:hover { @@ -278,7 +278,7 @@ } .changes-view-body .chat-editing-session-container.show-file-icons .monaco-scrollable-element .monaco-list-rows .monaco-list-row { - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); } /* Action bar in list rows */ diff --git a/src/vs/sessions/contrib/changes/browser/media/changesViewActions.css b/src/vs/sessions/contrib/changes/browser/media/changesViewActions.css index b848ca2f2d161..639362e884cc1 100644 --- a/src/vs/sessions/contrib/changes/browser/media/changesViewActions.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesViewActions.css @@ -9,7 +9,7 @@ gap: 3px; cursor: pointer; padding: 0 4px; - border-radius: 3px; + border-radius: var(--vscode-cornerRadius-small); } .changes-action-view-item:hover { diff --git a/src/vs/sessions/contrib/changes/browser/media/checksWidget.css b/src/vs/sessions/contrib/changes/browser/media/checksWidget.css index 49f8a949094a2..83c723fffe8f1 100644 --- a/src/vs/sessions/contrib/changes/browser/media/checksWidget.css +++ b/src/vs/sessions/contrib/changes/browser/media/checksWidget.css @@ -20,7 +20,7 @@ align-items: center; padding: 4px; margin-top: 4px; - border-radius: 6px; + border-radius: var(--vscode-cornerRadius-medium); min-height: 22px; font-weight: var(--vscode-agents-fontWeight-semiBold, 600); cursor: pointer; @@ -160,7 +160,7 @@ /* Individual check row */ .ci-status-widget .ci-status-widget-list .monaco-list-row { - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); } .ci-status-widget-check { diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index 7c30a74b1c454..04e60e0918b23 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -38,7 +38,7 @@ max-width: 800px; box-sizing: border-box; border: 1px solid var(--vscode-agentsChatInput-border); - border-radius: 8px; + border-radius: var(--vscode-cornerRadius-large); background-color: var(--vscode-agentsChatInput-background); color: var(--vscode-agentsChatInput-foreground); overflow: hidden; @@ -71,7 +71,7 @@ .sessions-chat-editor .monaco-editor .overflow-guard, .sessions-chat-editor .monaco-editor-background { background-color: transparent !important; - border-radius: 8px 8px 0 0; + border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) 0 0; } /* Toolbar */ @@ -128,7 +128,7 @@ padding: 3px 7px; background-color: transparent; border: none; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); cursor: pointer; touch-action: manipulation; color: var(--vscode-icon-foreground); @@ -305,7 +305,7 @@ width: 22px; height: 22px; flex-shrink: 0; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); cursor: pointer; color: var(--vscode-icon-foreground); background: transparent; @@ -347,7 +347,7 @@ font-size: 11px; padding: 0 4px 0 0; border: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-background, transparent)); - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); height: 18px; max-width: 200px; width: fit-content; diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index fa7e297957780..42a8b41439518 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -250,7 +250,7 @@ border: none; background-color: transparent; color: var(--vscode-foreground); - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); touch-action: manipulation; } @@ -308,7 +308,7 @@ cursor: pointer; touch-action: manipulation; white-space: nowrap; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); min-width: 0; overflow: hidden; } diff --git a/src/vs/sessions/contrib/chat/browser/media/noAgentHostEmptyState.css b/src/vs/sessions/contrib/chat/browser/media/noAgentHostEmptyState.css index 7b7c365d778d9..8aef4ad7fc884 100644 --- a/src/vs/sessions/contrib/chat/browser/media/noAgentHostEmptyState.css +++ b/src/vs/sessions/contrib/chat/browser/media/noAgentHostEmptyState.css @@ -59,7 +59,7 @@ .no-agent-host-empty-state .no-agent-host-description code { display: inline-block; padding: 1px 6px; - border-radius: 3px; + border-radius: var(--vscode-cornerRadius-small); background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); border: 1px solid color-mix(in srgb, var(--vscode-foreground) 12%, transparent); font-family: var(--monaco-monospace-font); diff --git a/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css b/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css index 29a029cc0d803..7e82e7f82ed5f 100644 --- a/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css +++ b/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css @@ -45,7 +45,7 @@ gap: 2px; padding: 2px; border: 1px solid var(--vscode-widget-border, rgba(255, 255, 255, 0.06)); - border-radius: 6px; + border-radius: var(--vscode-cornerRadius-medium); background-color: var(--vscode-input-background); } @@ -54,7 +54,7 @@ min-width: 76px; padding: 4px 8px; border: 1px solid transparent; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); background: transparent; color: var(--vscode-foreground); font-size: 12px; @@ -66,7 +66,7 @@ .run-script-action-section .monaco-custom-radio > .monaco-button:first-child, .run-script-action-section .monaco-custom-radio > .monaco-button:last-child { - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); } .run-script-action-section .monaco-custom-radio > .monaco-button:not(.active):not(:last-child), @@ -136,15 +136,15 @@ width: fit-content; min-height: 28px; padding: 5px 12px; - border-radius: 6px; + border-radius: var(--vscode-cornerRadius-medium); } .agent-sessions-workbench.run-script-action-modal-visible .quick-input-widget .quick-input-titlebar { gap: 8px; height: 32px; padding: 0 6px 0 0; - border-top-left-radius: 8px; - border-top-right-radius: 8px; + border-top-left-radius: var(--vscode-cornerRadius-large); + border-top-right-radius: var(--vscode-cornerRadius-large); border-bottom: 1px solid var(--vscode-titleBar-border, var(--vscode-widget-border, transparent)); box-sizing: border-box; background-color: var(--vscode-titleBar-activeBackground); @@ -163,7 +163,7 @@ } .agent-sessions-workbench.run-script-action-modal-visible .quick-input-widget { - border-radius: 8px; + border-radius: var(--vscode-cornerRadius-large); box-shadow: var(--vscode-shadow-xl); } diff --git a/src/vs/sessions/contrib/policyBlocked/browser/media/sessionsPolicyBlocked.css b/src/vs/sessions/contrib/policyBlocked/browser/media/sessionsPolicyBlocked.css index 9c70d12112ac2..169cf9f04d47c 100644 --- a/src/vs/sessions/contrib/policyBlocked/browser/media/sessionsPolicyBlocked.css +++ b/src/vs/sessions/contrib/policyBlocked/browser/media/sessionsPolicyBlocked.css @@ -72,7 +72,7 @@ width: 100%; height: 3px; background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-xSmall); overflow: hidden; margin-top: 16px; } @@ -81,7 +81,7 @@ width: 30%; height: 100%; background: var(--vscode-progressBar-background, #0078d4); - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-xSmall); animation: sessions-policy-blocked-progress 2s ease-in-out infinite; } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/media/hostFilter.css b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/media/hostFilter.css index a514a2108a16d..d633ef310a17e 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/media/hostFilter.css +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/media/hostFilter.css @@ -83,7 +83,7 @@ margin: 0 2px; cursor: pointer; color: var(--vscode-titleBar-activeForeground); - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); font-size: 12px; user-select: none; -webkit-user-select: none; @@ -150,7 +150,7 @@ margin-left: auto; width: 22px; height: 22px; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); cursor: pointer; touch-action: manipulation; -webkit-user-select: none; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/media/hostPickerDropdown.css b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/media/hostPickerDropdown.css index 18c16247b0616..94e65a39ef270 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/media/hostPickerDropdown.css +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/media/hostPickerDropdown.css @@ -58,7 +58,7 @@ width: 36px; height: 4px; margin: 8px auto 4px; - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-xSmall); background: color-mix(in srgb, var(--vscode-foreground) 30%, transparent); flex-shrink: 0; } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/media/hostPickerSheet.css b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/media/hostPickerSheet.css index 18c16247b0616..94e65a39ef270 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/media/hostPickerSheet.css +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/media/hostPickerSheet.css @@ -58,7 +58,7 @@ width: 36px; height: 4px; margin: 8px auto 4px; - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-xSmall); background: color-mix(in srgb, var(--vscode-foreground) 30%, transparent); flex-shrink: 0; } diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index ac331b0153f6e..7febc0e384ad5 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -9,7 +9,7 @@ min-height: 0; .monaco-list-row:has(.session-item) { - border-radius: 6px; + border-radius: var(--vscode-cornerRadius-medium); margin: 0 10px; width: calc(100% - 20px); } @@ -258,7 +258,7 @@ padding: 4px 4px 4px 6px; box-sizing: border-box; border: 1px solid var(--vscode-contrastBorder, var(--vscode-widget-border, transparent)); - border-radius: 8px; + border-radius: var(--vscode-cornerRadius-large); background-color: var(--vscode-editor-background); color: var(--vscode-editor-foreground); align-items: center; @@ -380,6 +380,10 @@ display: none; } + .session-section-toolbar .monaco-action-bar .action-label { + border-radius: var(--vscode-cornerRadius-small); + } + .session-section-chevron { flex-shrink: 0; margin-left: 4px; @@ -388,7 +392,7 @@ font-size: var(--vscode-codiconFontSize-compact, 12px); width: 22px; height: 22px; - border-radius: 5px; + border-radius: var(--vscode-cornerRadius-small); justify-content: center; } } @@ -529,7 +533,7 @@ * lives inside the row via padding. */ margin: 0 8px; width: calc(100% - 16px); - border-radius: 10px; + border-radius: var(--vscode-cornerRadius-xLarge); } .session-item { diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index f5b08defb8bea..cc91331341e65 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -128,7 +128,7 @@ font-weight: 600; font-variant-numeric: tabular-nums; text-align: center; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); background-color: var(--vscode-agentsUnreadBadge-background); color: var(--vscode-agentsUnreadBadge-foreground); pointer-events: none; diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index 9edc816237ecc..e529395137868 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -158,7 +158,7 @@ padding: 2px 8px; font-size: 12px; line-height: 18px; - border-radius: var(--vscode-cornerRadius-medium); + border-radius: var(--vscode-cornerRadius-small); border: 1px solid var(--vscode-agentsNewSessionButton-border, var(--vscode-button-border, color-mix(in srgb, var(--vscode-foreground) 20%, transparent))); background-color: var(--vscode-agentsNewSessionButton-background, transparent); color: var(--vscode-agentsNewSessionButton-foreground, var(--vscode-foreground)); @@ -196,7 +196,7 @@ line-height: 1; padding: 1px 3px; border: 1px solid transparent; - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-xSmall); background-color: var(--vscode-keybindingLabel-background); color: var(--vscode-keybindingLabel-foreground); box-shadow: none; diff --git a/src/vs/sessions/contrib/tunnelHost/electron-browser/media/tunnelHost.css b/src/vs/sessions/contrib/tunnelHost/electron-browser/media/tunnelHost.css index 0873011542d94..73127dad5e2c5 100644 --- a/src/vs/sessions/contrib/tunnelHost/electron-browser/media/tunnelHost.css +++ b/src/vs/sessions/contrib/tunnelHost/electron-browser/media/tunnelHost.css @@ -11,7 +11,7 @@ padding: 3px 6px; cursor: pointer; color: var(--vscode-icon-foreground); - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); } .tunnel-host-toggle:hover {