From 3de3afb50257197f9a0acb8c42b197eecee901a4 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 26 Feb 2026 14:42:33 +0000 Subject: [PATCH 01/19] fix: improve CSS handling on theme change by clearing cached stylesheets --- .../themes/browser/cssExtensionPoint.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/services/themes/browser/cssExtensionPoint.ts b/src/vs/workbench/services/themes/browser/cssExtensionPoint.ts index cdeca23bd2dd9..2387645598166 100644 --- a/src/vs/workbench/services/themes/browser/cssExtensionPoint.ts +++ b/src/vs/workbench/services/themes/browser/cssExtensionPoint.ts @@ -157,6 +157,10 @@ export class CSSExtensionPoint { // Check if this extension's theme is currently active if (this.isExtensionThemeActive(extensionId)) { this.activateExtensionCSS(extension); + } else if (this.stylesheetsByExtension.has(extensionId)) { + // Theme is no longer active but cached CSS is still loaded — remove it + this.removeStylesheets(extensionId); + this.clearCacheForExtension(extensionId); } } }); @@ -173,14 +177,17 @@ export class CSSExtensionPoint { } private onThemeChange(): void { - // Check all pending extensions and activate/deactivate as needed + // Activate pending extensions whose theme just became active for (const [extensionId, extension] of this.pendingExtensions) { - const isActive = this.stylesheetsByExtension.has(extensionId); - const shouldBeActive = this.isExtensionThemeActive(extensionId); - - if (shouldBeActive && !isActive) { + if (!this.stylesheetsByExtension.has(extensionId) && this.isExtensionThemeActive(extensionId)) { this.activateExtensionCSS(extension); - } else if (!shouldBeActive && isActive) { + } + } + + // Deactivate all extensions whose theme is no longer active, + // including cached CSS that may not yet be in pendingExtensions + for (const extensionId of [...this.stylesheetsByExtension.keys()]) { + if (!this.isExtensionThemeActive(extensionId)) { this.removeStylesheets(extensionId); this.clearCacheForExtension(extensionId); } From 4f66bceb92dba5c9f208a3eb8fa1a1d349f6ab00 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Thu, 26 Feb 2026 14:50:25 +0000 Subject: [PATCH 02/19] Update src/vs/workbench/services/themes/browser/cssExtensionPoint.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/workbench/services/themes/browser/cssExtensionPoint.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/services/themes/browser/cssExtensionPoint.ts b/src/vs/workbench/services/themes/browser/cssExtensionPoint.ts index 2387645598166..69b74111341b0 100644 --- a/src/vs/workbench/services/themes/browser/cssExtensionPoint.ts +++ b/src/vs/workbench/services/themes/browser/cssExtensionPoint.ts @@ -186,7 +186,7 @@ export class CSSExtensionPoint { // Deactivate all extensions whose theme is no longer active, // including cached CSS that may not yet be in pendingExtensions - for (const extensionId of [...this.stylesheetsByExtension.keys()]) { + for (const extensionId of this.stylesheetsByExtension.keys()) { if (!this.isExtensionThemeActive(extensionId)) { this.removeStylesheets(extensionId); this.clearCacheForExtension(extensionId); From 26c4eac9d80c0b9428f26e5a97c842239a768a8d Mon Sep 17 00:00:00 2001 From: Isidor Date: Fri, 27 Feb 2026 10:00:31 +0100 Subject: [PATCH 03/19] fixes #297159 --- .../chat/browser/widget/input/chatModelPicker.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 2eb6414caed91..d6a3689597bac 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -266,7 +266,6 @@ export function buildModelPickerItems( } // Render promoted section: available first, then sorted alphabetically by name - let hasShownActionLink = false; if (promotedItems.length > 0) { promotedItems.sort((a, b) => { const aAvail = a.kind === 'available' ? 0 : 1; @@ -283,11 +282,7 @@ export function buildModelPickerItems( if (item.kind === 'available') { items.push(createModelItem(createModelAction(item.model, selectedModelId, onSelect), item.model)); } else { - const showActionLink = item.reason === 'upgrade' ? !hasShownActionLink : true; - if (showActionLink && item.reason === 'upgrade') { - hasShownActionLink = true; - } - items.push(createUnavailableModelItem(item.id, item.entry, item.reason, manageSettingsUrl, updateStateType, undefined, showActionLink)); + items.push(createUnavailableModelItem(item.id, item.entry, item.reason, manageSettingsUrl, updateStateType)); } } } @@ -336,7 +331,7 @@ export function buildModelPickerItems( for (const model of otherModels) { const entry = controlModels[model.metadata.id] ?? controlModels[model.identifier]; if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - items.push(createUnavailableModelItem(model.metadata.id, entry, 'update', manageSettingsUrl, updateStateType, ModelPickerSection.Other, true)); + items.push(createUnavailableModelItem(model.metadata.id, entry, 'update', manageSettingsUrl, updateStateType, ModelPickerSection.Other)); } else { items.push(createModelItem(createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other), model)); } @@ -398,14 +393,11 @@ function createUnavailableModelItem( manageSettingsUrl: string | undefined, updateStateType: StateType, section?: string, - showActionLink: boolean = true, ): IActionListItem { let description: string | MarkdownString | undefined; if (reason === 'upgrade') { - description = showActionLink - ? new MarkdownString(localize('chat.modelPicker.upgradeLink', "[Upgrade your plan](command:workbench.action.chat.upgradePlan \" \")"), { isTrusted: true }) - : undefined; + description = new MarkdownString(localize('chat.modelPicker.upgradeLink', "[Upgrade your plan](command:workbench.action.chat.upgradePlan \" \")"), { isTrusted: true }); } else if (reason === 'update') { description = localize('chat.modelPicker.updateDescription', "Update VS Code"); } else { From a9c55d7cba9d76ae9d3f2291593606eb5b91d303 Mon Sep 17 00:00:00 2001 From: Isidor Date: Fri, 27 Feb 2026 10:07:10 +0100 Subject: [PATCH 04/19] update tests --- .../test/browser/widget/input/chatModelPicker.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts index b9d21246bba9c..d74122c7f6155 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts @@ -580,7 +580,7 @@ suite('buildModelPickerItems', () => { assert.strictEqual(unavailable.group?.icon?.id, Codicon.blank.id); }); - test('anonymous user sees upgrade description only on first unavailable model', () => { + test('anonymous user sees upgrade description on each unavailable model', () => { const auto = createAutoModel(); const items = callBuild([auto], { recentModelIds: ['model-a', 'model-b'], @@ -596,10 +596,11 @@ suite('buildModelPickerItems', () => { assert.strictEqual(disabledItems.length, 2); assert.ok(disabledItems[0].description instanceof MarkdownString); assert.ok(disabledItems[0].description.value.includes('Upgrade')); - assert.strictEqual(disabledItems[1].description, undefined); + assert.ok(disabledItems[1].description instanceof MarkdownString); + assert.ok(disabledItems[1].description.value.includes('Upgrade')); }); - test('free user sees upgrade description only on first unavailable model', () => { + test('free user sees upgrade description on each unavailable model', () => { const auto = createAutoModel(); const items = callBuild([auto], { recentModelIds: ['model-a', 'model-b'], @@ -614,7 +615,8 @@ suite('buildModelPickerItems', () => { assert.strictEqual(disabledItems.length, 2); assert.ok(disabledItems[0].description instanceof MarkdownString); assert.ok(disabledItems[0].description.value.includes('Upgrade')); - assert.strictEqual(disabledItems[1].description, undefined); + assert.ok(disabledItems[1].description instanceof MarkdownString); + assert.ok(disabledItems[1].description.value.includes('Upgrade')); }); test('anonymous user model selection triggers onSelect normally', () => { From ea2dc89c51d0d7c46841f39e945040cfe61be5bb Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Fri, 27 Feb 2026 11:10:40 +0000 Subject: [PATCH 05/19] fix: update border colors and remove unnecessary shadows in 2026 Light theme --- extensions/theme-2026/themes/2026-light.json | 36 ++++++++++---------- extensions/theme-2026/themes/styles.css | 21 +----------- 2 files changed, 19 insertions(+), 38 deletions(-) diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index f11ffef290574..c743617242634 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -11,7 +11,7 @@ "icon.foreground": "#606060", "focusBorder": "#0069CCFF", "textBlockQuote.background": "#EAEAEA", - "textBlockQuote.border": "#F2F3F4FF", + "textBlockQuote.border": "#F0F1F2FF", "textCodeBlock.background": "#EAEAEA", "textLink.foreground": "#0069CC", "textLink.activeForeground": "#0069CC", @@ -38,7 +38,7 @@ "input.placeholderForeground": "#999999", "inputOption.activeBackground": "#0069CC26", "inputOption.activeForeground": "#202020", - "inputOption.activeBorder": "#F2F3F4FF", + "inputOption.activeBorder": "#F0F1F2FF", "inputValidation.infoBackground": "#E6F2FA", "inputValidation.infoBorder": "#0069CC", "inputValidation.infoForeground": "#202020", @@ -79,7 +79,7 @@ "activityBar.background": "#FAFAFD", "activityBar.foreground": "#202020", "activityBar.inactiveForeground": "#606060", - "activityBar.border": "#F2F3F4FF", + "activityBar.border": "#F0F1F2FF", "activityBar.activeBorder": "#000000", "activityBar.activeFocusBorder": "#0069CCFF", "activityBarBadge.background": "#0069CC", @@ -87,16 +87,16 @@ "activityBarTop.activeBorder": "#000000", "sideBar.background": "#FAFAFD", "sideBar.foreground": "#202020", - "sideBar.border": "#F2F3F4FF", + "sideBar.border": "#F0F1F2FF", "sideBarTitle.foreground": "#202020", "sideBarSectionHeader.background": "#FAFAFD", "sideBarSectionHeader.foreground": "#202020", - "sideBarSectionHeader.border": "#F2F3F4FF", + "sideBarSectionHeader.border": "#F0F1F2FF", "titleBar.activeBackground": "#FAFAFD", "titleBar.activeForeground": "#606060", "titleBar.inactiveBackground": "#FAFAFD", "titleBar.inactiveForeground": "#606060", - "titleBar.border": "#F2F3F4FF", + "titleBar.border": "#F0F1F2FF", "menubar.selectionBackground": "#EAEAEA", "menubar.selectionForeground": "#202020", "menu.background": "#FAFAFD", @@ -104,7 +104,7 @@ "menu.selectionBackground": "#0069CC1A", "menu.selectionForeground": "#202020", "menu.separatorBackground": "#EEEEF1", - "menu.border": "#F2F3F4FF", + "menu.border": "#F0F1F2FF", "commandCenter.foreground": "#202020", "commandCenter.activeForeground": "#202020", "commandCenter.background": "#FAFAFD", @@ -133,7 +133,7 @@ "editorRuler.foreground": "#F7F7F7", "editorCodeLens.foreground": "#606060", "editorBracketMatch.background": "#0069CC40", - "editorBracketMatch.border": "#F2F3F4FF", + "editorBracketMatch.border": "#F0F1F2FF", "editorWidget.background": "#F0F0F3", "editorWidget.border": "#EEEEF1", "editorWidget.foreground": "#202020", @@ -160,7 +160,7 @@ "editorGutter.deletedBackground": "#ad0707", "diffEditor.insertedTextBackground": "#587c0c26", "diffEditor.removedTextBackground": "#ad070726", - "editorOverviewRuler.border": "#F2F3F4FF", + "editorOverviewRuler.border": "#F0F1F2FF", "editorOverviewRuler.findMatchForeground": "#0069CC99", "editorOverviewRuler.modifiedForeground": "#0069CC", "editorOverviewRuler.addedForeground": "#587c0c", @@ -169,13 +169,13 @@ "editorOverviewRuler.warningForeground": "#667309", "editorGutter.background": "#FFFFFF", "panel.background": "#FAFAFD", - "panel.border": "#F2F3F4FF", + "panel.border": "#F0F1F2FF", "panelTitle.activeBorder": "#000000", "panelTitle.activeForeground": "#202020", "panelTitle.inactiveForeground": "#606060", "statusBar.background": "#FAFAFD", "statusBar.foreground": "#606060", - "statusBar.border": "#F2F3F4FF", + "statusBar.border": "#F0F1F2FF", "statusBar.focusBorder": "#0069CCFF", "statusBar.debuggingBackground": "#0069CC", "statusBar.debuggingForeground": "#FFFFFF", @@ -192,8 +192,8 @@ "tab.activeForeground": "#202020", "tab.inactiveBackground": "#FAFAFD", "tab.inactiveForeground": "#606060", - "tab.border": "#F2F3F4FF", - "tab.lastPinnedBorder": "#F2F3F4FF", + "tab.border": "#F0F1F2FF", + "tab.lastPinnedBorder": "#F0F1F2FF", "tab.activeBorder": "#FAFAFD", "tab.activeBorderTop": "#000000", "tab.hoverBackground": "#DADADA4f", @@ -203,19 +203,19 @@ "tab.unfocusedInactiveBackground": "#FAFAFD", "tab.unfocusedInactiveForeground": "#BBBBBB", "editorGroupHeader.tabsBackground": "#FAFAFD", - "editorGroupHeader.tabsBorder": "#F2F3F4FF", + "editorGroupHeader.tabsBorder": "#F0F1F2FF", "breadcrumb.foreground": "#606060", "breadcrumb.background": "#FFFFFF", "breadcrumb.focusForeground": "#202020", "breadcrumb.activeSelectionForeground": "#202020", "breadcrumbPicker.background": "#F0F0F3", - "notificationCenter.border": "#F2F3F4FF", + "notificationCenter.border": "#F0F1F2FF", "notificationCenterHeader.foreground": "#202020", "notificationCenterHeader.background": "#FAFAFD", - "notificationToast.border": "#F2F3F4FF", + "notificationToast.border": "#F0F1F2FF", "notifications.foreground": "#202020", "notifications.background": "#FAFAFD", - "notifications.border": "#F2F3F4FF", + "notifications.border": "#F0F1F2FF", "notificationLink.foreground": "#0069CC", "notificationsWarningIcon.foreground": "#B69500", "notificationsErrorIcon.foreground": "#ad0707", @@ -250,7 +250,7 @@ "quickInput.border": "#D8D8D8", "gauge.foreground": "#0069CC", "gauge.background": "#0069CC40", - "gauge.border": "#F2F3F4FF", + "gauge.border": "#F0F1F2FF", "gauge.warningForeground": "#B69500", "gauge.warningBackground": "#B6950040", "gauge.errorForeground": "#ad0707", diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 45271d56843ea..af33a765e77aa 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -14,7 +14,6 @@ --shadow-xl: 0 0 20px rgba(0, 0, 0, 0.15); --shadow-hover: 0 0 8px rgba(0, 0, 0, 0.12); --shadow-sm-strong: 0 0 4px rgba(0, 0, 0, 0.18); - --shadow-button-active: inset 0 1px 2px rgba(0, 0, 0, 0.1); --shadow-active-tab: 0 8px 12px rgba(0, 0, 0, 0.02); /* Panel depth shadows cast onto the editor surface */ @@ -212,10 +211,6 @@ border-radius: var(--radius-lg); } -.monaco-workbench.vs .interactive-session .chat-input-container { - box-shadow: inset var(--shadow-sm); -} - .monaco-workbench .interactive-session .interactive-input-part .chat-editor-container .interactive-input-editor .monaco-editor, .monaco-workbench .interactive-session .chat-editing-session .chat-editing-session-container { border-radius: var(--radius-lg) var(--radius-lg) 0 0; @@ -466,11 +461,7 @@ } /* Input Boxes */ -.monaco-workbench .monaco-inputbox, -.monaco-workbench .suggest-input-container { - box-shadow: inset var(--shadow-sm); - border: none; -} + .monaco-inputbox .monaco-action-bar .action-item .codicon, .monaco-workbench .search-container .input-box, @@ -484,12 +475,6 @@ color: var(--vscode-icon-foreground) !important; } -/* Buttons */ - -.monaco-workbench .monaco-button:active { - box-shadow: var(--shadow-button-active); -} - /* Todo List Widget - remove shadows from buttons */ .monaco-workbench.vs .chat-todo-list-widget .todo-list-expand .monaco-button, .monaco-workbench.vs .chat-todo-list-widget .todo-list-expand .monaco-button:hover, @@ -628,7 +613,6 @@ /* Notebook */ .monaco-workbench .notebookOverlay .monaco-list-row .cell-editor-part:before { - box-shadow: inset var(--shadow-sm); border-radius: var(--radius-md); } @@ -653,7 +637,6 @@ /* Command Center */ .monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center { - box-shadow: inset var(--shadow-sm) !important; border-radius: var(--radius-lg) !important; background: color-mix(in srgb, var(--vscode-commandCenter-background) 60%, transparent) !important; -webkit-backdrop-filter: var(--backdrop-blur-md); @@ -661,12 +644,10 @@ } .monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:hover { - box-shadow: inset var(--shadow-sm) !important; background: color-mix(in srgb, var(--vscode-commandCenter-activeBackground) 60%, transparent) !important; } .monaco-workbench .part.titlebar .command-center .agent-status-pill { - box-shadow: inset var(--shadow-sm); border-color: var(--vscode-input-border); } From 55dbb7dd84c22b267313978e6e0fb0fdef551d9d Mon Sep 17 00:00:00 2001 From: Robo Date: Fri, 27 Feb 2026 22:06:30 +0900 Subject: [PATCH 06/19] chore: revert to electron@39.6.0 (#298230) * chore: revert to electron@39.6.0 * chore: bump distro --- .npmrc | 4 +- build/checksums/electron.txt | 150 ++++++++++++++++----------------- build/gulpfile.vscode.win32.ts | 2 +- cgmanifest.json | 6 +- package-lock.json | 8 +- package.json | 6 +- 6 files changed, 88 insertions(+), 88 deletions(-) diff --git a/.npmrc b/.npmrc index 2c7c6c1b58489..b07eade64d573 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="39.6.1" -ms_build_id="13369494" +target="39.6.0" +ms_build_id="13330601" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 90e662c384deb..3df57a48a97d2 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -71cee744399edac3516b0b56d6565b015a70abda53785501b94e7efe68efb556 *chromedriver-v39.6.1-darwin-arm64.zip -2284eb7a536b6001d2b53b57a69d8b397f7245044c6055515268a472e780e9b3 *chromedriver-v39.6.1-darwin-x64.zip -d64a85751e3b779ce9d4e88ab0e25db4970594f7c2b4995a21d5bfce8d6b63f0 *chromedriver-v39.6.1-linux-arm64.zip -83e938f9b5c19785d32f510a4ef5eab9ac6e63d40944b0e759a5f85867a02b72 *chromedriver-v39.6.1-linux-armv7l.zip -b1cd220f1c71edd4aeb57910cbf63bdf8862c62a1c3270ae7af5a4bd2098fe6f *chromedriver-v39.6.1-linux-x64.zip -c63e4ea9a0bdb883d2a6369919262a7ff3f9b432dfdcd32c827debbabfa6e8e4 *chromedriver-v39.6.1-mas-arm64.zip -d073ed5147b5adf3aefc88c5ee24c0998a8930849d10f886a1e553b99ed001b9 *chromedriver-v39.6.1-mas-x64.zip -7d475b4bbddb9a6b2ab1fd7793cbe5fd2a3b3e8ee291b804ff1773d3a6432482 *chromedriver-v39.6.1-win32-arm64.zip -ff7175e1953a604a3d033eac76c0be6c0c6cadce014459c23186b4ce35ee632b *chromedriver-v39.6.1-win32-ia32.zip -2c3591abbc4a11ea9508d02eb9e9152eba7ef693746696723239cac8db4faac1 *chromedriver-v39.6.1-win32-x64.zip -c97d31018c4f3229555607f71e14ddf44ecf78b684055dce548ae5117b4fd284 *electron-api.json -9777c57ec393cb9961500f645166b3f05a9e1ddd8472474c3c6ae9f8224cc029 *electron-v39.6.1-darwin-arm64-dsym-snapshot.zip -005d1a2b75c7e022eda48ecb5b8026e7556b9e12ad6bbd3de39b555a299edbd9 *electron-v39.6.1-darwin-arm64-dsym.zip -fbd66751111a813295d29cfb152d67610d6fa3d606712f649c809883d087aae2 *electron-v39.6.1-darwin-arm64-symbols.zip -287d3bbff9709e37abb9a8c2780e6227f99a165c066dc870a104e173ef4bfe95 *electron-v39.6.1-darwin-arm64.zip -1dee7165235ead83950170805f84be03d6f89a4224e74ed9d570cd7393c2e9d3 *electron-v39.6.1-darwin-x64-dsym-snapshot.zip -e8f1c0b4d61272f95db0fa43a83b181ca449c31665012bb4d65b7320d3b450e1 *electron-v39.6.1-darwin-x64-dsym.zip -c04988ad2b72293fc5b26dba1c291cb3eb755dee9e1d274ec0553c35b306b339 *electron-v39.6.1-darwin-x64-symbols.zip -a9d801eaa52cdfbcb0e238c77b264ef04dc3831e4ff960b666f9bd414cdcac27 *electron-v39.6.1-darwin-x64.zip -43803eeeb2c85c8c122e7f2b036d577fdc761b469cfe503beffed96f6896dfbc *electron-v39.6.1-linux-arm64-debug.zip -9bd95e9fbdf836c0bce62a9e071e54a544e10d3509753e09a71e9ad8c1b74ff7 *electron-v39.6.1-linux-arm64-symbols.zip -fb5f0d71b908f9e49e845cda014ddcafa0637bbf21d811ad30ac799cb453d0a9 *electron-v39.6.1-linux-arm64.zip -9451c34d1608030b841018ce5df2af4319c70e43528e8033c2c469b836a4e15f *electron-v39.6.1-linux-armv7l-debug.zip -bfe9d26d2070ee4f330be8f89de9886ae121efffe6740f8af068b6c65be091eb *electron-v39.6.1-linux-armv7l-symbols.zip -6346f457f12ac728eacb63a782873438580291b853d7c2f387387da363cd021e *electron-v39.6.1-linux-armv7l.zip -d8b0d22e49cf1d7af1318608c791564637a6055584cae558f94a4c6c48422fee *electron-v39.6.1-linux-x64-debug.zip -1e25737611d32a47aec6941d803f9affad90713d9fac695c926806f8d852ca03 *electron-v39.6.1-linux-x64-symbols.zip -2c8ce4905bfeba655df1f8528981361225a3c8f2eb3e9fd1df1f7c2a6e0a03fa *electron-v39.6.1-linux-x64.zip -ef108445cada7fd68a1ec43037e5dd5c542ebe9f579ef59b4c2f4ee33dcf2d2f *electron-v39.6.1-mas-arm64-dsym-snapshot.zip -1139730349a8ab01f0c28f0c1066b58d79b815c6c99d88c19e9effe2e0f0ec37 *electron-v39.6.1-mas-arm64-dsym.zip -08bb61490eca6157ef5e6fff36fe0fc194994446fcab3c2a779190b45d14c206 *electron-v39.6.1-mas-arm64-symbols.zip -5c1e6db65a37e9810e88e172328b18bc7f6fa30f6edf865d600dbd3f399a9dd2 *electron-v39.6.1-mas-arm64.zip -88879e5383ee0066d138587f47ee1eacce18d1494fdfef851f448621fb69de50 *electron-v39.6.1-mas-x64-dsym-snapshot.zip -3125230a1c02498adbd404852eef94040114532990d2a84ced6f30f9fe58c0d1 *electron-v39.6.1-mas-x64-dsym.zip -4a2d0deaa795d2488d3a708ddbf887d6d8421256a03ae0945ec7c8cca383874d *electron-v39.6.1-mas-x64-symbols.zip -c1f9e7faef99ab132bdaf9cef3c773ca92a32683c7ba787d00abcfdbd87c99e2 *electron-v39.6.1-mas-x64.zip -ed8ac6af71ad036a8b3b725db1f45ac92ea346f6c7d6f8f9a9e6af89fcb98405 *electron-v39.6.1-win32-arm64-pdb.zip -66f0e675d11cf174960d0e700678dbbb92b993b52f058f1eb34613d3b66705e5 *electron-v39.6.1-win32-arm64-symbols.zip -efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.6.1-win32-arm64-toolchain-profile.zip -41e1368fe8ea044cc872c5da3d747ea9ab1576386b99580a55a8e14c7375dec9 *electron-v39.6.1-win32-arm64.zip -8079d11539a2924eb07c05639d711ab12273e4d333aaeb3d6ae346bf7c5e45f6 *electron-v39.6.1-win32-ia32-pdb.zip -b22f3898fbb0e49f8c0506bd45b4f3510a0a0fdd947c0a62114bbc709c061eb5 *electron-v39.6.1-win32-ia32-symbols.zip -efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.6.1-win32-ia32-toolchain-profile.zip -bb5563fc6ce349271e2bee3456a15445afd3af742c4d315b44ffd868d0211bfc *electron-v39.6.1-win32-ia32.zip -4af89e83c7c56c9418f83f8f534133bc664287bbde49b066607b45217f5acfa9 *electron-v39.6.1-win32-x64-pdb.zip -d6eda384bd6ebf6e39173b0363490f920fba92ecb8fdd53b3061080c72806c12 *electron-v39.6.1-win32-x64-symbols.zip -efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.6.1-win32-x64-toolchain-profile.zip -87ddfa6c8f2c1178582dd79af76da6b267d09dcbb52cc59d40d30c01c39f49f0 *electron-v39.6.1-win32-x64.zip -2ebffb8530e804319443d49288343c982c08cbae0aac314f70b9859cb2722f42 *electron.d.ts -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.6.1-darwin-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.6.1-darwin-x64.zip -52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.6.1-linux-arm64.zip -622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.6.1-linux-armv7l.zip -ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.6.1-linux-x64.zip -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.6.1-mas-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.6.1-mas-x64.zip -2a358c2dbeeb259c0b6a18057b52ffb0109de69112086cb2ce02f3a79bd70cee *ffmpeg-v39.6.1-win32-arm64.zip -4555510880a7b8dff5d5d0520f641665c62494689782adbed67fa0e24b45ae67 *ffmpeg-v39.6.1-win32-ia32.zip -091ab3c97d5a1cda1e04c6bd263a2c07ea63ed7ec3fd06600af6d7e23bbbbe15 *ffmpeg-v39.6.1-win32-x64.zip -c98648b15b337bc6a6443ba009a46cfc696d657032445078a2475e5bc835cae9 *hunspell_dictionaries.zip -7ac45cdcc0b3f46c92a23689aacae8f5d341d71bf61f7fbaf9254087840e0a8d *libcxx-objects-v39.6.1-linux-arm64.zip -901825379fba3261a94e9967f6b3759366d4c0e59a3e1a2e0c787e3e3f725ebb *libcxx-objects-v39.6.1-linux-armv7l.zip -832d867351c2a0ae6adc02efdd24bdf44173a011f07009f489df80e8a5ccc483 *libcxx-objects-v39.6.1-linux-x64.zip -9d6081a9fc90693d7bd54bb0bdef147d9c37bd7a179a1eea63018570ee7813f0 *libcxx_headers.zip -23be04f992ee37ce8f29ffb9e53bd5bf461c50a91b6eddedaecc9f20b8967937 *libcxxabi_headers.zip -beccf9d63fc88de743a5497626096e886a70c631b08bc3e3a924a134bc32c9dc *mksnapshot-v39.6.1-darwin-arm64.zip -40757d33f3e939efbc126b5c9a52cfc1e102098295bcd4c5ccdd588fb03fd918 *mksnapshot-v39.6.1-darwin-x64.zip -68a16dd164cd6fcd2cc44fa530a60ac17061c5d3bbe4a95dc534fb8de74c06ee *mksnapshot-v39.6.1-linux-arm64-x64.zip -5b6fee728d9a54afd8cd11b8c153c32c8ff1a7cdbe2473e4bb4edf771610b118 *mksnapshot-v39.6.1-linux-armv7l-x64.zip -1653b0c1d087058be4f16f1b5877decfeb0705b3732f9428eadff003ffc0f166 *mksnapshot-v39.6.1-linux-x64.zip -0a1d2064c80cc60bc44d7b4609fb83f1870bc66de5fbc2d4907f6baf0bc6e0a0 *mksnapshot-v39.6.1-mas-arm64.zip -6294fc74584a6b20fb3cdd6fcff4c644234ce0471896728b9652ecac00b577f6 *mksnapshot-v39.6.1-mas-x64.zip -d997856786ba5a72a9fddbdc13a085979a613a2c94a343286f0e136d2f3d84c5 *mksnapshot-v39.6.1-win32-arm64-x64.zip -76dcbe1c3cf7d36bee79ecb7f8c02a9b75167c48983ecd8df047da5793d6248c *mksnapshot-v39.6.1-win32-ia32.zip -4c9f0bd54704a6333f5268645625b65cd87d61aa8c6d52482d9d39cc1f85808a *mksnapshot-v39.6.1-win32-x64.zip +1a1bb622d9788793310458b7bf9eedcea8347da9556dd1d7661b757c15ebfdd5 *chromedriver-v39.6.0-darwin-arm64.zip +c84565c127adeca567ca69e85bbd8f387fff1f83c09e69f6f851528f5602dc4e *chromedriver-v39.6.0-darwin-x64.zip +f50df11f99a2e3df84560d5331608cd0a9d7a147a1490f25edfd8a95531918a2 *chromedriver-v39.6.0-linux-arm64.zip +a571fd25e33f3b3bded91506732a688319d93eb652e959bb19a09cd3f67f9e5f *chromedriver-v39.6.0-linux-armv7l.zip +2a50751190bbfe07984f7d8cbf2f12c257a4c132a36922a78c4e320169b8f498 *chromedriver-v39.6.0-linux-x64.zip +cf6034c20b727c48a6f44bb87b1ec89fd4189f56200a32cd39cedaab3f19e007 *chromedriver-v39.6.0-mas-arm64.zip +d2107db701c41fa5f3aaa04c279275ac4dcffde4542c032c806939acd8c6cd6c *chromedriver-v39.6.0-mas-x64.zip +1593ed5550fa11c549fd4ff5baea5cb7806548bff15b79340343ac24a86d6de3 *chromedriver-v39.6.0-win32-arm64.zip +deee89cbeed935a57551294fbc59f6a346b76769e27dd78a59a35a82ae3037d9 *chromedriver-v39.6.0-win32-ia32.zip +f88a23ebc246ed2a506d6d172eb9ffbb4c9d285103285a735e359268fcd08895 *chromedriver-v39.6.0-win32-x64.zip +2e1ec8568f4fda21dc4bb7231cdb0427fa31bb03c4bc39f8aa36659894f2d23e *electron-api.json +03e743428685b44beeab9aa51bad7437387dc2ce299b94745ed8fb0923dd9a07 *electron-v39.6.0-darwin-arm64-dsym-snapshot.zip +723d64530286ebd58539bc29deb65e9334ae8450a714b075d369013b4bbfdce0 *electron-v39.6.0-darwin-arm64-dsym.zip +8f529fbbed8c386f3485614fa059ea9408ebe17d3f0c793269ea52ef3efdf8df *electron-v39.6.0-darwin-arm64-symbols.zip +dace1f9e5c49f4f63f32341f8b0fb7f16b8cf07ce5fcb17abcc0b33782966b8c *electron-v39.6.0-darwin-arm64.zip +e2425514469c4382be374e676edff6779ef98ca1c679b1500337fa58aa863e98 *electron-v39.6.0-darwin-x64-dsym-snapshot.zip +877e72afd7d8695e8a4420a74765d45c30fad30606d3dbab07a0e88fe600e3f6 *electron-v39.6.0-darwin-x64-dsym.zip +ae958c150c6fe76fc7989a28ddb6104851f15d2e24bd32fe60f51e308954a816 *electron-v39.6.0-darwin-x64-symbols.zip +bed88dac3ac28249a020397d83f3f61871c7eaea2099d5bf6b1e92878cb14f19 *electron-v39.6.0-darwin-x64.zip +a86e9470d6084611f38849c9f9b3311584393fa81b55d0bbf7e284a649b729cf *electron-v39.6.0-linux-arm64-debug.zip +e7d7aec3873a6d2f2c9fe406a27a8668910f8b4fdf55a36b5302d9db3ec390db *electron-v39.6.0-linux-arm64-symbols.zip +d6ded47a49046eb031800cf70f2b5d763ccac11dac64e70a874c62aaa115ccba *electron-v39.6.0-linux-arm64.zip +2bf6a75c9f3c2400698c325e48c9b6444d108e4d76544fb130d04605002ae084 *electron-v39.6.0-linux-armv7l-debug.zip +421d02c8a063602b22e4f16a2614fe6cc13e07f9d4ead309fe40aeac296fe951 *electron-v39.6.0-linux-armv7l-symbols.zip +ee34896d1317f1572ed4f3ed8eb1719f599f250d442fc6afb6ec40091c4f4cdc *electron-v39.6.0-linux-armv7l.zip +233f55caae4514144310928248a96bd3a3ce7ac6dc1ff99e7531737a579793b1 *electron-v39.6.0-linux-x64-debug.zip +eca69e741b00ce141b9c2e6e63c1f77cd834a85aa095385f032fdb58d3154fff *electron-v39.6.0-linux-x64-symbols.zip +94bf4bee48f3c657edffd4556abbe62556ca8225cbb4528d62eb858233a3c34b *electron-v39.6.0-linux-x64.zip +6dfebeb760627df74c65ff8da7088fb77e0ae222cab5590fea4cdd37c060ea06 *electron-v39.6.0-mas-arm64-dsym-snapshot.zip +b327d41507546799451a684b6061caed10f1c16ee39a7e686aac71187f8b7afe *electron-v39.6.0-mas-arm64-dsym.zip +02a56a9c3c3522ebc653f03ad88be9a2f46594c730a767a28e7322ddb7a789b7 *electron-v39.6.0-mas-arm64-symbols.zip +2fe93cd39521371bb5722c358feebadc5e79d79628b07a79a00a9d918e261de4 *electron-v39.6.0-mas-arm64.zip +f25ddc8a9b2b699d6d9e54fdf66220514e387ae36e45efeb4d8217b1462503f6 *electron-v39.6.0-mas-x64-dsym-snapshot.zip +6732026b6a3728bea928af0c5928bf82d565eebeb3f5dc5b6991639d27e7c457 *electron-v39.6.0-mas-x64-dsym.zip +5260dabf5b0fc369e0f69d3286fbcce9d67bc65e3364e17f7bb13dd49e320422 *electron-v39.6.0-mas-x64-symbols.zip +905f7cf95270afa92972b6c9242fc50c0afd65ffd475a81ded6033588f27a613 *electron-v39.6.0-mas-x64.zip +9204c9844e89f5ca0b32a8347cf9141d8dcb66671906e299afa06004f464d9b0 *electron-v39.6.0-win32-arm64-pdb.zip +6778c54d8cf7a0d305e4334501c3b877daf4737197187120ac18064f4e093b23 *electron-v39.6.0-win32-arm64-symbols.zip +efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.6.0-win32-arm64-toolchain-profile.zip +22b96aca4cf8f7823b98e3b20b6131e521e0100c5cd03ab76f106eefbd0399cf *electron-v39.6.0-win32-arm64.zip +f5b69c8c1c9349a1f3b4309fb3fa1cf6326953e0807d2063fc27ba9f1400232e *electron-v39.6.0-win32-ia32-pdb.zip +1d6e103869acdeb0330b26ee08089667e0b5afc506efcd7021ba761ed8b786b5 *electron-v39.6.0-win32-ia32-symbols.zip +efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.6.0-win32-ia32-toolchain-profile.zip +2b30e5bc923fff1443e2a4d1971cb9b26f61bd6a454cfbb991042457bab4d623 *electron-v39.6.0-win32-ia32.zip +5f93924c317206a2a4800628854e44e68662a9c40b3457c9e72690d6fff884d3 *electron-v39.6.0-win32-x64-pdb.zip +eab07439f0a21210cd560c1169c04ea5e23c6fe0ab65bd60cffce2b9f69fd36e *electron-v39.6.0-win32-x64-symbols.zip +efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.6.0-win32-x64-toolchain-profile.zip +e8eee36be3bb85ba6fd8fcd26cf3a264bc946ac0717762c64e168896695c8e34 *electron-v39.6.0-win32-x64.zip +2e84c606e40c7bab5530e4c83bbf3a24c28143b0a768dafa5ecf78b18d889297 *electron.d.ts +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.6.0-darwin-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.6.0-darwin-x64.zip +52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.6.0-linux-arm64.zip +622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.6.0-linux-armv7l.zip +ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.6.0-linux-x64.zip +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.6.0-mas-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.6.0-mas-x64.zip +2a358c2dbeeb259c0b6a18057b52ffb0109de69112086cb2ce02f3a79bd70cee *ffmpeg-v39.6.0-win32-arm64.zip +4555510880a7b8dff5d5d0520f641665c62494689782adbed67fa0e24b45ae67 *ffmpeg-v39.6.0-win32-ia32.zip +091ab3c97d5a1cda1e04c6bd263a2c07ea63ed7ec3fd06600af6d7e23bbbbe15 *ffmpeg-v39.6.0-win32-x64.zip +650fb5fbc7e6cc27e5caeb016f72aba756469772bbfdfb3ec0b229f973d8ad46 *hunspell_dictionaries.zip +669ef1bf8ed0f6378e67f4f8bc23d2907d7cc1db7369dbdf468e164f4ef49365 *libcxx-objects-v39.6.0-linux-arm64.zip +996d81ad796524246144e15e22ffef75faff055a102c49021d70b03f039c3541 *libcxx-objects-v39.6.0-linux-armv7l.zip +1ffb610613c11169640fa76e4790137034a0deb3b48e2aef51a01c9b96b7700a *libcxx-objects-v39.6.0-linux-x64.zip +6dd8db57473992367c7914b50d06cae3a1b713cc09ceebecfcd4107df333e759 *libcxx_headers.zip +e5c18f813cc64a7d3b0404ee9adeb9cbb49e7ee5e1054b62c71fa7d1a448ad1b *libcxxabi_headers.zip +7f58d6e1d8c75b990f7d2259de8d0896414d0f2cff2f0fe4e5c7f8037d8fe879 *mksnapshot-v39.6.0-darwin-arm64.zip +be1178e4aa1f4910ba2b8f35b5655e12182657b9e32d509b47f0b2db033f0ac5 *mksnapshot-v39.6.0-darwin-x64.zip +5e36a594067fea08bb3d7bcd60873c3e240ebcee2208bcebfbc9f77d3075cc0d *mksnapshot-v39.6.0-linux-arm64-x64.zip +2db9196d2af0148ebb7b6f1f597f46a535b7af482f95739bd1ced78e1ebf39e7 *mksnapshot-v39.6.0-linux-armv7l-x64.zip +cd673e0a908fc950e0b4246e2b099018a8ee879d12a62973a01cb7de522f5bcf *mksnapshot-v39.6.0-linux-x64.zip +0749d8735a1fd8c666862cd7020b81317c45203d01319c9be089d1e750cb2c15 *mksnapshot-v39.6.0-mas-arm64.zip +81ae98e064485f8c6c69cd6c875ee72666c0cc801a8549620d382c2d0cea3b5c *mksnapshot-v39.6.0-mas-x64.zip +2e44f75df797922e7c8bad61a1b41fed14b070a54257a6a751892b2b8b9dfe29 *mksnapshot-v39.6.0-win32-arm64-x64.zip +fb5d73a8bf4b8db80f61b7073aa8458b5c46cce5c2a4b23591e851c6fcbd0144 *mksnapshot-v39.6.0-win32-ia32.zip +118ae88dbcd6b260cfa370e46ccfb0ab00af5efbf59495aaeea56a2831f604b2 *mksnapshot-v39.6.0-win32-x64.zip diff --git a/build/gulpfile.vscode.win32.ts b/build/gulpfile.vscode.win32.ts index 0f81323c98db2..1f525cff35a90 100644 --- a/build/gulpfile.vscode.win32.ts +++ b/build/gulpfile.vscode.win32.ts @@ -113,7 +113,7 @@ function buildWin32Setup(arch: string, target: string): task.CallbackTask { Quality: quality }; - const isInsiderOrExploration = quality === 'insider' || quality === 'exploration'; + const isInsiderOrExploration = false; const embedded = isInsiderOrExploration ? (product as typeof product & { embedded?: EmbeddedProductInfo }).embedded : undefined; diff --git a/cgmanifest.json b/cgmanifest.json index 45f578c1b9767..21554434500a7 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "2ffb9e1e05c55975112ff6b23037af0eeda79142", - "tag": "39.6.1" + "commitHash": "a229dbf7a56336b847b34dfff1bac79afc311eee", + "tag": "39.6.0" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "39.6.1" + "version": "39.6.0" }, { "component": { diff --git a/package-lock.json b/package-lock.json index bb626ac34e2b9..b10be95329b35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,7 +103,7 @@ "css-loader": "^6.9.1", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.6.1", + "electron": "39.6.0", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -6826,9 +6826,9 @@ "dev": true }, "node_modules/electron": { - "version": "39.6.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.6.1.tgz", - "integrity": "sha512-pgmTbWnT3rP+eo3EolO5EdNw5f7/x/0S7vP+eXC8Zyp2sWGjP4+kmo1RyeAYCChwIRWJFKQ2rQVl/ZkqwK6O2Q==", + "version": "39.6.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.6.0.tgz", + "integrity": "sha512-KQK3sJ6JCyymY3HQxV0N/bVBQwKQETRW0N/+OYcrL9H6tZhpmTSaZY3qSxcruWrPIuouvoiP3Vk/JKUpw05ZIw==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/package.json b/package.json index 67e53ae161026..41bb092c6e117 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.110.0", - "distro": "3ddf7ca3e6b5b372de64cc436eb175522d30f8ab", + "distro": "a8b2188eff543b023e27d161865bc195fa8ba421", "author": { "name": "Microsoft Corporation" }, @@ -172,7 +172,7 @@ "css-loader": "^6.9.1", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.6.1", + "electron": "39.6.0", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -250,4 +250,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} \ No newline at end of file +} From 7dd41ab3d996e4403ac4f990fab626eb9bdc5b21 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 27 Feb 2026 15:15:08 +0100 Subject: [PATCH 07/19] improve worktree label (#298218) --- .../browser/workspaceFolderManagement.ts | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts index 44fa452df3abe..020c7861d46bf 100644 --- a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts +++ b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts @@ -13,6 +13,7 @@ import { IWorkspaceTrustManagementService } from '../../../../platform/workspace import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { URI } from '../../../../base/common/uri.js'; import { autorun } from '../../../../base/common/observable.js'; +import { IWorkspaceFolderCreationData } from '../../../../platform/workspaces/common/workspaces.js'; export class WorkspaceFolderManagementContribution extends Disposable implements IWorkbenchContribution { @@ -34,10 +35,10 @@ export class WorkspaceFolderManagementContribution extends Disposable implements private async updateWorkspaceFoldersForSession(session: IActiveSessionItem | undefined): Promise { await this.manageTrustWorkspaceForSession(session); - const activeSessionRepo = session?.providerType === AgentSessionProviders.Background ? session.worktree ?? session.repository : undefined; + const activeSessionFolderData = this.getActiveSessionFolderData(session); const currentRepo = this.workspaceContextService.getWorkspace().folders[0]?.uri; - if (!activeSessionRepo) { + if (!activeSessionFolderData) { if (currentRepo) { await this.workspaceEditingService.removeFolders([currentRepo], true); } @@ -45,15 +46,34 @@ export class WorkspaceFolderManagementContribution extends Disposable implements } if (!currentRepo) { - await this.workspaceEditingService.addFolders([{ uri: activeSessionRepo }], true); + await this.workspaceEditingService.addFolders([activeSessionFolderData], true); return; } - if (this.uriIdentityService.extUri.isEqual(currentRepo, activeSessionRepo)) { + if (this.uriIdentityService.extUri.isEqual(currentRepo, activeSessionFolderData.uri)) { return; } - await this.workspaceEditingService.updateFolders(0, 1, [{ uri: activeSessionRepo }], true); + await this.workspaceEditingService.updateFolders(0, 1, [activeSessionFolderData], true); + } + + private getActiveSessionFolderData(session: IActiveSessionItem | undefined): IWorkspaceFolderCreationData | undefined { + if (session?.providerType !== AgentSessionProviders.Background) { + return undefined; + } + + if (session.worktree) { + return { + uri: session.worktree, + name: session.repository ? `${this.uriIdentityService.extUri.basename(session.repository)} (worktree)` : undefined + }; + } + + if (session.repository) { + return { uri: session.repository }; + } + + return undefined; } private async manageTrustWorkspaceForSession(session: IActiveSessionItem | undefined): Promise { From cf2e09993971c1f968844070296f524658611172 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Fri, 27 Feb 2026 15:23:43 +0100 Subject: [PATCH 08/19] sessions - hide open changes button for now (#298251) --- .../contrib/chat/browser/chatEditing/chatEditingActions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index 460a79c2b5716..2e6371ba03160 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -38,6 +38,7 @@ import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { ChatTreeItem, IChatWidget, IChatWidgetService } from '../chat.js'; import { IAgentSession, isAgentSession } from '../agentSessions/agentSessionsModel.js'; import { AgentSessionProviders } from '../agentSessions/agentSessions.js'; +import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; export abstract class EditingSessionAction extends Action2 { @@ -362,7 +363,7 @@ export class ViewAllSessionChangesAction extends Action2 { id: MenuId.AgentSessionItemToolbar, group: 'navigation', order: 0, - when: ChatContextKeys.hasAgentSessionChanges + when: ContextKeyExpr.and(ChatContextKeys.hasAgentSessionChanges, IsSessionsWindowContext.negate()) } ], }); From 917576cc858924171152cd5a1ab21d2796111668 Mon Sep 17 00:00:00 2001 From: Aiday Marlen Kyzy Date: Fri, 27 Feb 2026 15:31:51 +0100 Subject: [PATCH 09/19] rendering whitespace in overlay for lines with variable heights and fonts (#298247) --- src/vs/editor/browser/viewParts/viewLines/viewLine.ts | 2 +- src/vs/editor/browser/viewParts/whitespace/whitespace.ts | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/vs/editor/browser/viewParts/viewLines/viewLine.ts b/src/vs/editor/browser/viewParts/viewLines/viewLine.ts index 4aaa9200561a9..3bf79d8b67925 100644 --- a/src/vs/editor/browser/viewParts/viewLines/viewLine.ts +++ b/src/vs/editor/browser/viewParts/viewLines/viewLine.ts @@ -116,7 +116,7 @@ export class ViewLine implements IVisibleLine { const lineData = viewportData.getViewLineRenderingData(lineNumber); const options = this._options; const actualInlineDecorations = LineDecoration.filter(lineData.inlineDecorations, lineNumber, lineData.minColumn, lineData.maxColumn); - const renderWhitespace = (lineData.hasVariableFonts || options.experimentalWhitespaceRendering === 'off') ? options.renderWhitespace : 'none'; + const renderWhitespace = options.experimentalWhitespaceRendering === 'off' ? options.renderWhitespace : 'none'; const allowFastRendering = !lineData.hasVariableFonts; // Only send selection information when needed for rendering whitespace diff --git a/src/vs/editor/browser/viewParts/whitespace/whitespace.ts b/src/vs/editor/browser/viewParts/whitespace/whitespace.ts index 546d268130c31..42ee0e30dade4 100644 --- a/src/vs/editor/browser/viewParts/whitespace/whitespace.ts +++ b/src/vs/editor/browser/viewParts/whitespace/whitespace.ts @@ -122,9 +122,6 @@ export class WhitespaceOverlay extends DynamicViewOverlay { } private _applyRenderWhitespace(ctx: RenderingContext, lineNumber: number, selections: OffsetRange[] | null, lineData: ViewLineRenderingData): string { - if (lineData.hasVariableFonts) { - return ''; - } if (this._options.renderWhitespace === 'selection' && !selections) { return ''; } From 14fdd0b64f957eb217b925b68b992ba6ced404ab Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Fri, 27 Feb 2026 15:43:11 +0100 Subject: [PATCH 10/19] sessions - fix chat bar layout sizes (#298256) --- src/vs/sessions/browser/parts/chatBarPart.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/src/vs/sessions/browser/parts/chatBarPart.ts b/src/vs/sessions/browser/parts/chatBarPart.ts index 9a74bb7021bd0..ebafc12e5c3b5 100644 --- a/src/vs/sessions/browser/parts/chatBarPart.ts +++ b/src/vs/sessions/browser/parts/chatBarPart.ts @@ -34,8 +34,7 @@ export class ChatBarPart extends AbstractPaneCompositePart { static readonly placeholderViewContainersKey = 'workbench.chatbar.placeholderPanels'; static readonly viewContainersWorkspaceStateKey = 'workbench.chatbar.viewContainersWorkspaceState'; - // Use the side bar dimensions - override readonly minimumWidth: number = 170; + override readonly minimumWidth: number = 300; override readonly maximumWidth: number = Number.POSITIVE_INFINITY; override readonly minimumHeight: number = 0; override readonly maximumHeight: number = Number.POSITIVE_INFINITY; @@ -44,21 +43,6 @@ export class ChatBarPart extends AbstractPaneCompositePart { return this.layoutService.mainContainerDimension.height * 0.4; } - get preferredWidth(): number | undefined { - const activeComposite = this.getActivePaneComposite(); - - if (!activeComposite) { - return undefined; - } - - const width = activeComposite.getOptimalWidth(); - if (typeof width !== 'number') { - return undefined; - } - - return Math.max(width, 300); - } - readonly priority = LayoutPriority.High; constructor( From b4081d0c4df671ee29136c604274f2f0286328da Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 27 Feb 2026 09:11:01 -0600 Subject: [PATCH 11/19] improve chat terminal progress part (#298082) improve chat terminal progress part, verifying an issue is fixed for endgame --- .../chatTerminalToolProgressPart.ts | 143 ++++++++++++++---- 1 file changed, 113 insertions(+), 30 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index d864ae17d5a2a..4fc2868f40fb2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -57,9 +57,36 @@ import { PANEL_BACKGROUND } from '../../../../../../common/theme.js'; import { editorBackground } from '../../../../../../../platform/theme/common/colorRegistry.js'; import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js'; +/** + * Minimum number of rows to display in the terminal output view. + */ const MIN_OUTPUT_ROWS = 1; + +/** + * Maximum number of rows to display in the terminal output view before scrolling. + */ const MAX_OUTPUT_ROWS = 10; +/** + * Maximum number of characters to display in the command title before truncating. + */ +const MAX_COMMAND_TITLE_LENGTH = 50; + +/** + * Maximum number of retries when waiting for terminal output to appear. + */ +const MAX_OUTPUT_POLL_RETRIES = 10; + +/** + * Delay between retries when polling for terminal output (in milliseconds). + */ +const OUTPUT_POLL_DELAY_MS = 100; + +/** + * Minimum number of data events that indicate real output (vs shell integration sequences). + */ +const MIN_DATA_EVENTS_FOR_REAL_OUTPUT = 2; + /** * Remembers whether a tool invocation was last expanded so state survives virtualization re-renders. */ @@ -196,6 +223,25 @@ class TerminalCommandDecoration extends Disposable { } } +/** + * A chat content part that displays terminal tool invocation progress. + * + * This component shows: + * - The command being executed with syntax highlighting + * - A status decoration indicating success/failure/running state + * - Expandable terminal output with live streaming support + * - Actions to focus the terminal, show/hide output, and continue in background + * + * The component supports two rendering modes: + * - Standard mode: Shows full progress with status indicators + * - Collapsible wrapper mode: For thinking containers with simplified UI + * + * Output auto-expansion behavior: + * - Long-running commands with output auto-expand after a short delay + * - Fast commands that complete quickly don't auto-expand (prevents flickering) + * - Failed commands can be configured to auto-expand via settings + * - Successful commands auto-collapse if output was auto-expanded + */ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart implements IChatTerminalToolProgressPart { public readonly domNode: HTMLElement; @@ -393,9 +439,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart private _createCollapsibleWrapper(contentElement: HTMLElement, commandText: string, toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, context: IChatContentPartRenderContext): HTMLElement { // truncate header when it's too long - const maxCommandLength = 50; - const truncatedCommand = commandText.length > maxCommandLength - ? commandText.substring(0, maxCommandLength) + '...' + const truncatedCommand = commandText.length > MAX_COMMAND_TITLE_LENGTH + ? commandText.substring(0, MAX_COMMAND_TITLE_LENGTH) + '...' : commandText; const isComplete = IChatToolInvocation.isComplete(toolInvocation); @@ -597,6 +642,27 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._decoration.update(); } + /** + * Determines whether the terminal output should auto-expand. + * Returns false if already expanded, user has manually toggled, component is disposed, + * or if the invocation was previously expanded (to preserve state across re-renders). + */ + private _shouldAutoExpand(): boolean { + return !this._outputView.isExpanded && + !this._userToggledOutput && + !this._store.isDisposed && + !expandedStateByInvocation.get(this.toolInvocation); + } + + /** + * Registers event listeners on the terminal instance to track command execution, + * manage auto-expansion of output, and handle command completion. + * + * This method sets up: + * - Command detection listeners for tracking command lifecycle + * - Auto-expand logic based on command output and duration + * - Instance disposal handling to clean up actions and state + */ private _registerInstanceListener(terminalInstance: ITerminalInstance): void { const commandDetectionListener = this._register(new MutableDisposable()); const tryResolveCommand = async (): Promise => { @@ -637,14 +703,14 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart // hasn't moved past the marker (e.g., progress bars updating on same line) // Shell integration sequences fire a couple times per command (PromptStart, CommandStart, // CommandExecuted), so we need a small threshold to filter those out - return receivedDataCount > 2; + return receivedDataCount > MIN_DATA_EVENTS_FOR_REAL_OUTPUT; }; // Use the extracted auto-expand logic const autoExpand = store.add(new TerminalToolAutoExpand({ commandDetection, onWillData: terminalInstance.onWillData, - shouldAutoExpand: () => !this._outputView.isExpanded && !this._userToggledOutput && !this._store.isDisposed && !expandedStateByInvocation.get(this.toolInvocation), + shouldAutoExpand: () => this._shouldAutoExpand(), hasRealOutput, })); store.add(autoExpand.onDidRequestExpand(() => { @@ -667,18 +733,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._addActions(terminalInstance, this._terminalData.terminalToolSessionId); const resolvedCommand = this._getResolvedCommand(terminalInstance); - // update title - this.markCollapsibleWrapperComplete(); + this._handleCommandCompletion(resolvedCommand); - // Auto-collapse on success - if (resolvedCommand?.exitCode === 0 && this._outputView.isExpanded && !this._userToggledOutput) { - this._toggleOutput(false); - } - // keep outer wrapper expanded on error - const autoExpandFailures = this._configurationService.getValue(ChatConfiguration.AutoExpandToolFailures); - if (autoExpandFailures && resolvedCommand?.exitCode !== undefined && resolvedCommand.exitCode !== 0 && this._thinkingCollapsibleWrapper) { - this.expandCollapsibleWrapper(); - } if (resolvedCommand?.endMarker) { commandDetectionListener.clear(); } @@ -688,17 +744,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart const resolvedImmediately = await tryResolveCommand(); if (resolvedImmediately?.endMarker) { commandDetectionListener.clear(); - // update title - this.markCollapsibleWrapperComplete(); - // Auto-collapse on success - if (resolvedImmediately.exitCode === 0 && this._outputView.isExpanded && !this._userToggledOutput) { - this._toggleOutput(false); - } - // keep outer wrapper expanded on error - const autoExpandFailures = this._configurationService.getValue(ChatConfiguration.AutoExpandToolFailures); - if (autoExpandFailures && resolvedImmediately.exitCode !== undefined && resolvedImmediately.exitCode !== 0 && this._thinkingCollapsibleWrapper) { - this.expandCollapsibleWrapper(); - } + this._handleCommandCompletion(resolvedImmediately); return; } }; @@ -753,6 +799,29 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._continueInBackgroundAction.clear(); } + /** + * Handles the completion of a terminal command by updating the UI state. + * This includes marking the collapsible wrapper as complete, auto-collapsing + * successful commands, and keeping failed commands expanded. + * + * @param resolvedCommand The completed terminal command with exit code information. + */ + private _handleCommandCompletion(resolvedCommand: ITerminalCommand | undefined): void { + // Update title to show completion state + this.markCollapsibleWrapperComplete(); + + // Auto-collapse on success (exit code 0) + if (resolvedCommand?.exitCode === 0 && this._outputView.isExpanded && !this._userToggledOutput) { + this._toggleOutput(false); + } + + // Keep outer wrapper expanded on error for visibility + const autoExpandFailures = this._configurationService.getValue(ChatConfiguration.AutoExpandToolFailures); + if (autoExpandFailures && resolvedCommand?.exitCode !== undefined && resolvedCommand.exitCode !== 0 && this._thinkingCollapsibleWrapper) { + this.expandCollapsibleWrapper(); + } + } + private async _toggleOutput(expanded: boolean): Promise { const didChange = await this._outputView.toggle(expanded); const isExpanded = this._outputView.isExpanded; @@ -877,6 +946,21 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } } +/** + * A component that displays terminal command output in an expandable/collapsible section. + * + * This component supports two modes of displaying output: + * - **Live output**: Mirrors the output from a running terminal instance in real-time, + * supporting streaming updates, scroll-lock behavior, and user input forwarding. + * - **Snapshot output**: Displays a static snapshot of previously captured terminal output, + * useful for serialized/restored chat sessions. + * + * Features: + * - Automatic height calculation based on line count (min/max row limits) + * - Scroll-lock behavior: stays at bottom during streaming, respects user scroll position + * - Accessibility: proper ARIA labels and accessible view support + * - Theme-aware background color that adapts to panel vs editor context + */ class ChatTerminalToolOutputSection extends Disposable { public readonly domNode: HTMLElement; @@ -1130,9 +1214,8 @@ class ChatTerminalToolOutputSection extends Disposable { // 1. Command is running but executedMarker isn't set yet (renderCommand returns undefined) // 2. Command finished quickly but buffer isn't ready yet if (!hasOutput) { - const maxRetries = 10; - for (let retry = 0; retry < maxRetries && !hasOutput; retry++) { - await timeout(100); + for (let retry = 0; retry < MAX_OUTPUT_POLL_RETRIES && !hasOutput; retry++) { + await timeout(OUTPUT_POLL_DELAY_MS); if (this._store.isDisposed) { return true; } From 8f0ea5c65c784efc4fd3e3cb66d1f913cc0b65e0 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Fri, 27 Feb 2026 16:11:16 +0100 Subject: [PATCH 12/19] sessions - shorter button for PR open/create (#298257) --- extensions/github/package.nls.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/github/package.nls.json b/extensions/github/package.nls.json index 4acc8acabcbab..04497595eb6d2 100644 --- a/extensions/github/package.nls.json +++ b/extensions/github/package.nls.json @@ -5,8 +5,8 @@ "command.publish": "Publish to GitHub", "command.openOnGitHub": "Open on GitHub", "command.openOnVscodeDev": "Open in vscode.dev", - "command.createPullRequest": "Create Pull Request", - "command.openPullRequest": "Open Pull Request", + "command.createPullRequest": "Create PR", + "command.openPullRequest": "Open PR", "config.branchProtection": "Controls whether to query repository rules for GitHub repositories", "config.gitAuthentication": "Controls whether to enable automatic GitHub authentication for git commands within VS Code.", "config.gitProtocol": "Controls which protocol is used to clone a GitHub repository", From 5e2614b085330345da9bf8479a6dc492975123cf Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Fri, 27 Feb 2026 16:11:37 +0100 Subject: [PATCH 13/19] sessions - fix wrong use of ahead/behind in sync action (#298254) fix - update sync action registration logic --- .../contrib/gitSync/browser/gitSync.contribution.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/gitSync/browser/gitSync.contribution.ts b/src/vs/sessions/contrib/gitSync/browser/gitSync.contribution.ts index 45e2dcf0af3b3..57f7e8a23ccbe 100644 --- a/src/vs/sessions/contrib/gitSync/browser/gitSync.contribution.ts +++ b/src/vs/sessions/contrib/gitSync/browser/gitSync.contribution.ts @@ -48,13 +48,17 @@ class GitSyncContribution extends Disposable implements IWorkbenchContribution { return; } + const repoDisposables = this._gitRepoDisposables.add(new DisposableStore()); this.gitService.openRepository(worktreeUri).then(repository => { + if (repoDisposables.isDisposed) { + return; + } if (!repository) { this._syncActionDisposable.clear(); contextKey.set(false); return; } - this._gitRepoDisposables.add(autorun(innerReader => { + repoDisposables.add(autorun(innerReader => { const state = repository.state.read(innerReader); const head = state.HEAD; if (!head?.upstream) { @@ -66,7 +70,7 @@ class GitSyncContribution extends Disposable implements IWorkbenchContribution { const behind = head.behind ?? 0; const hasSyncChanges = ahead > 0 || behind > 0; contextKey.set(hasSyncChanges); - this._syncActionDisposable.value = registerSyncAction(ahead, behind); + this._syncActionDisposable.value = registerSyncAction(behind, ahead); })); }); })); From 7027856936a70297c054f4b74d38d4e045a6680e Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Fri, 27 Feb 2026 16:27:08 +0100 Subject: [PATCH 14/19] sessions - allow callback scheme in auth (#298270) --- extensions/github-authentication/src/common/env.ts | 5 ++++- extensions/microsoft-authentication/src/common/env.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/extensions/github-authentication/src/common/env.ts b/extensions/github-authentication/src/common/env.ts index ebc474936aa40..5456fb864ee4c 100644 --- a/extensions/github-authentication/src/common/env.ts +++ b/extensions/github-authentication/src/common/env.ts @@ -8,12 +8,15 @@ import { AuthProviderType } from '../github'; const VALID_DESKTOP_CALLBACK_SCHEMES = [ 'vscode', 'vscode-insiders', + 'vscode-exploration', + 'vscode-sessions', + 'vscode-sessions-insiders', + 'vscode-sessions-exploration', // On Windows, some browsers don't seem to redirect back to OSS properly. // As a result, you get stuck in the auth flow. We exclude this from the // list until we can figure out a way to fix this behavior in browsers. // 'code-oss', 'vscode-wsl', - 'vscode-exploration' ]; export function isSupportedClient(uri: Uri): boolean { diff --git a/extensions/microsoft-authentication/src/common/env.ts b/extensions/microsoft-authentication/src/common/env.ts index 9460a27d2fea9..b63c94195a9e9 100644 --- a/extensions/microsoft-authentication/src/common/env.ts +++ b/extensions/microsoft-authentication/src/common/env.ts @@ -9,12 +9,15 @@ export const DEFAULT_REDIRECT_URI = 'https://vscode.dev/redirect'; const VALID_DESKTOP_CALLBACK_SCHEMES = [ 'vscode', 'vscode-insiders', + 'vscode-exploration', + 'vscode-sessions', + 'vscode-sessions-insiders', + 'vscode-sessions-exploration', // On Windows, some browsers don't seem to redirect back to OSS properly. // As a result, you get stuck in the auth flow. We exclude this from the // list until we can figure out a way to fix this behavior in browsers. // 'code-oss', 'vscode-wsl', - 'vscode-exploration' ]; export function isSupportedClient(uri: Uri): boolean { From 3d374070c9a7441fbaa30175dca211befd3900c1 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 27 Feb 2026 09:33:12 -0600 Subject: [PATCH 15/19] improve tip service (#298271) --- .../contrib/chat/browser/chatTipCatalog.ts | 363 ++++++++++ .../chat/browser/chatTipEligibilityTracker.ts | 327 +++++++++ .../contrib/chat/browser/chatTipService.ts | 635 +++--------------- .../chat/browser/chatTipStorageKeys.ts | 48 ++ .../chatContentParts/chatTipContentPart.ts | 4 +- .../chat/test/browser/chatTipService.test.ts | 168 +++-- 6 files changed, 948 insertions(+), 597 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatTipEligibilityTracker.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatTipStorageKeys.ts diff --git a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts new file mode 100644 index 0000000000000..d130efd72758d --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts @@ -0,0 +1,363 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { localize } from '../../../../nls.js'; +import { ContextKeyExpr, ContextKeyExpression } from '../../../../platform/contextkey/common/contextkey.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; +import { ChatConfiguration, ChatModeKind } from '../common/constants.js'; +import { localChatSessionType } from '../common/chatSessionsService.js'; +import { ITipExclusionConfig } from './chatTipEligibilityTracker.js'; +import { TipTrackingCommands } from './chatTipStorageKeys.js'; +import { + GENERATE_AGENT_COMMAND_ID, + GENERATE_ON_DEMAND_INSTRUCTIONS_COMMAND_ID, + GENERATE_PROMPT_COMMAND_ID, + GENERATE_SKILL_COMMAND_ID, +} from './actions/chatActions.js'; + +/** + * Context provided to tip builders for dynamic message construction. + */ +export interface ITipBuildContext { + /** + * Keybinding service for looking up keyboard shortcuts. + */ + readonly keybindingService: IKeybindingService; +} + +/** + * Gets the display label for a command, looking it up from MenuRegistry. + * Falls back to extracting a readable name from the command ID. + */ +export function getCommandLabel(commandId: string): string { + const command = MenuRegistry.getCommand(commandId); + if (command?.title) { + // Handle both string and ILocalizedString formats + return typeof command.title === 'string' ? command.title : command.title.value; + } + // Fallback: extract readable name from command ID + // e.g., 'workbench.action.chat.openEditSession' -> 'openEditSession' + const parts = commandId.split('.'); + return parts[parts.length - 1]; +} + +/** + * Formats a keybinding for display in a tip message. + * Returns empty string if no keybinding is bound. + */ +function formatKeybinding(ctx: ITipBuildContext, commandId: string): string { + const kb = ctx.keybindingService.lookupKeybinding(commandId); + return kb ? ` (${kb.getLabel()})` : ''; +} + +/** + * Extracts command IDs from command: links in a markdown string. + * Used to automatically populate enabledCommands for trusted markdown. + */ +export function extractCommandIds(markdown: string): string[] { + const commandPattern = /\[.*?\]\(command:([^?\s)]+)/g; + const commands = new Set(); + let match; + while ((match = commandPattern.exec(markdown)) !== null) { + commands.add(match[1]); + } + return [...commands]; +} + +/** + * Interface for tip definitions in the catalog. + */ +export interface ITipDefinition extends ITipExclusionConfig { + readonly id: string; + /** + * Builds the tip message dynamically at runtime. + * This enables keybindings and command labels to be looked up fresh. + * The returned MarkdownString should NOT include the "Tip:" prefix. + */ + buildMessage(ctx: ITipBuildContext): MarkdownString; + /** + * When clause expression that determines if this tip is eligible to be shown. + */ + readonly when?: ContextKeyExpression; + /** + * Chat model IDs for which this tip is eligible (lowercase). + */ + readonly onlyWhenModelIds?: readonly string[]; + /** + * Setting keys that, if changed from default, make this tip ineligible. + */ + readonly excludeWhenSettingsChanged?: readonly string[]; + /** + * Command IDs that dismiss this tip when clicked from the tip markdown. + */ + readonly dismissWhenCommandsClicked?: readonly string[]; +} + +// ----------------------------------------------------------------------------- +// Tip Catalog +// ----------------------------------------------------------------------------- + +/** + * Static catalog of tips. Tips are built dynamically at runtime to enable + * keybindings and command labels to be resolved fresh. + */ +export const TIP_CATALOG: readonly ITipDefinition[] = [ + { + id: 'tip.switchToAuto', + buildMessage(ctx) { + const label = getCommandLabel('workbench.action.chat.openModelPicker'); + const kb = formatKeybinding(ctx, 'workbench.action.chat.openModelPicker'); + return new MarkdownString( + localize( + 'tip.switchToAuto', + "Using gpt-4.1? Try switching to [{0}](command:workbench.action.chat.openModelPicker){1} for better coding performance.", + label, + kb + ) + ); + }, + onlyWhenModelIds: ['gpt-4.1'], + }, + { + id: 'tip.createInstruction', + buildMessage(ctx) { + const kb = formatKeybinding(ctx, GENERATE_ON_DEMAND_INSTRUCTIONS_COMMAND_ID); + return new MarkdownString( + localize( + 'tip.createInstruction', + "Use [{0}](command:{1}){2} to generate an on-demand instructions file with the agent.", + '/create-instructions', + GENERATE_ON_DEMAND_INSTRUCTIONS_COMMAND_ID, + kb + ) + ); + }, + when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + excludeWhenCommandsExecuted: [ + GENERATE_ON_DEMAND_INSTRUCTIONS_COMMAND_ID, + TipTrackingCommands.CreateAgentInstructionsUsed, + ], + }, + { + id: 'tip.createPrompt', + buildMessage(ctx) { + const kb = formatKeybinding(ctx, GENERATE_PROMPT_COMMAND_ID); + return new MarkdownString( + localize( + 'tip.createPrompt', + "Use [{0}](command:{1}){2} to generate a reusable prompt file with the agent.", + '/create-prompt', + GENERATE_PROMPT_COMMAND_ID, + kb + ) + ); + }, + when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + excludeWhenCommandsExecuted: [ + GENERATE_PROMPT_COMMAND_ID, + TipTrackingCommands.CreatePromptUsed, + ], + }, + { + id: 'tip.createAgent', + buildMessage(ctx) { + const kb = formatKeybinding(ctx, GENERATE_AGENT_COMMAND_ID); + return new MarkdownString( + localize( + 'tip.createAgent', + "Use [{0}](command:{1}){2} to scaffold a custom agent for your workflow.", + '/create-agent', + GENERATE_AGENT_COMMAND_ID, + kb + ) + ); + }, + when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + excludeWhenCommandsExecuted: [ + GENERATE_AGENT_COMMAND_ID, + TipTrackingCommands.CreateAgentUsed, + ], + }, + { + id: 'tip.createSkill', + buildMessage(ctx) { + const kb = formatKeybinding(ctx, GENERATE_SKILL_COMMAND_ID); + return new MarkdownString( + localize( + 'tip.createSkill', + "Use [{0}](command:{1}){2} to create a skill the agent can load when relevant.", + '/create-skill', + GENERATE_SKILL_COMMAND_ID, + kb + ) + ); + }, + when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + excludeWhenCommandsExecuted: [ + GENERATE_SKILL_COMMAND_ID, + TipTrackingCommands.CreateSkillUsed, + ], + }, + { + id: 'tip.agentMode', + buildMessage(ctx) { + const label = getCommandLabel('workbench.action.chat.openEditSession'); + const kb = formatKeybinding(ctx, 'workbench.action.chat.openEditSession'); + return new MarkdownString( + localize( + 'tip.agentMode', + "Try [{0}](command:workbench.action.chat.openEditSession){1} to make edits across your project and run commands.", + label, + kb + ) + ); + }, + when: ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Agent), + excludeWhenModesUsed: [ChatModeKind.Agent], + }, + { + id: 'tip.planMode', + buildMessage(ctx) { + const kb = formatKeybinding(ctx, 'workbench.action.chat.openPlan'); + return new MarkdownString( + localize( + 'tip.planMode', + "Try the [{0}](command:workbench.action.chat.openPlan){1} to research and plan before implementing changes.", + 'Plan agent', + kb + ) + ); + }, + when: ChatContextKeys.chatModeName.notEqualsTo('Plan'), + excludeWhenCommandsExecuted: ['workbench.action.chat.openPlan'], + excludeWhenModesUsed: ['Plan'], + }, + { + id: 'tip.attachFiles', + buildMessage() { + return new MarkdownString( + localize('tip.attachFiles', "Reference files or folders with # to give the agent more context about the task.") + ); + }, + excludeWhenCommandsExecuted: [ + 'workbench.action.chat.attachContext', + 'workbench.action.chat.attachFile', + 'workbench.action.chat.attachFolder', + 'workbench.action.chat.attachSelection', + TipTrackingCommands.AttachFilesReferenceUsed, + ], + }, + { + id: 'tip.codeActions', + buildMessage() { + return new MarkdownString( + localize('tip.codeActions', "Select a code block in the editor and right-click to access more AI actions.") + ); + }, + excludeWhenCommandsExecuted: ['inlineChat.start'], + }, + { + id: 'tip.undoChanges', + buildMessage() { + return new MarkdownString( + localize('tip.undoChanges', "Select \"Restore Checkpoint\" to undo changes after that point in the chat conversation.") + ); + }, + when: ContextKeyExpr.and( + ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + ContextKeyExpr.or( + ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Edit), + ), + ), + excludeWhenCommandsExecuted: ['workbench.action.chat.restoreCheckpoint', 'workbench.action.chat.restoreLastCheckpoint'], + }, + { + id: 'tip.messageQueueing', + buildMessage() { + return new MarkdownString( + localize('tip.messageQueueing', "Steer the agent mid-task by sending follow-up messages. They queue and apply in order.") + ); + }, + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + excludeWhenCommandsExecuted: ['workbench.action.chat.queueMessage', 'workbench.action.chat.steerWithMessage'], + }, + { + id: 'tip.yoloMode', + buildMessage() { + return new MarkdownString( + localize( + 'tip.yoloMode', + "Enable [{0}](command:workbench.action.openSettings?%5B%22{1}%22%5D) to give the agent full control without manual confirmation.", + 'auto approve', + ChatConfiguration.GlobalAutoApprove + ) + ); + }, + when: ContextKeyExpr.and( + ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + ContextKeyExpr.notEquals('config.chat.tools.global.autoApprove', true), + ), + excludeWhenSettingsChanged: [ChatConfiguration.GlobalAutoApprove], + dismissWhenCommandsClicked: ['workbench.action.openSettings'], + }, + { + id: 'tip.agenticBrowser', + buildMessage() { + return new MarkdownString( + localize( + 'tip.agenticBrowser', + "Enable [{0}](command:workbench.action.openSettings?%5B%22workbench.browser.enableChatTools%22%5D) 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', + buildMessage() { + return new MarkdownString( + localize('tip.mermaid', "Ask the agent to draw an architectural diagram or flow chart; it can render Mermaid diagrams directly in chat.") + ); + }, + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + excludeWhenToolsInvoked: ['renderMermaidDiagram'], + }, + { + id: 'tip.subagents', + buildMessage() { + return new MarkdownString( + localize('tip.subagents', "Ask the agent to work in parallel to complete large tasks faster.") + ); + }, + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + excludeWhenToolsInvoked: ['runSubagent'], + }, + { + id: 'tip.thinkingPhrases', + buildMessage() { + return new MarkdownString( + localize( + 'tip.thinkingPhrases', + "Customize the loading messages shown while the agent works with [{0}](command:workbench.action.openSettings?%5B%22{1}%22%5D).", + 'thinking phrases', + ChatConfiguration.ThinkingPhrases + ) + ); + }, + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + excludeWhenSettingsChanged: [ChatConfiguration.ThinkingPhrases], + dismissWhenCommandsClicked: ['workbench.action.openSettings'], + }, +]; diff --git a/src/vs/workbench/contrib/chat/browser/chatTipEligibilityTracker.ts b/src/vs/workbench/contrib/chat/browser/chatTipEligibilityTracker.ts new file mode 100644 index 0000000000000..df72fa41ab419 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatTipEligibilityTracker.ts @@ -0,0 +1,327 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; +import { AgentFileType, IPromptsService } from '../common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../common/promptSyntax/promptTypes.js'; +import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; +import { TipEligibilityStorageKeys } from './chatTipStorageKeys.js'; + +/** + * Interface for tip definitions that have exclusion criteria tracked by this class. + * This subset is all TipEligibilityTracker needs to know about tip definitions. + */ +export interface ITipExclusionConfig { + readonly id: string; + /** Command IDs that, if ever executed, make this tip ineligible. */ + readonly excludeWhenCommandsExecuted?: readonly string[]; + /** Chat mode names that, if ever used, make this tip ineligible. */ + readonly excludeWhenModesUsed?: readonly string[]; + /** Tool IDs that, if ever invoked, make this tip ineligible. */ + readonly excludeWhenToolsInvoked?: readonly string[]; + /** File-based exclusion configuration. */ + readonly excludeWhenPromptFilesExist?: { + readonly promptType: PromptsType; + readonly agentFileType?: AgentFileType; + readonly excludeUntilChecked?: boolean; + }; +} + +/** + * Tracks user-level signals that determine whether certain tips should be + * excluded. Persists state to application storage and disposes listeners once all + * signals of interest have been observed. + */ +export class TipEligibilityTracker extends Disposable { + + private readonly _executedCommands: Set; + private readonly _usedModes: Set; + private readonly _invokedTools: Set; + + private readonly _pendingCommands: Set; + private readonly _pendingModes: Set; + private readonly _pendingTools: Set; + + private readonly _commandListener = this._register(new MutableDisposable()); + private readonly _toolListener = this._register(new MutableDisposable()); + + /** + * Tip IDs excluded because prompt files of the required type exist in the workspace. + * Tips with `excludeUntilChecked` are pre-added and removed if no files are found. + */ + private readonly _excludedByFiles = new Set(); + + /** Tips that have file-based exclusions, kept for re-checks. */ + private readonly _tipsWithFileExclusions: readonly ITipExclusionConfig[]; + + /** Generation counter per tip ID to discard stale async file-check results. */ + private readonly _fileCheckGeneration = new Map(); + private readonly _fileChecksInFlight = new Map>(); + + constructor( + tips: readonly ITipExclusionConfig[], + @ICommandService commandService: ICommandService, + @IStorageService private readonly _storageService: IStorageService, + @IPromptsService private readonly _promptsService: IPromptsService, + @ILanguageModelToolsService private readonly _languageModelToolsService: ILanguageModelToolsService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + // --- Restore persisted state ------------------------------------------- + + const storedCmds = this._readApplicationWithProfileFallback(TipEligibilityStorageKeys.ExecutedCommands); + this._executedCommands = new Set(storedCmds ? JSON.parse(storedCmds) : []); + + const storedModes = this._readApplicationWithProfileFallback(TipEligibilityStorageKeys.UsedModes); + this._usedModes = new Set(storedModes ? JSON.parse(storedModes) : []); + + const storedTools = this._readApplicationWithProfileFallback(TipEligibilityStorageKeys.InvokedTools); + this._invokedTools = new Set(storedTools ? JSON.parse(storedTools) : []); + + // --- Derive what still needs tracking ---------------------------------- + + this._pendingCommands = new Set(); + for (const tip of tips) { + for (const cmd of tip.excludeWhenCommandsExecuted ?? []) { + if (!this._executedCommands.has(cmd)) { + this._pendingCommands.add(cmd); + } + } + } + + this._pendingModes = new Set(); + for (const tip of tips) { + for (const mode of tip.excludeWhenModesUsed ?? []) { + if (!this._usedModes.has(mode)) { + this._pendingModes.add(mode); + } + } + } + + this._pendingTools = new Set(); + for (const tip of tips) { + for (const toolId of tip.excludeWhenToolsInvoked ?? []) { + if (!this._invokedTools.has(toolId)) { + this._pendingTools.add(toolId); + } + } + } + + // --- Set up command listener (auto-disposes when all seen) -------------- + + if (this._pendingCommands.size > 0) { + this._commandListener.value = commandService.onDidExecuteCommand(e => { + this.recordCommandExecuted(e.commandId); + }); + } + + // --- Set up tool listener (auto-disposes when all seen) ----------------- + + if (this._pendingTools.size > 0) { + this._toolListener.value = this._languageModelToolsService.onDidInvokeTool(e => { + // Track explicit tool IDs + if (this._pendingTools.has(e.toolId)) { + this._invokedTools.add(e.toolId); + this._pendingTools.delete(e.toolId); + + this._persistSet(TipEligibilityStorageKeys.InvokedTools, this._invokedTools); + } + + if (this._pendingTools.size === 0) { + this._toolListener.clear(); + } + }); + } + + // --- Async file checks ------------------------------------------------- + + this._tipsWithFileExclusions = tips.filter(t => t.excludeWhenPromptFilesExist); + for (const tip of this._tipsWithFileExclusions) { + if (tip.excludeWhenPromptFilesExist!.excludeUntilChecked) { + this._excludedByFiles.add(tip.id); + } + this._checkForPromptFiles(tip); + } + + // Re-check agent file exclusions when custom agents change (covers late discovery) + this._register(this._promptsService.onDidChangeCustomAgents(() => { + for (const tip of this._tipsWithFileExclusions) { + if (tip.excludeWhenPromptFilesExist!.promptType === PromptsType.agent) { + this._checkForPromptFiles(tip); + } + } + })); + } + + recordCommandExecuted(commandId: string): void { + if (!this._pendingCommands.has(commandId)) { + return; + } + + this._executedCommands.add(commandId); + this._persistSet(TipEligibilityStorageKeys.ExecutedCommands, this._executedCommands); + this._pendingCommands.delete(commandId); + + if (this._pendingCommands.size === 0) { + this._commandListener.clear(); + } + } + + /** + * Records the current chat mode (kind + name) so future tip eligibility + * checks can exclude mode-related tips. No-ops once all tracked modes + * have been observed. + */ + recordCurrentMode(contextKeyService: IContextKeyService): void { + if (this._pendingModes.size === 0) { + return; + } + + let changed = false; + const kind = contextKeyService.getContextKeyValue(ChatContextKeys.chatModeKind.key); + if (kind && !this._usedModes.has(kind)) { + this._usedModes.add(kind); + this._pendingModes.delete(kind); + changed = true; + } + const name = contextKeyService.getContextKeyValue(ChatContextKeys.chatModeName.key); + if (name && !this._usedModes.has(name)) { + this._usedModes.add(name); + this._pendingModes.delete(name); + changed = true; + } + if (changed) { + this._persistSet(TipEligibilityStorageKeys.UsedModes, this._usedModes); + } + } + + /** + * Returns `true` when the tip should be **excluded** from the eligible set. + */ + isExcluded(tip: ITipExclusionConfig): boolean { + if (tip.excludeWhenCommandsExecuted) { + for (const cmd of tip.excludeWhenCommandsExecuted) { + if (this._executedCommands.has(cmd)) { + this._logService.debug('#ChatTips: tip excluded because command was executed', tip.id, cmd); + return true; + } + } + } + if (tip.excludeWhenModesUsed) { + for (const mode of tip.excludeWhenModesUsed) { + if (this._usedModes.has(mode)) { + this._logService.debug('#ChatTips: tip excluded because mode was used', tip.id, mode); + return true; + } + } + } + if (tip.excludeWhenToolsInvoked) { + for (const toolId of tip.excludeWhenToolsInvoked) { + if (this._invokedTools.has(toolId)) { + this._logService.debug('#ChatTips: tip excluded because tool was invoked', tip.id, toolId); + return true; + } + } + } + if (tip.excludeWhenPromptFilesExist && this._excludedByFiles.has(tip.id)) { + this._logService.debug('#ChatTips: tip excluded because prompt files exist', tip.id); + return true; + } + return false; + } + + /** + * Revalidates all file-based tip exclusions. Tips with `excludeUntilChecked` + * are conservatively hidden until the re-check completes. + */ + refreshPromptFileExclusions(): void { + for (const tip of this._tipsWithFileExclusions) { + if (tip.excludeWhenPromptFilesExist!.excludeUntilChecked) { + this._excludedByFiles.add(tip.id); + } + this._checkForPromptFiles(tip); + } + } + + private async _checkForPromptFiles(tip: ITipExclusionConfig): Promise { + const inFlight = this._fileChecksInFlight.get(tip.id); + if (inFlight) { + await inFlight; + return; + } + + const checkPromise = this._doCheckForPromptFiles(tip); + this._fileChecksInFlight.set(tip.id, checkPromise); + try { + await checkPromise; + } finally { + if (this._fileChecksInFlight.get(tip.id) === checkPromise) { + this._fileChecksInFlight.delete(tip.id); + } + } + } + + private async _doCheckForPromptFiles(tip: ITipExclusionConfig): Promise { + const config = tip.excludeWhenPromptFilesExist!; + const generation = (this._fileCheckGeneration.get(tip.id) ?? 0) + 1; + this._fileCheckGeneration.set(tip.id, generation); + + try { + const [promptFiles, agentInstructions] = await Promise.all([ + this._promptsService.listPromptFiles(config.promptType, CancellationToken.None), + config.agentFileType ? this._promptsService.listAgentInstructions(CancellationToken.None) : Promise.resolve([]), + ]); + + // Discard stale result if a newer check was started while we were awaiting + if (this._fileCheckGeneration.get(tip.id) !== generation) { + return; + } + + const hasPromptFiles = promptFiles.length > 0; + const hasAgentFile = config.agentFileType + ? agentInstructions.some(f => f.type === config.agentFileType) + : false; + const hasPromptFilesOrAgentFile = hasPromptFiles || hasAgentFile; + + if (hasPromptFilesOrAgentFile) { + this._excludedByFiles.add(tip.id); + } else { + this._excludedByFiles.delete(tip.id); + } + } catch { + if (this._fileCheckGeneration.get(tip.id) !== generation) { + return; + } + if (config.excludeUntilChecked) { + this._excludedByFiles.add(tip.id); + } + } + } + + private _persistSet(key: string, set: Set): void { + this._storageService.store(key, JSON.stringify([...set]), StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + private _readApplicationWithProfileFallback(key: string): string | undefined { + const applicationValue = this._storageService.get(key, StorageScope.APPLICATION); + if (applicationValue) { + return applicationValue; + } + + const profileValue = this._storageService.get(key, StorageScope.PROFILE); + if (profileValue) { + this._storageService.store(key, profileValue, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + return profileValue; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index e4c5387b9c5a3..3bc000338552c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -5,28 +5,26 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; -import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration } from '../common/constants.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { AgentFileType, IPromptsService } from '../common/promptSyntax/service/promptsService.js'; -import { PromptsType } from '../common/promptSyntax/promptTypes.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; import { localize } from '../../../../nls.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; -import { localChatSessionType } from '../common/chatSessionsService.js'; import { IChatService } from '../common/chatService/chatService.js'; import { CreateSlashCommandsUsageTracker } from './createSlashCommandsUsageTracker.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, IParsedChatRequest } from '../common/requestParser/chatParserTypes.js'; -import { GENERATE_AGENT_COMMAND_ID, GENERATE_ON_DEMAND_INSTRUCTIONS_COMMAND_ID, GENERATE_PROMPT_COMMAND_ID, GENERATE_SKILL_COMMAND_ID } from './actions/chatActions.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { TipEligibilityTracker } from './chatTipEligibilityTracker.js'; +import { extractCommandIds, ITipBuildContext, ITipDefinition, TIP_CATALOG } from './chatTipCatalog.js'; +import { ChatTipStorageKeys, TipTrackingCommands } from './chatTipStorageKeys.js'; type ChatTipEvent = { tipId: string; @@ -42,11 +40,20 @@ type ChatTipClassification = { comment: 'Tracks user interactions with chat tips to understand which tips resonate and which are dismissed.'; }; -export const ATTACH_FILES_REFERENCE_TRACKING_COMMAND = 'chat.tips.attachFiles.referenceUsed'; -export const CREATE_AGENT_INSTRUCTIONS_TRACKING_COMMAND = 'chat.tips.createAgentInstructions.commandUsed'; -export const CREATE_PROMPT_TRACKING_COMMAND = 'chat.tips.createPrompt.commandUsed'; -export const CREATE_AGENT_TRACKING_COMMAND = 'chat.tips.createAgent.commandUsed'; -export const CREATE_SKILL_TRACKING_COMMAND = 'chat.tips.createSkill.commandUsed'; +// Re-export tracking commands for backwards compatibility +export { + TipTrackingCommands, +}; +/** @deprecated Use TipTrackingCommands.AttachFilesReferenceUsed */ +export const ATTACH_FILES_REFERENCE_TRACKING_COMMAND = TipTrackingCommands.AttachFilesReferenceUsed; +/** @deprecated Use TipTrackingCommands.CreateAgentInstructionsUsed */ +export const CREATE_AGENT_INSTRUCTIONS_TRACKING_COMMAND = TipTrackingCommands.CreateAgentInstructionsUsed; +/** @deprecated Use TipTrackingCommands.CreatePromptUsed */ +export const CREATE_PROMPT_TRACKING_COMMAND = TipTrackingCommands.CreatePromptUsed; +/** @deprecated Use TipTrackingCommands.CreateAgentUsed */ +export const CREATE_AGENT_TRACKING_COMMAND = TipTrackingCommands.CreateAgentUsed; +/** @deprecated Use TipTrackingCommands.CreateSkillUsed */ +export const CREATE_SKILL_TRACKING_COMMAND = TipTrackingCommands.CreateSkillUsed; export const IChatTipService = createDecorator('chatTipService'); @@ -118,6 +125,12 @@ export interface IChatTipService { */ navigateToPreviousTip(): IChatTip | undefined; + /** + * Gets the next eligible tip after the current one, without requiring multiple tips. + * Used after dismissing a tip to show the next available tip (even if it's the only one left). + */ + getNextEligibleTip(): IChatTip | undefined; + /** * Returns whether there are multiple eligible tips for navigation. */ @@ -129,508 +142,9 @@ export interface IChatTipService { clearDismissedTips(): void; } -export interface ITipDefinition { - readonly id: string; - readonly message: string; - /** - * When clause expression that determines if this tip is eligible to be shown. - * If undefined, the tip is always eligible. - */ - readonly when?: ContextKeyExpression; - /** - * Command IDs that are allowed to be executed from this tip's markdown. - */ - readonly enabledCommands?: string[]; - /** - * Chat model IDs for which this tip is eligible. - * Compared against the lowercased `chatModelId` context key. - */ - readonly onlyWhenModelIds?: readonly string[]; - /** - * Command IDs that, if ever executed in this workspace, make this tip ineligible. - * The tip won't be shown if the user has already performed the action it suggests. - */ - readonly excludeWhenCommandsExecuted?: string[]; - /** - * Chat mode names that, if ever used in this workspace, make this tip ineligible. - * The tip won't be shown if the user has already used the mode it suggests. - * Matches against both mode kind (e.g. 'agent') and mode name (e.g. 'Plan'). - */ - readonly excludeWhenModesUsed?: string[]; - /** - * Tool IDs that, if ever invoked in this workspace, make this tip ineligible. - * The tip won't be shown if the tool it describes has already been used. - */ - readonly excludeWhenToolsInvoked?: string[]; - /** - * If set, exclude this tip when prompt files of the specified type exist in the workspace. - */ - readonly excludeWhenPromptFilesExist?: { - readonly promptType: PromptsType; - /** Also check for this specific agent instruction file type. */ - readonly agentFileType?: AgentFileType; - /** If true, exclude the tip until the async file check completes. Default: false. */ - readonly excludeUntilChecked?: boolean; - }; - /** - * Setting keys that, if changed from their default value, make this tip ineligible. - * The tip won't be shown if the user has already customized the setting it describes. - */ - readonly excludeWhenSettingsChanged?: string[]; - /** - * Command IDs that dismiss this tip when clicked from the tip markdown - * while the tip is currently shown. - */ - readonly dismissWhenCommandsClicked?: string[]; -} - -/** - * Static catalog of tips. Each tip has an optional when clause for eligibility. - */ -const TIP_CATALOG: ITipDefinition[] = [ - { - id: 'tip.switchToAuto', - message: localize('tip.switchToAuto', "Tip: Using gpt-4.1? Try switching to [Auto](command:workbench.action.chat.openModelPicker) in the model picker for better coding performance."), - enabledCommands: ['workbench.action.chat.openModelPicker'], - onlyWhenModelIds: ['gpt-4.1'], - }, - { - id: 'tip.createInstruction', - message: localize( - 'tip.createInstruction', - "Tip: Use [/create-instructions](command:{0}) to generate an on-demand instructions file with the agent.", - GENERATE_ON_DEMAND_INSTRUCTIONS_COMMAND_ID - ), - when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), - enabledCommands: [GENERATE_ON_DEMAND_INSTRUCTIONS_COMMAND_ID], - excludeWhenCommandsExecuted: [ - GENERATE_ON_DEMAND_INSTRUCTIONS_COMMAND_ID, - CREATE_AGENT_INSTRUCTIONS_TRACKING_COMMAND, - ], - }, - { - id: 'tip.createPrompt', - message: localize( - 'tip.createPrompt', - "Tip: Use [/create-prompt](command:{0}) to generate a reusable prompt file with the agent.", - GENERATE_PROMPT_COMMAND_ID - ), - when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), - enabledCommands: [GENERATE_PROMPT_COMMAND_ID], - excludeWhenCommandsExecuted: [ - GENERATE_PROMPT_COMMAND_ID, - CREATE_PROMPT_TRACKING_COMMAND, - ], - }, - { - id: 'tip.createAgent', - message: localize( - 'tip.createAgent', - "Tip: Use [/create-agent](command:{0}) to scaffold a custom agent for your workflow.", - GENERATE_AGENT_COMMAND_ID - ), - when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), - enabledCommands: [GENERATE_AGENT_COMMAND_ID], - excludeWhenCommandsExecuted: [ - GENERATE_AGENT_COMMAND_ID, - CREATE_AGENT_TRACKING_COMMAND, - ], - }, - { - id: 'tip.createSkill', - message: localize( - 'tip.createSkill', - "Tip: Use [/create-skill](command:{0}) to create a skill the agent can load when relevant.", - GENERATE_SKILL_COMMAND_ID - ), - when: ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), - enabledCommands: [GENERATE_SKILL_COMMAND_ID], - excludeWhenCommandsExecuted: [ - GENERATE_SKILL_COMMAND_ID, - CREATE_SKILL_TRACKING_COMMAND, - ], - }, - { - id: 'tip.agentMode', - message: localize('tip.agentMode', "Tip: Try [Agents](command:workbench.action.chat.openEditSession) to make edits across your project and run commands."), - when: ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Agent), - enabledCommands: ['workbench.action.chat.openEditSession'], - excludeWhenModesUsed: [ChatModeKind.Agent], - }, - { - id: 'tip.planMode', - message: localize('tip.planMode', "Tip: Try the [Plan agent](command:workbench.action.chat.openPlan) to research and plan before implementing changes."), - when: ChatContextKeys.chatModeName.notEqualsTo('Plan'), - enabledCommands: ['workbench.action.chat.openPlan'], - excludeWhenCommandsExecuted: ['workbench.action.chat.openPlan'], - excludeWhenModesUsed: ['Plan'], - }, - { - id: 'tip.attachFiles', - message: localize('tip.attachFiles', "Tip: Reference files or folders with # to give the agent more context about the task."), - excludeWhenCommandsExecuted: ['workbench.action.chat.attachContext', 'workbench.action.chat.attachFile', 'workbench.action.chat.attachFolder', 'workbench.action.chat.attachSelection', ATTACH_FILES_REFERENCE_TRACKING_COMMAND], - }, - { - id: 'tip.codeActions', - message: localize('tip.codeActions', "Tip: Select a code block in the editor and right-click to access more AI actions."), - excludeWhenCommandsExecuted: ['inlineChat.start'], - }, - { - id: 'tip.undoChanges', - message: localize('tip.undoChanges', "Tip: Select \"Restore Checkpoint\" to undo changes after that point in the chat conversation."), - when: ContextKeyExpr.and( - ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), - ContextKeyExpr.or( - ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Edit), - ), - ), - excludeWhenCommandsExecuted: ['workbench.action.chat.restoreCheckpoint', 'workbench.action.chat.restoreLastCheckpoint'], - }, - { - id: 'tip.messageQueueing', - message: localize('tip.messageQueueing', "Tip: Steer the agent mid-task by sending follow-up messages. They queue and apply in order."), - when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - excludeWhenCommandsExecuted: ['workbench.action.chat.queueMessage', 'workbench.action.chat.steerWithMessage'], - }, - { - id: 'tip.yoloMode', - message: localize('tip.yoloMode', "Tip: Enable [auto approve](command:workbench.action.openSettings?%5B%22chat.tools.global.autoApprove%22%5D) to give the agent full control without manual confirmation."), - when: ContextKeyExpr.and( - ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - ContextKeyExpr.notEquals('config.chat.tools.global.autoApprove', true), - ), - enabledCommands: ['workbench.action.openSettings'], - excludeWhenSettingsChanged: [ChatConfiguration.GlobalAutoApprove], - dismissWhenCommandsClicked: ['workbench.action.openSettings'], - }, - { - id: 'tip.agenticBrowser', - message: localize('tip.agenticBrowser', "Tip: Enable [agentic browser integration](command:workbench.action.openSettings?%5B%22workbench.browser.enableChatTools%22%5D) to let the agent open and interact with pages in the Integrated Browser."), - when: ContextKeyExpr.and( - ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - ContextKeyExpr.notEquals('config.workbench.browser.enableChatTools', true), - ), - enabledCommands: ['workbench.action.openSettings'], - excludeWhenSettingsChanged: ['workbench.browser.enableChatTools'], - dismissWhenCommandsClicked: ['workbench.action.openSettings'], - }, - { - id: 'tip.mermaid', - message: localize('tip.mermaid', "Tip: Ask the agent to draw an architectural diagram or flow chart; it can render Mermaid diagrams directly in chat."), - when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - excludeWhenToolsInvoked: ['renderMermaidDiagram'], - }, - { - id: 'tip.subagents', - message: localize('tip.subagents', "Tip: Ask the agent to work in parallel to complete large tasks faster."), - when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - excludeWhenToolsInvoked: ['runSubagent'], - }, - { - id: 'tip.thinkingPhrases', - message: localize('tip.thinkingPhrases', "Tip: Customize the loading messages shown while the agent works with [thinking phrases](command:workbench.action.openSettings?%5B%22chat.agent.thinking.phrases%22%5D)."), - when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - enabledCommands: ['workbench.action.openSettings'], - excludeWhenSettingsChanged: ['chat.agent.thinking.phrases'], - dismissWhenCommandsClicked: ['workbench.action.openSettings'], - }, -]; - -/** - * Tracks user-level signals that determine whether certain tips should be - * excluded. Persists state to application storage and disposes listeners once all - * signals of interest have been observed. - */ -export class TipEligibilityTracker extends Disposable { - - private static readonly _COMMANDS_STORAGE_KEY = 'chat.tips.executedCommands'; - private static readonly _MODES_STORAGE_KEY = 'chat.tips.usedModes'; - private static readonly _TOOLS_STORAGE_KEY = 'chat.tips.invokedTools'; - - private readonly _executedCommands: Set; - private readonly _usedModes: Set; - private readonly _invokedTools: Set; - - private readonly _pendingCommands: Set; - private readonly _pendingModes: Set; - private readonly _pendingTools: Set; - - private readonly _commandListener = this._register(new MutableDisposable()); - private readonly _toolListener = this._register(new MutableDisposable()); - - /** - * Tip IDs excluded because prompt files of the required type exist in the workspace. - * Tips with `excludeUntilChecked` are pre-added and removed if no files are found. - */ - private readonly _excludedByFiles = new Set(); - - /** Tips that have file-based exclusions, kept for re-checks. */ - private readonly _tipsWithFileExclusions: readonly ITipDefinition[]; - - /** Generation counter per tip ID to discard stale async file-check results. */ - private readonly _fileCheckGeneration = new Map(); - private readonly _fileChecksInFlight = new Map>(); - - constructor( - tips: readonly ITipDefinition[], - @ICommandService commandService: ICommandService, - @IStorageService private readonly _storageService: IStorageService, - @IPromptsService private readonly _promptsService: IPromptsService, - @ILanguageModelToolsService private readonly _languageModelToolsService: ILanguageModelToolsService, - @ILogService private readonly _logService: ILogService, - ) { - super(); - - // --- Restore persisted state ------------------------------------------- - - const storedCmds = this._readApplicationWithProfileFallback(TipEligibilityTracker._COMMANDS_STORAGE_KEY); - this._executedCommands = new Set(storedCmds ? JSON.parse(storedCmds) : []); - - const storedModes = this._readApplicationWithProfileFallback(TipEligibilityTracker._MODES_STORAGE_KEY); - this._usedModes = new Set(storedModes ? JSON.parse(storedModes) : []); - - const storedTools = this._readApplicationWithProfileFallback(TipEligibilityTracker._TOOLS_STORAGE_KEY); - this._invokedTools = new Set(storedTools ? JSON.parse(storedTools) : []); - - // --- Derive what still needs tracking ---------------------------------- - - this._pendingCommands = new Set(); - for (const tip of tips) { - for (const cmd of tip.excludeWhenCommandsExecuted ?? []) { - if (!this._executedCommands.has(cmd)) { - this._pendingCommands.add(cmd); - } - } - } - - this._pendingModes = new Set(); - for (const tip of tips) { - for (const mode of tip.excludeWhenModesUsed ?? []) { - if (!this._usedModes.has(mode)) { - this._pendingModes.add(mode); - } - } - } - - this._pendingTools = new Set(); - for (const tip of tips) { - for (const toolId of tip.excludeWhenToolsInvoked ?? []) { - if (!this._invokedTools.has(toolId)) { - this._pendingTools.add(toolId); - } - } - } - - // --- Set up command listener (auto-disposes when all seen) -------------- - - if (this._pendingCommands.size > 0) { - this._commandListener.value = commandService.onDidExecuteCommand(e => { - this.recordCommandExecuted(e.commandId); - }); - } - - // --- Set up tool listener (auto-disposes when all seen) ----------------- - - if (this._pendingTools.size > 0) { - this._toolListener.value = this._languageModelToolsService.onDidInvokeTool(e => { - // Track explicit tool IDs - if (this._pendingTools.has(e.toolId)) { - this._invokedTools.add(e.toolId); - this._pendingTools.delete(e.toolId); - - this._persistSet(TipEligibilityTracker._TOOLS_STORAGE_KEY, this._invokedTools); - } - - if (this._pendingTools.size === 0) { - this._toolListener.clear(); - } - }); - } - - // --- Async file checks ------------------------------------------------- - - this._tipsWithFileExclusions = tips.filter(t => t.excludeWhenPromptFilesExist); - for (const tip of this._tipsWithFileExclusions) { - if (tip.excludeWhenPromptFilesExist!.excludeUntilChecked) { - this._excludedByFiles.add(tip.id); - } - this._checkForPromptFiles(tip); - } - - // Re-check agent file exclusions when custom agents change (covers late discovery) - this._register(this._promptsService.onDidChangeCustomAgents(() => { - for (const tip of this._tipsWithFileExclusions) { - if (tip.excludeWhenPromptFilesExist!.promptType === PromptsType.agent) { - this._checkForPromptFiles(tip); - } - } - })); - } - - recordCommandExecuted(commandId: string): void { - if (!this._pendingCommands.has(commandId)) { - return; - } - - this._executedCommands.add(commandId); - this._persistSet(TipEligibilityTracker._COMMANDS_STORAGE_KEY, this._executedCommands); - this._pendingCommands.delete(commandId); - - if (this._pendingCommands.size === 0) { - this._commandListener.clear(); - } - } - - /** - * Records the current chat mode (kind + name) so future tip eligibility - * checks can exclude mode-related tips. No-ops once all tracked modes - * have been observed. - */ - recordCurrentMode(contextKeyService: IContextKeyService): void { - if (this._pendingModes.size === 0) { - return; - } - - let changed = false; - const kind = contextKeyService.getContextKeyValue(ChatContextKeys.chatModeKind.key); - if (kind && !this._usedModes.has(kind)) { - this._usedModes.add(kind); - this._pendingModes.delete(kind); - changed = true; - } - const name = contextKeyService.getContextKeyValue(ChatContextKeys.chatModeName.key); - if (name && !this._usedModes.has(name)) { - this._usedModes.add(name); - this._pendingModes.delete(name); - changed = true; - } - if (changed) { - this._persistSet(TipEligibilityTracker._MODES_STORAGE_KEY, this._usedModes); - } - } - - /** - * Returns `true` when the tip should be **excluded** from the eligible set. - */ - isExcluded(tip: ITipDefinition): boolean { - if (tip.excludeWhenCommandsExecuted) { - for (const cmd of tip.excludeWhenCommandsExecuted) { - if (this._executedCommands.has(cmd)) { - this._logService.debug('#ChatTips: tip excluded because command was executed', tip.id, cmd); - return true; - } - } - } - if (tip.excludeWhenModesUsed) { - for (const mode of tip.excludeWhenModesUsed) { - if (this._usedModes.has(mode)) { - this._logService.debug('#ChatTips: tip excluded because mode was used', tip.id, mode); - return true; - } - } - } - if (tip.excludeWhenToolsInvoked) { - for (const toolId of tip.excludeWhenToolsInvoked) { - if (this._invokedTools.has(toolId)) { - this._logService.debug('#ChatTips: tip excluded because tool was invoked', tip.id, toolId); - return true; - } - } - } - if (tip.excludeWhenPromptFilesExist && this._excludedByFiles.has(tip.id)) { - this._logService.debug('#ChatTips: tip excluded because prompt files exist', tip.id); - return true; - } - return false; - } - - /** - * Revalidates all file-based tip exclusions. Tips with `excludeUntilChecked` - * are conservatively hidden until the re-check completes. - */ - refreshPromptFileExclusions(): void { - for (const tip of this._tipsWithFileExclusions) { - if (tip.excludeWhenPromptFilesExist!.excludeUntilChecked) { - this._excludedByFiles.add(tip.id); - } - this._checkForPromptFiles(tip); - } - } - - private async _checkForPromptFiles(tip: ITipDefinition): Promise { - const inFlight = this._fileChecksInFlight.get(tip.id); - if (inFlight) { - await inFlight; - return; - } - - const checkPromise = this._doCheckForPromptFiles(tip); - this._fileChecksInFlight.set(tip.id, checkPromise); - try { - await checkPromise; - } finally { - if (this._fileChecksInFlight.get(tip.id) === checkPromise) { - this._fileChecksInFlight.delete(tip.id); - } - } - } - - private async _doCheckForPromptFiles(tip: ITipDefinition): Promise { - const config = tip.excludeWhenPromptFilesExist!; - const generation = (this._fileCheckGeneration.get(tip.id) ?? 0) + 1; - this._fileCheckGeneration.set(tip.id, generation); - - try { - const [promptFiles, agentInstructions] = await Promise.all([ - this._promptsService.listPromptFiles(config.promptType, CancellationToken.None), - config.agentFileType ? this._promptsService.listAgentInstructions(CancellationToken.None) : Promise.resolve([]), - ]); - - // Discard stale result if a newer check was started while we were awaiting - if (this._fileCheckGeneration.get(tip.id) !== generation) { - return; - } - - const hasPromptFiles = promptFiles.length > 0; - const hasAgentFile = config.agentFileType - ? agentInstructions.some(f => f.type === config.agentFileType) - : false; - const hasPromptFilesOrAgentFile = hasPromptFiles || hasAgentFile; - - if (hasPromptFilesOrAgentFile) { - this._excludedByFiles.add(tip.id); - } else { - this._excludedByFiles.delete(tip.id); - } - } catch { - if (this._fileCheckGeneration.get(tip.id) !== generation) { - return; - } - if (config.excludeUntilChecked) { - this._excludedByFiles.add(tip.id); - } - } - } - - private _persistSet(key: string, set: Set): void { - this._storageService.store(key, JSON.stringify([...set]), StorageScope.APPLICATION, StorageTarget.MACHINE); - } - - private _readApplicationWithProfileFallback(key: string): string | undefined { - const applicationValue = this._storageService.get(key, StorageScope.APPLICATION); - if (applicationValue) { - return applicationValue; - } - - const profileValue = this._storageService.get(key, StorageScope.PROFILE); - if (profileValue) { - this._storageService.store(key, profileValue, StorageScope.APPLICATION, StorageTarget.MACHINE); - } - - return profileValue; - } -} +// Re-export types for backwards compatibility +export type { ITipDefinition } from './chatTipCatalog.js'; +export { TipEligibilityTracker } from './chatTipEligibilityTracker.js'; export class ChatTipService extends Disposable implements IChatTipService { readonly _serviceBrand: undefined; @@ -664,10 +178,7 @@ export class ChatTipService extends Disposable implements IChatTipService { */ private _contextKeyService: IContextKeyService | undefined; - private static readonly _DISMISSED_TIP_KEY = 'chat.tip.dismissed'; - private static readonly _LAST_TIP_ID_KEY = 'chat.tip.lastTipId'; - private static readonly _YOLO_EVER_ENABLED_KEY = 'chat.tip.yoloModeEverEnabled'; - private static readonly _THINKING_PHRASES_EVER_MODIFIED_KEY = 'chat.tip.thinkingPhrasesEverModified'; + private readonly _tracker: TipEligibilityTracker; private readonly _createSlashCommandsUsageTracker: CreateSlashCommandsUsageTracker; private _yoloModeEverEnabled: boolean; @@ -684,6 +195,7 @@ export class ChatTipService extends Disposable implements IChatTipService { @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, @ICommandService private readonly _commandService: ICommandService, @ITelemetryService private readonly _telemetryService: ITelemetryService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, ) { super(); this._tracker = this._register(instantiationService.createInstance(TipEligibilityTracker, TIP_CATALOG)); @@ -701,7 +213,7 @@ export class ChatTipService extends Disposable implements IChatTipService { } if (this._hasFileOrFolderReference(message)) { - this._tracker.recordCommandExecuted(ATTACH_FILES_REFERENCE_TRACKING_COMMAND); + this._tracker.recordCommandExecuted(TipTrackingCommands.AttachFilesReferenceUsed); } const createCommandTrackingId = this._getCreateSlashCommandTrackingId(message); @@ -711,10 +223,10 @@ export class ChatTipService extends Disposable implements IChatTipService { })); // Track whether yolo mode was ever enabled - this._yoloModeEverEnabled = this._storageService.getBoolean(ChatTipService._YOLO_EVER_ENABLED_KEY, StorageScope.APPLICATION, false); + this._yoloModeEverEnabled = this._storageService.getBoolean(ChatTipStorageKeys.YoloModeEverEnabled, StorageScope.APPLICATION, false); if (!this._yoloModeEverEnabled && this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove)) { this._yoloModeEverEnabled = true; - this._storageService.store(ChatTipService._YOLO_EVER_ENABLED_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + this._storageService.store(ChatTipStorageKeys.YoloModeEverEnabled, true, StorageScope.APPLICATION, StorageTarget.MACHINE); } if (!this._yoloModeEverEnabled) { const configListener = this._register(new MutableDisposable()); @@ -722,23 +234,23 @@ export class ChatTipService extends Disposable implements IChatTipService { if (e.affectsConfiguration(ChatConfiguration.GlobalAutoApprove)) { if (this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove)) { this._yoloModeEverEnabled = true; - this._storageService.store(ChatTipService._YOLO_EVER_ENABLED_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + this._storageService.store(ChatTipStorageKeys.YoloModeEverEnabled, true, StorageScope.APPLICATION, StorageTarget.MACHINE); configListener.clear(); } } }); } - this._thinkingPhrasesEverModified = this._storageService.getBoolean(ChatTipService._THINKING_PHRASES_EVER_MODIFIED_KEY, StorageScope.APPLICATION, false); + this._thinkingPhrasesEverModified = this._storageService.getBoolean(ChatTipStorageKeys.ThinkingPhrasesEverModified, StorageScope.APPLICATION, false); if (!this._thinkingPhrasesEverModified && this._isSettingModified(ChatConfiguration.ThinkingPhrases)) { this._thinkingPhrasesEverModified = true; - this._storageService.store(ChatTipService._THINKING_PHRASES_EVER_MODIFIED_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + this._storageService.store(ChatTipStorageKeys.ThinkingPhrasesEverModified, true, StorageScope.APPLICATION, StorageTarget.MACHINE); } if (!this._thinkingPhrasesEverModified) { this._register(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(ChatConfiguration.ThinkingPhrases)) { this._thinkingPhrasesEverModified = true; - this._storageService.store(ChatTipService._THINKING_PHRASES_EVER_MODIFIED_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + this._storageService.store(ChatTipStorageKeys.ThinkingPhrasesEverModified, true, StorageScope.APPLICATION, StorageTarget.MACHINE); } })); } @@ -794,7 +306,7 @@ export class ChatTipService extends Disposable implements IChatTipService { this._logTipTelemetry(this._shownTip.id, 'dismissed'); const dismissed = new Set(this._getDismissedTipIds()); dismissed.add(this._shownTip.id); - this._storageService.store(ChatTipService._DISMISSED_TIP_KEY, JSON.stringify([...dismissed]), StorageScope.APPLICATION, StorageTarget.MACHINE); + this._storageService.store(ChatTipStorageKeys.DismissedTips, JSON.stringify([...dismissed]), StorageScope.APPLICATION, StorageTarget.MACHINE); } // Keep the current tip reference so callers can navigate relative to it // (for example, dismiss -> next should mirror next/previous behavior). @@ -803,8 +315,8 @@ export class ChatTipService extends Disposable implements IChatTipService { } clearDismissedTips(): void { - this._storageService.remove(ChatTipService._DISMISSED_TIP_KEY, StorageScope.APPLICATION); - this._storageService.remove(ChatTipService._DISMISSED_TIP_KEY, StorageScope.PROFILE); + this._storageService.remove(ChatTipStorageKeys.DismissedTips, StorageScope.APPLICATION); + this._storageService.remove(ChatTipStorageKeys.DismissedTips, StorageScope.PROFILE); this._shownTip = undefined; this._tipRequestId = undefined; this._contextKeyService = undefined; @@ -812,7 +324,7 @@ export class ChatTipService extends Disposable implements IChatTipService { } private _getDismissedTipIds(): string[] { - const raw = this._readApplicationWithProfileFallback(ChatTipService._DISMISSED_TIP_KEY); + const raw = this._readApplicationWithProfileFallback(ChatTipStorageKeys.DismissedTips); if (!raw) { return []; } @@ -898,7 +410,7 @@ export class ChatTipService extends Disposable implements IChatTipService { const nextTip = this._findNextEligibleTip(this._shownTip.id, contextKeyService); if (nextTip) { this._shownTip = nextTip; - this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, nextTip.id, StorageScope.APPLICATION, StorageTarget.USER); + this._storageService.store(ChatTipStorageKeys.LastTipId, nextTip.id, StorageScope.APPLICATION, StorageTarget.USER); const tip = this._createTip(nextTip); this._onDidNavigateTip.fire(tip); return tip; @@ -940,7 +452,7 @@ export class ChatTipService extends Disposable implements IChatTipService { let selectedTip: ITipDefinition | undefined; // Determine where to start in the catalog based on the last-shown tip. - const lastTipId = this._readApplicationWithProfileFallback(ChatTipService._LAST_TIP_ID_KEY); + const lastTipId = this._readApplicationWithProfileFallback(ChatTipStorageKeys.LastTipId); const lastCatalogIndex = lastTipId ? TIP_CATALOG.findIndex(tip => tip.id === lastTipId) : -1; const startIndex = lastCatalogIndex === -1 ? 0 : (lastCatalogIndex + 1) % TIP_CATALOG.length; @@ -960,7 +472,7 @@ export class ChatTipService extends Disposable implements IChatTipService { } // Persist the selected tip id so the next use advances to the following one. - this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, selectedTip.id, StorageScope.APPLICATION, StorageTarget.USER); + this._storageService.store(ChatTipStorageKeys.LastTipId, selectedTip.id, StorageScope.APPLICATION, StorageTarget.USER); // Record that we've shown a tip this session this._tipRequestId = sourceId; @@ -986,6 +498,35 @@ export class ChatTipService extends Disposable implements IChatTipService { return this._navigateTip(-1, this._contextKeyService); } + getNextEligibleTip(): IChatTip | undefined { + if (!this._contextKeyService || !this._shownTip) { + return undefined; + } + + this._createSlashCommandsUsageTracker.syncContextKey(this._contextKeyService); + const currentIndex = TIP_CATALOG.findIndex(t => t.id === this._shownTip!.id); + if (currentIndex === -1) { + return undefined; + } + + const dismissedIds = new Set(this._getDismissedTipIds()); + for (let i = 1; i < TIP_CATALOG.length; i++) { + const idx = (currentIndex + i) % TIP_CATALOG.length; + const candidate = TIP_CATALOG[idx]; + if (!dismissedIds.has(candidate.id) && this._isEligible(candidate, this._contextKeyService)) { + // Found the next eligible tip - update state and return it + this._shownTip = candidate; + this._tipRequestId = 'welcome'; + this._storageService.store(ChatTipStorageKeys.LastTipId, candidate.id, StorageScope.APPLICATION, StorageTarget.USER); + this._logTipTelemetry(candidate.id, 'shown'); + this._trackTipCommandClicks(candidate); + return this._createTip(candidate); + } + } + + return undefined; + } + hasMultipleTips(): boolean { if (!this._contextKeyService) { return false; @@ -1011,7 +552,7 @@ export class ChatTipService extends Disposable implements IChatTipService { this._logTipTelemetry(this._shownTip.id, direction === 1 ? 'navigateNext' : 'navigatePrevious'); this._shownTip = candidate; this._tipRequestId = 'welcome'; - this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, candidate.id, StorageScope.APPLICATION, StorageTarget.USER); + this._storageService.store(ChatTipStorageKeys.LastTipId, candidate.id, StorageScope.APPLICATION, StorageTarget.USER); this._logTipTelemetry(candidate.id, 'shown'); this._trackTipCommandClicks(candidate); const tip = this._createTip(candidate); @@ -1161,13 +702,23 @@ export class ChatTipService extends Disposable implements IChatTipService { } private _createTip(tipDef: ITipDefinition): IChatTip { - const markdown = new MarkdownString(tipDef.message, { - isTrusted: tipDef.enabledCommands ? { enabledCommands: tipDef.enabledCommands } : false, + // Build the tip message with dynamic keybindings and command labels + const ctx: ITipBuildContext = { keybindingService: this._keybindingService }; + const rawMessage = tipDef.buildMessage(ctx); + + // Add "Tip:" prefix once here, avoiding duplication in individual tip definitions + const prefixedMessage = localize('tipPrefix', "**Tip:** {0}", rawMessage.value); + + // Auto-extract enabled commands from the built message + const enabledCommands = extractCommandIds(prefixedMessage); + + const markdown = new MarkdownString(prefixedMessage, { + isTrusted: enabledCommands.length > 0 ? { enabledCommands } : false, }); return { id: tipDef.id, content: markdown, - enabledCommands: tipDef.enabledCommands, + enabledCommands, }; } @@ -1181,10 +732,16 @@ export class ChatTipService extends Disposable implements IChatTipService { private _trackTipCommandClicks(tip: ITipDefinition): void { this._tipCommandListener.clear(); - if (!tip.enabledCommands?.length) { + + // Build message to extract enabled commands dynamically + const ctx: ITipBuildContext = { keybindingService: this._keybindingService }; + const rawMessage = tip.buildMessage(ctx); + const enabledCommands = extractCommandIds(rawMessage.value); + + if (!enabledCommands.length) { return; } - const enabledCommandSet = new Set(tip.enabledCommands); + const enabledCommandSet = new Set(enabledCommands); const dismissCommandSet = new Set(tip.dismissWhenCommandsClicked); this._tipCommandListener.value = this._commandService.onDidExecuteCommand(e => { if (enabledCommandSet.has(e.commandId) && this._shownTip?.id === tip.id) { diff --git a/src/vs/workbench/contrib/chat/browser/chatTipStorageKeys.ts b/src/vs/workbench/contrib/chat/browser/chatTipStorageKeys.ts new file mode 100644 index 0000000000000..1b7a995f8c86b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatTipStorageKeys.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Storage keys used by ChatTipService for persisting tip state. + */ +export const ChatTipStorageKeys = { + /** IDs of tips that have been permanently dismissed by the user. */ + DismissedTips: 'chat.tip.dismissed', + /** The ID of the last tip that was shown, for round-robin selection. */ + LastTipId: 'chat.tip.lastTipId', + /** Whether the user has ever enabled global auto-approve (yolo mode). */ + YoloModeEverEnabled: 'chat.tip.yoloModeEverEnabled', + /** Whether the user has ever modified the thinking phrases setting. */ + ThinkingPhrasesEverModified: 'chat.tip.thinkingPhrasesEverModified', +}; + +/** + * Storage keys used by TipEligibilityTracker for tracking user signals. + */ +export const TipEligibilityStorageKeys = { + /** Command IDs that have been executed (for excludeWhenCommandsExecuted). */ + ExecutedCommands: 'chat.tips.executedCommands', + /** Chat modes that have been used (for excludeWhenModesUsed). */ + UsedModes: 'chat.tips.usedModes', + /** Tool IDs that have been invoked (for excludeWhenToolsInvoked). */ + InvokedTools: 'chat.tips.invokedTools', +}; + +/** + * Synthetic command IDs used to track user actions that don't have real commands. + * These are recorded when the user performs the action, allowing tips to be excluded + * via excludeWhenCommandsExecuted. + */ +export const TipTrackingCommands = { + /** Tracked when user attaches a file/folder reference with #. */ + AttachFilesReferenceUsed: 'chat.tips.attachFiles.referenceUsed', + /** Tracked when user executes /create-instructions. */ + CreateAgentInstructionsUsed: 'chat.tips.createAgentInstructions.commandUsed', + /** Tracked when user executes /create-prompt. */ + CreatePromptUsed: 'chat.tips.createPrompt.commandUsed', + /** Tracked when user executes /create-agent. */ + CreateAgentUsed: 'chat.tips.createAgent.commandUsed', + /** Tracked when user executes /create-skill. */ + CreateSkillUsed: 'chat.tips.createSkill.commandUsed', +} as const; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index 892ed35e18d20..75ac2bef68ec2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -74,7 +74,9 @@ export class ChatTipContentPart extends Disposable { this._renderTip(tip); this._register(this._chatTipService.onDidDismissTip(() => { - const nextTip = this._chatTipService.navigateToNextTip(); + // Use getNextEligibleTip instead of navigateToNextTip to show the next + // available tip even if it's the only one left (no multiple-tip requirement) + const nextTip = this._chatTipService.getNextEligibleTip(); if (nextTip) { this._renderTip(nextTip); dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(this.domNode), () => this.focus()); 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 814c6c740b9ab..ba43aa820ee2c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -5,12 +5,14 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ICommandEvent, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ContextKeyExpression, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; @@ -80,6 +82,18 @@ suite('ChatTipService', () => { return testDisposables.add(instantiationService.createInstance(ChatTipService)); } + /** + * Creates a mock ITipDefinition with a buildMessage function. + * Tests can provide any ITipDefinition properties except buildMessage. + */ + function createMockTip(overrides: Omit & { message?: string }): ITipDefinition { + const { message, ...rest } = overrides; + return { + ...rest, + buildMessage: () => new MarkdownString(message ?? 'test'), + }; + } + setup(() => { instantiationService = testDisposables.add(new TestInstantiationService()); contextKeyService = new MockContextKeyServiceWithRulesMatching(); @@ -107,6 +121,9 @@ suite('ChatTipService', () => { instantiationService.stub(IChatEntitlementService, chatEntitlementService); instantiationService.stub(IChatService, new MockChatService()); instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(IKeybindingService, { + lookupKeybinding: () => undefined, + } as Partial as IKeybindingService); }); test('returns a welcome tip', () => { @@ -323,6 +340,62 @@ suite('ChatTipService', () => { } }); + test('getNextEligibleTip returns next tip even when only one remains', async () => { + const service = createService(); + + // Flush microtask queue so async file-check exclusions resolve + await new Promise(r => queueMicrotask(r)); + + // Get the initial tip + const tip1 = service.getWelcomeTip(contextKeyService); + assert.ok(tip1, 'Should have an initial tip'); + + // Navigate to next tip + const tip2 = service.navigateToNextTip(); + assert.ok(tip2, 'Should have a second tip'); + assert.notStrictEqual(tip1.id, tip2.id, 'Second tip should be different'); + + // Dismiss all tips except tip1 by dismissing current tip and using getNextEligibleTip + const dismissedIds = new Set(); + dismissedIds.add(tip2.id); + service.dismissTip(); + + // Keep dismissing until we can't get any more tips + let nextTip = service.getNextEligibleTip(); + while (nextTip && !dismissedIds.has(nextTip.id)) { + if (nextTip.id === tip1.id) { + // We found tip1 again - this is the expected behavior (bug fix verification) + break; + } + dismissedIds.add(nextTip.id); + service.dismissTip(); + nextTip = service.getNextEligibleTip(); + } + + // The key assertion: getNextEligibleTip should return tip1 even if it's the only one left + assert.ok(nextTip, 'getNextEligibleTip should return the last remaining eligible tip'); + }); + + test('getNextEligibleTip returns undefined when all tips are dismissed', async () => { + const service = createService(); + + // Flush microtask queue so async file-check exclusions resolve + await new Promise(r => queueMicrotask(r)); + + // Dismiss all tips + for (let i = 0; i < 100; i++) { + const tip = service.getWelcomeTip(contextKeyService); + if (!tip) { + break; + } + service.dismissTip(); + } + + // After dismissing all, getNextEligibleTip should return undefined + const nextTip = service.getNextEligibleTip(); + assert.strictEqual(nextTip, undefined, 'getNextEligibleTip should return undefined when all tips are dismissed'); + }); + test('dismissTip fires onDidDismissTip event', () => { const service = createService(); @@ -448,11 +521,10 @@ suite('ChatTipService', () => { } test('excludes tip.undoChanges when restore checkpoint command has been executed', () => { - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.undoChanges', - message: 'test', excludeWhenCommandsExecuted: ['workbench.action.chat.restoreCheckpoint'], - }; + }); const tracker = testDisposables.add(new TipEligibilityTracker( [tip], @@ -471,11 +543,10 @@ suite('ChatTipService', () => { }); test('persists executed command exclusions in application storage', () => { - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.undoChanges', - message: 'test', excludeWhenCommandsExecuted: ['workbench.action.chat.restoreCheckpoint'], - }; + }); testDisposables.add(new TipEligibilityTracker( [tip], @@ -494,11 +565,10 @@ suite('ChatTipService', () => { }); test('migrates executed command exclusions from profile to application storage', () => { - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.undoChanges', - message: 'test', excludeWhenCommandsExecuted: ['workbench.action.chat.restoreCheckpoint'], - }; + }); storageService.store('chat.tips.executedCommands', JSON.stringify(['workbench.action.chat.restoreCheckpoint']), StorageScope.PROFILE, StorageTarget.MACHINE); @@ -516,11 +586,10 @@ suite('ChatTipService', () => { }); test('excludes tip.customInstructions when copilot-instructions.md exists in workspace', async () => { - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.customInstructions', - message: 'test', excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, - }; + }); const tracker = testDisposables.add(new TipEligibilityTracker( [tip], @@ -538,11 +607,10 @@ suite('ChatTipService', () => { }); test('does not exclude tip.customInstructions when only AGENTS.md exists', async () => { - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.customInstructions', - message: 'test', excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, - }; + }); const tracker = testDisposables.add(new TipEligibilityTracker( [tip], @@ -560,11 +628,10 @@ suite('ChatTipService', () => { }); test('excludes tip.customInstructions when .instructions.md files exist in workspace', async () => { - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.customInstructions', - message: 'test', excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, - }; + }); const tracker = testDisposables.add(new TipEligibilityTracker( [tip], @@ -582,11 +649,10 @@ suite('ChatTipService', () => { }); test('does not exclude tip.customInstructions when no instruction files exist', async () => { - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.customInstructions', - message: 'test', excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, - }; + }); const tracker = testDisposables.add(new TipEligibilityTracker( [tip], @@ -604,11 +670,10 @@ suite('ChatTipService', () => { }); test('excludes tip.customInstructions when generate instructions command has been executed', () => { - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.customInstructions', - message: 'test', excludeWhenCommandsExecuted: [GENERATE_AGENT_INSTRUCTIONS_COMMAND_ID], - }; + }); const tracker = testDisposables.add(new TipEligibilityTracker( [tip], @@ -627,11 +692,10 @@ suite('ChatTipService', () => { }); test('excludes tip.agentMode when agent mode has been used in workspace', () => { - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.agentMode', - message: 'test', excludeWhenModesUsed: [ChatModeKind.Agent], - }; + }); contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); contextKeyService.createKey(ChatContextKeys.chatModeName.key, 'Agent'); @@ -653,11 +717,10 @@ suite('ChatTipService', () => { }); test('excludes tip.planMode when Plan mode has been used in workspace', () => { - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.planMode', - message: 'test', excludeWhenModesUsed: ['Plan'], - }; + }); contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); contextKeyService.createKey(ChatContextKeys.chatModeName.key, 'Plan'); @@ -679,11 +742,10 @@ suite('ChatTipService', () => { }); test('excludes tip.planMode when open plan command has been executed', () => { - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.planMode', - message: 'test', excludeWhenCommandsExecuted: ['workbench.action.chat.openPlan'], - }; + }); const tracker = testDisposables.add(new TipEligibilityTracker( [tip], @@ -702,11 +764,10 @@ suite('ChatTipService', () => { }); test('persists command exclusions to workspace storage across tracker instances', () => { - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.undoChanges', - message: 'test', excludeWhenCommandsExecuted: ['workbench.action.chat.restoreCheckpoint'], - }; + }); const tracker1 = testDisposables.add(new TipEligibilityTracker( [tip], @@ -734,11 +795,10 @@ suite('ChatTipService', () => { }); test('persists mode exclusions to workspace storage across tracker instances', () => { - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.agentMode', - message: 'test', excludeWhenModesUsed: [ChatModeKind.Agent], - }; + }); contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); contextKeyService.createKey(ChatContextKeys.chatModeName.key, 'Agent'); @@ -804,11 +864,10 @@ suite('ChatTipService', () => { test('excludes tip when tracked tool has been invoked', () => { const mockToolsService = createMockToolsService(); - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.mermaid', - message: 'test', excludeWhenToolsInvoked: ['renderMermaidDiagram'], - }; + }); const tracker = testDisposables.add(new TipEligibilityTracker( [tip], @@ -828,11 +887,10 @@ suite('ChatTipService', () => { test('persists tool exclusions to workspace storage across tracker instances', () => { const mockToolsService = createMockToolsService(); - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.subagents', - message: 'test', excludeWhenToolsInvoked: ['runSubagent'], - }; + }); const tracker1 = testDisposables.add(new TipEligibilityTracker( [tip], @@ -860,11 +918,10 @@ suite('ChatTipService', () => { }); test('excludes tip.skill when skill files exist in workspace', async () => { - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.skill', - message: 'test', excludeWhenPromptFilesExist: { promptType: PromptsType.skill }, - }; + }); const tracker = testDisposables.add(new TipEligibilityTracker( [tip], @@ -882,11 +939,10 @@ suite('ChatTipService', () => { }); test('does not exclude tip.skill when no skill files exist', async () => { - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.skill', - message: 'test', excludeWhenPromptFilesExist: { promptType: PromptsType.skill }, - }; + }); const tracker = testDisposables.add(new TipEligibilityTracker( [tip], @@ -1268,11 +1324,10 @@ suite('ChatTipService', () => { const agentChangeEmitter = testDisposables.add(new Emitter()); let agentFiles: IPromptPath[] = []; - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.customAgent', - message: 'test', excludeWhenPromptFilesExist: { promptType: PromptsType.agent, excludeUntilChecked: true }, - }; + }); const tracker = testDisposables.add(new TipEligibilityTracker( [tip], @@ -1301,11 +1356,10 @@ suite('ChatTipService', () => { test('refreshPromptFileExclusions re-checks instruction files after startup', async () => { let instructionFiles: IPromptPath[] = []; - const tip: ITipDefinition = { + const tip = createMockTip({ id: 'tip.customInstructions', - message: 'test', excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, - }; + }); const tracker = testDisposables.add(new TipEligibilityTracker( [tip], From 14e8d6503a13d5862fb804d6eb902d53092fce36 Mon Sep 17 00:00:00 2001 From: Harald Kirschner Date: Fri, 27 Feb 2026 08:04:04 -0800 Subject: [PATCH 16/19] chat: show ask-questions header as title, full question text below with multi-line options (#296902) --- .../chatQuestionCarouselPart.ts | 19 +++- .../media/chatQuestionCarousel.css | 87 ++++++++++++------- .../chatQuestionCarouselPart.test.ts | 20 ++--- 3 files changed, 83 insertions(+), 43 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index 13931c98e94f8..ddd912f7c0c55 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -489,11 +489,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Render question header row with title and close button const headerRow = dom.$('.chat-question-header-row'); - // Render question message with title styling (no progress prefix) - // Fall back to question.title if message is not provided - const questionText = question.message ?? question.title; - if (questionText) { + // Render question title (short header) in the header bar as plain text + if (question.title) { const title = dom.$('.chat-question-title'); + const questionText = question.title; const messageContent = this.getQuestionText(questionText); title.setAttribute('aria-label', messageContent); @@ -529,6 +528,18 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._questionContainer.appendChild(headerRow); + // Render full question text below the header row (supports multi-line and markdown) + if (question.message) { + const messageEl = dom.$('.chat-question-message'); + if (isMarkdownString(question.message)) { + const renderedMessage = questionRenderStore.add(this._markdownRendererService.render(MarkdownString.lift(question.message))); + messageEl.appendChild(renderedMessage.element); + } else { + messageEl.textContent = this.getQuestionText(question.message); + } + this._questionContainer.appendChild(messageEl); + } + const isSingleQuestion = this.carousel.questions.length === 1; // Update step indicator in footer if (this._stepIndicator) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 18a456c7fbb17..9407257b4789c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -43,38 +43,44 @@ .chat-question-header-row { display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; - min-width: 0; - padding-bottom: 5px; - margin-left: -16px; - margin-right: -16px; - padding-left: 16px; - padding-right: 16px; - border-bottom: 1px solid var(--vscode-chat-requestBorder); + flex-direction: column; + background: var(--vscode-chat-requestBackground); + padding: 0 16px 10px 16px; + overflow: hidden; .chat-question-title { flex: 1; min-width: 0; - word-wrap: break-word; - overflow-wrap: break-word; - font-weight: 500; - font-size: var(--vscode-chat-font-size-body-s); - margin: 0; + padding-top: 4px; + padding-bottom: 4px; + margin-left: -16px; + margin-right: -16px; + padding-left: 16px; + padding-right: 16px; + border-bottom: 1px solid var(--vscode-chat-requestBorder); + + .chat-question-title { + flex: 1; + min-width: 0; + word-wrap: break-word; + overflow-wrap: break-word; + font-weight: 500; + font-size: var(--vscode-chat-font-size-body-s); + margin: 0; - .rendered-markdown { - a { - color: var(--vscode-textLink-foreground); - } + .rendered-markdown { + a { + color: var(--vscode-textLink-foreground); + } - a:hover, - a:active { - color: var(--vscode-textLink-activeForeground); - } + a:hover, + a:active { + color: var(--vscode-textLink-activeForeground); + } - p { - margin: 0; + p { + margin: 0; + } } } @@ -105,6 +111,29 @@ background: var(--vscode-toolbar-hoverBackground) !important; } } + + .chat-question-message { + padding-top: 8px; + font-size: var(--vscode-chat-font-size-body-s); + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.4; + + .rendered-markdown { + a { + color: var(--vscode-textLink-foreground); + } + + a:hover, + a:active { + color: var(--vscode-textLink-activeForeground); + } + + p { + margin: 0; + } + } + } } } @@ -142,7 +171,7 @@ .chat-question-list-item { display: flex; - align-items: center; + align-items: flex-start; gap: 8px; padding: 3px 8px; cursor: pointer; @@ -167,9 +196,9 @@ .chat-question-list-label { font-size: var(--vscode-chat-font-size-body-s); flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + word-wrap: break-word; + overflow-wrap: break-word; + padding-top: 2px; } .chat-question-list-label-title { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts index 65d42852f534d..7fbf788530a3b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts @@ -99,13 +99,15 @@ suite('ChatQuestionCarouselPart', () => { const title = widget.domNode.querySelector('.chat-question-title'); assert.ok(title, 'title element should exist'); - assert.ok(title?.querySelector('.rendered-markdown'), 'markdown content should be rendered'); - assert.strictEqual(title?.textContent?.includes('**details**'), false, 'markdown syntax should not be shown as raw text'); - const link = title?.querySelector('a') as HTMLAnchorElement | null; + const messageEl = widget.domNode.querySelector('.chat-question-message'); + assert.ok(messageEl, 'message element should exist'); + assert.ok(messageEl?.querySelector('.rendered-markdown'), 'markdown content should be rendered'); + assert.strictEqual(messageEl?.textContent?.includes('**details**'), false, 'markdown syntax should not be shown as raw text'); + const link = messageEl?.querySelector('a') as HTMLAnchorElement | null; assert.ok(link, 'markdown link should render as anchor'); }); - test('renders markdown in plain string question message', () => { + test('renders plain string question message as text', () => { const carousel = createMockCarousel([ { id: 'q1', @@ -116,12 +118,10 @@ suite('ChatQuestionCarouselPart', () => { ]); createWidget(carousel); - const title = widget.domNode.querySelector('.chat-question-title'); - assert.ok(title, 'title element should exist'); - assert.ok(title?.querySelector('.rendered-markdown'), 'markdown content should be rendered for plain string messages'); - assert.strictEqual(title?.textContent?.includes('**details**'), false, 'markdown syntax should not be shown as raw text'); - const link = title?.querySelector('a') as HTMLAnchorElement | null; - assert.ok(link, 'markdown link should render as anchor'); + const messageEl = widget.domNode.querySelector('.chat-question-message'); + assert.ok(messageEl, 'message element should exist'); + assert.ok(messageEl?.textContent?.includes('details'), 'plain text content should be rendered'); + assert.strictEqual(messageEl?.querySelector('.rendered-markdown'), null, 'plain string message should not use markdown renderer'); }); test('renders progress indicator correctly', () => { From a0a6cd83e26452a59250091fd8970483bb9e5df7 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Fri, 27 Feb 2026 08:06:37 -0800 Subject: [PATCH 17/19] Improve sanity tests stability (#298253) --- build/azure-pipelines/common/sanity-tests.yml | 25 +++++++-- test/sanity/src/context.ts | 27 +++++++++- test/sanity/src/desktop.test.ts | 13 ++--- test/sanity/src/index.ts | 5 +- test/sanity/src/main.ts | 3 +- test/sanity/src/serverWeb.test.ts | 20 ++++---- test/sanity/src/uiTest.ts | 51 +++++++++++++++---- test/sanity/src/wsl.test.ts | 40 ++++++++------- 8 files changed, 133 insertions(+), 51 deletions(-) diff --git a/build/azure-pipelines/common/sanity-tests.yml b/build/azure-pipelines/common/sanity-tests.yml index 3606777f9a375..4e47f4672a002 100644 --- a/build/azure-pipelines/common/sanity-tests.yml +++ b/build/azure-pipelines/common/sanity-tests.yml @@ -29,9 +29,19 @@ jobs: name: ${{ parameters.poolName }} os: ${{ parameters.os }} timeoutInMinutes: 30 + templateContext: + outputs: + - output: pipelineArtifact + targetPath: $(SCREENSHOTS_DIR) + artifactName: screenshots-${{ parameters.name }} + displayName: Publish Screenshots + condition: succeededOrFailed() + continueOnError: true + sbomEnabled: false variables: TEST_DIR: $(Build.SourcesDirectory)/test/sanity LOG_FILE: $(TEST_DIR)/results.xml + SCREENSHOTS_DIR: $(TEST_DIR)/screenshots DOCKER_CACHE_DIR: $(Pipeline.Workspace)/docker-cache DOCKER_CACHE_FILE: $(DOCKER_CACHE_DIR)/${{ parameters.container }}.tar steps: @@ -41,6 +51,14 @@ jobs: sparseCheckoutDirectories: test/sanity .nvmrc displayName: Checkout test/sanity + - ${{ if eq(parameters.os, 'windows') }}: + - script: mkdir "$(SCREENSHOTS_DIR)" + displayName: Create Screenshots Directory + + - ${{ else }}: + - bash: mkdir -p "$(SCREENSHOTS_DIR)" + displayName: Create Screenshots Directory + - ${{ if and(eq(parameters.os, 'windows'), eq(parameters.arch, 'arm64')) }}: - script: | @echo off @@ -101,19 +119,19 @@ jobs: # Windows - ${{ if eq(parameters.os, 'windows') }}: - - script: $(TEST_DIR)/scripts/run-win32.cmd -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -v ${{ parameters.args }} + - script: $(TEST_DIR)/scripts/run-win32.cmd -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -s $(SCREENSHOTS_DIR) -v ${{ parameters.args }} workingDirectory: $(TEST_DIR) displayName: Run Sanity Tests # macOS - ${{ if eq(parameters.os, 'macOS') }}: - - bash: $(TEST_DIR)/scripts/run-macOS.sh -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -v ${{ parameters.args }} + - bash: $(TEST_DIR)/scripts/run-macOS.sh -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -s $(SCREENSHOTS_DIR) -v ${{ parameters.args }} workingDirectory: $(TEST_DIR) displayName: Run Sanity Tests # Native Linux host - ${{ if and(eq(parameters.container, ''), eq(parameters.os, 'linux')) }}: - - bash: $(TEST_DIR)/scripts/run-ubuntu.sh -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -v ${{ parameters.args }} + - bash: $(TEST_DIR)/scripts/run-ubuntu.sh -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -s $(SCREENSHOTS_DIR) -v ${{ parameters.args }} workingDirectory: $(TEST_DIR) displayName: Run Sanity Tests @@ -141,6 +159,7 @@ jobs: --quality "$(BUILD_QUALITY)" \ --commit "$(BUILD_COMMIT)" \ --test-results "/root/results.xml" \ + --screenshots-dir "/root/screenshots" \ --verbose \ ${{ parameters.args }} workingDirectory: $(TEST_DIR) diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index d04fc6703b4a9..62eee018d0687 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -38,6 +38,7 @@ export class TestContext { private readonly tempDirs = new Set(); private readonly wslTempDirs = new Set(); private nextPort = 3010; + private currentTestName: string | undefined; public constructor(public readonly options: Readonly<{ quality: 'stable' | 'insider' | 'exploration'; @@ -47,6 +48,7 @@ export class TestContext { checkSigning: boolean; headlessBrowser: boolean; downloadOnly: boolean; + screenshotsDir: string | undefined; }>) { } @@ -88,6 +90,7 @@ export class TestContext { const self = this; return test(name, async function () { + self.currentTestName = name; self.log(`Starting test: ${name}`); const homeDir = os.homedir(); @@ -102,6 +105,8 @@ export class TestContext { throw error; } finally { + self.currentTestName = undefined; + process.chdir(homeDir); self.log(`Changed working directory to: ${homeDir}`); @@ -1070,6 +1075,26 @@ export class TestContext { return page; } + /** + * Captures a screenshot of the current page if one is active. + */ + public async captureScreenshot(page: Page) { + if (!this.currentTestName) { + return; + } + + try { + const screenshotDir = this.options.screenshotsDir ?? path.join(this.osTempDir, 'vscode-sanity-screenshots'); + fs.mkdirSync(screenshotDir, { recursive: true }); + const sanitizedName = this.currentTestName.replace(/[^a-zA-Z0-9_-]/g, '_'); + const screenshotPath = path.join(screenshotDir, `${sanitizedName}.png`); + await page.screenshot({ path: screenshotPath, fullPage: true }); + this.log(`Screenshot saved to: ${screenshotPath}`); + } catch (e) { + this.log(`Failed to capture screenshot: ${e instanceof Error ? e.message : String(e)}`); + } + } + /** * Constructs a web server URL with optional token and folder parameters. * @param port The port number of the web server. @@ -1165,7 +1190,7 @@ export class TestContext { await new Promise((resolve, reject) => { app.stderr.on('data', (data) => { const text = `[${name}] ${data.toString().trim()}`; - if (/ECONNRESET/.test(text)) { + if (/ECONNRESET|ECONNABORTED/.test(text)) { this.log(text); } else { reject(new Error(text)); diff --git a/test/sanity/src/desktop.test.ts b/test/sanity/src/desktop.test.ts index abb37db72f009..c2d65c157db1c 100644 --- a/test/sanity/src/desktop.test.ts +++ b/test/sanity/src/desktop.test.ts @@ -249,12 +249,13 @@ export function setup(context: TestContext) { context.log(`Starting VS Code ${entryPoint} with args ${args.join(' ')}`); const app = await _electron.launch({ executablePath: entryPoint, args }); - const window = await context.getPage(app.firstWindow()); - - await test.run(window); - - context.log('Closing the application'); - await app.close(); + try { + const window = await context.getPage(app.firstWindow()); + await test.run(window); + } finally { + context.log('Closing the application'); + await app.close(); + } test.validate(); } diff --git a/test/sanity/src/index.ts b/test/sanity/src/index.ts index 6439018a9b601..a251128892529 100644 --- a/test/sanity/src/index.ts +++ b/test/sanity/src/index.ts @@ -10,9 +10,9 @@ import path from 'path'; import { fileURLToPath } from 'url'; const options = minimist(process.argv.slice(2), { - string: ['fgrep', 'grep', 'test-results', 'timeout'], + string: ['fgrep', 'grep', 'test-results', 'timeout', 'screenshots-dir'], boolean: ['help'], - alias: { fgrep: 'f', grep: 'g', help: 'h', 'test-results': 't' }, + alias: { fgrep: 'f', grep: 'g', help: 'h', 'test-results': 't', 'screenshots-dir': 's' }, }); if (options.help) { @@ -28,6 +28,7 @@ if (options.help) { console.info(' --fgrep, -f Only run tests containing the given '); console.info(' --test-results, -t Output test results in JUnit format to the specified path'); console.info(' --timeout Set the test-case timeout in seconds (default: 600 seconds)'); + console.info(' --screenshots-dir, -s Save failure screenshots to the specified directory'); console.info(' --verbose, -v Enable verbose logging'); console.info(' --help, -h Show this help message'); process.exit(0); diff --git a/test/sanity/src/main.ts b/test/sanity/src/main.ts index cf3a7780350af..840364e68a52a 100644 --- a/test/sanity/src/main.ts +++ b/test/sanity/src/main.ts @@ -13,7 +13,7 @@ import { setup as setupServerWebTests } from './serverWeb.test.js'; import { setup as setupWSLTests } from './wsl.test.js'; const options = minimist(process.argv.slice(2), { - string: ['commit', 'quality'], + string: ['commit', 'quality', 'screenshots-dir'], boolean: ['cleanup', 'verbose', 'signing-check', 'headless', 'detection'], alias: { commit: 'c', quality: 'q', verbose: 'v' }, default: { cleanup: true, verbose: false, 'signing-check': true, headless: true, 'detection': true }, @@ -35,6 +35,7 @@ const context = new TestContext({ checkSigning: options['signing-check'], headlessBrowser: options.headless, downloadOnly: !options['detection'], + screenshotsDir: options['screenshots-dir'], }); context.log(`Arguments: ${process.argv.slice(2).join(' ')}`); diff --git a/test/sanity/src/serverWeb.test.ts b/test/sanity/src/serverWeb.test.ts index 4b765c0cd1083..3099ba70d5a6d 100644 --- a/test/sanity/src/serverWeb.test.ts +++ b/test/sanity/src/serverWeb.test.ts @@ -91,18 +91,20 @@ export function setup(context: TestContext) { const url = context.getWebServerUrl(port, token, test.workspaceDir).toString(); const browser = await context.launchBrowser(); - const page = await context.getPage(browser.newPage()); + try { + const page = await context.getPage(browser.newPage()); - context.log(`Navigating to ${url}`); - await page.goto(url, { waitUntil: 'networkidle' }); + context.log(`Navigating to ${url}`); + await page.goto(url, { waitUntil: 'networkidle' }); - context.log('Waiting for the workbench to load'); - await page.waitForSelector('.monaco-workbench'); + context.log('Waiting for the workbench to load'); + await page.waitForSelector('.monaco-workbench'); - await test.run(page); - - context.log('Closing browser'); - await browser.close(); + await test.run(page); + } finally { + context.log('Closing browser'); + await browser.close(); + } test.validate(); return true; diff --git a/test/sanity/src/uiTest.ts b/test/sanity/src/uiTest.ts index f6dda6bc1a4a2..582c4eeab3b4b 100644 --- a/test/sanity/src/uiTest.ts +++ b/test/sanity/src/uiTest.ts @@ -52,9 +52,14 @@ export class UITest { * Run the UI test actions. */ public async run(page: Page) { - await this.dismissWorkspaceTrustDialog(page); - await this.createTextFile(page); - await this.installExtension(page); + try { + await this.dismissWorkspaceTrustDialog(page); + await this.createTextFile(page); + await this.installExtension(page); + } catch (error) { + await this.context.captureScreenshot(page); + throw error; + } } /** @@ -80,9 +85,11 @@ export class UITest { private async runCommand(page: Page, command: string) { this.context.log(`Running command: ${command}`); await page.keyboard.press('F1'); - await page.getByPlaceholder(/^Type the name of a command/).fill(`>${command}`); - await page.locator('span.monaco-highlighted-label', { hasText: new RegExp(`^${command}$`) }).click(); - await page.waitForTimeout(1000); + const input = page.getByPlaceholder(/^Type the name of a command/); + await input.fill(`>${command}`); + const item = page.locator('span.monaco-highlighted-label', { hasText: new RegExp(`^${command}$`) }); + await item.click(); + await input.waitFor({ state: 'hidden' }); } /** @@ -125,15 +132,37 @@ export class UITest { this.context.log('Typing extension name to search for'); await page.getByText('Search Extensions in Marketplace').focus(); - await page.keyboard.insertText('GitHub Pull Requests'); + await page.keyboard.type('GitHub Pull Requests', { delay: 50 }); + + this.context.log('Waiting for extension to appear in search results'); + const extensionItem = page.locator('.extension-list-item').getByText(/^GitHub Pull Requests$/); + const messageContainer = page.locator('.extensions-viewlet .message-container:not(.hidden)').first(); + + for (let attempt = 0; attempt < 3; attempt++) { + const result = await Promise.race([ + extensionItem.waitFor().then(() => 'found' as const), + messageContainer.waitFor().then(() => 'message' as const), + ]); + + if (result === 'found') { + break; + } + + const message = await messageContainer.locator('.message').innerText(); + this.context.log(`Marketplace message: ${message} (attempt ${attempt + 1}/3), clicking Refresh`); + await page.getByRole('button', { name: 'Refresh' }).click(); + await messageContainer.waitFor({ state: 'hidden', timeout: 30_000 }); + } + + await extensionItem.waitFor(); this.context.log('Clicking Install on the first extension in the list'); - await page.locator('.extension-list-item').getByText(/^GitHub Pull Requests$/).waitFor(); - await page.locator('.extension-action:not(.disabled)', { hasText: /Install/ }).first().click(); - await page.waitForTimeout(1000); + const installButton = page.locator('.extension-action:not(.disabled)', { hasText: /Install/ }).first(); + await installButton.waitFor(); + await installButton.click(); this.context.log('Waiting for extension to be installed'); - await page.locator('.extension-action:not(.disabled)', { hasText: /Uninstall/ }).waitFor(); + await page.locator('.extension-action:not(.disabled)', { hasText: /Uninstall/ }).waitFor({ timeout: 5 * 60_1000 }); } /** diff --git a/test/sanity/src/wsl.test.ts b/test/sanity/src/wsl.test.ts index ab2c581763460..77df84d72758f 100644 --- a/test/sanity/src/wsl.test.ts +++ b/test/sanity/src/wsl.test.ts @@ -117,18 +117,20 @@ export function setup(context: TestContext) { const url = context.getWebServerUrl(port, token, wslWorkspaceDir).toString(); const browser = await context.launchBrowser(); - const page = await context.getPage(browser.newPage()); + try { + const page = await context.getPage(browser.newPage()); - context.log(`Navigating to ${url}`); - await page.goto(url, { waitUntil: 'networkidle' }); + context.log(`Navigating to ${url}`); + await page.goto(url, { waitUntil: 'networkidle' }); - context.log('Waiting for the workbench to load'); - await page.waitForSelector('.monaco-workbench'); + context.log('Waiting for the workbench to load'); + await page.waitForSelector('.monaco-workbench'); - await test.run(page); - - context.log('Closing browser'); - await browser.close(); + await test.run(page); + } finally { + context.log('Closing browser'); + await browser.close(); + } test.validate(); return true; @@ -152,18 +154,20 @@ export function setup(context: TestContext) { context.log(`Starting VS Code ${entryPoint} with args ${args.join(' ')}`); const app = await _electron.launch({ executablePath: entryPoint, args }); - const window = await context.getPage(app.firstWindow()); + try { + const window = await context.getPage(app.firstWindow()); - context.log('Installing WSL extension'); - await window.getByRole('button', { name: 'Install and Reload' }).click(); + context.log('Installing WSL extension'); + await window.getByRole('button', { name: 'Install and Reload' }).click(); - context.log('Waiting for WSL connection'); - await window.getByText(/WSL/).waitFor(); + context.log('Waiting for WSL connection'); + await window.getByText(/WSL/).waitFor(); - await test.run(window); - - context.log('Closing the application'); - await app.close(); + await test.run(window); + } finally { + context.log('Closing the application'); + await app.close(); + } test.validate(); } From 79d358e5ea2c7b209c54ff4da420b9abe01826f4 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Fri, 27 Feb 2026 17:20:26 +0100 Subject: [PATCH 18/19] distro (#298281) --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 41bb092c6e117..92aade626d160 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.110.0", - "distro": "a8b2188eff543b023e27d161865bc195fa8ba421", + "distro": "8445dd0629deae101785f6b8e406ab3784a42fa6", "author": { "name": "Microsoft Corporation" }, @@ -250,4 +250,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} +} \ No newline at end of file From 77044e33d814b919dd9d4439bfa161c30c2be540 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Fri, 27 Feb 2026 17:22:14 +0100 Subject: [PATCH 19/19] sessions - specific auth branding for auth redirect (#298277) --- .../github-authentication/media/index.html | 8 +++++++ .../media/sessions-icon.svg | 24 +++++++++++++++++++ extensions/github-authentication/package.json | 3 ++- .../src/node/authServer.ts | 13 +++++----- .../github-authentication/tsconfig.json | 3 ++- 5 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 extensions/github-authentication/media/sessions-icon.svg diff --git a/extensions/github-authentication/media/index.html b/extensions/github-authentication/media/index.html index 3292e2a08fc9f..2df45293528fa 100644 --- a/extensions/github-authentication/media/index.html +++ b/extensions/github-authentication/media/index.html @@ -30,9 +30,17 @@

Launching