diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 24d2dae8040b5..4b842c07844ce 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -1127,6 +1127,11 @@ export class Repository implements Disposable { return undefined; } + // Since we are inspecting the resource groups + // we have to ensure that the repository state + // is up to date + // await this.status(); + // Ignore path that is inside a merge group if (this.mergeGroup.resourceStates.some(r => pathEquals(r.resourceUri.fsPath, uri.fsPath))) { this.logger.trace(`[Repository][provideOriginalResource] Resource is part of a merge group: ${uri.toString()}`); @@ -1888,7 +1893,7 @@ export class Repository implements Disposable { }); } - private async _getWorktreeIncludeFiles(): Promise> { + private async _getWorktreeIncludePaths(): Promise> { const config = workspace.getConfiguration('git', Uri.file(this.root)); const worktreeIncludeFiles = config.get('worktreeIncludeFiles', ['**/node_modules/**']); @@ -1917,29 +1922,51 @@ export class Repository implements Disposable { gitIgnoredFiles.delete(uri.fsPath); } - return gitIgnoredFiles; + // Add the folder paths for git ignored files + const gitIgnoredPaths = new Set(gitIgnoredFiles); + + for (const filePath of gitIgnoredFiles) { + let dir = path.dirname(filePath); + while (dir !== this.root && !gitIgnoredFiles.has(dir)) { + gitIgnoredPaths.add(dir); + dir = path.dirname(dir); + } + } + + return gitIgnoredPaths; } private async _copyWorktreeIncludeFiles(worktreePath: string): Promise { - const ignoredFiles = await this._getWorktreeIncludeFiles(); - if (ignoredFiles.size === 0) { + const gitIgnoredPaths = await this._getWorktreeIncludePaths(); + if (gitIgnoredPaths.size === 0) { return; } try { - // Copy files + // Find minimal set of paths (folders and files) to copy. + // The goal is to reduce the number of copy operations + // needed. + const pathsToCopy = new Set(); + for (const filePath of gitIgnoredPaths) { + const relativePath = path.relative(this.root, filePath); + const firstSegment = relativePath.split(path.sep)[0]; + pathsToCopy.add(path.join(this.root, firstSegment)); + } + const startTime = Date.now(); const limiter = new Limiter(15); - const files = Array.from(ignoredFiles); + const files = Array.from(pathsToCopy); + // Copy files const results = await Promise.allSettled(files.map(sourceFile => limiter.queue(async () => { const targetFile = path.join(worktreePath, relativePath(this.root, sourceFile)); await fsPromises.mkdir(path.dirname(targetFile), { recursive: true }); await fsPromises.cp(sourceFile, targetFile, { + filter: src => gitIgnoredPaths.has(src), force: true, mode: fs.constants.COPYFILE_FICLONE, - recursive: false, + recursive: true, verbatimSymlinks: true }); }) @@ -1947,18 +1974,18 @@ export class Repository implements Disposable { // Log any failed operations const failedOperations = results.filter(r => r.status === 'rejected'); - this.logger.info(`[Repository][_copyWorktreeIncludeFiles] Copied ${files.length - failedOperations.length} files to worktree. Failed to copy ${failedOperations.length} files. [${Date.now() - startTime}ms]`); + this.logger.info(`[Repository][_copyWorktreeIncludeFiles] Copied ${files.length - failedOperations.length}/${files.length} folder(s)/file(s) to worktree. [${Date.now() - startTime}ms]`); if (failedOperations.length > 0) { - window.showWarningMessage(l10n.t('Failed to copy {0} files to the worktree.', failedOperations.length)); + window.showWarningMessage(l10n.t('Failed to copy {0} folder(s)/file(s) to the worktree.', failedOperations.length)); - this.logger.warn(`[Repository][_copyWorktreeIncludeFiles] Failed to copy ${failedOperations.length} files to worktree.`); + this.logger.warn(`[Repository][_copyWorktreeIncludeFiles] Failed to copy ${failedOperations.length} folder(s)/file(s) to worktree.`); for (const error of failedOperations) { this.logger.warn(` - ${(error as PromiseRejectedResult).reason}`); } } } catch (err) { - this.logger.warn(`[Repository][_copyWorktreeIncludeFiles] Failed to copy files to worktree: ${err}`); + this.logger.warn(`[Repository][_copyWorktreeIncludeFiles] Failed to copy folder(s)/file(s) to worktree: ${err}`); } } @@ -3273,6 +3300,11 @@ export class StagedResourceQuickDiffProvider implements QuickDiffProvider { return undefined; } + // Since we are inspecting the resource groups + // we have to ensure that the repository state + // is up to date + // await this._repository.status(); + // Ignore resources that are not in the index group if (!this._repository.indexGroup.resourceStates.some(r => pathEquals(r.resourceUri.fsPath, uri.fsPath))) { this.logger.trace(`[StagedResourceQuickDiffProvider][provideOriginalResource] Resource is not part of a index group: ${uri.toString()}`); diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 5031dc271c715..4cfdf87334359 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -171,7 +171,7 @@ "statusBar.debuggingForeground": "#FFFFFF", "statusBar.noFolderBackground": "#191B1D", "statusBar.noFolderForeground": "#bfbfbf", - "statusBarItem.activeBackground": "#498FAE", + "statusBarItem.activeBackground": "#4A4D4F", "statusBarItem.hoverBackground": "#252829", "statusBarItem.focusBorder": "#498FADB3", "statusBarItem.prominentBackground": "#498FAE", diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index 2ef9666b6565f..93d43d10d5bb0 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -9,22 +9,22 @@ "descriptionForeground": "#666666", "icon.foreground": "#666666", "focusBorder": "#4466CCFF", - "textBlockQuote.background": "#E9E9E9", - "textBlockQuote.border": "#EEEEEE00", - "textCodeBlock.background": "#E9E9E9", + "textBlockQuote.background": "#EDEDED", + "textBlockQuote.border": "#ECEDEEFF", + "textCodeBlock.background": "#EDEDED", "textLink.foreground": "#3457C0", - "textLink.activeForeground": "#395DC9", + "textLink.activeForeground": "#3355BA", "textPreformat.foreground": "#666666", - "textSeparator.foreground": "#EEEEEE00", + "textSeparator.foreground": "#EEEEEEFF", "button.background": "#4466CC", "button.foreground": "#FFFFFF", - "button.hoverBackground": "#4F6FCF", - "button.border": "#EEEEEE00", - "button.secondaryBackground": "#E9E9E9", + "button.hoverBackground": "#3E61CA", + "button.border": "#ECEDEEFF", + "button.secondaryBackground": "#EDEDED", "button.secondaryForeground": "#202020", - "button.secondaryHoverBackground": "#F5F5F5", - "checkbox.background": "#E9E9E9", - "checkbox.border": "#EEEEEE00", + "button.secondaryHoverBackground": "#E6E6E6", + "checkbox.background": "#EDEDED", + "checkbox.border": "#ECEDEEFF", "checkbox.foreground": "#202020", "dropdown.background": "#F9F9F9", "dropdown.border": "#D6D7D8", @@ -36,15 +36,15 @@ "input.placeholderForeground": "#999999", "inputOption.activeBackground": "#4466CC33", "inputOption.activeForeground": "#202020", - "inputOption.activeBorder": "#EEEEEE00", + "inputOption.activeBorder": "#ECEDEEFF", "inputValidation.errorBackground": "#F9F9F9", - "inputValidation.errorBorder": "#EEEEEE00", + "inputValidation.errorBorder": "#ECEDEEFF", "inputValidation.errorForeground": "#202020", "inputValidation.infoBackground": "#F9F9F9", - "inputValidation.infoBorder": "#EEEEEE00", + "inputValidation.infoBorder": "#ECEDEEFF", "inputValidation.infoForeground": "#202020", "inputValidation.warningBackground": "#F9F9F9", - "inputValidation.warningBorder": "#EEEEEE00", + "inputValidation.warningBorder": "#ECEDEEFF", "inputValidation.warningForeground": "#202020", "scrollbar.shadow": "#F5F6F84D", "scrollbarSlider.background": "#4466CC33", @@ -55,9 +55,9 @@ "progressBar.background": "#666666", "list.activeSelectionBackground": "#4466CC26", "list.activeSelectionForeground": "#202020", - "list.inactiveSelectionBackground": "#E9E9E9", + "list.inactiveSelectionBackground": "#EDEDED", "list.inactiveSelectionForeground": "#202020", - "list.hoverBackground": "#FFFFFF", + "list.hoverBackground": "#F2F2F2", "list.hoverForeground": "#202020", "list.dropBackground": "#4466CC1A", "list.focusBackground": "#4466CC26", @@ -70,35 +70,35 @@ "activityBar.background": "#F9F9F9", "activityBar.foreground": "#202020", "activityBar.inactiveForeground": "#666666", - "activityBar.border": "#EEEEEE00", - "activityBar.activeBorder": "#EEEEEE00", + "activityBar.border": "#ECEDEEFF", + "activityBar.activeBorder": "#ECEDEEFF", "activityBar.activeFocusBorder": "#4466CCFF", "activityBarBadge.background": "#4466CC", "activityBarBadge.foreground": "#FFFFFF", "sideBar.background": "#F9F9F9", "sideBar.foreground": "#202020", - "sideBar.border": "#EEEEEE00", + "sideBar.border": "#ECEDEEFF", "sideBarTitle.foreground": "#202020", "sideBarSectionHeader.background": "#F9F9F9", "sideBarSectionHeader.foreground": "#202020", - "sideBarSectionHeader.border": "#EEEEEE00", + "sideBarSectionHeader.border": "#ECEDEEFF", "titleBar.activeBackground": "#F9F9F9", "titleBar.activeForeground": "#424242", "titleBar.inactiveBackground": "#F9F9F9", "titleBar.inactiveForeground": "#666666", - "titleBar.border": "#EEEEEE00", - "menubar.selectionBackground": "#E9E9E9", + "titleBar.border": "#ECEDEEFF", + "menubar.selectionBackground": "#EDEDED", "menubar.selectionForeground": "#202020", "menu.background": "#FCFCFC", "menu.foreground": "#202020", "menu.selectionBackground": "#4466CC26", "menu.selectionForeground": "#202020", "menu.separatorBackground": "#F4F4F4", - "menu.border": "#EEEEEE00", + "menu.border": "#ECEDEEFF", "commandCenter.foreground": "#202020", "commandCenter.activeForeground": "#202020", "commandCenter.background": "#F9F9F9", - "commandCenter.activeBackground": "#FFFFFF", + "commandCenter.activeBackground": "#F2F2F2", "commandCenter.border": "#D6D7D880", "editor.background": "#FDFDFD", "editor.foreground": "#202123", @@ -106,16 +106,16 @@ "editorLineNumber.activeForeground": "#202123", "editorCursor.foreground": "#202123", "editor.selectionBackground": "#4466CC26", - "editor.inactiveSelectionBackground": "#4466CC80", + "editor.inactiveSelectionBackground": "#4466CC26", "editor.selectionHighlightBackground": "#4466CC1A", "editor.wordHighlightBackground": "#4466CC33", "editor.wordHighlightStrongBackground": "#4466CC33", "editor.findMatchBackground": "#4466CC4D", "editor.findMatchHighlightBackground": "#4466CC26", - "editor.findRangeHighlightBackground": "#E9E9E9", - "editor.hoverHighlightBackground": "#E9E9E9", - "editor.lineHighlightBackground": "#E9E9E9", - "editor.rangeHighlightBackground": "#E9E9E9", + "editor.findRangeHighlightBackground": "#EDEDED", + "editor.hoverHighlightBackground": "#EDEDED", + "editor.lineHighlightBackground": "#EDEDED55", + "editor.rangeHighlightBackground": "#EDEDED", "editorLink.activeForeground": "#4466CC", "editorWhitespace.foreground": "#6666664D", "editorIndentGuide.background": "#F4F4F44D", @@ -123,27 +123,27 @@ "editorRuler.foreground": "#F4F4F4", "editorCodeLens.foreground": "#666666", "editorBracketMatch.background": "#4466CC55", - "editorBracketMatch.border": "#EEEEEE00", + "editorBracketMatch.border": "#ECEDEEFF", "editorWidget.background": "#FCFCFC", - "editorWidget.border": "#EEEEEE00", + "editorWidget.border": "#ECEDEEFF", "editorWidget.foreground": "#202020", "editorSuggestWidget.background": "#FCFCFC", - "editorSuggestWidget.border": "#EEEEEE00", + "editorSuggestWidget.border": "#ECEDEEFF", "editorSuggestWidget.foreground": "#202020", "editorSuggestWidget.highlightForeground": "#202020", "editorSuggestWidget.selectedBackground": "#4466CC26", - "editorHoverWidget.background": "#FCFCFC", - "editorHoverWidget.border": "#EEEEEE00", - "peekView.border": "#EEEEEE00", + "editorHoverWidget.background": "#FCFCFC55", + "editorHoverWidget.border": "#ECEDEEFF", + "peekView.border": "#ECEDEEFF", "peekViewEditor.background": "#F9F9F9", "peekViewEditor.matchHighlightBackground": "#4466CC33", - "peekViewResult.background": "#E9E9E9", + "peekViewResult.background": "#EDEDED", "peekViewResult.fileForeground": "#202020", "peekViewResult.lineForeground": "#666666", "peekViewResult.matchHighlightBackground": "#4466CC33", "peekViewResult.selectionBackground": "#4466CC26", "peekViewResult.selectionForeground": "#202020", - "peekViewTitle.background": "#E9E9E9", + "peekViewTitle.background": "#EDEDED", "peekViewTitleDescription.foreground": "#666666", "peekViewTitleLabel.foreground": "#202020", "editorGutter.background": "#FDFDFD", @@ -151,7 +151,7 @@ "editorGutter.deletedBackground": "#ad0707", "diffEditor.insertedTextBackground": "#587c0c26", "diffEditor.removedTextBackground": "#ad070726", - "editorOverviewRuler.border": "#EEEEEE00", + "editorOverviewRuler.border": "#ECEDEEFF", "editorOverviewRuler.findMatchForeground": "#4466CC99", "editorOverviewRuler.modifiedForeground": "#007acc", "editorOverviewRuler.addedForeground": "#587c0c", @@ -159,20 +159,20 @@ "editorOverviewRuler.errorForeground": "#ad0707", "editorOverviewRuler.warningForeground": "#667309", "panel.background": "#F9F9F9", - "panel.border": "#EEEEEE00", + "panel.border": "#ECEDEEFF", "panelTitle.activeBorder": "#4466CC", "panelTitle.activeForeground": "#202020", "panelTitle.inactiveForeground": "#666666", "statusBar.background": "#F9F9F9", "statusBar.foreground": "#202020", - "statusBar.border": "#EEEEEE00", + "statusBar.border": "#ECEDEEFF", "statusBar.focusBorder": "#4466CCFF", "statusBar.debuggingBackground": "#4466CC", "statusBar.debuggingForeground": "#FFFFFF", "statusBar.noFolderBackground": "#F9F9F9", "statusBar.noFolderForeground": "#202020", - "statusBarItem.activeBackground": "#4466CC", - "statusBarItem.hoverBackground": "#FFFFFF", + "statusBarItem.activeBackground": "#E0E0E0", + "statusBarItem.hoverBackground": "#F2F2F2", "statusBarItem.focusBorder": "#4466CCFF", "statusBarItem.prominentBackground": "#4466CC", "statusBarItem.prominentForeground": "#FFFFFF", @@ -181,41 +181,41 @@ "tab.activeForeground": "#202020", "tab.inactiveBackground": "#F9F9F9", "tab.inactiveForeground": "#666666", - "tab.border": "#EEEEEE00", - "tab.lastPinnedBorder": "#EEEEEE00", + "tab.border": "#ECEDEEFF", + "tab.lastPinnedBorder": "#ECEDEEFF", "tab.activeBorder": "#FBFBFD", - "tab.hoverBackground": "#FFFFFF", + "tab.hoverBackground": "#F2F2F2", "tab.hoverForeground": "#202020", "tab.unfocusedActiveBackground": "#FDFDFD", "tab.unfocusedActiveForeground": "#666666", "tab.unfocusedInactiveBackground": "#F9F9F9", "tab.unfocusedInactiveForeground": "#BBBBBB", "editorGroupHeader.tabsBackground": "#F9F9F9", - "editorGroupHeader.tabsBorder": "#EEEEEE00", + "editorGroupHeader.tabsBorder": "#ECEDEEFF", "breadcrumb.foreground": "#666666", "breadcrumb.background": "#FDFDFD", "breadcrumb.focusForeground": "#202020", "breadcrumb.activeSelectionForeground": "#202020", "breadcrumbPicker.background": "#FCFCFC", - "notificationCenter.border": "#EEEEEE00", + "notificationCenter.border": "#ECEDEEFF", "notificationCenterHeader.foreground": "#202020", - "notificationCenterHeader.background": "#E9E9E9", - "notificationToast.border": "#EEEEEE00", + "notificationCenterHeader.background": "#EDEDED", + "notificationToast.border": "#ECEDEEFF", "notifications.foreground": "#202020", "notifications.background": "#FCFCFC", - "notifications.border": "#EEEEEE00", + "notifications.border": "#ECEDEEFF", "notificationLink.foreground": "#4466CC", "extensionButton.prominentBackground": "#4466CC", "extensionButton.prominentForeground": "#FFFFFF", - "extensionButton.prominentHoverBackground": "#4F6FCF", - "pickerGroup.border": "#EEEEEE00", + "extensionButton.prominentHoverBackground": "#3E61CA", + "pickerGroup.border": "#ECEDEEFF", "pickerGroup.foreground": "#202020", "quickInput.background": "#FCFCFC", "quickInput.foreground": "#202020", "quickInputList.focusBackground": "#4466CC26", "quickInputList.focusForeground": "#202020", "quickInputList.focusIconForeground": "#202020", - "quickInputList.hoverBackground": "#FAFAFA", + "quickInputList.hoverBackground": "#E7E7E7", "terminal.selectionBackground": "#4466CC33", "terminalCursor.foreground": "#202020", "terminalCursor.background": "#F9F9F9", diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 589033dccf3ae..91f1e05826f68 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -19,12 +19,26 @@ .monaco-workbench.panel-position-left .part.panel { box-shadow: 0 0 6px rgba(0, 0, 0, 0.08); } .monaco-workbench.panel-position-right .part.panel { box-shadow: 0 0 6px rgba(0, 0, 0, 0.08); } +/* Sashes - ensure they extend full height and are above other panels */ +.monaco-workbench .monaco-sash { z-index: 45; } +.monaco-workbench .monaco-sash.vertical { z-index: 45; } +.monaco-workbench .monaco-sash.horizontal { z-index: 45; } + +.monaco-workbench .activitybar.left.bordered::before, +.monaco-workbench .activitybar.right.bordered::before { + border: none; +} + /* Editor */ .monaco-workbench .part.editor { position: relative; } .monaco-workbench .part.editor > .content .editor-group-container > .title { box-shadow: none; position: relative; z-index: 10; } .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active { box-shadow: 0 0 5px rgba(0, 0, 0, 0.10); position: relative; z-index: 5; border-radius: 4px 4px 0 0; border-top: none !important; } .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:hover:not(.active) { box-shadow: 0 0 4px rgba(0, 0, 0, 0.08); } +/* Tab border bottom - make transparent */ +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container { --tabs-border-bottom-color: transparent !important; } +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab { --tab-border-bottom-color: transparent !important; } + /* Title Bar */ .monaco-workbench .part.titlebar { box-shadow: 0 0 6px rgba(0, 0, 0, 0.08); z-index: 60; position: relative; overflow: visible !important; } .monaco-workbench .part.titlebar .titlebar-container, @@ -54,19 +68,16 @@ .monaco-workbench .quick-input-widget .quick-input-message, /* .monaco-workbench .quick-input-widget .monaco-inputbox, */ .monaco-workbench .quick-input-widget .monaco-list, -.monaco-workbench .quick-input-widget .monaco-list-row { border: none !important; border-color: transparent !important; outline: none !important; } +.monaco-workbench .quick-input-widget .monaco-list-row { border-color: transparent !important; outline: none !important; } .monaco-workbench .quick-input-widget .monaco-inputbox { box-shadow: none !important; background: transparent !important; } .monaco-workbench .quick-input-widget .quick-input-filter .monaco-inputbox { background: rgba(249, 250, 251, 0.4) !important; border-radius: 6px; } .monaco-workbench.vs-dark .quick-input-widget .quick-input-filter .monaco-inputbox, -.monaco-workbench.hc-black .quick-input-widget .quick-input-filter .monaco-inputbox { background: rgba(20, 20, 22, 0.6) !important; } .monaco-workbench .quick-input-widget .monaco-list.list_id_6:not(.drop-target):not(.dragging) .monaco-list-row:hover:not(.selected):not(.focused) { background-color: color-mix(in srgb, var(--vscode-list-hoverBackground) 40%, transparent); } - /* Chat Widget */ .monaco-workbench .interactive-session .chat-input-container { box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.08); border-radius: 6px; } .monaco-workbench .interactive-session .interactive-input-part .chat-editor-container .interactive-input-editor .monaco-editor, -.monaco-workbench .interactive-session .interactive-input-part .chat-editor-container .interactive-input-editor .monaco-editor .monaco-editor-background { background-color: var(--vscode-panel-background, var(--vscode-sideBar-background)) !important; } .monaco-workbench .interactive-session .chat-editing-session .chat-editing-session-container { border-radius: 4px 4px 0 0; } .monaco-workbench .interactive-input-part:has(.chat-editing-session > .chat-editing-session-container) .chat-input-container { border-radius: 0 0 6px 6px; } .monaco-workbench .part.panel .interactive-session, @@ -77,9 +88,25 @@ } /* Notifications */ -.monaco-workbench .notifications-toasts { box-shadow: 0 0 12px rgba(0, 0, 0, 0.14); } -.monaco-workbench .notification-toast { box-shadow: none !important; border: none !important; } -.monaco-workbench .notifications-center { border: none !important; } + +.monaco-workbench .notifications-toasts { + box-shadow: 0 0 12px rgba(0, 0, 0, 0.14); + border-radius: 4px; + /* backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); */ +} +.monaco-workbench .notification-toast { box-shadow: none !important; margin: 0 !important;} +.monaco-workbench .notifications-center { + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + background-color: rgba(255, 255, 255, 0.6) !important; + +} +.monaco-workbench .notifications-list-container, +.monaco-workbench > .notifications-center > .notifications-center-header, +.monaco-workbench .notifications-list-container .monaco-list-rows { + background: transparent !important; +} /* Context Menus */ .monaco-workbench .monaco-menu .monaco-action-bar.vertical { box-shadow: 0 0 12px rgba(0, 0, 0, 0.14); border: none; border-radius: 12px; overflow: hidden; backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); } @@ -113,6 +140,9 @@ .monaco-workbench .monaco-editor .peekview-widget .body { background: transparent !important; } .monaco-workbench .monaco-editor .peekview-widget .ref-tree { background: var(--vscode-editor-background, #EDEDED) !important; } + +.monaco-editor .monaco-hover { box-shadow: 0 0 4px rgba(0, 0, 0, 0.18); border-radius: 8px; overflow: hidden; backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); } +.monaco-hover.workbench-hover { backdrop-filter: blur(40px) saturate(180%) !important; -webkit-backdrop-filter: blur(40px) saturate(180%) !important; } /* Settings */ .monaco-workbench .settings-editor .settings-toc-container { box-shadow: 0 0 4px rgba(0, 0, 0, 0.08); } @@ -121,13 +151,12 @@ .monaco-workbench .part.editor .welcomePageContainer .tile:hover { box-shadow: 0 0 8px rgba(0, 0, 0, 0.12); } /* Extensions */ -.monaco-workbench .extensions-list .extension-list-item { box-shadow: 0 0 4px rgba(0, 0, 0, 0.08); border: none; border-radius: 6px; margin: 4px 0; } +.monaco-workbench .extensions-list .extension-list-item { box-shadow: 0 0 4px rgba(0, 0, 0, 0.08); border: none; } .monaco-workbench .extensions-list .extension-list-item:hover { box-shadow: 0 0 6px rgba(0, 0, 0, 0.10); } /* Breadcrumbs */ .monaco-workbench .part.editor > .content .editor-group-container > .title .breadcrumbs-control { box-shadow: 0 0 6px rgba(0, 0, 0, 0.08) !important; background: rgba(252, 252, 253, 0.65) !important; backdrop-filter: blur(40px) saturate(180%) !important; -webkit-backdrop-filter: blur(40px) saturate(180%) !important; } .monaco-workbench.vs-dark .part.editor > .content .editor-group-container > .title .breadcrumbs-control, -.monaco-workbench.hc-black .part.editor > .content .editor-group-container > .title .breadcrumbs-control { background: rgba(10, 10, 11, 0.85) !important; box-shadow: 0 0 6px rgba(0, 0, 0, 0.08) !important; } /* Input Boxes */ .monaco-workbench .monaco-inputbox, @@ -139,13 +168,9 @@ color: var(--vscode-icon-foreground) !important; } -/* .scm-view .scm-editor { - box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.08); -} */ - /* Buttons */ .monaco-workbench .monaco-button { box-shadow: 0 0 2px rgba(0, 0, 0, 0.06); } -.monaco-workbench .monaco-button:hover { box-shadow: 0 0 4px rgba(0, 0, 0, 0.08); } +.monaco-workbench .monaco-button:hover { box-shadow: 0 0 4px rgba(0, 0, 0, 0.08); } .monaco-workbench .monaco-button:active { box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); } /* Dropdowns */ @@ -158,26 +183,24 @@ .monaco-workbench .scm-view .scm-provider { box-shadow: 0 0 4px rgba(0, 0, 0, 0.08); border-radius: 6px; } /* Debug Toolbar */ -.monaco-workbench .debug-toolbar { box-shadow: 0 0 12px rgba(0, 0, 0, 0.14); border: none; border-radius: 8px; } +.monaco-workbench .debug-toolbar { box-shadow: 0 0 12px rgba(0, 0, 0, 0.14); border: none; border-radius: 8px; backdrop-filter: blur(40px) saturate(180%) !important; -webkit-backdrop-filter: blur(40px) saturate(180%) !important; } /* Action Widget */ -.monaco-workbench .action-widget { box-shadow: 0 0 12px rgba(0, 0, 0, 0.14) !important; border: none !important; border-radius: 8px; } +.monaco-workbench .action-widget { box-shadow: 0 0 12px rgba(0, 0, 0, 0.14) !important; border-radius: 8px; } /* Parameter Hints */ .monaco-workbench .monaco-editor .parameter-hints-widget { box-shadow: 0 0 8px rgba(0, 0, 0, 0.12); border: none; border-radius: 12px; overflow: hidden; backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); } .monaco-workbench.vs-dark .monaco-editor .parameter-hints-widget, -.monaco-workbench.hc-black .monaco-editor .parameter-hints-widget { background: rgba(10, 10, 11, 0.85) !important; } .monaco-workbench.vs .monaco-editor .parameter-hints-widget { background: rgba(252, 252, 253, 0.85) !important; } /* Minimap */ .monaco-workbench .monaco-editor .minimap { background: rgba(252, 252, 253, 0.65) !important; backdrop-filter: blur(40px) saturate(180%) !important; -webkit-backdrop-filter: blur(40px) saturate(180%) !important; border-radius: 8px 0 0 8px; } .monaco-workbench .monaco-editor .minimap canvas { opacity: 0.85; } .monaco-workbench.vs-dark .monaco-editor .minimap, -.monaco-workbench.hc-black .monaco-editor .minimap { background: rgba(5, 5, 6, 0.85) !important; box-shadow: 0 0 6px rgba(0, 0, 0, 0.10) !important; } .monaco-workbench .monaco-editor .minimap-shadow-visible { box-shadow: 0 0 6px rgba(0, 0, 0, 0.10); } /* Sticky Scroll */ -.monaco-workbench .monaco-editor .sticky-widget { box-shadow: 0 0 6px rgba(0, 0, 0, 0.10) !important; border-bottom: none !important; border: none !important; background: rgba(252, 252, 253, 0.65) !important; backdrop-filter: blur(40px) saturate(180%) !important; -webkit-backdrop-filter: blur(40px) saturate(180%) !important; border-radius: 0 0 8px 8px !important; } +.monaco-workbench .monaco-editor .sticky-widget { box-shadow: 0 0 6px rgba(0, 0, 0, 0.10) !important; border-bottom: none !important; background: rgba(252, 252, 253, 0.65) !important; backdrop-filter: blur(40px) saturate(180%) !important; -webkit-backdrop-filter: blur(40px) saturate(180%) !important; border-radius: 0 0 8px 8px !important; } .monaco-workbench .monaco-editor .sticky-widget *, .monaco-workbench .monaco-editor .sticky-widget > *, .monaco-workbench .monaco-editor .sticky-widget .sticky-widget-line-numbers, @@ -187,7 +210,6 @@ .monaco-workbench .monaco-editor .sticky-widget .sticky-line-content, .monaco-workbench .monaco-editor .sticky-widget .sticky-line-content:hover { background-color: transparent !important; background: transparent !important; } .monaco-workbench.vs-dark .monaco-editor .sticky-widget, -.monaco-workbench.hc-black .monaco-editor .sticky-widget { background: rgba(10, 10, 11, 0.85) !important; backdrop-filter: blur(40px) saturate(180%) !important; -webkit-backdrop-filter: blur(40px) saturate(180%) !important; box-shadow: 0 0 6px rgba(0, 0, 0, 0.10) !important; } .monaco-workbench .monaco-editor .sticky-widget-focus-preview, .monaco-workbench .monaco-editor .sticky-scroll-focus-line, .monaco-workbench .monaco-editor .focused .sticky-widget, @@ -196,10 +218,6 @@ .monaco-workbench.vs-dark .monaco-editor .sticky-scroll-focus-line, .monaco-workbench.vs-dark .monaco-editor .focused .sticky-widget, .monaco-workbench.vs-dark .monaco-editor:has(.sticky-widget:focus-within) .sticky-widget, -.monaco-workbench.hc-black .monaco-editor .sticky-widget-focus-preview, -.monaco-workbench.hc-black .monaco-editor .sticky-scroll-focus-line, -.monaco-workbench.hc-black .monaco-editor .focused .sticky-widget, -.monaco-workbench.hc-black .monaco-editor:has(.sticky-widget:focus-within) .sticky-widget { background: rgba(10, 10, 11, 0.90) !important; box-shadow: 0 0 8px rgba(0, 0, 0, 0.12) !important; } /* Notebook */ .monaco-workbench .notebookOverlay .monaco-list .cell-focus-indicator { box-shadow: 0 0 4px rgba(0, 0, 0, 0.08); border-radius: 6px; } @@ -209,12 +227,10 @@ .monaco-workbench .monaco-editor .inline-chat { box-shadow: 0 0 12px rgba(0, 0, 0, 0.14); border: none; border-radius: 12px; } /* Command Center */ -.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center { box-shadow: 0 0 8px rgba(0, 0, 0, 0.09) !important; border-radius: 8px !important; background: rgba(249, 250, 251, 0.55) !important; backdrop-filter: blur(24px) saturate(150%); -webkit-backdrop-filter: blur(24px) saturate(150%); overflow: visible !important; } -.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:hover { box-shadow: 0 0 10px rgba(0, 0, 0, 0.11) !important; background: rgba(249, 250, 251, 0.70) !important; } +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center { box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.09) !important; border-radius: 8px !important; background: rgba(249, 250, 251, 0.55) !important; backdrop-filter: blur(24px) saturate(150%); -webkit-backdrop-filter: blur(24px) saturate(150%); overflow: visible !important; } +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:hover { box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.11) !important; background: rgba(249, 250, 251, 0.70) !important; } .monaco-workbench.vs-dark .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center, -.monaco-workbench.hc-black .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center { background: rgba(10, 10, 11, 0.75) !important; backdrop-filter: blur(24px) saturate(150%); -webkit-backdrop-filter: blur(24px) saturate(150%); } .monaco-workbench.vs-dark .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:hover, -.monaco-workbench.hc-black .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:hover { background: rgba(15, 15, 17, 0.85) !important; } .monaco-workbench .part.titlebar .command-center .agent-status-pill { box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.08); } @@ -223,6 +239,10 @@ background-color: transparent; } +.monaco-dialog-modal-block .dialog-shadow { + border-radius: 12px; +} + /* Remove Borders */ .monaco-workbench.vs .part.sidebar { border-right: none !important; border-left: none !important; } .monaco-workbench.vs .part.auxiliarybar { border-right: none !important; border-left: none !important; } @@ -230,5 +250,4 @@ .monaco-workbench.vs .part.activitybar { border-right: none !important; border-left: none !important; } .monaco-workbench.vs .part.titlebar { border-bottom: none !important; } .monaco-workbench.vs .part.statusbar { border-top: none !important; } -.monaco-workbench .part.editor > .content .editor-group-container { border: none !important; } .monaco-workbench .pane-composite-part:not(.empty) > .header { border-bottom: none !important; } diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index 1a62307464bb2..36848442d368b 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -123,6 +123,66 @@ export const { //#endregion +//#region External Focus Tracking + +/** + * A registry for functions that check if a component outside the normal DOM tree has focus. + * This is used to extend the concept of "window has focus" to include things like + * Electron WebContentsViews (browser views) that exist outside the workbench DOM. + */ +const externalFocusCheckers = new Set<() => boolean>(); + +/** + * Register a function that checks if a component outside the DOM has focus. + * This allows `hasExternalFocus` to detect when focus is in components like browser views. + * + * @param checker A function that returns true if the component has focus + * @returns A disposable to unregister the checker + */ +export function registerExternalFocusChecker(checker: () => boolean): IDisposable { + externalFocusCheckers.add(checker); + + return toDisposable(() => { + externalFocusCheckers.delete(checker); + }); +} + +/** + * Check if any registered external component has focus. + * This is used to extend focus detection beyond the normal DOM to include + * components like Electron WebContentsViews. + * + * @returns true if any registered external component has focus + */ +export function hasExternalFocus(): boolean { + for (const checker of externalFocusCheckers) { + if (checker()) { + return true; + } + } + return false; +} + +/** + * Check if the application has focus in any window, either via the normal DOM or via an + * external component like a browser view (which exists outside the document tree). + * + * @returns true if the application owns the current focus + */ +export function hasAppFocus(): boolean { + for (const { window } of getWindows()) { + if (window.document.hasFocus()) { + return true; + } + } + if (hasExternalFocus()) { + return true; + } + return false; +} + +//#endregion + export function clearNode(node: HTMLElement): void { while (node.firstChild) { node.firstChild.remove(); diff --git a/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts b/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts index 67b7424bdebf9..017a7bc627054 100644 --- a/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts +++ b/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts @@ -5,11 +5,10 @@ import './nativeEditContext.css'; import { isFirefox } from '../../../../../base/browser/browser.js'; -import { addDisposableListener, getActiveElement, getWindow, getWindowId, scheduleAtNextAnimationFrame } from '../../../../../base/browser/dom.js'; +import { addDisposableListener, getActiveElement, getWindow, getWindowId } from '../../../../../base/browser/dom.js'; import { FastDomNode } from '../../../../../base/browser/fastDomNode.js'; import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; -import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { EditorOption } from '../../../../common/config/editorOptions.js'; import { EndOfLinePreference, IModelDeltaDecoration } from '../../../../common/model.js'; @@ -69,7 +68,6 @@ export class NativeEditContext extends AbstractEditContext { private _targetWindowId: number = -1; private _scrollTop: number = 0; private _scrollLeft: number = 0; - private _selectionAndControlBoundsUpdateDisposable: IDisposable | undefined; private readonly _focusTracker: FocusTracker; @@ -257,8 +255,6 @@ export class NativeEditContext extends AbstractEditContext { this.domNode.domNode.blur(); this.domNode.domNode.remove(); this._imeTextArea.domNode.remove(); - this._selectionAndControlBoundsUpdateDisposable?.dispose(); - this._selectionAndControlBoundsUpdateDisposable = undefined; super.dispose(); } @@ -531,19 +527,7 @@ export class NativeEditContext extends AbstractEditContext { } } - private _updateSelectionAndControlBoundsAfterRender(): void { - if (this._selectionAndControlBoundsUpdateDisposable) { - return; - } - // Schedule this work after render so we avoid triggering a layout while still painting. - const targetWindow = getWindow(this.domNode.domNode); - this._selectionAndControlBoundsUpdateDisposable = scheduleAtNextAnimationFrame(targetWindow, () => { - this._selectionAndControlBoundsUpdateDisposable = undefined; - this._applySelectionAndControlBounds(); - }); - } - - private _applySelectionAndControlBounds(): void { + private _updateSelectionAndControlBoundsAfterRender() { const options = this._context.configuration.options; const contentLeft = options.get(EditorOption.layoutInfo).contentLeft; diff --git a/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts b/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts index d3d56b8f69063..f19cc424373e8 100644 --- a/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts +++ b/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts @@ -6,7 +6,6 @@ import './textAreaEditContext.css'; import * as nls from '../../../../../nls.js'; import * as browser from '../../../../../base/browser/browser.js'; -import { scheduleAtNextAnimationFrame, getWindow } from '../../../../../base/browser/dom.js'; import { FastDomNode, createFastDomNode } from '../../../../../base/browser/fastDomNode.js'; import { IKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; import * as platform from '../../../../../base/common/platform.js'; @@ -32,7 +31,6 @@ import { MOUSE_CURSOR_TEXT_CSS_CLASS_NAME } from '../../../../../base/browser/ui import { TokenizationRegistry } from '../../../../common/languages.js'; import { ColorId, ITokenPresentation } from '../../../../common/encodedTokenAttributes.js'; import { Color } from '../../../../../base/common/color.js'; -import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { IME } from '../../../../../base/common/ime.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -141,7 +139,6 @@ export class TextAreaEditContext extends AbstractEditContext { * This is useful for hit-testing and determining the mouse position. */ private _lastRenderPosition: Position | null; - private _scheduledRender: IDisposable | null = null; public readonly textArea: FastDomNode; public readonly textAreaCover: FastDomNode; @@ -467,8 +464,6 @@ export class TextAreaEditContext extends AbstractEditContext { } public override dispose(): void { - this._scheduledRender?.dispose(); - this._scheduledRender = null; super.dispose(); this.textArea.domNode.remove(); this.textAreaCover.domNode.remove(); @@ -692,20 +687,7 @@ export class TextAreaEditContext extends AbstractEditContext { public render(ctx: RestrictedRenderingContext): void { this._textAreaInput.writeNativeTextAreaContent('render'); - this._scheduleRender(); - } - - // Delay expensive DOM updates until the next animation frame to reduce reflow pressure. - private _scheduleRender(): void { - if (this._scheduledRender) { - return; - } - - const targetWindow = getWindow(this.textArea.domNode); - this._scheduledRender = scheduleAtNextAnimationFrame(targetWindow, () => { - this._scheduledRender = null; - this._render(); - }); + this._render(); } private _render(): void { diff --git a/src/vs/editor/browser/editorBrowser.ts b/src/vs/editor/browser/editorBrowser.ts index cf774c63bba3e..a510c12506a51 100644 --- a/src/vs/editor/browser/editorBrowser.ts +++ b/src/vs/editor/browser/editorBrowser.ts @@ -1229,6 +1229,11 @@ export interface ICodeEditor extends editorCommon.IEditor { */ render(forceRedraw?: boolean): void; + /** + * Render the editor at the next animation frame. + */ + renderAsync(forceRedraw?: boolean): void; + /** * Get the hit test target at coordinates `clientX` and `clientY`. * The coordinates are relative to the top-left of the viewport. diff --git a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts index a53d266f5f47e..9a4ae242fce30 100644 --- a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts +++ b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts @@ -1703,6 +1703,15 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE }); } + public renderAsync(forceRedraw: boolean = false): void { + if (!this._modelData || !this._modelData.hasRealView) { + return; + } + this._modelData.viewModel.batchEvents(() => { + this._modelData!.view.render(false, forceRedraw); + }); + } + public setAriaOptions(options: editorBrowser.IEditorAriaOptions): void { if (!this._modelData || !this._modelData.hasRealView) { return; diff --git a/src/vs/editor/common/model.ts b/src/vs/editor/common/model.ts index 28862df0b5d80..c70fa95a387c9 100644 --- a/src/vs/editor/common/model.ts +++ b/src/vs/editor/common/model.ts @@ -222,7 +222,7 @@ export interface IModelDecorationOptions { */ glyphMargin?: IModelDecorationGlyphMarginOptions | null; /** - * If set, the decoration will override the line height of the lines it spans. Maximum value is 300px. + * If set, the decoration will override the line height of the lines it spans. This value is a multiplier to the default line height. */ lineHeight?: number | null; /** diff --git a/src/vs/editor/common/model/tokens/tokenizationFontDecorationsProvider.ts b/src/vs/editor/common/model/tokens/tokenizationFontDecorationsProvider.ts index a5335fa9fca53..0481e1507fc92 100644 --- a/src/vs/editor/common/model/tokens/tokenizationFontDecorationsProvider.ts +++ b/src/vs/editor/common/model/tokens/tokenizationFontDecorationsProvider.ts @@ -137,6 +137,7 @@ export class TokenizationFontDecorationProvider extends Disposable implements De options: { description: 'FontOptionDecoration', inlineClassName: className, + lineHeight: anno.fontToken.lineHeightMultiplier, affectsFont }, ownerId: 0, diff --git a/src/vs/editor/common/viewModel/viewModelDecorations.ts b/src/vs/editor/common/viewModel/viewModelDecorations.ts index 50cf40decf42e..8b4f52f96bf38 100644 --- a/src/vs/editor/common/viewModel/viewModelDecorations.ts +++ b/src/vs/editor/common/viewModel/viewModelDecorations.ts @@ -29,7 +29,7 @@ export interface IViewDecorationsCollection { /** * Whether the decorations affect the fonts. */ - readonly hasVariableFonts: boolean; + readonly hasVariableFonts: boolean[]; } export class ViewModelDecorations implements IDisposable { @@ -131,11 +131,12 @@ export class ViewModelDecorations implements IDisposable { const decorationsInViewport: ViewModelDecoration[] = []; let decorationsInViewportLen = 0; const inlineDecorations: InlineDecoration[][] = []; + const hasVariableFonts: boolean[] = []; for (let j = startLineNumber; j <= endLineNumber; j++) { inlineDecorations[j - startLineNumber] = []; + hasVariableFonts[j - startLineNumber] = false; } - let hasVariableFonts = false; for (let i = 0, len = modelDecorations.length; i < len; i++) { const modelDecoration = modelDecorations[i]; const decorationOptions = modelDecoration.options; @@ -155,6 +156,9 @@ export class ViewModelDecorations implements IDisposable { const intersectedEndLineNumber = Math.min(endLineNumber, viewRange.endLineNumber); for (let j = intersectedStartLineNumber; j <= intersectedEndLineNumber; j++) { inlineDecorations[j - startLineNumber].push(inlineDecoration); + if (decorationOptions.affectsFont) { + hasVariableFonts[j - startLineNumber] = true; + } } } if (decorationOptions.beforeContentClassName) { @@ -165,6 +169,9 @@ export class ViewModelDecorations implements IDisposable { InlineDecorationType.Before ); inlineDecorations[viewRange.startLineNumber - startLineNumber].push(inlineDecoration); + if (decorationOptions.affectsFont) { + hasVariableFonts[viewRange.startLineNumber - startLineNumber] = true; + } } } if (decorationOptions.afterContentClassName) { @@ -175,11 +182,11 @@ export class ViewModelDecorations implements IDisposable { InlineDecorationType.After ); inlineDecorations[viewRange.endLineNumber - startLineNumber].push(inlineDecoration); + if (decorationOptions.affectsFont) { + hasVariableFonts[viewRange.endLineNumber - startLineNumber] = true; + } } } - if (decorationOptions.affectsFont) { - hasVariableFonts = true; - } } return { diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index 8b5b053b4df29..0657caff859a6 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -199,6 +199,7 @@ export class ViewModel extends Disposable implements IViewModel { if (!allowVariableLineHeights) { return []; } + const defaultLineHeight = this._configuration.options.get(EditorOption.lineHeight); const decorations = this.model.getCustomLineHeightsDecorations(this._editorId); return decorations.map((d) => { const lineNumber = d.range.startLineNumber; @@ -207,7 +208,7 @@ export class ViewModel extends Disposable implements IViewModel { decorationId: d.id, startLineNumber: viewRange.startLineNumber, endLineNumber: viewRange.endLineNumber, - lineHeight: d.options.lineHeight || 0 + lineHeight: d.options.lineHeight ? d.options.lineHeight * defaultLineHeight : 0 }; }); } @@ -861,13 +862,15 @@ export class ViewModel extends Disposable implements IViewModel { public getViewportViewLineRenderingData(visibleRange: Range, lineNumber: number): ViewLineRenderingData { const viewportDecorationsCollection = this._decorations.getDecorationsViewportData(visibleRange); - const inlineDecorations = viewportDecorationsCollection.inlineDecorations[lineNumber - visibleRange.startLineNumber]; - return this._getViewLineRenderingData(lineNumber, inlineDecorations, viewportDecorationsCollection.hasVariableFonts, viewportDecorationsCollection.decorations); + const relativeLineNumber = lineNumber - visibleRange.startLineNumber; + const inlineDecorations = viewportDecorationsCollection.inlineDecorations[relativeLineNumber]; + const hasVariableFonts = viewportDecorationsCollection.hasVariableFonts[relativeLineNumber]; + return this._getViewLineRenderingData(lineNumber, inlineDecorations, hasVariableFonts, viewportDecorationsCollection.decorations); } public getViewLineRenderingData(lineNumber: number): ViewLineRenderingData { const decorationsCollection = this._decorations.getDecorationsOnLine(lineNumber); - return this._getViewLineRenderingData(lineNumber, decorationsCollection.inlineDecorations[0], decorationsCollection.hasVariableFonts, decorationsCollection.decorations); + return this._getViewLineRenderingData(lineNumber, decorationsCollection.inlineDecorations[0], decorationsCollection.hasVariableFonts[0], decorationsCollection.decorations); } private _getViewLineRenderingData(lineNumber: number, inlineDecorations: InlineDecoration[], hasVariableFonts: boolean, decorations: ViewModelDecoration[]): ViewLineRenderingData { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.css b/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.css index 16148bd87b094..e46dca6d726d5 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.css +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.css @@ -64,6 +64,13 @@ border-bottom: 4px double var(--vscode-editorWarning-border); } +.monaco-editor .ghost-text-decoration.short-text, +.monaco-editor .ghost-text-decoration-preview.short-text, +.monaco-editor .suggest-preview-text .ghost-text.short-text { + text-decoration: underline dotted var(--vscode-editorGhostText-foreground); + text-underline-position: under; +} + .ghost-text-view-warning-widget-icon { .codicon { color: var(--vscode-editorWarning-foreground) !important; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts index e244d37aaf23f..96053b4e19aa3 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/ghostText/ghostTextView.ts @@ -78,6 +78,7 @@ export class GhostTextView extends Disposable { private readonly _shouldKeepCursorStable: boolean; private readonly _minReservedLineCount: IObservable; private readonly _useSyntaxHighlighting: IObservable; + private readonly _highlightShortText: boolean; constructor( private readonly _editor: ICodeEditor, @@ -88,6 +89,7 @@ export class GhostTextView extends Disposable { shouldKeepCursorStable?: boolean; minReservedLineCount?: IObservable; useSyntaxHighlighting?: IObservable; + highlightShortSuggestions?: boolean; }, @ILanguageService private readonly _languageService: ILanguageService ) { @@ -98,6 +100,7 @@ export class GhostTextView extends Disposable { this._shouldKeepCursorStable = options.shouldKeepCursorStable ?? false; this._minReservedLineCount = options.minReservedLineCount ?? constObservable(0); this._useSyntaxHighlighting = options.useSyntaxHighlighting ?? constObservable(true); + this._highlightShortText = options.highlightShortSuggestions ?? false; this._editorObs = observableCodeEditor(this._editor); this._additionalLinesWidget = this._register( @@ -203,14 +206,25 @@ export class GhostTextView extends Disposable { return undefined; } + private readonly _nonWhitespaceCount = derived(this, reader => { + const data = this._data.read(reader); + if (!data) { return undefined; } + const ghostText = data.ghostText; + const allText = ghostText.parts.map(p => p.lines.map(l => l.line).join('')).join(''); + return allText.replace(/\s/g, '').length; + }); + private readonly _extraClassNames = derived(this, reader => { const extraClasses = this._extraClasses.slice(); - if (this._useSyntaxHighlighting.read(reader)) { - extraClasses.push('syntax-highlighted'); - } if (USE_SQUIGGLES_FOR_WARNING && this._warningState.read(reader)) { extraClasses.push('warning'); } + const nonWhitespaceCount = this._nonWhitespaceCount.read(reader); + if (this._highlightShortText && nonWhitespaceCount && nonWhitespaceCount < 3) { + extraClasses.push('short-text'); + } else if (this._useSyntaxHighlighting.read(reader)) { + extraClasses.push('syntax-highlighted'); + } const extraClassNames = extraClasses.map(c => ` ${c}`).join(''); return extraClassNames; }); @@ -301,6 +315,10 @@ export class GhostTextView extends Disposable { } for (const p of uiState.inlineTexts) { + let inlineExtraClassNames = ''; + if (this._highlightShortText && p.text.length < 5) { + inlineExtraClassNames += ' short-text'; + } decorations.push({ range: Range.fromPositions(new Position(uiState.lineNumber, p.column)), options: { @@ -311,6 +329,7 @@ export class GhostTextView extends Disposable { inlineClassName: (p.preview ? 'ghost-text-decoration-preview' : 'ghost-text-decoration') + (this._isClickable ? ' clickable' : '') + extraClassNames + + inlineExtraClassNames + p.lineDecorations.map(d => ' ' + d.className).join(' '), // TODO: take the ranges into account for line decorations cursorStops: InjectedTextCursorStops.Left, attachedData: new GhostTextAttachedData(this), diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineSuggestionsView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineSuggestionsView.ts index 2b18dfa6d1552..21a2c598aa084 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineSuggestionsView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineSuggestionsView.ts @@ -146,6 +146,7 @@ export class InlineSuggestionsView extends Disposable { }), { useSyntaxHighlighting: this._editorObs.getOption(EditorOption.inlineSuggest).map(v => v.syntaxHighlightingEnabled), + highlightShortSuggestions: true, }, ); } diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index a85f63bf973e6..22641b4e01a3a 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -1747,7 +1747,7 @@ declare namespace monaco.editor { */ glyphMargin?: IModelDecorationGlyphMarginOptions | null; /** - * If set, the decoration will override the line height of the lines it spans. Maximum value is 300px. + * If set, the decoration will override the line height of the lines it spans. This value is a multiplier to the default line height. */ lineHeight?: number | null; /** @@ -6389,6 +6389,10 @@ declare namespace monaco.editor { * Force an editor render now. */ render(forceRedraw?: boolean): void; + /** + * Render the editor at the next animation frame. + */ + renderAsync(forceRedraw?: boolean): void; /** * Get the hit test target at coordinates `clientX` and `clientY`. * The coordinates are relative to the top-left of the viewport. diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 335147600e69e..c666c29087bf8 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -26,6 +26,7 @@ export interface IBrowserViewState { canGoBack: boolean; canGoForward: boolean; loading: boolean; + focused: boolean; isDevToolsOpen: boolean; lastScreenshot: VSBuffer | undefined; lastFavicon: string | undefined; diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 0468d5b82e134..1830d245e3447 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -261,6 +261,7 @@ export class BrowserView extends Disposable { canGoBack: webContents.navigationHistory.canGoBack(), canGoForward: webContents.navigationHistory.canGoForward(), loading: webContents.isLoading(), + focused: webContents.isFocused(), isDevToolsOpen: webContents.isDevToolsOpened(), lastScreenshot: this._lastScreenshot, lastFavicon: this._lastFavicon, diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index 63652a5639e15..3841cfa34a759 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -392,6 +392,12 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { } win.focus(); + + // When focusing the window, the workbench should always be the view that receives focus. + // However, in scenarios where the window has multiple child views (e.g. browser WebContentsViews), + // the last focused view in the window may not be the workbench. + // So we explicitly focus the workbench web contents here to ensure it gets focus. + win.webContents.focus(); } //#region Window Control Overlays diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index 4d02ef1a57f5a..f59cbb25ec58c 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -7,7 +7,6 @@ import type * as vscode from 'vscode'; import { raceCancellation } from '../../../base/common/async.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; -import { Lazy } from '../../../base/common/lazy.js'; import { IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { revive } from '../../../base/common/marshalling.js'; import { generateUuid } from '../../../base/common/uuid.js'; @@ -25,29 +24,8 @@ import * as typeConvert from './extHostTypeConverters.js'; class Tool { private _data: IToolDataDto; - private _apiObject = new Lazy(() => { - const that = this; - return Object.freeze({ - get name() { return that._data.id; }, - get description() { return that._data.modelDescription; }, - get inputSchema() { return that._data.inputSchema; }, - get tags() { return that._data.tags ?? []; }, - get source() { return undefined; } - }); - }); - - private _apiObjectWithChatParticipantAdditions = new Lazy(() => { - const that = this; - const source = typeConvert.LanguageModelToolSource.to(that._data.source); - - return Object.freeze({ - get name() { return that._data.id; }, - get description() { return that._data.modelDescription; }, - get inputSchema() { return that._data.inputSchema; }, - get tags() { return that._data.tags ?? []; }, - get source() { return source; } - }); - }); + private _apiObject: vscode.LanguageModelToolInformation | undefined; + private _apiObjectWithChatParticipantAdditions: vscode.LanguageModelToolInformation | undefined; constructor(data: IToolDataDto) { this._data = data; @@ -55,6 +33,8 @@ class Tool { update(newData: IToolDataDto): void { this._data = newData; + this._apiObject = undefined; + this._apiObjectWithChatParticipantAdditions = undefined; } get data(): IToolDataDto { @@ -62,11 +42,29 @@ class Tool { } get apiObject(): vscode.LanguageModelToolInformation { - return this._apiObject.value; + if (!this._apiObject) { + this._apiObject = Object.freeze({ + name: this._data.id, + description: this._data.modelDescription, + inputSchema: this._data.inputSchema, + tags: this._data.tags ?? [], + source: undefined + }); + } + return this._apiObject; } get apiObjectWithChatParticipantAdditions() { - return this._apiObjectWithChatParticipantAdditions.value; + if (!this._apiObjectWithChatParticipantAdditions) { + this._apiObjectWithChatParticipantAdditions = Object.freeze({ + name: this._data.id, + description: this._data.modelDescription, + inputSchema: this._data.inputSchema, + tags: this._data.tags ?? [], + source: typeConvert.LanguageModelToolSource.to(this._data.source) + }); + } + return this._apiObjectWithChatParticipantAdditions; } } @@ -138,7 +136,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape $onDidChangeTools(tools: IToolDataDto[]): void { - const oldTools = new Set(this._registeredTools.keys()); + const oldTools = new Set(this._allTools.keys()); for (const tool of tools) { oldTools.delete(tool.id); diff --git a/src/vs/workbench/browser/window.ts b/src/vs/workbench/browser/window.ts index 63dfbb43d3569..bff3f2cf1b0e9 100644 --- a/src/vs/workbench/browser/window.ts +++ b/src/vs/workbench/browser/window.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { isSafari, setFullscreen } from '../../base/browser/browser.js'; -import { addDisposableListener, EventHelper, EventType, getActiveWindow, getWindow, getWindowById, getWindows, getWindowsCount, windowOpenNoOpener, windowOpenPopup, windowOpenWithSuccess } from '../../base/browser/dom.js'; +import { addDisposableListener, EventHelper, EventType, getWindow, getWindowById, getWindows, getWindowsCount, hasAppFocus, windowOpenNoOpener, windowOpenPopup, windowOpenWithSuccess } from '../../base/browser/dom.js'; import { DomEmitter } from '../../base/browser/event.js'; import { HidDeviceData, requestHidDevice, requestSerialPort, requestUsbDevice, SerialPortData, UsbDeviceData } from '../../base/browser/deviceAccess.js'; import { timeout } from '../../base/common/async.js'; @@ -74,8 +74,9 @@ export abstract class BaseWindow extends Disposable { } private onElementFocus(targetWindow: CodeWindow): void { - const activeWindow = getActiveWindow(); - if (activeWindow !== targetWindow && activeWindow.document.hasFocus()) { + + // Check if focus should transfer: the application currently has focus somewhere, but not in the target window. + if (!targetWindow.document.hasFocus() && hasAppFocus()) { // Call original focus() targetWindow.focus(); diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 8af47a79bb197..3d644c832296b 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -79,6 +79,7 @@ export interface IBrowserViewModel extends IDisposable { readonly favicon: string | undefined; readonly screenshot: VSBuffer | undefined; readonly loading: boolean; + readonly focused: boolean; readonly canGoBack: boolean; readonly isDevToolsOpen: boolean; readonly canGoForward: boolean; @@ -117,6 +118,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { private _favicon: string | undefined = undefined; private _screenshot: VSBuffer | undefined = undefined; private _loading: boolean = false; + private _focused: boolean = false; private _isDevToolsOpen: boolean = false; private _canGoBack: boolean = false; private _canGoForward: boolean = false; @@ -141,6 +143,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { get title(): string { return this._title; } get favicon(): string | undefined { return this._favicon; } get loading(): boolean { return this._loading; } + get focused(): boolean { return this._focused; } get isDevToolsOpen(): boolean { return this._isDevToolsOpen; } get canGoBack(): boolean { return this._canGoBack; } get canGoForward(): boolean { return this._canGoForward; } @@ -207,6 +210,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._url = state.url; this._title = state.title; this._loading = state.loading; + this._focused = state.focused; this._isDevToolsOpen = state.isDevToolsOpen; this._canGoBack = state.canGoBack; this._canGoForward = state.canGoForward; @@ -244,6 +248,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._register(this.onDidChangeFavicon(e => { this._favicon = e.favicon; })); + + this._register(this.onDidChangeFocus(({ focused }) => { + this._focused = focused; + })); } async layout(bounds: IBrowserViewBounds): Promise { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index dacaecd4ccd46..ccececc4b40c7 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -5,7 +5,7 @@ import './media/browser.css'; import { localize } from '../../../../nls.js'; -import { $, addDisposableListener, disposableWindowInterval, EventType, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, disposableWindowInterval, EventType, isHTMLElement, registerExternalFocusChecker, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { RawContextKey, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -247,13 +247,9 @@ export class BrowserEditor extends EditorPane { } })); - this._register(addDisposableListener(this._browserContainer, EventType.BLUR, () => { - // When focus goes to another part of the workbench, make sure the workbench view becomes focused. - const focused = this.window.document.activeElement; - if (focused && focused !== this._browserContainer) { - this.window.focus(); - } - })); + // Register external focus checker so that cross-window focus logic knows when + // this browser view has focus (since it's outside the normal DOM tree). + this._register(registerExternalFocusChecker(() => this._model?.focused ?? false)); } override async setInput(input: BrowserEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { @@ -308,9 +304,13 @@ export class BrowserEditor extends EditorPane { })); this._inputDisposables.add(this._model.onDidChangeFocus(({ focused }) => { - // When the view gets focused, make sure the container also has focus. + // When the view gets focused, make sure the editor reports that it has focus, + // but focus is removed from the workbench. if (focused) { - this._browserContainer.focus(); + this._onDidFocus?.fire(); + if (isHTMLElement(this.window.document.activeElement)) { + this.window.document.activeElement.blur(); + } } })); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index acc95dc6441e9..1fac6850829e1 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -798,6 +798,10 @@ export class CancelAction extends Action2 { keybinding: { weight: KeybindingWeight.WorkbenchContrib, primary: KeyMod.CtrlCmd | KeyCode.Escape, + when: ContextKeyExpr.and( + ChatContextKeys.requestInProgress, + ChatContextKeys.remoteJobCreating.negate() + ), win: { primary: KeyMod.Alt | KeyCode.Backspace }, } }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts index a8ad2887cac24..f37633f0f9410 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts @@ -188,7 +188,7 @@ class ConfigureToolsAction extends Action2 { }); try { - const result = await instaService.invokeFunction(showToolsPicker, placeholder, description, () => entriesMap.get(), cts.token); + const result = await instaService.invokeFunction(showToolsPicker, placeholder, 'chatInput', description, () => entriesMap.get(), cts.token); if (result) { widget.input.selectedToolsModel.set(result, false); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts index 75a41db3f6e95..2c17502c21b68 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts @@ -17,6 +17,7 @@ import { IContextKeyService } from '../../../../../platform/contextkey/common/co import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickTreeItem } from '../../../../../platform/quickinput/common/quickInput.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ExtensionEditorTab, IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; import { McpCommandIds } from '../../../mcp/common/mcpCommandIds.js'; @@ -190,6 +191,7 @@ function createToolSetTreeItem(toolset: ToolSet, checked: boolean, editorService export async function showToolsPicker( accessor: ServicesAccessor, placeHolder: string, + source: string, description?: string, getToolsEntries?: () => ReadonlyMap, token?: CancellationToken @@ -203,6 +205,7 @@ export async function showToolsPicker( const editorService = accessor.get(IEditorService); const mcpWorkbenchService = accessor.get(IMcpWorkbenchService); const toolsService = accessor.get(ILanguageModelToolsService); + const telemetryService = accessor.get(ITelemetryService); const toolLimit = accessor.get(IContextKeyService).getContextKeyValue(ChatContextKeys.chatToolGroupingThreshold.key); const mcpServerByTool = new Map(); @@ -593,11 +596,45 @@ export async function showToolsPicker( })); } + // Capture initial state for telemetry comparison + const initialStateString = serializeToolsState(collectResults()); + treePicker.show(); await Promise.race([Event.toPromise(Event.any(treePicker.onDidHide, didAcceptFinalItem.event), store)]); + // Send telemetry whether the tool selection changed + sendDidChangeEvent(source, telemetryService, initialStateString !== serializeToolsState(collectResults())); + store.dispose(); return didAccept ? collectResults() : undefined; } + +function serializeToolsState(state: ReadonlyMap): string { + const entries: [string, boolean][] = []; + state.forEach((value, key) => { + entries.push([key.id, value]); + }); + entries.sort((a, b) => a[0].localeCompare(b[0])); + return JSON.stringify(entries); +} + +function sendDidChangeEvent(source: string, telemetryService: ITelemetryService, changed: boolean): void { + type ToolPickerClosedEvent = { + changed: boolean; + source: string; + }; + + type ToolPickerClosedClassification = { + changed: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user changed the tool selection from the initial state.' }; + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source of the tool picker event.' }; + owner: 'benibenj'; + comment: 'Tracks whether users modify tool selection in the tool picker.'; + }; + + telemetryService.publicLog2('chatToolPickerClosed', { + source, + changed, + }); +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 16ed35cb16b0a..4cc9edf8ac918 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -15,7 +15,8 @@ export enum AgentSessionProviders { Local = localChatSessionType, Background = 'copilotcli', Cloud = 'copilot-cloud-agent', - ClaudeCode = 'claude-code', + Claude = 'claude-code', + Codex = 'openai-codex', } export function getAgentSessionProvider(sessionResource: URI | string): AgentSessionProviders | undefined { @@ -24,7 +25,8 @@ export function getAgentSessionProvider(sessionResource: URI | string): AgentSes case AgentSessionProviders.Local: case AgentSessionProviders.Background: case AgentSessionProviders.Cloud: - case AgentSessionProviders.ClaudeCode: + case AgentSessionProviders.Claude: + case AgentSessionProviders.Codex: return type; default: return undefined; @@ -39,8 +41,10 @@ export function getAgentSessionProviderName(provider: AgentSessionProviders): st return localize('chat.session.providerLabel.background', "Background"); case AgentSessionProviders.Cloud: return localize('chat.session.providerLabel.cloud', "Cloud"); - case AgentSessionProviders.ClaudeCode: - return localize('chat.session.providerLabel.claude', "Claude"); + case AgentSessionProviders.Claude: + return 'Claude'; + case AgentSessionProviders.Codex: + return 'Codex'; } } @@ -52,7 +56,8 @@ export function getAgentSessionProviderIcon(provider: AgentSessionProviders): Th return Codicon.worktree; case AgentSessionProviders.Cloud: return Codicon.cloud; - case AgentSessionProviders.ClaudeCode: + case AgentSessionProviders.Codex: + case AgentSessionProviders.Claude: return Codicon.code; } } @@ -63,7 +68,20 @@ export function isFirstPartyAgentSessionProvider(provider: AgentSessionProviders case AgentSessionProviders.Background: case AgentSessionProviders.Cloud: return true; - case AgentSessionProviders.ClaudeCode: + case AgentSessionProviders.Claude: + case AgentSessionProviders.Codex: + return false; + } +} + +export function getAgentCanContinueIn(provider: AgentSessionProviders): boolean { + switch (provider) { + case AgentSessionProviders.Local: + case AgentSessionProviders.Background: + case AgentSessionProviders.Cloud: + return true; + case AgentSessionProviders.Claude: + case AgentSessionProviders.Codex: return false; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index da55fc44340b8..943aa770a5c4c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -29,7 +29,7 @@ import { IStyleOverride } from '../../../../../platform/theme/browser/defaultSty import { IAgentSessionsControl } from './agentSessions.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; import { URI } from '../../../../../base/common/uri.js'; -import { openSession } from './agentSessionsOpener.js'; +import { ISessionOpenOptions, openSession } from './agentSessionsOpener.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; import { IMouseEvent } from '../../../../../base/browser/mouseEvent.js'; @@ -43,6 +43,7 @@ export interface IAgentSessionsControlOptions extends IAgentSessionsSorterOption getHoverPosition(): HoverPosition; trackActiveEditorSession(): boolean; + overrideSessionOpenOptions?(openEvent: IOpenEvent): ISessionOpenOptions; notifySessionOpened?(resource: URI, widget: IChatWidget): void; } @@ -220,7 +221,8 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo source: this.options.source }); - const widget = await this.instantiationService.invokeFunction(openSession, element, e); + const options = this.options.overrideSessionOpenOptions?.(e) ?? e; + const widget = await this.instantiationService.invokeFunction(openSession, element, options); if (widget) { this.options.notifySessionOpened?.(element.resource, widget); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 74f5db7b4cc5c..50a3b94aea5bc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -316,6 +316,10 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre return undefined; } + if (elapsed < 30000) { + return localize('secondsDuration', "now"); + } + return getDurationString(elapsed, useFullTimeWords); } @@ -328,7 +332,7 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre } if (!timeLabel) { - timeLabel = fromNow(session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created); + timeLabel = fromNow(session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created, true); } return timeLabel; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index 5cc56bd920736..a4a9e1ba750ab 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -677,6 +677,9 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio // Mark as no longer being modified await entry.acceptStreamingEditsEnd(); + // Accept the changes + await entry.accept(); + // Clear external edit mode entry.stopExternalEdit(); } diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts index e2205448dbe87..614e2b2f281eb 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts @@ -7,11 +7,9 @@ import { distinct } from '../../../../../base/common/arrays.js'; import { IMatch, IFilter, or, matchesCamelCase, matchesWords, matchesBaseContiguousSubString } from '../../../../../base/common/filters.js'; import { Emitter } from '../../../../../base/common/event.js'; import { ILanguageModelsService, IUserFriendlyLanguageModel, ILanguageModelChatMetadataAndIdentifier } from '../../../chat/common/languageModels.js'; -import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { localize } from '../../../../../nls.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { ILanguageModelsProviderGroup, ILanguageModelsConfigurationService } from '../../common/languageModelsConfiguration.js'; -import { Throttler } from '../../../../../base/common/async.js'; +import { ILanguageModelsProviderGroup } from '../../common/languageModelsConfiguration.js'; import Severity from '../../../../../base/common/severity.js'; export const MODEL_ENTRY_TEMPLATE_ID = 'model.entry.template'; @@ -143,17 +141,12 @@ export class ChatModelsViewModel extends Disposable { } } - private readonly refreshThrottler = this._register(new Throttler()); - constructor( @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, - @ILanguageModelsConfigurationService private readonly languageModelsConfigurationService: ILanguageModelsConfigurationService, - @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService ) { super(); this.languageModels = []; - this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.refresh())); - this._register(this.languageModelsConfigurationService.onDidChangeLanguageModelGroups(() => this.refresh())); + this._register(this.languageModelsService.onDidChangeLanguageModels(vendor => this.refreshVendor(vendor))); } private readonly _viewModelEntries: IViewModelEntry[] = []; @@ -470,52 +463,73 @@ export class ChatModelsViewModel extends Disposable { }); } - refresh(): Promise { - return this.refreshThrottler.queue(() => this.doRefresh()); + async refresh(): Promise { + await this.languageModelsService.selectLanguageModels({}); + await this.refreshAllVendors(); } - private async doRefresh(): Promise { + private async refreshAllVendors(): Promise { this.languageModels = []; this.languageModelGroupStatuses = []; for (const vendor of this.getVendors()) { - const models: ILanguageModel[] = []; - const languageModelsGroups = await this.languageModelsService.fetchLanguageModelGroups(vendor.vendor); - for (const group of languageModelsGroups) { - const provider: ILanguageModelProvider = { - group: group.group ?? { - vendor: vendor.vendor, - name: vendor.displayName - }, - vendor - }; - if (group.status) { - this.languageModelGroupStatuses.push({ - provider, - status: { - message: group.status.message, - severity: group.status.severity - } - }); - } - for (const identifier of group.modelIdentifiers) { - const metadata = this.languageModelsService.lookupLanguageModel(identifier); - if (!metadata) { - continue; - } - if (vendor.vendor === 'copilot' && metadata.id === 'auto') { - continue; + this.addVendorModels(vendor); + } + this.languageModelGroups = this.groupModels(this.languageModels); + this.doFilter(); + } + + private refreshVendor(vendorId: string): void { + const vendor = this.getVendors().find(v => v.vendor === vendorId); + if (!vendor) { + return; + } + + // Remove existing models for this vendor + this.languageModels = this.languageModels.filter(m => m.provider.vendor.vendor !== vendorId); + this.languageModelGroupStatuses = this.languageModelGroupStatuses.filter(s => s.provider.vendor.vendor !== vendorId); + + // Add updated models for this vendor + this.addVendorModels(vendor); + this.languageModelGroups = this.groupModels(this.languageModels); + this.doFilter(); + } + + private addVendorModels(vendor: IUserFriendlyLanguageModel): void { + const models: ILanguageModel[] = []; + const languageModelsGroups = this.languageModelsService.getLanguageModelGroups(vendor.vendor); + for (const group of languageModelsGroups) { + const provider: ILanguageModelProvider = { + group: group.group ?? { + vendor: vendor.vendor, + name: vendor.displayName + }, + vendor + }; + if (group.status) { + this.languageModelGroupStatuses.push({ + provider, + status: { + message: group.status.message, + severity: group.status.severity } - models.push({ - identifier, - metadata, - provider, - }); + }); + } + for (const identifier of group.modelIdentifiers) { + const metadata = this.languageModelsService.lookupLanguageModel(identifier); + if (!metadata) { + continue; + } + if (vendor.vendor === 'copilot' && metadata.id === 'auto') { + continue; } + models.push({ + identifier, + metadata, + provider, + }); } - this.languageModels.push(...models.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name))); - this.languageModelGroups = this.groupModels(this.languageModels); - this.doFilter(); } + this.languageModels.push(...models.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name))); } toggleVisibility(model: ILanguageModelEntry): void { diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index bd8ffdb7f7bc2..1dc5e58c8ebda 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -992,6 +992,7 @@ export class ChatModelsWidget extends Disposable { // Create table this.createTable(); this._register(this.viewModel.onDidChangeGrouping(() => this.createTable())); + this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.updateAddModelsButton())); } private createTable(): void { @@ -1167,24 +1168,7 @@ export class ChatModelsWidget extends Disposable { this.table.setFocus([selectedEntryIndex]); this.table.setSelection([selectedEntryIndex]); } - - const configurableVendors = this.languageModelsService.getVendors().filter(vendor => vendor.managementCommand || vendor.configuration); - - const entitlement = this.chatEntitlementService.entitlement; - const supportsAddingModels = this.chatEntitlementService.isInternal - || (entitlement !== ChatEntitlement.Unknown - && entitlement !== ChatEntitlement.Available - && entitlement !== ChatEntitlement.Business - && entitlement !== ChatEntitlement.Enterprise); - this.addButton.enabled = supportsAddingModels && configurableVendors.length > 0; - - this.dropdownActions = configurableVendors.map(vendor => toAction({ - id: `enable-${vendor.vendor}`, - label: vendor.displayName, - run: async () => { - await this.addModelsForVendor(vendor); - } - })); + this.updateAddModelsButton(); })); this.tableDisposables.add(this.table.onDidOpen(async ({ element, browserEvent }) => { @@ -1212,6 +1196,26 @@ export class ChatModelsWidget extends Disposable { this.layout(this.element.clientHeight, this.element.clientWidth); } + private updateAddModelsButton(): void { + const configurableVendors = this.languageModelsService.getVendors().filter(vendor => vendor.managementCommand || vendor.configuration); + + const entitlement = this.chatEntitlementService.entitlement; + const supportsAddingModels = this.chatEntitlementService.isInternal + || (entitlement !== ChatEntitlement.Unknown + && entitlement !== ChatEntitlement.Available + && entitlement !== ChatEntitlement.Business + && entitlement !== ChatEntitlement.Enterprise); + this.addButton.enabled = supportsAddingModels && configurableVendors.length > 0; + + this.dropdownActions = configurableVendors.map(vendor => toAction({ + id: `enable-${vendor.vendor}`, + label: vendor.displayName, + run: async () => { + await this.addModelsForVendor(vendor); + } + })); + } + private filterModels(): void { this.delayedFiltering.trigger(() => { this.viewModel.filter(this.searchWidget.getValue()); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 7b973bc922dc8..9212545a22fd7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -49,6 +49,7 @@ import { BugIndicatingError } from '../../../../../base/common/errors.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { LocalChatSessionUri } from '../../common/model/chatUri.js'; import { assertNever } from '../../../../../base/common/assert.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; const extensionPoint = ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'chatSessions', @@ -516,12 +517,18 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const disposables = new DisposableStore(); // Mirror all create submenu actions into the global Chat New menu - for (const action of menuActions) { + for (let i = 0; i < menuActions.length; i++) { + const action = menuActions[i]; if (action instanceof MenuItemAction) { - disposables.add(MenuRegistry.appendMenuItem(MenuId.ChatNewMenu, { - command: action.item, - group: '4_externally_contributed', - })); + // TODO: This is an odd way to do this, but the best we can do currently + if (i === 0 && !contribution.canDelegate) { + disposables.add(registerNewSessionExternalAction(contribution.type, contribution.displayName, action.item.id)); + } else { + disposables.add(MenuRegistry.appendMenuItem(MenuId.ChatNewMenu, { + command: action.item, + group: '4_externally_contributed', + })); + } } } return { @@ -1125,6 +1132,24 @@ function registerNewSessionInPlaceAction(type: string, displayName: string): IDi }); } +function registerNewSessionExternalAction(type: string, displayName: string, commandId: string): IDisposable { + return registerAction2(class NewChatSessionExternalAction extends Action2 { + constructor() { + super({ + id: `workbench.action.chat.openNewChatSessionExternal.${type}`, + title: localize2('interactiveSession.openNewChatSessionExternal', "New {0}", displayName), + category: CHAT_CATEGORY, + f1: false, + precondition: ChatContextKeys.enabled, + }); + } + async run(accessor: ServicesAccessor): Promise { + const commandService = accessor.get(ICommandService); + await commandService.executeCommand(commandId); + } + }); +} + enum ChatSessionPosition { Editor = 'editor', Sidebar = 'sidebar' diff --git a/src/vs/workbench/contrib/chat/browser/contextContrib/chatContext.contribution.ts b/src/vs/workbench/contrib/chat/browser/contextContrib/chatContext.contribution.ts index 33472b08f2f7d..8109c362c3b1c 100644 --- a/src/vs/workbench/contrib/chat/browser/contextContrib/chatContext.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/contextContrib/chatContext.contribution.ts @@ -41,7 +41,12 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint(); - readonly onDidChangeLanguageModelGroups: Event = this._onDidChangeLanguageModelGroups.event; + private readonly _onDidChangeLanguageModelGroups = new Emitter(); + readonly onDidChangeLanguageModelGroups: Event = this._onDidChangeLanguageModelGroups.event; private languageModelsProviderGroups: LanguageModelsProviderGroups = []; @@ -62,11 +62,29 @@ export class LanguageModelsConfigurationService extends Disposable implements IL } private setLanguageModelsConfiguration(languageModelsConfiguration: LanguageModelsProviderGroups): void { - if (equals(this.languageModelsProviderGroups, languageModelsConfiguration)) { - return; + const changedGroups: ILanguageModelsProviderGroup[] = []; + const oldGroupMap = new Map(this.languageModelsProviderGroups.map(g => [`${g.vendor}:${g.name}`, g])); + const newGroupMap = new Map(languageModelsConfiguration.map(g => [`${g.vendor}:${g.name}`, g])); + + // Find added or modified groups + for (const [key, newGroup] of newGroupMap) { + const oldGroup = oldGroupMap.get(key); + if (!oldGroup || !equals(oldGroup, newGroup)) { + changedGroups.push(newGroup); + } + } + + // Find removed groups + for (const [key, oldGroup] of oldGroupMap) { + if (!newGroupMap.has(key)) { + changedGroups.push(oldGroup); + } } + this.languageModelsProviderGroups = languageModelsConfiguration; - this._onDidChangeLanguageModelGroups.fire(); + if (changedGroups.length > 0) { + this._onDidChangeLanguageModelGroups.fire(changedGroups); + } } private async updateLanguageModelsConfiguration(): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts index 4c9a0c6320398..37a96a89d1030 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts @@ -85,7 +85,7 @@ class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider private async updateTools(model: ITextModel, range: Range, selectedTools: readonly string[], target: string | undefined): Promise { const selectedToolsNow = () => this.languageModelToolsService.toToolAndToolSetEnablementMap(selectedTools, target); - const newSelectedAfter = await this.instantiationService.invokeFunction(showToolsPicker, localize('placeholder', "Select tools"), undefined, selectedToolsNow); + const newSelectedAfter = await this.instantiationService.invokeFunction(showToolsPicker, localize('placeholder', "Select tools"), 'codeLens', undefined, selectedToolsNow); if (!newSelectedAfter) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index 8e571e80b466f..6485bd8f9dc6d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -447,6 +447,14 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP this.mathLayoutParticipants.forEach(layout => layout()); } + onDidRemount(): void { + for (const ref of this.allRefs) { + if (ref.object instanceof CodeBlockPart) { + ref.object.onDidRemount(); + } + } + } + addDisposable(disposable: IDisposable): void { this._register(disposable); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts index d19dd33ecf428..35b7d57c59e78 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts @@ -389,7 +389,12 @@ export class CodeBlockPart extends Disposable { const editorBorder = 2; width = width - editorBorder - (this.currentCodeBlockData?.renderOptions?.reserveWidth ?? 0); - this.editor.layout({ width: isRequestVM(this.currentCodeBlockData?.element) ? width * 0.9 : width, height }); + // !!!! + // Important: Using here postponeRendering = true to avoid doing a sync layout on the editor + // which can be very expensive if there are many code blocks being laid out at once. + // This allows multiple editors to coordinate and render together at the next animation frame. + // !!!! + this.editor.layout({ width: isRequestVM(this.currentCodeBlockData?.element) ? width * 0.9 : width, height }, /* postponeRendering */ true); this.updatePaddingForLayout(); } @@ -454,6 +459,15 @@ export class CodeBlockPart extends Disposable { this.currentCodeBlockData = undefined; } + onDidRemount(): void { + if (this.currentCodeBlockData) { + // !!!! + // Important: if the editor was off-dom and is now connected, we need to re-render it + // !!!! + this.editor.renderAsync(true); + } + } + private clearWidgets() { ContentHoverController.get(this.editor)?.hideContentHover(); GlyphHoverController.get(this.editor)?.hideGlyphHover(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts index c5c0fc6ad2e86..9e562c53e3d32 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { IActionWidgetDropdownAction } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { ACTION_ID_NEW_CHAT } from '../../actions/chatActions.js'; -import { AgentSessionProviders, getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; +import { AgentSessionProviders, getAgentCanContinueIn, getAgentSessionProvider, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { ISessionTypeItem, SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; /** @@ -49,8 +49,19 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt return this._getSelectedSessionType() !== type; // Always allow switching back to active session } + protected override _isVisible(type: AgentSessionProviders): boolean { + if (this.delegate.getActiveSessionProvider() === type) { + return true; // Always show active session type + } + + return getAgentCanContinueIn(type); + } + protected override _getSessionCategory(sessionTypeItem: ISessionTypeItem) { - return { label: localize('continueIn', "Continue In"), order: 1, showHeader: true }; + if (isFirstPartyAgentSessionProvider(sessionTypeItem.type)) { + return { label: localize('continueIn', "Continue In"), order: 1, showHeader: true }; + } + return { label: localize('continueInThirdParty', "Continue In (Third Party)"), order: 2, showHeader: false }; } protected override _getSessionDescription(sessionTypeItem: ISessionTypeItem): string | undefined { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts index a6ed9a671a428..8e5feb2fd94b1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts @@ -96,6 +96,8 @@ function getModelPickerActionBarActionProvider(commandService: ICommandService, chatEntitlementService.entitlement === ChatEntitlement.Free || chatEntitlementService.entitlement === ChatEntitlement.Pro || chatEntitlementService.entitlement === ChatEntitlement.ProPlus || + chatEntitlementService.entitlement === ChatEntitlement.Business || + chatEntitlementService.entitlement === ChatEntitlement.Enterprise || chatEntitlementService.isInternal ) { additionalActions.push({ diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index 5f5761e0e3077..bc378fc83fe33 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -58,6 +58,10 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { const actions: IActionWidgetDropdownAction[] = [...this._getAdditionalActions()]; for (const sessionTypeItem of this._sessionTypeItems) { + if (!this._isVisible(sessionTypeItem.type)) { + continue; + } + actions.push({ ...action, id: sessionTypeItem.commandId, @@ -134,14 +138,14 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { } private _updateAgentSessionItems(): void { - const localSessionItem = { + const localSessionItem: ISessionTypeItem = { type: AgentSessionProviders.Local, label: getAgentSessionProviderName(AgentSessionProviders.Local), description: localize('chat.sessionTarget.local.description', "Local chat session"), commandId: `workbench.action.chat.openNewChatSessionInPlace.${AgentSessionProviders.Local}`, }; - const agentSessionItems = [localSessionItem]; + const agentSessionItems: ISessionTypeItem[] = [localSessionItem]; const contributions = this.chatSessionsService.getAllChatSessionContributions(); for (const contribution of contributions) { @@ -154,12 +158,18 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { type: agentSessionType, label: getAgentSessionProviderName(agentSessionType), description: contribution.description, - commandId: `workbench.action.chat.openNewChatSessionInPlace.${contribution.type}`, + commandId: contribution.canDelegate ? + `workbench.action.chat.openNewChatSessionInPlace.${contribution.type}` : + `workbench.action.chat.openNewChatSessionExternal.${contribution.type}`, }); } this._sessionTypeItems = agentSessionItems; } + protected _isVisible(type: AgentSessionProviders): boolean { + return true; + } + protected _isSessionTypeEnabled(type: AgentSessionProviders): boolean { return true; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index caa434d031b4a..055cd427deb90 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -943,6 +943,7 @@ have to be updated for changes to the rules above, or to support more deeply nes flex-direction: row; gap: 4px; margin-top: 6px; + margin-right: 20px; flex-wrap: wrap; cursor: default; } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 7ad78b3c13148..2036f2ffddc3b 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -408,6 +408,12 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { trackActiveEditorSession: () => { return !this._widget || this._widget.isEmpty(); // only track and reveal if chat widget is empty }, + overrideSessionOpenOptions: openEvent => { + if (this.sessionsViewerOrientation === AgentSessionsViewerOrientation.Stacked && !openEvent.sideBySide) { + return { ...openEvent, editorOptions: { ...openEvent.editorOptions, preserveFocus: false /* focus the chat widget when opening from stacked sessions viewer since this closes the stacked viewer */ } }; + } + return openEvent; + }, overrideCompare(sessionA: IAgentSession, sessionB: IAgentSession): number | undefined { // When limited where only few sessions show, sort unread sessions to the top diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index a612296c3d61e..66a79b87c222f 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -13,6 +13,7 @@ import { hash } from '../../../../base/common/hash.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { IJSONSchema, TypeFromJsonSchema } from '../../../../base/common/jsonSchema.js'; import { DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { equals } from '../../../../base/common/objects.js'; import Severity from '../../../../base/common/severity.js'; import { format, isFalsyOrWhitespace } from '../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; @@ -20,7 +21,6 @@ import { isString } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; @@ -296,7 +296,7 @@ export interface ILanguageModelsService { lookupLanguageModel(modelId: string): ILanguageModelChatMetadata | undefined; - fetchLanguageModelGroups(vendor: string): Promise; + getLanguageModelGroups(vendor: string): ILanguageModelsGroup[]; /** * Given a selector, returns a list of model identifiers @@ -401,6 +401,8 @@ export const languageModelChatProviderExtensionPoint = ExtensionsRegistry.regist } }); +const CHAT_MODEL_PICKER_PREFERENCES_STORAGE_KEY = 'chatModelPickerPreferences'; + export class LanguageModelsService implements ILanguageModelsService { private static SECRET_KEY_PREFIX = 'chat.lm.secret.'; @@ -427,15 +429,16 @@ export class LanguageModelsService implements ILanguageModelsService { @ILogService private readonly _logService: ILogService, @IStorageService private readonly _storageService: IStorageService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, - @IConfigurationService private readonly _configurationService: IConfigurationService, @ILanguageModelsConfigurationService private readonly _languageModelsConfigurationService: ILanguageModelsConfigurationService, @IQuickInputService private readonly _quickInputService: IQuickInputService, @ISecretStorageService private readonly _secretStorageService: ISecretStorageService, ) { this._hasUserSelectableModels = ChatContextKeys.languageModelsAreUserSelectable.bindTo(_contextKeyService); - this._modelPickerUserPreferences = this._storageService.getObject>('chatModelPickerPreferences', StorageScope.PROFILE, this._modelPickerUserPreferences); + this._modelPickerUserPreferences = this._readModelPickerPreferences(); + this._store.add(this._storageService.onDidChangeValue(StorageScope.PROFILE, CHAT_MODEL_PICKER_PREFERENCES_STORAGE_KEY, this._store)(() => this._onDidChangeModelPickerPreferences())); this._store.add(this.onDidChangeLanguageModels(() => this._hasUserSelectableModels.set(this._modelCache.size > 0 && Array.from(this._modelCache.values()).some(model => model.isUserSelectable)))); + this._store.add(this._languageModelsConfigurationService.onDidChangeLanguageModelGroups(changedGroups => this._onDidChangeLanguageModelGroups(changedGroups))); this._store.add(languageModelChatProviderExtensionPoint.setHandler((extensions) => { @@ -470,6 +473,54 @@ export class LanguageModelsService implements ILanguageModelsService { })); } + private async _onDidChangeLanguageModelGroups(changedGroups: readonly ILanguageModelsProviderGroup[]): Promise { + const changedVendors = new Set(changedGroups.map(g => g.vendor)); + await Promise.all(Array.from(changedVendors).map(vendor => this._resolveAllLanguageModels(vendor, true))); + } + + private _readModelPickerPreferences(): IStringDictionary { + return this._storageService.getObject>(CHAT_MODEL_PICKER_PREFERENCES_STORAGE_KEY, StorageScope.PROFILE, {}); + } + + private _onDidChangeModelPickerPreferences(): void { + const newPreferences = this._readModelPickerPreferences(); + const oldPreferences = this._modelPickerUserPreferences; + + // Check if there are any changes by computing diff + const affectedVendors = new Set(); + let hasChanges = false; + + // Check for added or updated keys + for (const modelId in newPreferences) { + if (oldPreferences[modelId] !== newPreferences[modelId]) { + hasChanges = true; + const model = this._modelCache.get(modelId); + if (model) { + affectedVendors.add(model.vendor); + } + } + } + + // Check for removed keys + for (const modelId in oldPreferences) { + if (!newPreferences.hasOwnProperty(modelId)) { + hasChanges = true; + const model = this._modelCache.get(modelId); + if (model) { + affectedVendors.add(model.vendor); + } + } + } + + if (hasChanges) { + this._logService.trace('[LM] Updated model picker preferences from storage'); + this._modelPickerUserPreferences = newPreferences; + for (const vendor of affectedVendors) { + this._onLanguageModelChange.fire(vendor); + } + } + } + private _hasStoredModelForVendor(vendor: string): boolean { return Object.keys(this._modelPickerUserPreferences).some(modelId => { return modelId.startsWith(vendor); @@ -477,7 +528,7 @@ export class LanguageModelsService implements ILanguageModelsService { } private _saveModelPickerPreferences(): void { - this._storageService.store('chatModelPickerPreferences', this._modelPickerUserPreferences, StorageScope.PROFILE, StorageTarget.USER); + this._storageService.store(CHAT_MODEL_PICKER_PREFERENCES_STORAGE_KEY, this._modelPickerUserPreferences, StorageScope.PROFILE, StorageTarget.USER); } updateModelPickerPreference(modelIdentifier: string, showInModelPicker: boolean): void { @@ -514,9 +565,6 @@ export class LanguageModelsService implements ILanguageModelsService { lookupLanguageModel(modelIdentifier: string): ILanguageModelChatMetadata | undefined { const model = this._modelCache.get(modelIdentifier); - if (model && this._configurationService.getValue('chat.experimentalShowAllModels')) { - return { ...model, isUserSelectable: true }; - } if (model && this._modelPickerUserPreferences[modelIdentifier] !== undefined) { return { ...model, isUserSelectable: this._modelPickerUserPreferences[modelIdentifier] }; } @@ -601,21 +649,29 @@ export class LanguageModelsService implements ILanguageModelsService { } this._modelsGroups.set(vendorId, languageModelsGroups); - this._clearModelCache(vendorId); + const oldModels = this._clearModelCache(vendorId); + let hasChanges = false; for (const model of allModels) { if (this._modelCache.has(model.identifier)) { this._logService.warn(`[LM] Model ${model.identifier} is already registered. Skipping.`); continue; } this._modelCache.set(model.identifier, model.metadata); + hasChanges = hasChanges || !equals(oldModels.get(model.identifier), model.metadata); + oldModels.delete(model.identifier); } this._logService.trace(`[LM] Resolved language models for vendor ${vendorId}`, allModels); - this._onLanguageModelChange.fire(vendorId); + hasChanges = hasChanges || oldModels.size > 0; + + if (hasChanges) { + this._onLanguageModelChange.fire(vendorId); + } else { + this._logService.trace(`[LM] No changes in language models for vendor ${vendorId}`); + } }); } - async fetchLanguageModelGroups(vendor: string): Promise { - await this._resolveAllLanguageModels(vendor, true); + getLanguageModelGroups(vendor: string): ILanguageModelsGroup[] { return this._modelsGroups.get(vendor) ?? []; } @@ -990,12 +1046,15 @@ export class LanguageModelsService implements ILanguageModelsService { return secretInput.substring(secretInput.indexOf(':') + 1, secretInput.length - 1); } - private _clearModelCache(vendor: string): void { + private _clearModelCache(vendor: string): Map { + const removed = new Map(); for (const [id, model] of this._modelCache.entries()) { if (model.vendor === vendor) { + removed.set(id, model); this._modelCache.delete(id); } } + return removed; } private async _resolveConfiguration(group: ILanguageModelsProviderGroup, schema: IJSONSchema | undefined): Promise> { diff --git a/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts b/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts index 9ed7cd0e3780c..9573426343e92 100644 --- a/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts +++ b/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts @@ -16,7 +16,7 @@ export interface ILanguageModelsConfigurationService { readonly configurationFile: URI; - readonly onDidChangeLanguageModelGroups: Event; + readonly onDidChangeLanguageModelGroups: Event; getLanguageModelsProviderGroups(): readonly ILanguageModelsProviderGroup[]; diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts index 4e68b258c8583..ad914109af164 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts @@ -40,6 +40,7 @@ export class BuiltinToolsContribution extends Disposable implements IWorkbenchCo const registerRunSubagentTool = () => { runSubagentRegistration?.dispose(); toolSetRegistration?.dispose(); + toolsService.flushToolUpdates(); const runSubagentToolData = runSubagentTool.getToolData(); runSubagentRegistration = toolsService.registerTool(runSubagentToolData, runSubagentTool); toolSetRegistration = toolsService.agentToolSet.addTool(runSubagentToolData); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index d008e7e89a8cf..0e3ebc1a642dc 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -4,17 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Emitter } from '../../../../../../base/common/event.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatSelector, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel } from '../../../common/languageModels.js'; import { ChatModelGroup, ChatModelsViewModel, ILanguageModelEntry, ILanguageModelProviderEntry, isLanguageModelProviderEntry, isLanguageModelGroupEntry, ILanguageModelGroupEntry } from '../../../browser/chatManagement/chatModelsViewModel.js'; -import { IChatEntitlementService, ChatEntitlement } from '../../../../../services/chat/common/chatEntitlementService.js'; -import { IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IStringDictionary } from '../../../../../../base/common/collections.js'; -import { ILanguageModelsConfigurationService, ILanguageModelsProviderGroup } from '../../../common/languageModelsConfiguration.js'; -import { mock } from '../../../../../../base/test/common/mock.js'; +import { ILanguageModelsProviderGroup } from '../../../common/languageModelsConfiguration.js'; import { ChatAgentLocation } from '../../../common/constants.js'; class MockLanguageModelsService implements ILanguageModelsService { @@ -113,7 +110,7 @@ class MockLanguageModelsService implements ILanguageModelsService { async addLanguageModelsProviderGroup(name: string, vendorId: string, configuration: IStringDictionary | undefined): Promise { } - async fetchLanguageModelGroups(vendor: string): Promise { + getLanguageModelGroups(vendor: string): ILanguageModelsGroup[] { return this.modelGroups.get(vendor) || []; } @@ -123,67 +120,13 @@ class MockLanguageModelsService implements ILanguageModelsService { async migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise { } } -class MockChatEntitlementService implements IChatEntitlementService { - _serviceBrand: undefined; - - private readonly _onDidChangeEntitlement = new Emitter(); - readonly onDidChangeEntitlement = this._onDidChangeEntitlement.event; - - readonly entitlement = ChatEntitlement.Unknown; - readonly entitlementObs: IObservable = observableValue('entitlement', ChatEntitlement.Unknown); - - readonly organisations: string[] | undefined = undefined; - readonly isInternal = false; - readonly sku: string | undefined = undefined; - - readonly onDidChangeQuotaExceeded = Event.None; - readonly onDidChangeQuotaRemaining = Event.None; - - readonly quotas = { - chat: { - total: 100, - remaining: 100, - percentRemaining: 100, - overageEnabled: false, - overageCount: 0, - unlimited: false - }, - completions: { - total: 100, - remaining: 100, - percentRemaining: 100, - overageEnabled: false, - overageCount: 0, - unlimited: false - } - }; - - readonly onDidChangeSentiment = Event.None; - readonly sentiment: any = { installed: true, hidden: false, disabled: false }; - readonly sentimentObs: IObservable = observableValue('sentiment', { installed: true, hidden: false, disabled: false }); - - readonly onDidChangeAnonymous = Event.None; - readonly anonymous = false; - readonly anonymousObs: IObservable = observableValue('anonymous', false); - - fireEntitlementChange(): void { - this._onDidChangeEntitlement.fire(); - } - - async update(): Promise { - // Not needed for tests - } -} - suite('ChatModelsViewModel', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let languageModelsService: MockLanguageModelsService; - let chatEntitlementService: MockChatEntitlementService; let viewModel: ChatModelsViewModel; setup(async () => { languageModelsService = new MockLanguageModelsService(); - chatEntitlementService = new MockChatEntitlementService(); // Setup test data languageModelsService.addVendor({ @@ -286,15 +229,7 @@ suite('ChatModelsViewModel', () => { } }); - viewModel = store.add(new ChatModelsViewModel( - languageModelsService, - new class extends mock() { - override get onDidChangeLanguageModelGroups() { - return Event.None; - } - }, - chatEntitlementService, - )); + viewModel = store.add(new ChatModelsViewModel(languageModelsService)); await viewModel.refresh(); }); @@ -513,20 +448,6 @@ suite('ChatModelsViewModel', () => { assert.strictEqual(copilotModelsAfterExpand.length, 2); }); - test('should fire onDidChangeModelEntries when entitlement changes', async () => { - let fired = false; - store.add(viewModel.onDidChange(() => { - fired = true; - })); - - chatEntitlementService.fireEntitlementChange(); - - // Wait a bit for async resolve - await new Promise(resolve => setTimeout(resolve, 10)); - - assert.strictEqual(fired, true); - }); - test('should handle quoted search strings', () => { // When a search string is fully quoted (starts and ends with quotes), // the completeMatch flag is set to true, which currently skips all matching @@ -594,7 +515,7 @@ suite('ChatModelsViewModel', () => { } }); - function createSingleVendorViewModel(chatEntitlementService: IChatEntitlementService, includeSecondModel: boolean = true): { service: MockLanguageModelsService; viewModel: ChatModelsViewModel } { + function createSingleVendorViewModel(includeSecondModel: boolean = true): { service: MockLanguageModelsService; viewModel: ChatModelsViewModel } { const service = new MockLanguageModelsService(); service.addVendor({ vendor: 'copilot', @@ -648,16 +569,12 @@ suite('ChatModelsViewModel', () => { }); } - const viewModel = store.add(new ChatModelsViewModel(service, new class extends mock() { - override get onDidChangeLanguageModelGroups() { - return Event.None; - } - }, chatEntitlementService)); + const viewModel = store.add(new ChatModelsViewModel(service)); return { service, viewModel }; } test('should not show vendor header when only one vendor exists', async () => { - const { viewModel: singleVendorViewModel } = createSingleVendorViewModel(chatEntitlementService); + const { viewModel: singleVendorViewModel } = createSingleVendorViewModel(); await singleVendorViewModel.refresh(); const results = singleVendorViewModel.filter(''); @@ -684,7 +601,7 @@ suite('ChatModelsViewModel', () => { }); test('should filter single vendor models by capability', async () => { - const { viewModel: singleVendorViewModel } = createSingleVendorViewModel(chatEntitlementService); + const { viewModel: singleVendorViewModel } = createSingleVendorViewModel(); await singleVendorViewModel.refresh(); const results = singleVendorViewModel.filter('@capability:agent'); @@ -798,7 +715,7 @@ suite('ChatModelsViewModel', () => { }); test('should not show vendor headers when filtered if only one vendor exists', async () => { - const { viewModel: singleVendorViewModel } = createSingleVendorViewModel(chatEntitlementService); + const { viewModel: singleVendorViewModel } = createSingleVendorViewModel(); await singleVendorViewModel.refresh(); const results = singleVendorViewModel.filter('GPT'); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts index 3bc0df9fce853..a18dc6c871980 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts @@ -16,9 +16,9 @@ import { ExtensionsRegistry } from '../../../../services/extensions/common/exten import { DEFAULT_MODEL_PICKER_CATEGORY } from '../../common/widget/input/modelPickerWidget.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { Event } from '../../../../../base/common/event.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; -import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js'; import { ILanguageModelsConfigurationService } from '../../common/languageModelsConfiguration.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; @@ -43,8 +43,8 @@ suite('LanguageModels', function () { new NullLogService(), new TestStorageService(), new MockContextKeyService(), - new TestConfigurationService(), new class extends mock() { + override onDidChangeLanguageModelGroups = Event.None; override getLanguageModelsProviderGroups() { return []; } @@ -257,8 +257,9 @@ suite('LanguageModels - When Clause', function () { new NullLogService(), new TestStorageService(), contextKeyService, - new TestConfigurationService(), - new class extends mock() { }, + new class extends mock() { + override onDidChangeLanguageModelGroups = Event.None; + }, new class extends mock() { }, new TestSecretStorageService(), ); @@ -304,3 +305,596 @@ suite('LanguageModels - When Clause', function () { assert.ok(!vendors.some(v => v.vendor === 'hidden-vendor'), 'hidden-vendor should be hidden when falseKey is false'); }); }); + +suite('LanguageModels - Model Picker Preferences Storage', function () { + + let languageModelsService: LanguageModelsService; + let storageService: TestStorageService; + const disposables = new DisposableStore(); + + setup(async function () { + storageService = new TestStorageService(); + + languageModelsService = new LanguageModelsService( + new class extends mock() { + override activateByEvent(name: string) { + return Promise.resolve(); + } + }, + new NullLogService(), + storageService, + new MockContextKeyService(), + new class extends mock() { + override onDidChangeLanguageModelGroups = Event.None; + override getLanguageModelsProviderGroups() { + return []; + } + }, + new class extends mock() { }, + new TestSecretStorageService(), + ); + + // Register vendor1 used in most tests + const ext = ExtensionsRegistry.getExtensionPoints().find(e => e.name === languageModelChatProviderExtensionPoint.name)!; + ext.acceptUsers([{ + description: { ...nullExtensionDescription }, + value: { vendor: 'vendor1' }, + collector: null! + }]); + + disposables.add(languageModelsService.registerLanguageModelProvider('vendor1', { + onDidChange: Event.None, + provideLanguageModelChatInfo: async () => { + return [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Model 1', + vendor: 'vendor1', + family: 'family1', + version: '1.0', + id: 'vendor1/model1', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: 'vendor1/model1' + }]; + }, + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); } + })); + + // Populate the model cache + await languageModelsService.selectLanguageModels({}); + }); + + teardown(function () { + languageModelsService.dispose(); + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('fires onChange event when new model preferences are added', async function () { + // Listen for change event + let firedVendorId: string | undefined; + disposables.add(languageModelsService.onDidChangeLanguageModels(vendorId => firedVendorId = vendorId)); + + // Add new preferences to storage - store() automatically triggers change event synchronously + const preferences = { + 'vendor1/model1': true + }; + storageService.store('chatModelPickerPreferences', JSON.stringify(preferences), StorageScope.PROFILE, StorageTarget.USER); + + // Verify change event was fired + assert.strictEqual(firedVendorId, 'vendor1', 'Should fire change event for vendor1'); + + // Verify preference was updated + const model = languageModelsService.lookupLanguageModel('vendor1/model1'); + assert.ok(model); + assert.strictEqual(model.isUserSelectable, true); + }); + + test('fires onChange event when model preferences are removed', async function () { + // Set initial preference using the API + languageModelsService.updateModelPickerPreference('vendor1/model1', true); + + // Listen for change event + let firedVendorId: string | undefined; + disposables.add(languageModelsService.onDidChangeLanguageModels(vendorId => { + firedVendorId = vendorId; + })); + + // Remove preferences via storage API + const updatedPreferences = {}; + storageService.store('chatModelPickerPreferences', JSON.stringify(updatedPreferences), StorageScope.PROFILE, StorageTarget.USER); + + // Verify change event was fired + assert.strictEqual(firedVendorId, 'vendor1', 'Should fire change event for vendor1 when preference removed'); + + // Verify preference was removed + const model = languageModelsService.lookupLanguageModel('vendor1/model1'); + assert.ok(model); + assert.strictEqual(model.isUserSelectable, undefined); + }); + + test('fires onChange event when model preferences are updated', async function () { + // Set initial preference using the API + languageModelsService.updateModelPickerPreference('vendor1/model1', true); + + // Listen for change event + let firedVendorId: string | undefined; + disposables.add(languageModelsService.onDidChangeLanguageModels(vendorId => { + firedVendorId = vendorId; + })); + + // Update the preference value + const updatedPreferences = { + 'vendor1/model1': false + }; + storageService.store('chatModelPickerPreferences', JSON.stringify(updatedPreferences), StorageScope.PROFILE, StorageTarget.USER); + + // Verify change event was fired + assert.strictEqual(firedVendorId, 'vendor1', 'Should fire change event for vendor1 when preference updated'); + + // Verify preference was updated + const model = languageModelsService.lookupLanguageModel('vendor1/model1'); + assert.ok(model); + assert.strictEqual(model.isUserSelectable, false); + }); + + test('only fires onChange event for affected vendors', async function () { + // Register vendor2 + const ext = ExtensionsRegistry.getExtensionPoints().find(e => e.name === languageModelChatProviderExtensionPoint.name)!; + ext.acceptUsers([{ + description: { ...nullExtensionDescription }, + value: { vendor: 'vendor2' }, + collector: null! + }]); + + disposables.add(languageModelsService.registerLanguageModelProvider('vendor2', { + onDidChange: Event.None, + provideLanguageModelChatInfo: async () => { + return [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Model 2', + vendor: 'vendor2', + family: 'family2', + version: '1.0', + id: 'vendor2/model2', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: 'vendor2/model2' + }]; + }, + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); } + })); + + await languageModelsService.selectLanguageModels({}); + + // Set initial preferences using the API + languageModelsService.updateModelPickerPreference('vendor1/model1', true); + languageModelsService.updateModelPickerPreference('vendor2/model2', false); + + // Listen for change event + let firedVendorId: string | undefined; + disposables.add(languageModelsService.onDidChangeLanguageModels(vendorId => { + firedVendorId = vendorId; + })); + + // Update only vendor1 preference + const updatedPreferences = { + 'vendor1/model1': false, + 'vendor2/model2': false // unchanged + }; + storageService.store('chatModelPickerPreferences', JSON.stringify(updatedPreferences), StorageScope.PROFILE, StorageTarget.USER); + + // Verify only vendor1 was affected + assert.strictEqual(firedVendorId, 'vendor1', 'Should only affect vendor1'); + + // Verify preferences were updated correctly + const model1 = languageModelsService.lookupLanguageModel('vendor1/model1'); + assert.ok(model1); + assert.strictEqual(model1.isUserSelectable, false, 'vendor1/model1 should be updated to false'); + + const model2 = languageModelsService.lookupLanguageModel('vendor2/model2'); + assert.ok(model2); + assert.strictEqual(model2.isUserSelectable, false, 'vendor2/model2 should remain false'); + }); + + test('does not fire onChange event when preferences are unchanged', async function () { + // Set initial preference using the API + languageModelsService.updateModelPickerPreference('vendor1/model1', true); + + // Listen for change event + let eventFired = false; + disposables.add(languageModelsService.onDidChangeLanguageModels(() => { + eventFired = true; + })); + + // Store the same preferences again + const initialPreferences = { + 'vendor1/model1': true + }; + storageService.store('chatModelPickerPreferences', JSON.stringify(initialPreferences), StorageScope.PROFILE, StorageTarget.USER); + + // Verify no event was fired + assert.strictEqual(eventFired, false, 'Should not fire event when preferences are unchanged'); + + // Verify preference remains the same + const model = languageModelsService.lookupLanguageModel('vendor1/model1'); + assert.ok(model); + assert.strictEqual(model.isUserSelectable, true); + }); + + test('handles malformed JSON in storage gracefully', function () { + // Listen for change event + let eventFired = false; + disposables.add(languageModelsService.onDidChangeLanguageModels(() => { + eventFired = true; + })); + + // Store empty preferences - store() automatically triggers change event + storageService.store('chatModelPickerPreferences', '{}', StorageScope.PROFILE, StorageTarget.USER); + + // Verify no event was fired - empty preferences is valid and causes no changes + assert.strictEqual(eventFired, false, 'Should not fire event for empty preferences'); + }); +}); + +suite('LanguageModels - Model Change Events', function () { + + let languageModelsService: LanguageModelsService; + let storageService: TestStorageService; + const disposables = new DisposableStore(); + + setup(async function () { + storageService = new TestStorageService(); + const ext = ExtensionsRegistry.getExtensionPoints().find(e => e.name === languageModelChatProviderExtensionPoint.name)!; + ext.acceptUsers([{ + description: { ...nullExtensionDescription }, + value: { vendor: 'test-vendor' }, + collector: null! + }]); + + languageModelsService = new LanguageModelsService( + new class extends mock() { + override activateByEvent(name: string) { + return Promise.resolve(); + } + }, + new NullLogService(), + storageService, + new MockContextKeyService(), + new class extends mock() { + override onDidChangeLanguageModelGroups = Event.None; + override getLanguageModelsProviderGroups() { + return []; + } + }, + new class extends mock() { }, + new TestSecretStorageService(), + ); + }); + + teardown(function () { + languageModelsService.dispose(); + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('fires onChange event when new models are added', async function () { + // Create a promise that resolves when the event fires + const eventPromise = new Promise((resolve) => { + disposables.add(languageModelsService.onDidChangeLanguageModels((vendorId) => { + resolve(vendorId); + })); + }); + + // Store a preference to trigger auto-resolution when provider is registered + storageService.store('chatModelPickerPreferences', JSON.stringify({ 'test-vendor/model1': true }), StorageScope.PROFILE, StorageTarget.USER); + + disposables.add(languageModelsService.registerLanguageModelProvider('test-vendor', { + onDidChange: Event.None, + provideLanguageModelChatInfo: async () => { + return [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Model 1', + vendor: 'test-vendor', + family: 'family1', + version: '1.0', + id: 'model1', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: undefined, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: 'test-vendor/model1' + }]; + }, + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); } + })); + + const firedVendorId = await eventPromise; + assert.strictEqual(firedVendorId, 'test-vendor', 'Should fire event when new models are added'); + }); + + test('does not fire onChange event when models are unchanged', async function () { + const models = [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Model 1', + vendor: 'test-vendor', + family: 'family1', + version: '1.0', + id: 'model1', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: undefined, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: 'test-vendor/model1' + }]; + + let onDidChangeEmitter: any; + disposables.add(languageModelsService.registerLanguageModelProvider('test-vendor', { + onDidChange: (listener) => { + onDidChangeEmitter = { fire: () => listener() }; + return { dispose: () => { } }; + }, + provideLanguageModelChatInfo: async () => models, + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); } + })); + + // Initial resolution + await languageModelsService.selectLanguageModels({ vendor: 'test-vendor' }); + + // Listen for change event + let eventFired = false; + disposables.add(languageModelsService.onDidChangeLanguageModels(() => { + eventFired = true; + })); + // Trigger provider change with same models + onDidChangeEmitter.fire(); + + // Call selectLanguageModels again - provider will return different models + await languageModelsService.selectLanguageModels({ vendor: 'test-vendor' }); + assert.strictEqual(eventFired, false, 'Should not fire event when models are unchanged'); + }); + + test('fires onChange event when model metadata changes', async function () { + const initialModels = [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Model 1', + vendor: 'test-vendor', + family: 'family1', + version: '1.0', + id: 'model1', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: undefined, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: 'test-vendor/model1' + }]; + + let currentModels = initialModels; + let onDidChangeEmitter: any; + disposables.add(languageModelsService.registerLanguageModelProvider('test-vendor', { + onDidChange: (listener) => { + onDidChangeEmitter = { fire: () => listener() }; + return { dispose: () => { } }; + }, + provideLanguageModelChatInfo: async () => currentModels, + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); } + })); + + // Initial resolution + await languageModelsService.selectLanguageModels({ vendor: 'test-vendor' }); + + // Create a promise that resolves when the event fires + const eventPromise = new Promise((resolve) => { + disposables.add(languageModelsService.onDidChangeLanguageModels(() => { + resolve(); + })); + }); + + // Change model metadata (e.g., maxInputTokens) + currentModels = [{ + metadata: { + ...initialModels[0].metadata, + maxInputTokens: 200 // Changed from 100 + }, + identifier: 'test-vendor/model1' + }]; + + onDidChangeEmitter.fire(); + + await eventPromise; + assert.ok(true, 'Event fired when model metadata changed'); + }); + + test('fires onChange event when models are removed', async function () { + let currentModels = [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Model 1', + vendor: 'test-vendor', + family: 'family1', + version: '1.0', + id: 'model1', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: undefined, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: 'test-vendor/model1' + }]; + + let onDidChangeEmitter: any; + disposables.add(languageModelsService.registerLanguageModelProvider('test-vendor', { + onDidChange: (listener) => { + onDidChangeEmitter = { fire: () => listener() }; + return { dispose: () => { } }; + }, + provideLanguageModelChatInfo: async () => currentModels, + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); } + })); + + // Initial resolution + await languageModelsService.selectLanguageModels({ vendor: 'test-vendor' }); + + // Create a promise that resolves when the event fires + const eventPromise = new Promise((resolve) => { + disposables.add(languageModelsService.onDidChangeLanguageModels(() => { + resolve(); + })); + }); + + // Remove all models + currentModels = []; + + onDidChangeEmitter.fire(); + + await eventPromise; + assert.ok(true, 'Event fired when models were removed'); + }); + + test('fires onChange event when new model is added to existing set', async function () { + let currentModels = [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Model 1', + vendor: 'test-vendor', + family: 'family1', + version: '1.0', + id: 'model1', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: undefined, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: 'test-vendor/model1' + }]; + + let onDidChangeEmitter: any; + disposables.add(languageModelsService.registerLanguageModelProvider('test-vendor', { + onDidChange: (listener) => { + onDidChangeEmitter = { fire: () => listener() }; + return { dispose: () => { } }; + }, + provideLanguageModelChatInfo: async () => currentModels, + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); } + })); + + // Initial resolution + await languageModelsService.selectLanguageModels({ vendor: 'test-vendor' }); + + // Create a promise that resolves when the event fires + const eventPromise = new Promise((resolve) => { + disposables.add(languageModelsService.onDidChangeLanguageModels(() => { + resolve(); + })); + }); + + // Add a new model + currentModels = [ + ...currentModels, + { + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Model 2', + vendor: 'test-vendor', + family: 'family2', + version: '1.0', + id: 'model2', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: undefined, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: 'test-vendor/model2' + } + ]; + + onDidChangeEmitter.fire(); + + await eventPromise; + assert.ok(true, 'Event fired when new model was added'); + }); + + test('fires onChange event when models change without provider emitting change event', async function () { + let callCount = 0; + disposables.add(languageModelsService.registerLanguageModelProvider('test-vendor', { + onDidChange: Event.None, // Provider doesn't emit change events + provideLanguageModelChatInfo: async () => { + callCount++; + if (callCount === 1) { + // First call returns initial model + return [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Model 1', + vendor: 'test-vendor', + family: 'family1', + version: '1.0', + id: 'model1', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: undefined, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: 'test-vendor/model1' + }]; + } else { + // Subsequent calls return different model + return [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Model 2', + vendor: 'test-vendor', + family: 'family2', + version: '2.0', + id: 'model2', + maxInputTokens: 200, + maxOutputTokens: 200, + modelPickerCategory: undefined, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: 'test-vendor/model2' + }]; + } + }, + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); } + })); + + // Initial resolution + await languageModelsService.selectLanguageModels({ vendor: 'test-vendor' }); + + // Listen for change event + let eventFired = false; + disposables.add(languageModelsService.onDidChangeLanguageModels(() => { + eventFired = true; + })); + + // Call selectLanguageModels again - provider will return different models + await languageModelsService.selectLanguageModels({ vendor: 'test-vendor' }); + + assert.strictEqual(eventFired, true, 'Should fire event when models change even without provider change event'); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts index 8b60a21829111..fd336e7fb3752 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts @@ -48,7 +48,7 @@ export class NullLanguageModelsService implements ILanguageModelsService { return; } - async fetchLanguageModelGroups(vendor: string): Promise { + getLanguageModelGroups(vendor: string): ILanguageModelsGroup[] { return []; } diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts b/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts index ba8d0ada377bf..85e5fba39ef12 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts @@ -116,6 +116,7 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget private _commentThreadWidget!: CommentThreadWidget; private readonly _onDidClose = new Emitter(); private readonly _onDidCreateThread = new Emitter(); + private readonly _onDidChangeExpandedState = new Emitter(); private _isExpanded?: boolean; private _initialCollapsibleState?: languages.CommentThreadCollapsibleState; private _commentGlyph?: CommentGlyphWidget; @@ -185,6 +186,10 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget return this._onDidCreateThread.event; } + public get onDidChangeExpandedState(): Event { + return this._onDidChangeExpandedState.event; + } + public getPosition(): IPosition | undefined { if (this.position) { return this.position; @@ -540,10 +545,14 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget range = new Range(range.startLineNumber + distance, range.startColumn, range.endLineNumber + distance, range.endColumn); } + const wasExpanded = this._isExpanded; this._isExpanded = true; super.show(range ?? new Range(0, 0, 0, 0), heightInLines); this._commentThread.collapsibleState = languages.CommentThreadCollapsibleState.Expanded; this._refresh(this._commentThreadWidget.getDimensions()); + if (!wasExpanded) { + this._onDidChangeExpandedState.fire(true); + } } async collapseAndFocusRange() { @@ -563,6 +572,7 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget if (!this._commentThread.comments || !this._commentThread.comments.length) { this.deleteCommentThread(); } + this._onDidChangeExpandedState.fire(false); } super.hide(); } diff --git a/src/vs/workbench/contrib/comments/browser/commentsAccessibility.ts b/src/vs/workbench/contrib/comments/browser/commentsAccessibility.ts index 89a885968f01b..3eeff582704ac 100644 --- a/src/vs/workbench/contrib/comments/browser/commentsAccessibility.ts +++ b/src/vs/workbench/contrib/comments/browser/commentsAccessibility.ts @@ -20,7 +20,7 @@ export namespace CommentAccessibilityHelpNLS { export const intro = nls.localize('intro', "The editor contains commentable range(s). Some useful commands include:"); export const tabFocus = nls.localize('introWidget', "This widget contains a text area, for composition of new comments, and actions, that can be tabbed to once tab moves focus mode has been enabled with the command Toggle Tab Key Moves Focus{0}.", ``); export const commentCommands = nls.localize('commentCommands', "Some useful comment commands include:"); - export const escape = nls.localize('escape', "- Dismiss Comment (Escape)"); + export const escape = nls.localize('escape', "- Dismiss Comment{0}.", ``); export const nextRange = nls.localize('next', "- Go to Next Commenting Range{0}.", ``); export const previousRange = nls.localize('previous', "- Go to Previous Commenting Range{0}.", ``); export const nextCommentThread = nls.localize('nextCommentThreadKb', "- Go to Next Comment Thread{0}.", ``); diff --git a/src/vs/workbench/contrib/comments/browser/commentsController.ts b/src/vs/workbench/contrib/comments/browser/commentsController.ts index b5ef3f9abf763..475634f67c2e5 100644 --- a/src/vs/workbench/contrib/comments/browser/commentsController.ts +++ b/src/vs/workbench/contrib/comments/browser/commentsController.ts @@ -471,6 +471,7 @@ export class CommentController implements IEditorContribution { private _activeCursorHasCommentingRange: IContextKey; private _activeCursorHasComment: IContextKey; private _activeEditorHasCommentingRange: IContextKey; + private _commentWidgetVisible: IContextKey; private _hasRespondedToEditorChange: boolean = false; constructor( @@ -496,6 +497,7 @@ export class CommentController implements IEditorContribution { this._activeCursorHasCommentingRange = CommentContextKeys.activeCursorHasCommentingRange.bindTo(contextKeyService); this._activeCursorHasComment = CommentContextKeys.activeCursorHasComment.bindTo(contextKeyService); this._activeEditorHasCommentingRange = CommentContextKeys.activeEditorHasCommentingRange.bindTo(contextKeyService); + this._commentWidgetVisible = CommentContextKeys.commentWidgetVisible.bindTo(contextKeyService); if (editor instanceof EmbeddedCodeEditorWidget) { return; @@ -740,6 +742,28 @@ export class CommentController implements IEditorContribution { } } + public async collapseVisibleComments(): Promise { + if (!this.editor) { + return; + } + const visibleRanges = this.editor.getVisibleRanges(); + for (const widget of this._commentWidgets) { + if (widget.expanded && widget.commentThread.range) { + const isVisible = visibleRanges.some(visibleRange => + Range.areIntersectingOrTouching(visibleRange, widget.commentThread.range!) + ); + if (isVisible) { + await widget.collapse(true); + } + } + } + } + + private _updateCommentWidgetVisibleContext(): void { + const hasExpanded = this._commentWidgets.some(widget => widget.expanded); + this._commentWidgetVisible.set(hasExpanded); + } + public expandAll(): void { for (const widget of this._commentWidgets) { widget.expand(); @@ -1074,6 +1098,8 @@ export class CommentController implements IEditorContribution { const zoneWidget = this.instantiationService.createInstance(ReviewZoneWidget, this.editor, uniqueOwner, thread, pendingComment ?? continueOnCommentReply?.comment, pendingEdits); await zoneWidget.display(thread.range, shouldReveal); this._commentWidgets.push(zoneWidget); + zoneWidget.onDidChangeExpandedState(() => this._updateCommentWidgetVisibleContext()); + zoneWidget.onDidClose(() => this._updateCommentWidgetVisibleContext()); this.openCommentsView(thread); } diff --git a/src/vs/workbench/contrib/comments/browser/commentsEditorContribution.ts b/src/vs/workbench/contrib/comments/browser/commentsEditorContribution.ts index dd96b65fef45a..aeb70b11d9a8f 100644 --- a/src/vs/workbench/contrib/comments/browser/commentsEditorContribution.ts +++ b/src/vs/workbench/contrib/comments/browser/commentsEditorContribution.ts @@ -435,6 +435,8 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ handler: async (accessor, args) => { const activeCodeEditor = accessor.get(ICodeEditorService).getFocusedCodeEditor(); const keybindingService = accessor.get(IKeybindingService); + const notificationService = accessor.get(INotificationService); + const commentService = accessor.get(ICommentService); // Unfortunate, but collapsing the comment thread might cause a dialog to show // If we don't wait for the key up here, then the dialog will consume it and immediately close await keybindingService.enableKeybindingHoldMode(CommentCommandId.Hide); @@ -445,8 +447,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ if (!controller) { return; } - const notificationService = accessor.get(INotificationService); - const commentService = accessor.get(ICommentService); + let error = false; try { const activeComment = commentService.lastActiveCommentcontroller?.activeComment; @@ -465,6 +466,27 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ } }); +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: CommentCommandId.Hide, + weight: KeybindingWeight.EditorContrib, + primary: KeyMod.CtrlCmd | KeyCode.Escape, + win: { primary: KeyMod.Alt | KeyCode.Backspace }, + when: ContextKeyExpr.and(EditorContextKeys.focus, CommentContextKeys.commentWidgetVisible), + handler: async (accessor, args) => { + const activeCodeEditor = accessor.get(ICodeEditorService).getFocusedCodeEditor(); + const keybindingService = accessor.get(IKeybindingService); + // Unfortunate, but collapsing the comment thread might cause a dialog to show + // If we don't wait for the key up here, then the dialog will consume it and immediately close + await keybindingService.enableKeybindingHoldMode(CommentCommandId.Hide); + if (activeCodeEditor) { + const controller = CommentController.get(activeCodeEditor); + if (controller) { + await controller.collapseVisibleComments(); + } + } + } +}); + export function getActiveEditor(accessor: ServicesAccessor): IActiveCodeEditor | null { let activeTextEditorControl = accessor.get(IEditorService).activeTextEditorControl; diff --git a/src/vs/workbench/contrib/comments/common/commentContextKeys.ts b/src/vs/workbench/contrib/comments/common/commentContextKeys.ts index 2a5d0776c450a..a26eee8c8b5b6 100644 --- a/src/vs/workbench/contrib/comments/common/commentContextKeys.ts +++ b/src/vs/workbench/contrib/comments/common/commentContextKeys.ts @@ -67,6 +67,11 @@ export namespace CommentContextKeys { */ export const commentFocused = new RawContextKey('commentFocused', false, { type: 'boolean', description: nls.localize('commentFocused', "Set when the comment is focused") }); + /** + * A context key that is set when a comment widget is visible in the editor. + */ + export const commentWidgetVisible = new RawContextKey('commentWidgetVisible', false, { type: 'boolean', description: nls.localize('commentWidgetVisible', "Set when a comment widget is visible in the editor") }); + /** * A context key that is set when commenting is enabled. */ diff --git a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts index f6c18c6432124..7c947abe9b0b3 100644 --- a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts +++ b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts @@ -394,6 +394,11 @@ export const schema: IJSONSchema = { body: 'onChatParticipant:${1:participantId}', description: nls.localize('vscode.extension.activationEvents.onChatParticipant', 'An activation event emitted when the specified chat participant is invoked.'), }, + { + label: 'onChatContextProvider', + body: 'onChatContextProvider:${1:contextProviderId}', + description: nls.localize('vscode.extension.activationEvents.onChatContextProvider', 'An activation event emitted when the specified chat context provider is invoked.'), + }, { label: 'onLanguageModelChatProvider', body: 'onLanguageModelChatProvider:${1:vendor}', diff --git a/src/vscode-dts/vscode.proposed.chatContextProvider.d.ts b/src/vscode-dts/vscode.proposed.chatContextProvider.d.ts index fb81077514219..043e067be16eb 100644 --- a/src/vscode-dts/vscode.proposed.chatContextProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatContextProvider.d.ts @@ -16,7 +16,10 @@ declare module 'vscode' { * Providers registered without a selector will not be called for resource-based context. * - Explicitly. These context items are shown as options when the user explicitly attaches context. * - * To ensure your extension is activated when chat context is requested, make sure to include the `onChatContextProvider:` activation event in your `package.json`. + * To ensure your extension is activated when chat context is requested, make sure to include the following activations events: + * - If your extension implements `provideWorkspaceChatContext` or `provideChatContextForResource`, find an activation event which is a good signal to activate. + * Ex: `onLanguage:`, `onWebviewPanel:`, etc.` + * - If your extension implements `provideChatContextExplicit`, your extension will be automatically activated when the user requests explicit context. * * @param selector Optional document selector to filter which resources the provider is called for. If omitted, the provider will only be called for explicit context requests. * @param id Unique identifier for the provider.