From ef58626b70294d8e5782ceade4c0da7f4557ab7e Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 18 Aug 2026 06:40:53 -0400 Subject: [PATCH 01/18] Fix Luna dictation alias test mock (#331405) --- .../vscode-node/test/languageModelAccess.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts index 1fbab195e9876c..4738a2f1c4119b 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts @@ -11,6 +11,7 @@ import { MockChatMLFetcher } from '../../../../platform/chat/test/common/mockCha import { CopilotToken, createTestExtendedTokenInfo } from '../../../../platform/authentication/common/copilotToken'; import { ICopilotTokenManager } from '../../../../platform/authentication/common/copilotTokenManager'; import { IAutomodeService } from '../../../../platform/endpoint/node/automodeService'; +import { AutoChatEndpoint } from '../../../../platform/endpoint/node/autoChatEndpoint'; import { IEndpointProvider } from '../../../../platform/endpoint/common/endpointProvider'; import { CustomDataPartMimeTypes } from '../../../../platform/endpoint/common/endpointTypes'; import { CopilotChatEndpoint } from '../../../../platform/endpoint/node/copilotChatEndpoint'; @@ -462,10 +463,11 @@ suite('LanguageModelAccess model info', () => { getCopilotToken: async () => copilotToken, resetCopilotToken: () => { }, } as unknown as ICopilotTokenManager); + const autoPickerEndpoint = new DeferredPromise(); testingServiceCollection.define(IAutomodeService, { _serviceBrand: undefined, resolveAutoModeEndpoint: async () => lunaEndpoint, - resolveAutoModePickerEndpoint: async () => lunaEndpoint, + resolveAutoModePickerEndpoint: () => autoPickerEndpoint.p, getAutoPickerMetadata: () => ({ discountRange: { low: 0, high: 0 } }), areAutoModeTiersSupported: () => false, onDidChangeAutoModeTierSupport: Event.None, @@ -481,10 +483,12 @@ suite('LanguageModelAccess model info', () => { getEmbeddingsEndpoint: async () => { throw new Error('Not implemented in test'); }, } as unknown as IEndpointProvider); const accessor = testingServiceCollection.createTestingAccessor(); + autoPickerEndpoint.complete(accessor.get(IInstantiationService).createInstance(AutoChatEndpoint, lunaEndpoint, '', 0, { low: 0, high: 0 })); const extensionContext = accessor.get(IVSCodeExtensionContext); const version = accessor.get(IEnvService).getVersion(); await extensionContext.globalState.update('lmBaseCount/gpt-5.6-luna', { extensionVersion: version, baseCount: 0 }); await extensionContext.globalState.update('lmBaseCount/some-hidden-model', { extensionVersion: version, baseCount: 0 }); + await extensionContext.globalState.update('lmBaseCount/auto', { extensionVersion: version, baseCount: 0 }); const languageModelAccess = accessor.get(IInstantiationService).createInstance(LanguageModelAccess); try { const testAccess = languageModelAccess as unknown as { @@ -527,6 +531,7 @@ suite('LanguageModelAccess model info', () => { languageModelAccess.dispose(); await extensionContext.globalState.update('lmBaseCount/gpt-5.6-luna', undefined); await extensionContext.globalState.update('lmBaseCount/some-hidden-model', undefined); + await extensionContext.globalState.update('lmBaseCount/auto', undefined); } }); }); From c1eb30b4e0489c6b5716549c97893e8ab0f7053d Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 18 Aug 2026 13:06:22 +0200 Subject: [PATCH 02/18] sessions: remove gaps above docked file trees (#331450) * sessions: remove gaps above docked file trees Align the docked details pane with the actual editor title boundary and remove legacy outer/internal spacing from single-pane Changes views. Update the Changes fixture to exercise the production single-pane composition and include the Agents launch task profile cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: correct docked details top coordinate Use the editor title height directly for the absolutely positioned details pane and assert both its containing-block offset and visual border-box alignment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .vscode/tasks.json | 4 - .../browser/dockedAuxiliaryBarController.ts | 17 ++-- .../browser/parts/singlePaneEditorPart.ts | 5 +- .../changes/browser/media/changesView.css | 8 ++ .../sessions/test/browser/workbench.test.ts | 79 +++++++++++++++++-- .../sessions/changesView.fixture.ts | 7 +- 6 files changed, 94 insertions(+), 26 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index caec8ac6495c24..b4f2b517111ce1 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -21,10 +21,6 @@ "windows": { "command": ".\\scripts\\code.bat" }, - "args": [ - "--user-data-dir=${workspaceFolder}/.profile-oss", - "--extensions-dir=${workspaceFolder}/.profile-oss/extensions" - ], "inAgents": true, "problemMatcher": [] }, diff --git a/src/vs/sessions/browser/dockedAuxiliaryBarController.ts b/src/vs/sessions/browser/dockedAuxiliaryBarController.ts index c6170f069a0295..c67b11414c8b69 100644 --- a/src/vs/sessions/browser/dockedAuxiliaryBarController.ts +++ b/src/vs/sessions/browser/dockedAuxiliaryBarController.ts @@ -22,8 +22,8 @@ export interface IDockedAuxiliaryBarHost { hideAuxiliaryBar(): void; /** Reserves space on the right of the breadcrumbs and editor pane while tabs remain full-width. */ setEditorContentRightInset(px: number): void; - /** Extra top offset (px) below the tab bar, e.g. reserved by the full-width header. */ - getHeaderHeight(): number; + /** Height of the full editor group title, including tabs and the optional header. */ + getTitleHeight(): number; } /** @@ -35,9 +35,6 @@ export interface IDockedAuxiliaryBarHost { */ export class DockedAuxiliaryBarController extends Disposable { - static readonly TOP = 34; - /** Thickness (px) of the header/tab-bar bottom divider the aux bar starts below. */ - static readonly DIVIDER = 1; static readonly MIN_WIDTH = 220; static readonly EDITOR_MIN_WIDTH = 300; static readonly DEFAULT_WIDTH = 300; @@ -87,7 +84,7 @@ export class DockedAuxiliaryBarController extends Disposable { const editorRect = this.editorPartContainer.getBoundingClientRect(); const editorContentHidden = !this.host.isEditorVisible(); const auxWidth = editorContentHidden ? editorRect.width : DockedAuxiliaryBarController.getEffectiveWidth(this.host.getWidth(), editorRect.width); - const top = DockedAuxiliaryBarController.TOP + DockedAuxiliaryBarController.DIVIDER + this.host.getHeaderHeight(); + const top = this._getTop(); const height = Math.max(0, editorRect.height - top); auxiliaryBarContainer.style.display = ''; @@ -129,8 +126,8 @@ export class DockedAuxiliaryBarController extends Disposable { const auxWidth = this.host.isEditorVisible() ? DockedAuxiliaryBarController.getEffectiveWidth(this.host.getWidth(), width) : width; return Math.max(0, width - auxWidth); }, - getVerticalSashTop: () => DockedAuxiliaryBarController.TOP + DockedAuxiliaryBarController.DIVIDER + this.host.getHeaderHeight(), - getVerticalSashHeight: () => Math.max(0, editorPartContainer.clientHeight - DockedAuxiliaryBarController.TOP - DockedAuxiliaryBarController.DIVIDER - this.host.getHeaderHeight()), + getVerticalSashTop: () => this._getTop(), + getVerticalSashHeight: () => Math.max(0, editorPartContainer.clientHeight - this._getTop()), }; const sash = this._register(new Sash(editorPartContainer, layoutProvider, { orientation: SashOrientation.VERTICAL })); @@ -161,4 +158,8 @@ export class DockedAuxiliaryBarController extends Disposable { this.layout(); })); } + + private _getTop(): number { + return this.host.getTitleHeight(); + } } diff --git a/src/vs/sessions/browser/parts/singlePaneEditorPart.ts b/src/vs/sessions/browser/parts/singlePaneEditorPart.ts index 5fa1687cade380..316cd5198dfe57 100644 --- a/src/vs/sessions/browser/parts/singlePaneEditorPart.ts +++ b/src/vs/sessions/browser/parts/singlePaneEditorPart.ts @@ -149,10 +149,7 @@ export class SinglePaneMainEditorPart extends MainEditorPart { isAuxiliaryBarVisible: () => layoutService.isVisible(Parts.AUXILIARYBAR_PART), hideAuxiliaryBar: () => layoutService.setAuxiliaryBarHiddenForResize(true), setEditorContentRightInset: (px: number) => this.setContentRightInset(px), - getHeaderHeight: () => { - const { total, offset } = (this.activeGroup as EditorGroupView).titleHeight; - return total - offset; - }, + getTitleHeight: () => (this.activeGroup as EditorGroupView).titleHeight.total, }, )); diff --git a/src/vs/sessions/contrib/changes/browser/media/changesView.css b/src/vs/sessions/contrib/changes/browser/media/changesView.css index 07386a33c2a6e9..c77aa3f91e37b7 100644 --- a/src/vs/sessions/contrib/changes/browser/media/changesView.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesView.css @@ -11,6 +11,14 @@ box-sizing: border-box; } +.agent-sessions-workbench.dock-detail-panel .changes-view-body { + padding: 0; +} + +.agent-sessions-workbench.dock-detail-panel .changes-view-body .chat-editing-session-container { + gap: 0; +} + /* SplitView container */ .changes-view-body .changes-splitview-container { flex: 1; diff --git a/src/vs/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts index b9cbf9da5cefa8..1da1f2fb5bcc7d 100644 --- a/src/vs/sessions/test/browser/workbench.test.ts +++ b/src/vs/sessions/test/browser/workbench.test.ts @@ -1918,6 +1918,70 @@ suite('Sessions - Workbench', () => { // --- DockedAuxiliaryBarController -------------------------------------- + test('aligns docked details with the editor title boundary', () => { + const editorContainer = document.createElement('div'); + const auxiliaryBarContainer = document.createElement('div'); + const layouts: { height: number; top: number }[] = []; + let titleHeight = 33; + + Object.defineProperties(editorContainer, { + clientHeight: { value: 600 }, + clientTop: { value: 1 }, + }); + editorContainer.getBoundingClientRect = () => ({ + width: 800, + height: 600, + top: 0, + right: 800, + bottom: 600, + left: 0, + x: 0, + y: 0, + toJSON: () => undefined, + }); + + const auxiliaryBarPart = { + getContainer: () => auxiliaryBarContainer, + layout: (_width: number, height: number, top: number) => layouts.push({ height, top }), + } as unknown as Part; + const host: IDockedAuxiliaryBarHost = { + getWidth: () => 260, + setWidth: () => { }, + isEditorAreaVisible: () => true, + isEditorVisible: () => true, + isAuxiliaryBarVisible: () => true, + hideAuxiliaryBar: () => { }, + setEditorContentRightInset: () => { }, + getTitleHeight: () => titleHeight, + }; + const controller = new DockedAuxiliaryBarController(editorContainer, auxiliaryBarPart, host); + + controller.layout(); + titleHeight = 62; + controller.layout(); + + assert.deepStrictEqual({ + layouts, + style: { + top: auxiliaryBarContainer.style.top, + height: auxiliaryBarContainer.style.height, + }, + visualTop: editorContainer.clientTop + Number.parseInt(auxiliaryBarContainer.style.top, 10), + }, { + layouts: [ + { height: 567, top: 33 }, + { height: 538, top: 62 }, + ], + style: { + top: '62px', + height: '538px', + }, + visualTop: 63, + }); + + controller.dispose(); + }); + test('fills the narrowed docked detail node and disables its overlay sash when editor content is hidden', () => { const editorContainer = document.createElement('div'); @@ -1930,6 +1994,7 @@ suite('Sessions - Workbench', () => { Object.defineProperty(editorContainer, 'clientWidth', { get: () => editorWidth }); Object.defineProperty(editorContainer, 'clientHeight', { value: 600 }); + Object.defineProperty(editorContainer, 'clientTop', { value: 1 }); editorContainer.getBoundingClientRect = () => ({ width: editorWidth, height: 600, @@ -1956,7 +2021,7 @@ suite('Sessions - Workbench', () => { isAuxiliaryBarVisible: () => true, hideAuxiliaryBar: () => { }, setEditorContentRightInset: px => insets.push(px), - getHeaderHeight: () => 0, + getTitleHeight: () => 34, }; const controller = new DockedAuxiliaryBarController(editorContainer, auxiliaryBarPart, host); @@ -1983,14 +2048,14 @@ suite('Sessions - Workbench', () => { insets: [260, 260], persistedWidths: [], layouts: [ - { width: 260, height: 565, top: 35, left: 540 }, - { width: 260, height: 565, top: 35, left: 0 }, + { width: 260, height: 566, top: 34, left: 540 }, + { width: 260, height: 566, top: 34, left: 0 }, ], style: { - top: '35px', + top: '34px', right: '0px', width: '260px', - height: '565px', + height: '566px', }, // The grid sash owns resizing/collapsing here; the overlay sash must be disabled. sashState: SashState.Disabled, @@ -2034,7 +2099,7 @@ suite('Sessions - Workbench', () => { isAuxiliaryBarVisible: () => true, hideAuxiliaryBar: () => { }, setEditorContentRightInset: px => insets.push(px), - getHeaderHeight: () => 0, + getTitleHeight: () => 35, }; const controller = new DockedAuxiliaryBarController(editorContainer, auxiliaryBarPart, host); @@ -2094,7 +2159,7 @@ suite('Sessions - Workbench', () => { isAuxiliaryBarVisible: () => true, hideAuxiliaryBar: () => hideCount++, setEditorContentRightInset: () => { }, - getHeaderHeight: () => 0, + getTitleHeight: () => 35, }; const controller = new DockedAuxiliaryBarController(editorContainer, auxiliaryBarPart, host); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts index 5e63cf5777e0bc..1bf299ef0b867d 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts @@ -37,7 +37,7 @@ import { ActiveSessionState, ChangesViewSection, IChangesDetailsViewState, IChan // eslint-disable-next-line local/code-import-patterns import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesViewMode, IsolationMode } from '../../../../../sessions/contrib/changes/common/changes.js'; // eslint-disable-next-line local/code-import-patterns -import { ChangesViewPane } from '../../../../../sessions/contrib/changes/browser/changesView.js'; +import { SinglePaneChangesViewPane } from '../../../../../sessions/contrib/changes/browser/changesView.js'; // eslint-disable-next-line local/code-import-patterns import { ISessionChangesService, SessionChangesService } from '../../../../../sessions/contrib/changes/browser/sessionChangesService.js'; // eslint-disable-next-line local/code-import-patterns @@ -153,7 +153,7 @@ const changesViewContainer: ViewContainer = { const changesViewDescriptor: IViewDescriptor = { id: CHANGES_VIEW_ID, name: localize2('fixtureChangesView', 'Changes'), - ctorDescriptor: new SyncDescriptor(ChangesViewPane), + ctorDescriptor: new SyncDescriptor(SinglePaneChangesViewPane), containerIcon: Codicon.gitCompare, }; @@ -386,6 +386,7 @@ function renderChangesView(ctx: ComponentFixtureContext, options: IChangesViewFi container.style.width = `${VIEW_WIDTH}px`; container.style.height = `${height}px`; container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + container.classList.add('agent-sessions-workbench', 'dock-detail-panel'); const host = dom.append(container, dom.$('.part.auxiliarybar')); host.style.width = '100%'; @@ -450,7 +451,7 @@ function renderChangesView(ctx: ComponentFixtureContext, options: IChangesViewFi }, }); - const view = disposableStore.add(instantiationService.createInstance(ChangesViewPane, { + const view = disposableStore.add(instantiationService.createInstance(SinglePaneChangesViewPane, { id: CHANGES_VIEW_ID, title: 'Changes', minimumBodySize: 0, From d07621e97e9e710166d693e04f31af2cf89ca1ac Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Tue, 18 Aug 2026 12:06:43 +0100 Subject: [PATCH 03/18] Rename `style-override` to `modern-ui` (#331451) rename style-override to modern-ui Co-authored-by: mrleemurray --- build/lib/i18n.resources.json | 2 +- build/lib/stylelint/validateHasSelectors.ts | 2 +- build/lib/test/rootAnchoredHas.test.ts | 4 +- .../browser/ui/contextview/contextview.ts | 6 +- .../ui/contextview/contextview.test.ts | 6 +- .../actionWidget/browser/actionWidget.css | 6 +- .../quickinput/browser/media/quickInput.css | 4 +- .../browser/quickInputController.ts | 2 +- .../test/browser/quickinput.test.ts | 4 +- src/vs/sessions/LAYOUT.md | 6 +- .../editor/browser/editor.contribution.ts | 2 +- src/vs/workbench/browser/layout.ts | 8 +- src/vs/workbench/browser/part.ts | 10 +- .../browser/parts/editor/editorPart.ts | 4 +- .../editor/media/multieditortabscontrol.css | 2 +- .../notifications/notificationsViewer.ts | 2 +- .../{styleOverrides => modernUI}/README.md | 0 .../modernUI/browser/media/activityBar.css | 265 ++++++++++++++++++ .../browser/media/commandCenter.css | 16 +- .../browser/media/editorBorder.css | 16 +- .../browser/media/fontRamp.css | 86 +++--- .../browser/media/keyboardFocusOnly.css | 12 +- .../browser/media/notificationsDialogs.css | 42 +-- .../browser/media/padding.css | 132 ++++----- .../browser/media/paneHeaders.css | 34 +-- .../browser/media/roundedCorners.css | 122 ++++---- .../browser/media/sashHandles.css | 32 +-- .../browser/media/shadows.css | 26 +- .../browser/media/statusBar.css | 22 +- .../browser/media/tabs.css | 6 +- .../browser/media/titlebar.css | 12 +- .../browser/modernUI.contribution.ts} | 50 ++-- .../browser/modernUI.contribution.test.ts} | 42 +-- .../browser/media/activityBar.css | 265 ------------------ .../editor/editorTabBar.fixture.ts | 2 +- src/vs/workbench/workbench.common.main.ts | 4 +- 36 files changed, 628 insertions(+), 628 deletions(-) rename src/vs/workbench/contrib/{styleOverrides => modernUI}/README.md (100%) create mode 100644 src/vs/workbench/contrib/modernUI/browser/media/activityBar.css rename src/vs/workbench/contrib/{styleOverrides => modernUI}/browser/media/commandCenter.css (73%) rename src/vs/workbench/contrib/{styleOverrides => modernUI}/browser/media/editorBorder.css (79%) rename src/vs/workbench/contrib/{styleOverrides => modernUI}/browser/media/fontRamp.css (57%) rename src/vs/workbench/contrib/{styleOverrides => modernUI}/browser/media/keyboardFocusOnly.css (75%) rename src/vs/workbench/contrib/{styleOverrides => modernUI}/browser/media/notificationsDialogs.css (60%) rename src/vs/workbench/contrib/{styleOverrides => modernUI}/browser/media/padding.css (52%) rename src/vs/workbench/contrib/{styleOverrides => modernUI}/browser/media/paneHeaders.css (74%) rename src/vs/workbench/contrib/{styleOverrides => modernUI}/browser/media/roundedCorners.css (69%) rename src/vs/workbench/contrib/{styleOverrides => modernUI}/browser/media/sashHandles.css (72%) rename src/vs/workbench/contrib/{styleOverrides => modernUI}/browser/media/shadows.css (64%) rename src/vs/workbench/contrib/{styleOverrides => modernUI}/browser/media/statusBar.css (69%) rename src/vs/workbench/contrib/{styleOverrides => modernUI}/browser/media/tabs.css (99%) rename src/vs/workbench/contrib/{styleOverrides => modernUI}/browser/media/titlebar.css (55%) rename src/vs/workbench/contrib/{styleOverrides/browser/styleOverrides.contribution.ts => modernUI/browser/modernUI.contribution.ts} (77%) rename src/vs/workbench/contrib/{styleOverrides/test/browser/styleOverrides.contribution.test.ts => modernUI/test/browser/modernUI.contribution.test.ts} (97%) delete mode 100644 src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index ca93bc576943d2..058938ee2b8662 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -235,7 +235,7 @@ "project": "vscode-workbench" }, { - "name": "vs/workbench/contrib/styleOverrides", + "name": "vs/workbench/contrib/modernUI", "project": "vscode-workbench" }, { diff --git a/build/lib/stylelint/validateHasSelectors.ts b/build/lib/stylelint/validateHasSelectors.ts index 6b3f684ab662f5..44081989f77cdf 100644 --- a/build/lib/stylelint/validateHasSelectors.ts +++ b/build/lib/stylelint/validateHasSelectors.ts @@ -52,7 +52,7 @@ interface StylesheetScanResult { /** * Whether a selector contains a `:has()` attached to a compound that anchors on * `body`, `html`, `:root` or `.monaco-workbench` - complete tokens only, at any - * position within the compound (`.style-override.monaco-workbench:has()` counts, + * position within the compound (`.modern-ui.monaco-workbench:has()` counts, * `.monaco-workbench .foo:has()` and `.monaco-workbench-like:has()` do not). * Such rules make DOM mutations anywhere pay workbench-wide style invalidation * (microsoft/vscode#324985). diff --git a/build/lib/test/rootAnchoredHas.test.ts b/build/lib/test/rootAnchoredHas.test.ts index eaeef686b9ffad..653f443a1c27bb 100644 --- a/build/lib/test/rootAnchoredHas.test.ts +++ b/build/lib/test/rootAnchoredHas.test.ts @@ -12,7 +12,7 @@ suite('stylelint root-anchored :has() check', () => { test('flags root anchors regardless of position in the compound', () => { assert.ok(containsRootAnchoredHas('body:has(.automation-dialog) .context-view.monaco-component')); assert.ok(containsRootAnchoredHas('.monaco-workbench.floating-panels:has(.part.activitybar.compact) .part.statusbar')); - assert.ok(containsRootAnchoredHas('.style-override.monaco-workbench:has(.part.sidebar) .part')); + assert.ok(containsRootAnchoredHas('.modern-ui.monaco-workbench:has(.part.sidebar) .part')); assert.ok(containsRootAnchoredHas('BODY:HAS(.foo)')); assert.ok(containsRootAnchoredHas(':root:has(.foo)')); assert.ok(containsRootAnchoredHas(':is(body, .embedded):has(.foo)')); @@ -29,7 +29,7 @@ suite('stylelint root-anchored :has() check', () => { test('ignores :has() anchored below the root', () => { assert.ok(!containsRootAnchoredHas('.monaco-workbench .part > .title:has(.start-debug-action-item) > .title-actions')); - assert.ok(!containsRootAnchoredHas('.style-override.monaco-workbench .part:not(.editor):has(> .content)')); + assert.ok(!containsRootAnchoredHas('.modern-ui.monaco-workbench .part:not(.editor):has(> .content)')); assert.ok(!containsRootAnchoredHas('.interactive-session .foo:has(.bar)')); assert.ok(!containsRootAnchoredHas('.automations-list-widget .automations-row:has(:focus-visible)')); assert.ok(!containsRootAnchoredHas('.foo:not(.monaco-workbench):has(.bar)')); diff --git a/src/vs/base/browser/ui/contextview/contextview.ts b/src/vs/base/browser/ui/contextview/contextview.ts index 724356b025a992..9d50417de51894 100644 --- a/src/vs/base/browser/ui/contextview/contextview.ts +++ b/src/vs/base/browser/ui/contextview/contextview.ts @@ -68,7 +68,7 @@ export interface IContextViewCloseAnimation { export const CONTEXT_VIEW_MENU_MOTION_CLASS = 'context-view-menu-motion'; export const CONTEXT_VIEW_MENU_MOTION_CLOSING_CLASS = 'context-view-menu-motion-closing'; export const CONTEXT_VIEW_MENU_MOTION_CLOSE_ANIMATION_DURATION = 150; -export const CONTEXT_VIEW_MENU_MOTION_ANCESTOR_CLASSES = ['style-override', 'monaco-enable-motion'] as const; +export const CONTEXT_VIEW_MENU_MOTION_ANCESTOR_CLASSES = ['modern-ui', 'monaco-enable-motion'] as const; export const CONTEXT_VIEW_CLOSE_ANIMATION_DURATION_VARIABLE = '--vscode-context-view-close-animation-duration'; export const CONTEXT_VIEW_MENU_MOTION_SHADOW_VARIABLE = '--vscode-context-view-menu-motion-shadow'; const CONTEXT_VIEW_MENU_MOTION_CLOSE_START_OPACITY_VARIABLE = '--vscode-context-view-menu-motion-close-start-opacity'; @@ -145,7 +145,7 @@ let contextViewMenuMotionStyleSheet: HTMLStyleElement | undefined; function ensureContextViewMenuMotionStyleSheet(): void { if (!contextViewMenuMotionStyleSheet) { contextViewMenuMotionStyleSheet = createStyleSheet(undefined, style => { - style.textContent = getContextViewMenuMotionCss('.style-override.monaco-enable-motion'); + style.textContent = getContextViewMenuMotionCss('.modern-ui.monaco-enable-motion'); }); } } @@ -509,5 +509,5 @@ const SHADOW_ROOT_CSS = /* css */ ` :host-context(.linux:lang(zh-Hant)) { font-family: system-ui, "Ubuntu", "Droid Sans", "Source Han Sans TC", "Source Han Sans TW", "Source Han Sans", sans-serif; } :host-context(.linux:lang(ja)) { font-family: system-ui, "Ubuntu", "Droid Sans", "Source Han Sans J", "Source Han Sans JP", "Source Han Sans", sans-serif; } :host-context(.linux:lang(ko)) { font-family: system-ui, "Ubuntu", "Droid Sans", "Source Han Sans K", "Source Han Sans JR", "Source Han Sans", "UnDotum", "FBaekmuk Gulim", sans-serif; } - ${getContextViewMenuMotionCss(':host-context(.style-override.monaco-enable-motion)')} + ${getContextViewMenuMotionCss(':host-context(.modern-ui.monaco-enable-motion)')} `; diff --git a/src/vs/base/test/browser/ui/contextview/contextview.test.ts b/src/vs/base/test/browser/ui/contextview/contextview.test.ts index 8d006f00fe121f..2d8fd72ab0cd54 100644 --- a/src/vs/base/test/browser/ui/contextview/contextview.test.ts +++ b/src/vs/base/test/browser/ui/contextview/contextview.test.ts @@ -47,7 +47,7 @@ suite('ContextView', () => { test('hide() delays render disposal for close animations', () => { const clock = sinon.useFakeTimers(); const container = $('.container'); - container.classList.add('style-override', 'monaco-enable-motion'); + container.classList.add('modern-ui', 'monaco-enable-motion'); const contextView = new ContextView(container, ContextViewDOMPosition.ABSOLUTE); let disposeCount = 0; @@ -61,7 +61,7 @@ suite('ContextView', () => { closeAnimation: { className: 'closing', duration: 100, - requiredAncestorClasses: ['style-override', 'monaco-enable-motion'] + requiredAncestorClasses: ['modern-ui', 'monaco-enable-motion'] } }; @@ -215,7 +215,7 @@ suite('ContextView', () => { test('menu motion does not retain a containing block for submenus (#326248)', () => { const container = $('.container'); - container.classList.add('style-override', 'monaco-enable-motion'); + container.classList.add('modern-ui', 'monaco-enable-motion'); document.body.appendChild(container); const surface = $('.monaco-scrollable-element'); diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index c7524fa662b8d6..b44507dd7ade57 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -18,17 +18,17 @@ box-shadow: var(--vscode-shadow-lg); } -.style-override.monaco-enable-motion .action-widget.action-widget-dropdown { +.modern-ui.monaco-enable-motion .action-widget.action-widget-dropdown { animation: action-widget-dropdown-open 250ms cubic-bezier(0.22, 1, 0.36, 1) both; transform-origin: bottom left; will-change: transform, opacity; } -.style-override.monaco-enable-motion .context-view.bottom .action-widget.action-widget-dropdown { +.modern-ui.monaco-enable-motion .context-view.bottom .action-widget.action-widget-dropdown { transform-origin: top left; } -.style-override.monaco-enable-motion .action-widget.action-widget-dropdown.action-widget-dropdown-closing { +.modern-ui.monaco-enable-motion .action-widget.action-widget-dropdown.action-widget-dropdown-closing { animation: action-widget-dropdown-close 150ms cubic-bezier(0.22, 1, 0.36, 1) both; pointer-events: none; } diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index 43db237b2c73c6..b3107e95e57377 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -13,13 +13,13 @@ box-shadow: var(--vscode-shadow-xl); } -.style-override.monaco-enable-motion .quick-input-widget:not(.quick-input-widget-overlay) { +.modern-ui.monaco-enable-motion .quick-input-widget:not(.quick-input-widget-overlay) { animation: quick-input-widget-open 250ms cubic-bezier(0.22, 1, 0.36, 1) both; transform-origin: top center; will-change: transform, opacity; } -.style-override.monaco-enable-motion .quick-input-widget.quick-input-widget-closing:not(.quick-input-widget-overlay) { +.modern-ui.monaco-enable-motion .quick-input-widget.quick-input-widget-closing:not(.quick-input-widget-overlay) { animation: quick-input-widget-close 150ms cubic-bezier(0.22, 1, 0.36, 1) both; pointer-events: none; } diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index 21250626a297a8..a1dfb1104f8b7f 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -47,7 +47,7 @@ const VIEWSTATE_STORAGE_KEY = 'workbench.quickInput.viewState'; const QUICK_INPUT_MOTION_CLOSING_CLASS = 'quick-input-widget-closing'; const QUICK_INPUT_OVERLAY_CLASS = 'quick-input-widget-overlay'; const QUICK_INPUT_CLOSE_ANIMATION_DURATION = 150; -const QUICK_INPUT_MOTION_ANCESTOR_CLASSES = ['style-override', 'monaco-enable-motion']; +const QUICK_INPUT_MOTION_ANCESTOR_CLASSES = ['modern-ui', 'monaco-enable-motion']; type QuickInputViewState = { readonly top?: number; diff --git a/src/vs/platform/quickinput/test/browser/quickinput.test.ts b/src/vs/platform/quickinput/test/browser/quickinput.test.ts index 3f25e1a308b862..83dadefb508224 100644 --- a/src/vs/platform/quickinput/test/browser/quickinput.test.ts +++ b/src/vs/platform/quickinput/test/browser/quickinput.test.ts @@ -140,7 +140,7 @@ suite('QuickInput', () => { // https://github.com/microsoft/vscode/issues/147543 visible: controller.isVisible(), }); - fixture.classList.add('style-override', 'monaco-reduce-motion'); + fixture.classList.add('modern-ui', 'monaco-reduce-motion'); quickpick.show(); quickpick.hide(); recordState(); @@ -168,7 +168,7 @@ suite('QuickInput', () => { // https://github.com/microsoft/vscode/issues/147543 test('overlay picker aligns its input with the anchor and bypasses motion', () => { fixture.style.width = '600px'; fixture.style.height = '400px'; - fixture.classList.add('style-override', 'monaco-enable-motion'); + fixture.classList.add('modern-ui', 'monaco-enable-motion'); controller.layout({ width: 600, height: 400 }, 0); const anchor = document.createElement('div'); diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 8f9ad6b557e805..f5aec24ad4f09a 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -42,7 +42,7 @@ Editors open as modal overlays via `ModalEditorPart`. The main editor part exist | Auxiliary Bar | Right side | Visible | Changes view, file tree | | Panel | Below Sessions Part + Aux Bar | Hidden | Terminal, debug output | -The Panel and Auxiliary Bar tab strips inherit the shared Modern UI pane-tab presentation from `workbench/contrib/styleOverrides/browser/media/tabs.css` through the workbench root's `modern-ui-tabs` class. The chat tab strip consumes the reusable editor-tab hooks from the same stylesheet. Sessions-owned styles define only the part surface, inset, and chat-specific adornments; tab geometry, typography, and active/hover/focus states remain owned by the shared editor-tab stylesheet so the Editor and Agents windows stay aligned. +The Panel and Auxiliary Bar tab strips inherit the shared Modern UI pane-tab presentation from `workbench/contrib/modernUI/browser/media/tabs.css` through the workbench root's `modern-ui-tabs` class. The chat tab strip consumes the reusable editor-tab hooks from the same stylesheet. Sessions-owned styles define only the part surface, inset, and chat-specific adornments; tab geometry, typography, and active/hover/focus states remain owned by the shared editor-tab stylesheet so the Editor and Agents windows stay aligned. Clean editor tabs in the Agents window use the compact action-overlay presentation, while tabs with persistent dirty or pinned indicators still reserve the indicator column. The Sessions-owned `EditorParts` enforces `tabActionReserveSpace: false`, independent of the global `workbench.editor.tabActionReserveSpace` preference (which reserves the action column by default), because its editor is commonly shown as a narrow side pane. Other workbench windows reserve the action column by default and may opt out through that preference or `enforcePartOptions()`. @@ -229,7 +229,7 @@ Within a session view, chats default to a single **chat group** rendered as a ta - `ChatGroupsView` ([browser/parts/chatGroupsView.ts](src/vs/sessions/browser/parts/chatGroupsView.ts)) owns the `SerializableGrid` plus the **group/chat assignment model**. The session's **visible chat tabs** (`IActiveSession.visibleChatTabs` — its open chats minus hidden subagent chats) are the source of truth for which chats the grid partitions; the grid is a UI-only partition over those chats. A reconcile autorun maps `session.visibleChatTabs` / `session.activeChat` into the groups: it prunes stale assignments, assigns newly added chats to the active group, removes empty groups (keeping at least one), and reflects a changed session-active chat onto its owning group. Focusing a group activates that group's chat through `ISessionsService`, keeping chat-scoped commands and context keys aligned without letting unrelated catalog updates steal the focused group. - **Persistence.** When a created session holds more than one group, its partition (each group's ordered chat resources + active chat, the grid tree, sizes, and active group) is persisted to **workspace storage** keyed by `session.sessionId` (a single `sessions.chatGroupsLayout` map). New-session drafts always use one fresh group and clear stale state for their ID. The layout is captured on mutations (split / move / active-group change / reconcile) and re-captured on session switch-away and dispose (to snapshot the latest sash sizes). On reopen, `_tryRestoreLayout` deserializes the grid (each leaf's `index` maps a node back to groups) instead of building a single group. Because a session's chat catalog loads asynchronously after reload, restore keeps a saved `resource → group` assignment alive and routes each chat (including late-loading ones) back to its saved group via the reconcile autorun. Restoration completes when all saved chats are present, the catalog changes from its initial snapshot, or `session.loading` reports that initialization has settled. Missing chats are then treated as deleted and empty groups collapse. Restore is fully observable-driven (no timeouts). A single-group session stores nothing and clears any prior entry. - `ChatGroupView` ([browser/parts/chatGroupView.ts](src/vs/sessions/browser/parts/chatGroupView.ts)) is a single grid leaf hosting a `ChatCompositeBar` (its group's tab strip) above a kind-switched chat view (see the table below). Each group independently renders its own active chat, so multiple chats can be visible side-by-side. When the group's active chat is **read-only** (non-interactive — e.g. a subagent transcript or an archived session), a `SessionReadOnlyBanner` ([browser/parts/sessionReadOnlyBanner.ts](src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts)) is shown flush below the tab strip in place of the composer, with an inline **Restore** action for archived sessions. The banner is aligned with the tab strip: with a lone group it is capped to the centered content band and centered (via a `.single-group` CSS rule in [chatGroupsView.css](src/vs/sessions/browser/parts/media/chatGroupsView.css), mirroring the tab strip's rule); with more than one group it spans the full leaf width. Its `toJSON` carries the group's serialization `index` so the grid deserializer can map restored nodes back to groups. -- `ChatCompositeBar` ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) is a tab-strip renderer driven by an `IChatCompositeBarDelegate` supplied by the owning group. Its tabs are draggable and render this group's `visibleChatTabs` in the group's order; a read-only chat's tab shows a **lock** icon. The tab strip is shown (via the group's `tabsVisible` observable) when more than one group exists, or — for a lone group — when `IActiveSession.shouldShowChatTabs` is set (the session has more than one visible chat tab). At the end of the strip a trailing **New Chat** button (gated on `ISessionCapabilities.supportsMultipleChats`, disabled for archived sessions) is pinned; New Chat routes back through the delegate so the new chat opens into the clicked group. The **Conversations** menu is not on the tab strip — it lives in the session header meta row (see §4.1). Each non-main tab renders its close button from the contributed per-tab `Menus.SessionChatTab` (context = `{ session, chat }`), whose `sessions.chatCompositeBar.closeChat` command hides the chat session-wide (reopenable from Conversations); the tab context menu offers **Rename** / **Delete Chat** gated on `getChatCapabilities`. A tab is also a drag source for a `#chat` reference (via `fillChatReferenceDragData`, resolving the chat's backend resource through `ISessionsProvidersService`) so it can be dropped into an agent-host chat input. Because the Agents workbench is always modern, the tab DOM consumes reusable editor-tab hooks from [workbench/contrib/styleOverrides/browser/media/tabs.css](src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css), while [chatCompositeBar.css](src/vs/sessions/browser/parts/media/chatCompositeBar.css) retains only chat-specific layout and adornments. `SessionView` exposes the shared hook's focused/unfocused group state so side-by-side sessions follow the same color branches as editor groups, and `applySessionBarThemeColors` ([browser/parts/sessionBarStyles.ts](src/vs/sessions/browser/parts/sessionBarStyles.ts)) supplies the shared visual tokens. +- `ChatCompositeBar` ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) is a tab-strip renderer driven by an `IChatCompositeBarDelegate` supplied by the owning group. Its tabs are draggable and render this group's `visibleChatTabs` in the group's order; a read-only chat's tab shows a **lock** icon. The tab strip is shown (via the group's `tabsVisible` observable) when more than one group exists, or — for a lone group — when `IActiveSession.shouldShowChatTabs` is set (the session has more than one visible chat tab). At the end of the strip a trailing **New Chat** button (gated on `ISessionCapabilities.supportsMultipleChats`, disabled for archived sessions) is pinned; New Chat routes back through the delegate so the new chat opens into the clicked group. The **Conversations** menu is not on the tab strip — it lives in the session header meta row (see §4.1). Each non-main tab renders its close button from the contributed per-tab `Menus.SessionChatTab` (context = `{ session, chat }`), whose `sessions.chatCompositeBar.closeChat` command hides the chat session-wide (reopenable from Conversations); the tab context menu offers **Rename** / **Delete Chat** gated on `getChatCapabilities`. A tab is also a drag source for a `#chat` reference (via `fillChatReferenceDragData`, resolving the chat's backend resource through `ISessionsProvidersService`) so it can be dropped into an agent-host chat input. Because the Agents workbench is always modern, the tab DOM consumes reusable editor-tab hooks from [workbench/contrib/modernUI/browser/media/tabs.css](src/vs/workbench/contrib/modernUI/browser/media/tabs.css), while [chatCompositeBar.css](src/vs/sessions/browser/parts/media/chatCompositeBar.css) retains only chat-specific layout and adornments. `SessionView` exposes the shared hook's focused/unfocused group state so side-by-side sessions follow the same color branches as editor groups, and `applySessionBarThemeColors` ([browser/parts/sessionBarStyles.ts](src/vs/sessions/browser/parts/sessionBarStyles.ts)) supplies the shared visual tokens. - Drag-and-drop is handled by `ChatGroupDropTarget` ([browser/parts/chatGroupDropTarget.ts](src/vs/sessions/browser/parts/chatGroupDropTarget.ts)), which displays a 5-zone overlay (left / right / top / bottom / center) on the hovered group. Dropping a chat onto a group's **center** moves it into that group; dropping it onto an **edge** splits it into a new group in that direction. Subagent pills in the transcript use the same payload and drop zones; because subagents are hidden from the tab strip until opened, the drop first surfaces the subagent and then places it in the selected group or split. Alt+Enter on a focused subagent pill provides the keyboard-equivalent open-to-side action. The dragged chat's `{ sessionId, resource }` is carried on the drag event's **`dataTransfer`** (mime `SessionsDataTransfers.CHAT`, via `fillSessionChatDragData`/`isSessionChatDrag`/`getSessionChatDragData` in [browser/dnd.ts](src/vs/sessions/browser/dnd.ts)); drops from a different session are ignored. **Pitfall:** the group-move payload must **not** use the shared `LocalSelectionTransfer` singleton, because a chat-tab drag also offers a chat-*reference* payload (`DraggedChatReferenceIdentifier`, dropped into a chat input) that uses that same singleton — and `LocalSelectionTransfer` is a single global slot, so whichever payload is set last wins. Reference-carrying tabs (agent-host chats) would otherwise clobber the group-move identifier, so the drop target's `dragenter` saw no chat drag and never showed the split zones. The `dataTransfer` keeps the two payloads independent: its `types` are readable during `dragover` (to gate the overlay) and its value on `drop`. - Keyboard users can focus the previous/next chat group, split the active chat right/down, and move it to the previous/next group through the corresponding Sessions commands. In multi-group layouts, each group and tab list announces its one-based position and total count. - A newly opened, unassigned chat whose `origin.parentChat` is visible uses an existing group adjacent to that parent when one is available, regardless of whether it was opened from the transcript, Chats menu, or another surface. Existing and manually moved assignments remain authoritative. Without an adjacent group it opens normally; explicit open-to-side creates a new group. @@ -286,7 +286,7 @@ Editors open as modal overlays rather than occupying grid space. The configurati When the editor part is shown in the grid (not as a modal), its title toolbar (`MenuId.EditorTitleLayout`, right of the tabs) hosts layout actions registered in `contrib/editor/browser/editor.contribution.ts`, ordered left-to-right as: open in modal editor, **maximize / restore editor area**, a single **Toggle Details** action for the auxiliary bar (labelled "Toggle Secondary Side Bar" in the non-single-pane layout), and **close editor area**. The auxiliary-bar toggle sits to the right of maximize/restore because it changes the right-hand side of the layout. It reuses the core `workbench.action.toggleAuxiliaryBar` command (already registered in the agents window by the workbench auxiliary bar part, and available in the Command Palette under **View**) surfaced through two `when`-gated menu items in `browser/layoutActions.ts` so the icon flips without rendering a checked/highlighted state: the `right-panel-show` codicon shows when the auxiliary bar is hidden (`AuxiliaryBarVisibleContext` negated, click to show) and the `right-panel-hide` codicon shows when it is visible (click to hide). In the Agents-window tab strip, the editor-actions side first shrinks down to 50px before the tab scroller starts shrinking. When tab actions are placed on the left, tabs retain trailing spacing consistent with the modern editor tab style. -The Agents workbench opts into the shared tab presentation through the tab-specific `modern-ui-tabs` root class and imports `workbench/contrib/styleOverrides/browser/media/tabs.css` and `workbench/services/themes/browser/modernTabColorCustomizations.ts` directly from its editor contribution. It does not apply the broad `style-override` class, because that class also changes workbench-wide part metrics and requires the complete Modern UI module set. Editor tab DOM, interaction behavior, presentation, and legacy color-customization fallbacks therefore stay aligned with the standard VS Code editor window; Sessions owns only the actions contributed to the shared tab strip. Do not copy shared editor-tab rules into a Sessions stylesheet: duplicated presentation immediately drifts when the common editor tab design changes. The shared add-tab host stretches across the tab row's actual hit-target height and remains sticky at the trailing edge so its icon stays aligned and available while tabs scroll. +The Agents workbench opts into the shared tab presentation through the tab-specific `modern-ui-tabs` root class and imports `workbench/contrib/modernUI/browser/media/tabs.css` and `workbench/services/themes/browser/modernTabColorCustomizations.ts` directly from its editor contribution. It does not apply the broad `modern-ui` class, because that class also changes workbench-wide part metrics and requires the complete Modern UI module set. Editor tab DOM, interaction behavior, presentation, and legacy color-customization fallbacks therefore stay aligned with the standard VS Code editor window; Sessions owns only the actions contributed to the shared tab strip. Do not copy shared editor-tab rules into a Sessions stylesheet: duplicated presentation immediately drifts when the common editor tab design changes. The shared add-tab host stretches across the tab row's actual hit-target height and remains sticky at the trailing edge so its icon stays aligned and available while tabs scroll. Agents-only editor-type exclusions are configuration defaults: `workbench.editor.hiddenEditorTypes` defaults to hiding Markdown Preview in the Agents window. Keep these exclusions at the picker boundary rather than threading Agents-specific editor ids through editor-group APIs; normal editor windows and editor resolution remain unchanged. diff --git a/src/vs/sessions/contrib/editor/browser/editor.contribution.ts b/src/vs/sessions/contrib/editor/browser/editor.contribution.ts index 272779a6aae05a..a6ddc9d85b0704 100644 --- a/src/vs/sessions/contrib/editor/browser/editor.contribution.ts +++ b/src/vs/sessions/contrib/editor/browser/editor.contribution.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import '../../../../workbench/contrib/styleOverrides/browser/media/tabs.css'; +import '../../../../workbench/contrib/modernUI/browser/media/tabs.css'; import './media/editorBreadcrumbs.css'; import './media/editorHeader.css'; import '../../../../workbench/services/themes/browser/modernTabColorCustomizations.js'; diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index c1c6a34ee04c6a..575291cb80858f 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -112,9 +112,9 @@ enum LayoutClasses { NO_SHADOWS = 'no-shadows', FLOATING_PANELS = 'floating-panels', // Presentation class for the Modern UI Update experiment, owned/toggled at - // runtime by `StyleOverridesContribution`. It is *also* applied here at render + // runtime by `ModernUIContribution`. It is *also* applied here at render // time (see `getLayoutClasses`) to avoid a flash of unstyled workbench chrome. - STYLE_OVERRIDE = 'style-override', + MODERN_UI = 'modern-ui', // Module-specific gate shared with the Agents workbench. MODERN_UI_TABS = 'modern-ui-tabs' } @@ -1930,8 +1930,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.state.runtime.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined, this.isShadowsDisabled() ? LayoutClasses.NO_SHADOWS : undefined, this.isFloatingPanelsEnabled() ? LayoutClasses.FLOATING_PANELS : undefined, - // Also seed the style-override class here (see `LayoutClasses.STYLE_OVERRIDE`). - this.isFloatingPanelsEnabled() ? LayoutClasses.STYLE_OVERRIDE : undefined, + // Also seed the modern-ui class here (see `LayoutClasses.MODERN_UI`). + this.isFloatingPanelsEnabled() ? LayoutClasses.MODERN_UI : undefined, this.isFloatingPanelsEnabled() ? LayoutClasses.MODERN_UI_TABS : undefined, `panel-position-${positionToString(this.getPanelPosition())}`, `panel-alignment-${this.getPanelAlignment()}` diff --git a/src/vs/workbench/browser/part.ts b/src/vs/workbench/browser/part.ts index 858f511d6fea48..c8b312db6e198c 100644 --- a/src/vs/workbench/browser/part.ts +++ b/src/vs/workbench/browser/part.ts @@ -218,8 +218,8 @@ class PartLayout { private static readonly HEADER_HEIGHT = 35; private static readonly TITLE_HEIGHT = 35; private static readonly FOOTER_HEIGHT = 35; - // KEEP IN SYNC WITH: styleOverrides/browser/media/padding.css - private static readonly AREA_HEIGHT_STYLE_OVERRIDE = 32; + // KEEP IN SYNC WITH: modernUI/browser/media/padding.css + private static readonly AREA_HEIGHT_MODERN_UI = 32; private headerVisible: boolean = false; private footerVisible: boolean = false; @@ -236,7 +236,7 @@ class PartLayout { // Title Size: Width (Fill), Height (Variable). let titleSize: Dimension; if (this.options.hasTitle) { - const titleHeight = isModernUI ? PartLayout.AREA_HEIGHT_STYLE_OVERRIDE : PartLayout.TITLE_HEIGHT; + const titleHeight = isModernUI ? PartLayout.AREA_HEIGHT_MODERN_UI : PartLayout.TITLE_HEIGHT; titleSize = new Dimension(width, Math.min(height, titleHeight)); } else { titleSize = Dimension.None; @@ -245,7 +245,7 @@ class PartLayout { // Header Size: Width (Fill), Height (Variable) let headerSize: Dimension; if (this.headerVisible) { - const headerHeight = isModernUI ? PartLayout.AREA_HEIGHT_STYLE_OVERRIDE : PartLayout.HEADER_HEIGHT; + const headerHeight = isModernUI ? PartLayout.AREA_HEIGHT_MODERN_UI : PartLayout.HEADER_HEIGHT; headerSize = new Dimension(width, Math.min(height, headerHeight)); } else { headerSize = Dimension.None; @@ -254,7 +254,7 @@ class PartLayout { // Footer Size: Width (Fill), Height (Variable) let footerSize: Dimension; if (this.footerVisible) { - const footerHeight = isModernUI ? PartLayout.AREA_HEIGHT_STYLE_OVERRIDE : PartLayout.FOOTER_HEIGHT; + const footerHeight = isModernUI ? PartLayout.AREA_HEIGHT_MODERN_UI : PartLayout.FOOTER_HEIGHT; footerSize = new Dimension(width, Math.min(height, footerHeight)); } else { footerSize = Dimension.None; diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index cd3ad87a3a7690..1a5a3cf15b0630 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -39,7 +39,7 @@ import { mainWindow } from '../../../../base/browser/window.js'; /** * The width (in pixels) of the editor card border drawn on every side when the - * Modern UI Update experiment is enabled (`styleOverrides/media/editorBorder.css`). + * Modern UI Update experiment is enabled (`modernUI/media/editorBorder.css`). * The editor reserves this thickness when laying out its contents so they sit * inside the frame instead of overflowing (and being clipped by) the border. * Keep in sync with the `--vscode-strokeThickness` (1px) token used there. @@ -1443,7 +1443,7 @@ export class EditorPart extends Part implements IEditorPart, const { top, bottom } = getFloatingEditorVerticalMargins(this.layoutService, mainWindow); height = Math.max(0, height - top - bottom); - // Reserve space for the Modern UI editor border (styleOverrides/media/editorBorder.css) so content doesn't get clipped. + // Reserve space for the Modern UI editor border (modernUI/media/editorBorder.css) so content doesn't get clipped. if (!this.element.classList.contains('modal-editor-part')) { width = Math.max(0, width - EDITOR_FRAME_BORDER_WIDTH * 2); height = Math.max(0, height - EDITOR_FRAME_BORDER_WIDTH * 2); diff --git a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css index 9bb3f44a27b42a..c1e576e5a9ca3a 100644 --- a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css +++ b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css @@ -270,7 +270,7 @@ display: none; } -/* Intentionally low specificity so the Modern UI style override, which renders this as a pill background, wins without !important. */ +/* Intentionally low specificity so the Modern UI treatment, which renders this as a pill background, wins without !important. */ .tabs-container > .tab > .tab-fill { display: none; } diff --git a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts index 8823506b77e990..082caa715ca573 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts @@ -44,7 +44,7 @@ export const onDidChangeNotificationRowHeight = onDidChangeNotificationRowHeight /** * Overrides the height (px) of a single notification row. Used by the Modern UI - * style-override experiment to shrink the collapsed notification card. + * Modern UI experiment to shrink the collapsed notification card. */ export function setNotificationRowHeight(height: number): void { if (height !== notificationRowHeight) { diff --git a/src/vs/workbench/contrib/styleOverrides/README.md b/src/vs/workbench/contrib/modernUI/README.md similarity index 100% rename from src/vs/workbench/contrib/styleOverrides/README.md rename to src/vs/workbench/contrib/modernUI/README.md diff --git a/src/vs/workbench/contrib/modernUI/browser/media/activityBar.css b/src/vs/workbench/contrib/modernUI/browser/media/activityBar.css new file mode 100644 index 00000000000000..763cbe06fe60e3 --- /dev/null +++ b/src/vs/workbench/contrib/modernUI/browser/media/activityBar.css @@ -0,0 +1,265 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * Activity Bar — replace the active (selected) item's left highlight border + * with a filled, rounded background that sits behind the icon. + */ + +/* Drop the 2px left border indicator on the active item. */ +.modern-ui .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator:before { + border-left: none !important; +} + +/* The indicator owns the inset background for both active and hovered items. */ +.modern-ui .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator, +.modern-ui .activitybar > .content:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked):hover .active-item-indicator { + z-index: 0; + top: 0; + left: calc((var(--activity-bar-width, 36px) - var(--activity-bar-action-height, 36px) + 4px) / 2); + width: calc(var(--activity-bar-action-height, 36px) - 4px); + height: calc(var(--activity-bar-action-height, 36px) - 4px); + border-radius: var(--vscode-cornerRadius-small); +} + +.modern-ui .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator { + background-color: var(--vscode-modernActivityBar-activeBackground); +} + +.modern-ui .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .action-label.codicon { + color: var(--vscode-modernActivityBar-activeForeground) !important; +} + +.modern-ui .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .action-label.uri-icon { + background-color: var(--vscode-modernActivityBar-activeForeground) !important; +} + +.modern-ui .activitybar > .content:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked):hover .active-item-indicator { + background-color: var(--vscode-modernActivityBar-hoverBackground); +} + +.modern-ui .activitybar > .content:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked):hover .action-label.codicon { + color: var(--vscode-modernActivityBar-hoverForeground) !important; +} + +.modern-ui .activitybar > .content:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked):hover .action-label.uri-icon { + background-color: var(--vscode-modernActivityBar-hoverForeground) !important; +} + +/* Compact: minimal horizontal inset — 24×24px box centered in the 28px item. */ +.modern-ui .activitybar.compact > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator, +.modern-ui .activitybar.compact > .content:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked):hover .active-item-indicator { + width: calc(var(--activity-bar-action-height, 28px) - 4px); + height: calc(var(--activity-bar-action-height, 28px) - 4px); +} + +:is(.hc-black, .hc-light).modern-ui .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator { + border-radius: var(--vscode-cornerRadius-small); + background-color: transparent; + outline: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +:is(.hc-black, .hc-light).modern-ui.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator { + display: block; +} + +:is(.hc-black, .hc-light).modern-ui .activitybar > .content:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked):hover .active-item-indicator { + border-radius: var(--vscode-cornerRadius-small); + background-color: transparent; + outline: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +/* + * Horizontal Activity Bar — top / bottom position on the primary sidebar, panel + * (bottom) and auxiliary bar. When the activity bar is moved to the top or + * bottom of a part, its composites render in a horizontal `.composite-bar` + * inside the part's header/footer (`.pane-composite-part > .header-or-footer`) + * rather than the dedicated activity bar. By default the active item is marked + * with a thin top/bottom border line plus a flat, full-bleed fill. Mirror the + * default (side) treatment so both layouts feel consistent: drop the line and + * the flat fill, and paint an inset, rounded background box behind the icon for + * the active and hovered items. + * + * The box geometry is symmetric (centered) within the ~35px tall icon item so + * it reads as a soft, selected chip — the horizontal analogue of the square box + * used in the default position. + */ + +/* + * Keep the icon (and its badge) above the background box. Unlike the dedicated + * activity bar, the composite-bar label is not promoted in the stacking order, + * so lift it explicitly; otherwise the indicator (appended last in the DOM) + * would paint over the icon. + */ +.modern-ui .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon :is(.action-label, .badge) { + position: relative; + z-index: 1; +} + +/* Drop the flat, full-bleed active fill — it is replaced by the rounded box. */ +.modern-ui .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon.checked { + background-color: transparent; +} + +/* + * Drop the thin top/bottom active border line. It is kept while the item is + * keyboard focused so the focus indicator still shows. + */ +.modern-ui .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon.checked:not(:focus) .active-item-indicator:before { + display: none; +} + +/* Active item: inset, rounded background box behind the icon. */ +.modern-ui .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) > .composite-bar > .monaco-action-bar .action-item.icon.checked:not(:active) .active-item-indicator { + z-index: 0; + top: 50%; + left: var(--vscode-spacing-size20); + width: 24px; + height: 24px; + border-radius: var(--vscode-cornerRadius-small); + transform: translateY(-50%); +} + +/* + * Hover item: identical rounded box. Skipped on the active item (which already + * shows the box) and while dragging (the action-item `::before`/`::after` are + * reused for the drop-line indicators). + */ +.modern-ui .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) > .composite-bar > .monaco-action-bar .action-item.icon:not(.checked):not(:active):hover .active-item-indicator { + z-index: 0; + top: 50%; + left: var(--vscode-spacing-size20); + width: 24px; + height: 24px; + border-radius: var(--vscode-cornerRadius-small); + transform: translateY(-50%); +} + +.modern-ui .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .action-label.codicon::before, +.modern-ui.monaco-workbench .part.sidebar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label::before, +.modern-ui.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label::before { + left: 0; +} + +.modern-ui .pane-composite-part.auxiliarybar > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .action-label.uri-icon { + top: 0px; + left: 0px; +} + +/* Render the composite icons at the standard 16px size. */ +.modern-ui .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .action-label.codicon { + font-size: 16px; + line-height: 12px; + font-weight: var(--vscode-fontWeight-regular); +} + +/* + * Size the "Additional Views" overflow control (`.codicon-more`) to a 24px + * square so it matches the other 24px composite controls. `box-sizing` keeps the + * 16px glyph centered within the box. + */ +.modern-ui.monaco-workbench .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-label.codicon-more { + box-sizing: border-box; + width: 24px; + height: 24px; + color: var(--vscode-icon-foreground) !important; +} + +/* + * Center the 24px overflow control within its item. The item is wider than the + * label where its width is not pinned to 24px, so without this the label sits at + * the flex start (left) and the glyph reads as horizontally off-center. + */ +.modern-ui.monaco-workbench .part.auxiliarybar > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-label.codicon-more::before { + position: relative; + left: 0; +} + +/* + * Drop the divider between the horizontal activity bar and the part body. The + * rounded box gives the active item its own shape, so the full-width rule under + * the top (header) bar — and over the bottom (footer) bar — is removed for the + * cleaner, default-position look. `.monaco-workbench` is added to outrank the + * base part rule. + */ +.modern-ui.monaco-workbench .pane-composite-part:not(.empty) > .header { + border-bottom: none; + overflow: visible; +} + +.modern-ui.monaco-workbench .pane-composite-part:not(.empty) > .footer { + border-top: none; +} + +.modern-ui.monaco-workbench .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact { + overflow: visible; +} + +/* When badge-content is empty (icon-only via ::before, e.g. the update badge), use uniform padding so it stays circular. */ +.modern-ui.monaco-workbench .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .badge .badge-content:empty { + padding: var(--vscode-spacing-size20); +} + +.modern-ui.monaco-workbench .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact .badge-content:empty { + padding: 0; + border-radius: var(--vscode-cornerRadius-circle); +} + +.modern-ui.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content { + top: 18px; + right: 3px; +} + +.modern-ui.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content:empty { + padding: 0; + border-radius: var(--vscode-cornerRadius-circle); + width: 16px; +} + +.modern-ui.monaco-workbench .activitybar.compact > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content { + top: 13px; + right: 3px; +} + +.modern-ui.monaco-workbench .activitybar.compact > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content:empty { + padding: 0; + border-radius: var(--vscode-cornerRadius-circle); + width: 13px; +} + +.modern-ui.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator { + overflow: visible; +} + +.modern-ui.monaco-workbench .pane-composite-part > .header-or-footer.header > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before { + bottom: 8px; + border-radius: var(--vscode-cornerRadius-small); +} + +.modern-ui.monaco-workbench .pane-composite-part > .header-or-footer.header > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:focus .active-item-indicator:before { + bottom: 0; +} + +.modern-ui.monaco-workbench .pane-composite-part > .header-or-footer.footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before { + border-radius: var(--vscode-cornerRadius-small); + top: 3px; +} + +.modern-ui.monaco-workbench .pane-composite-part > .header-or-footer.footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:focus .active-item-indicator:before { + top: 0; +} +.modern-ui.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .profile-badge .profile-text-overlay { + top: 16px; +} + +.modern-ui.monaco-workbench .activitybar.compact > .content :not(.monaco-menu) > .monaco-action-bar .profile-badge .profile-text-overlay { + top: 10px; +} + +.modern-ui .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon:not(.checked) .action-label.codicon.codicon-more:hover { + background: transparent; +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css b/src/vs/workbench/contrib/modernUI/browser/media/commandCenter.css similarity index 73% rename from src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css rename to src/vs/workbench/contrib/modernUI/browser/media/commandCenter.css index 390595d0f20606..91903274a6315b 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/commandCenter.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ /* - * Style-override module: "Agents Window Command Center". + * Modern UI module: "Agents Window Command Center". * * Mirrors the Agents window title-bar styling (see * src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css and @@ -13,13 +13,13 @@ * rest and only reveal the command-center active background/border on hover, * instead of carrying a solid fill the whole time. * - * All rules are gated behind the `.style-override` class, which - * the StyleOverridesContribution toggles onto the workbench container(s) when + * All rules are gated behind the `.modern-ui` class, which + * the ModernUIContribution toggles onto the workbench container(s) when * the `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. */ /* Keep unavailable navigation actions on the contributed command-center foreground. */ -.style-override.monaco-workbench .part.titlebar .command-center > .monaco-toolbar > .monaco-action-bar > .actions-container > .action-item > .action-label:is(.codicon-arrow-left, .codicon-arrow-right) { +.modern-ui.monaco-workbench .part.titlebar .command-center > .monaco-toolbar > .monaco-action-bar > .actions-container > .action-item > .action-label:is(.codicon-arrow-left, .codicon-arrow-right) { color: var(--vscode-commandCenter-foreground); } @@ -29,8 +29,8 @@ * `:hover` rules already reveal the active background, and the connected * `session-mode` pill keeps its active fill (excluded below). */ -.style-override .agent-status-pill:not(.session-mode), -.style-override .agent-status-pill .agent-status-input-area { +.modern-ui .agent-status-pill:not(.session-mode), +.modern-ui .agent-status-pill .agent-status-input-area { background-color: transparent !important; } @@ -41,7 +41,7 @@ * rule still reveals the active background, and the `filtered` state keeps its * own active fill (excluded below). */ -.style-override .agent-status-badge-section:not(.filtered), -.style-override .agent-status-command-center-toolbar { +.modern-ui .agent-status-badge-section:not(.filtered), +.modern-ui .agent-status-command-center-toolbar { background-color: transparent !important; } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/editorBorder.css b/src/vs/workbench/contrib/modernUI/browser/media/editorBorder.css similarity index 79% rename from src/vs/workbench/contrib/styleOverrides/browser/media/editorBorder.css rename to src/vs/workbench/contrib/modernUI/browser/media/editorBorder.css index 24e3eef3e5f1cf..6512056b6f8264 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/editorBorder.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/editorBorder.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ /* - * Style-override module: "Editor Border". + * Modern UI module: "Editor Border". * * Frames the editor area as a distinct card by drawing a subtle 1px border with * rounded corners around the whole editor part (tabs, breadcrumbs and editor @@ -17,13 +17,13 @@ * relayout. `overflow: hidden` clips the editor content to the rounded corners. * The floating editor window part (`.modal-editor-part`) is excluded. * - * All rules are gated behind the `.style-override` class, which the - * StyleOverridesContribution toggles onto the workbench container(s) when the + * All rules are gated behind the `.modern-ui` class, which the + * ModernUIContribution toggles onto the workbench container(s) when the * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * Without that class these rules never apply. */ -.style-override .monaco-grid-view .part.editor:not(.modal-editor-part) { +.modern-ui .monaco-grid-view .part.editor:not(.modal-editor-part) { border: var(--vscode-strokeThickness) solid var(--vscode-editorGroup-border, color-mix(in srgb, var(--vscode-foreground) 12%, transparent)); border-radius: var(--vscode-cornerRadius-large); box-sizing: border-box; @@ -36,22 +36,22 @@ * `::after` overlay) to set itself apart from the side bar. With the explicit * border above that shadow is redundant and would double up, so suppress it. */ -.style-override .monaco-grid-view .part.editor:not(.modal-editor-part)::after { +.modern-ui .monaco-grid-view .part.editor:not(.modal-editor-part)::after { box-shadow: none; } /* Keep the editor frame aligned with the shared floating-surface border by default. */ -.monaco-workbench.style-override.floating-panels .part.editor { +.monaco-workbench.modern-ui.floating-panels .part.editor { border-color: var(--vscode-editor-border, var(--vscode-surface-border, var(--vscode-widget-border, transparent))) !important; } -.monaco-workbench.style-override .chat-view-position-left > .voice-agent-controls-wrapper { +.monaco-workbench.modern-ui .chat-view-position-left > .voice-agent-controls-wrapper { .agent-sessions-container { border-right-color: var(--vscode-surface-border, color-mix(in srgb, var(--vscode-foreground) 12%, transparent)) !important; } } -.monaco-workbench.style-override .chat-view-position-right > .voice-agent-controls-wrapper { +.monaco-workbench.modern-ui .chat-view-position-right > .voice-agent-controls-wrapper { .agent-sessions-container { border-left-color: var(--vscode-surface-border, color-mix(in srgb, var(--vscode-foreground) 12%, transparent)) !important; } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css b/src/vs/workbench/contrib/modernUI/browser/media/fontRamp.css similarity index 57% rename from src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css rename to src/vs/workbench/contrib/modernUI/browser/media/fontRamp.css index 55d27e71543405..8e0d12e1ab6a82 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/fontRamp.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ /* - * Style-override module: "Font Ramp". + * Modern UI module: "Font Ramp". * * Applies a single, disciplined typographic ramp across the workbench. Each role * maps to a size and weight: @@ -21,14 +21,14 @@ * Headings, section titles, editor tabs and badges are mapped onto their * canonical surfaces using the shared font-size and font-weight tokens. * - * All rules are gated behind the `.style-override` class, which the - * StyleOverridesContribution toggles onto the workbench container(s) when the + * All rules are gated behind the `.modern-ui` class, which the + * ModernUIContribution toggles onto the workbench container(s) when the * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. Without * that class these rules never apply. */ /* Anchor the workbench base font on Body 1 so inheriting surfaces follow the ramp. */ -.style-override.monaco-workbench { +.modern-ui.monaco-workbench { font-size: var(--vscode-fontSize-body1); } @@ -37,14 +37,14 @@ * ========================================================================== */ /* Heading 1 (26px) — welcome / getting started screen title. */ -.style-override.monaco-workbench .part.editor > .content .gettingStartedContainer h1 { +.modern-ui.monaco-workbench .part.editor > .content .gettingStartedContainer h1 { font-size: var(--vscode-fontSize-heading1); font-weight: var(--vscode-fontWeight-semiBold); } /* Heading 2 (18px) — welcome subtitle and secondary screen titles. */ -.style-override.monaco-workbench .part.editor > .content .gettingStartedContainer .subtitle, -.style-override.monaco-workbench .part.editor > .content .gettingStartedContainer h2 { +.modern-ui.monaco-workbench .part.editor > .content .gettingStartedContainer .subtitle, +.modern-ui.monaco-workbench .part.editor > .content .gettingStartedContainer h2 { font-size: var(--vscode-fontSize-heading2); font-weight: var(--vscode-fontWeight-semiBold); } @@ -54,28 +54,28 @@ * ========================================================================== */ /* View pane (sidebar / panel section) header titles — section title, Strong. */ -.style-override .monaco-pane-view .pane > .pane-header .title { +.modern-ui .monaco-pane-view .pane > .pane-header .title { font-size: var(--vscode-fontSize-label1); font-weight: var(--vscode-fontWeight-semiBold); } /* Part (sidebar / auxiliary bar / panel) title labels — section title, Strong. */ -.style-override.monaco-workbench .part > .title > .title-label h2 { +.modern-ui.monaco-workbench .part > .title > .title-label h2 { font-size: var(--vscode-fontSize-label1); font-weight: var(--vscode-fontWeight-semiBold); } /* Auxiliary bar composite header labels use the same regular body role as other tabs. */ -.style-override.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { +.modern-ui.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { font-weight: var(--vscode-fontWeight-regular); } -.style-override.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label.codicon { +.modern-ui.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label.codicon { font-weight: var(--vscode-fontWeight-regular); } /* Editor tab labels use Body 1 for comfortable readability at a normal viewing distance. */ -.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab .tab-label { +.modern-ui.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab .tab-label { font-size: var(--vscode-fontSize-body1); } @@ -83,12 +83,12 @@ * Label 3 (10px) — badges and counters. * ========================================================================== */ -.style-override .monaco-count-badge, -.style-override .monaco-action-bar .badge .badge-content { +.modern-ui .monaco-count-badge, +.modern-ui .monaco-action-bar .badge .badge-content { font-size: var(--vscode-fontSize-label3); } -.style-override.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content { +.modern-ui.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content { font-weight: var(--vscode-fontWeight-regular); } @@ -96,13 +96,13 @@ * Casing — replace ALL-CAPS labels with capitalized (title-case) text. The * underlying strings are already properly cased, so these surfaces only need * their `text-transform: uppercase` swapped for `capitalize`. The extra - * `.style-override` ancestor raises specificity over the base rules. + * `.modern-ui` ancestor raises specificity over the base rules. * ========================================================================== */ /* View pane (sidebar / panel section) header titles. */ -.style-override .monaco-pane-view .pane > .pane-header > .title, +.modern-ui .monaco-pane-view .pane > .pane-header > .title, /* Part titles (sidebar, auxiliary bar, panel) — but never editor filenames. */ -.style-override.monaco-workbench .part:not(.editor) > .title > .title-label h2, +.modern-ui.monaco-workbench .part:not(.editor) > .title > .title-label h2, /* * Panel / composite bar action-item labels. The font-size must land on the * `.action-label` (the ``), not the `.action-item` (the `
  • `): the base @@ -110,16 +110,16 @@ * directly on the label, and a direct value always beats one inherited from the * parent item, so targeting the item alone leaves the visible text at 11px. */ -.style-override.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, -.style-override.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, +.modern-ui.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, +.modern-ui.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, /* Notifications center header. */ -.style-override.monaco-workbench > .notifications-center > .notifications-center-header > .notifications-center-header-title, +.modern-ui.monaco-workbench > .notifications-center > .notifications-center-header > .notifications-center-header-title, /* Debug call-stack session state labels. */ -.style-override .debug-pane .debug-call-stack .session > .state.label, +.modern-ui .debug-pane .debug-call-stack .session > .state.label, /* Settings editor section headers. */ -.style-override .monaco-editor .settings-header-widget .title-container .title, +.modern-ui .monaco-editor .settings-header-widget .title-container .title, /* Agent sessions list header ("SESSIONS"). */ -.style-override .chat-viewpane.has-sessions-control .agent-sessions-container .agent-sessions-title-container { +.modern-ui .chat-viewpane.has-sessions-control .agent-sessions-container .agent-sessions-title-container { text-transform: capitalize; font-size: var(--vscode-fontSize-label1); } @@ -129,7 +129,7 @@ * and size. Its semibold weight lives here so all of this header's typography * stays in one module. */ -.style-override.monaco-workbench > .notifications-center > .notifications-center-header > .notifications-center-header-title { +.modern-ui.monaco-workbench > .notifications-center > .notifications-center-header > .notifications-center-header-title { font-weight: var(--vscode-fontWeight-semiBold); } @@ -141,17 +141,17 @@ * correctly; only user-provided folder names (e.g. "vscode-dev") are left * untouched. */ -.style-override.monaco-workbench .part:not(.editor)[data-active-composite="workbench.view.explorer"] > .title > .title-label h2, -.style-override .monaco-pane-view .pane > .pane-header > .icon.codicon-explorer-view-icon + .title { +.modern-ui.monaco-workbench .part:not(.editor)[data-active-composite="workbench.view.explorer"] > .title > .title-label h2, +.modern-ui .monaco-pane-view .pane > .pane-header > .icon.codicon-explorer-view-icon + .title { text-transform: none; } /* Restore the legacy casing for view, part, and composite tab headers when requested. */ -.style-override.modern-ui-uppercase-view-headers .monaco-pane-view .pane > .pane-header > .title, -.style-override.modern-ui-uppercase-view-headers.monaco-workbench .part:not(.editor) > .title > .title-label h2, -.style-override.modern-ui-uppercase-view-headers.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, -.style-override.modern-ui-uppercase-view-headers.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, -.style-override.modern-ui-uppercase-view-headers .monaco-pane-view .pane > .pane-header > .icon.codicon-explorer-view-icon + .title { +.modern-ui.modern-ui-uppercase-view-headers .monaco-pane-view .pane > .pane-header > .title, +.modern-ui.modern-ui-uppercase-view-headers.monaco-workbench .part:not(.editor) > .title > .title-label h2, +.modern-ui.modern-ui-uppercase-view-headers.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, +.modern-ui.modern-ui-uppercase-view-headers.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, +.modern-ui.modern-ui-uppercase-view-headers .monaco-pane-view .pane > .pane-header > .icon.codicon-explorer-view-icon + .title { text-transform: uppercase; } @@ -161,7 +161,7 @@ * casing rather than forcing ALL-CAPS like the base rule * (chatViewTitleControl.css) does. */ -.style-override .chat-viewpane .chat-view-title-container .chat-view-title-label { +.modern-ui .chat-viewpane .chat-view-title-container .chat-view-title-label { text-transform: none; font-size: var(--vscode-fontSize-label1); } @@ -173,21 +173,21 @@ * tab. The base rules (auxiliaryBarPart.css) paint the indicator with * `!important` from a `.part.auxiliarybar` selector, so the override must mirror * those exact selectors (including the `:not(:focus)` / `.clicked:focus` states) - * and rely on the extra `.style-override` class to win on specificity. + * and rely on the extra `.modern-ui` class to win on specificity. */ -.style-override.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator:before, -.style-override.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked.clicked:focus .active-item-indicator:before, -.style-override.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator:before, -.style-override.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked.clicked:focus .active-item-indicator:before { +.modern-ui.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator:before, +.modern-ui.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked.clicked:focus .active-item-indicator:before, +.modern-ui.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator:before, +.modern-ui.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked.clicked:focus .active-item-indicator:before { border-top-color: transparent !important; border-bottom-color: transparent !important; } -.style-override .debug-pane .debug-call-stack .session > .state.label { +.modern-ui .debug-pane .debug-call-stack .session > .state.label { font-size: var(--vscode-fontSize-label2); } -.style-override.monaco-workbench .agent-sessions-viewer { +.modern-ui.monaco-workbench .agent-sessions-viewer { .agent-session-section { text-transform: none; } @@ -197,14 +197,14 @@ } } -.style-override.monaco-workbench .chat-viewpane.has-sessions-control { +.modern-ui.monaco-workbench .chat-viewpane.has-sessions-control { .agent-session-section { padding-left: var(--vscode-spacing-size120); } } -.style-override.monaco-workbench .monaco-icon-label::after, -.style-override.monaco-workbench .explorer-folders-view .explorer-item::after { +.modern-ui.monaco-workbench .monaco-icon-label::after, +.modern-ui.monaco-workbench .explorer-folders-view .explorer-item::after { margin-right: var(--vscode-spacing-size80); font-size: var(--vscode-fontSize-label2); } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css b/src/vs/workbench/contrib/modernUI/browser/media/keyboardFocusOnly.css similarity index 75% rename from src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css rename to src/vs/workbench/contrib/modernUI/browser/media/keyboardFocusOnly.css index 0e65b9873fba8c..6b8ad6793c189f 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/keyboardFocusOnly.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ /* - * Style-override module: "Keyboard-Only Focus Borders". + * Modern UI module: "Keyboard-Only Focus Borders". * * Hides the focus borders that appear when a panel surface is focused via the * mouse, while keeping them for keyboard navigation. This relies on the @@ -13,8 +13,8 @@ * outlines on `:focus:not(:focus-visible)` therefore removes mouse-click focus * rings and leaves keyboard focus rings intact. * - * All rules are gated behind the `.style-override` class, which - * the StyleOverridesContribution toggles onto the workbench container(s) when the + * All rules are gated behind the `.modern-ui` class, which + * the ModernUIContribution toggles onto the workbench container(s) when the * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * * Scoped to the panel surfaces (side bar, bottom panel, auxiliary bar, status @@ -27,7 +27,7 @@ * `:focus` but not `:focus-visible`, so drop the row ring in that case. Keyboard * focus (`:focus-visible`) keeps it. */ -.style-override :is(.part.sidebar, .part.panel, .part.auxiliarybar) .monaco-list:focus:not(:focus-visible) .monaco-list-row.focused { +.modern-ui :is(.part.sidebar, .part.panel, .part.auxiliarybar) .monaco-list:focus:not(:focus-visible) .monaco-list-row.focused { outline: 0 !important; } @@ -38,7 +38,7 @@ * keyboard focus. The part element is listed explicitly because a descendant * combinator would not match the part itself. */ -.style-override :is(.part.sidebar, .part.panel, .part.auxiliarybar, .part.statusbar):focus:not(:focus-visible), -.style-override :is(.part.sidebar, .part.panel, .part.auxiliarybar, .part.statusbar) :focus:not(:focus-visible) { +.modern-ui :is(.part.sidebar, .part.panel, .part.auxiliarybar, .part.statusbar):focus:not(:focus-visible), +.modern-ui :is(.part.sidebar, .part.panel, .part.auxiliarybar, .part.statusbar) :focus:not(:focus-visible) { outline-color: transparent !important; } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/notificationsDialogs.css b/src/vs/workbench/contrib/modernUI/browser/media/notificationsDialogs.css similarity index 60% rename from src/vs/workbench/contrib/styleOverrides/browser/media/notificationsDialogs.css rename to src/vs/workbench/contrib/modernUI/browser/media/notificationsDialogs.css index ca5c9cb3fa9232..6d9d3804e9eaf0 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/notificationsDialogs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/notificationsDialogs.css @@ -4,25 +4,25 @@ *--------------------------------------------------------------------------------------------*/ /* - * Style-override module: "Notifications and Dialogs". + * Modern UI module: "Notifications and Dialogs". * */ -.style-override.monaco-workbench .notifications-list-container .notification-list-item { +.modern-ui.monaco-workbench .notifications-list-container .notification-list-item { padding: var(--vscode-spacing-size60) var(--vscode-spacing-size20); } -.style-override.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-icon { +.modern-ui.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-icon { font-size: var(--vscode-codiconFontSize); margin: 0 var(--vscode-spacing-size80) 0 var(--vscode-spacing-size60); } -.style-override.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container > .monaco-button-dropdown, -.style-override.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container > .monaco-button { +.modern-ui.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container > .monaco-button-dropdown, +.modern-ui.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container > .monaco-button { margin: 0 var(--vscode-spacing-size40) 0 0; } -.style-override.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-source { +.modern-ui.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-source { color: var(--vscode-descriptionForeground); margin-left: var(--vscode-spacing-size240); } @@ -38,18 +38,18 @@ * (DEFAULT_CUSTOM_TITLEBAR_HEIGHT), which the Modern UI experiment always shows * — so the center tucks directly beneath it. */ -.style-override.monaco-workbench > .notifications-center { +.modern-ui.monaco-workbench > .notifications-center { right: var(--vscode-spacing-size80); bottom: var(--vscode-spacing-size320); } -.style-override.monaco-workbench > .notifications-center.bottom-left { +.modern-ui.monaco-workbench > .notifications-center.bottom-left { right: auto; left: var(--vscode-spacing-size80); bottom: var(--vscode-spacing-size320); } -.style-override.monaco-workbench > .notifications-center.top-right { +.modern-ui.monaco-workbench > .notifications-center.top-right { bottom: auto; top: var(--vscode-spacing-size360) !important; } @@ -59,61 +59,61 @@ * container offset is reduced by 4px to keep the visible toast surface aligned * with the center (8px / 32px / 35px once the margin is added back). */ -.style-override.monaco-workbench > .notifications-toasts { +.modern-ui.monaco-workbench > .notifications-toasts { right: var(--vscode-spacing-size40); bottom: var(--vscode-spacing-size280); } -.style-override.monaco-workbench > .notifications-toasts.bottom-left { +.modern-ui.monaco-workbench > .notifications-toasts.bottom-left { right: auto; left: var(--vscode-spacing-size40); bottom: var(--vscode-spacing-size280); } -.style-override.monaco-workbench > .notifications-toasts.top-right { +.modern-ui.monaco-workbench > .notifications-toasts.top-right { bottom: auto; top: var(--vscode-spacing-size320) !important; } -.style-override.monaco-workbench > .notifications-center > .notifications-center-header { +.modern-ui.monaco-workbench > .notifications-center > .notifications-center-header { padding-right: var(--vscode-spacing-size20); } -.style-override .monaco-dialog-box { +.modern-ui .monaco-dialog-box { padding: var(--vscode-spacing-size40); min-width: 440px; } -.style-override .monaco-dialog-box:not(.align-vertical) .dialog-message-row .dialog-message-container { +.modern-ui .monaco-dialog-box:not(.align-vertical) .dialog-message-row .dialog-message-container { padding-left: var(--vscode-spacing-size80); padding-right: var(--vscode-spacing-size200); } -.style-override .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message { +.modern-ui .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message { margin: var(--vscode-spacing-size20) 0 var(--vscode-spacing-size120) 0; font-size: var(--vscode-fontSize-heading3); font-weight: var(--vscode-fontWeight-semiBold); } -.style-override .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message-detail { +.modern-ui .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message-detail { color: var(--vscode-descriptionForeground); } -.style-override .monaco-dialog-box .dialog-toolbar-row { +.modern-ui .monaco-dialog-box .dialog-toolbar-row { position: absolute; top: var(--vscode-spacing-size80); right: var(--vscode-spacing-size80); } -.style-override .monaco-dialog-box .dialog-footer-row { +.modern-ui .monaco-dialog-box .dialog-footer-row { padding: 0 var(--vscode-spacing-size80); } -.style-override .monaco-dialog-box .dialog-message-row { +.modern-ui .monaco-dialog-box .dialog-message-row { padding: var(--vscode-spacing-size160) var(--vscode-spacing-size80) 0; } -.style-override .monaco-dialog-box > .dialog-buttons-row { +.modern-ui .monaco-dialog-box > .dialog-buttons-row { padding: var(--vscode-spacing-size160) 0 0; } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css b/src/vs/workbench/contrib/modernUI/browser/media/padding.css similarity index 52% rename from src/vs/workbench/contrib/styleOverrides/browser/media/padding.css rename to src/vs/workbench/contrib/modernUI/browser/media/padding.css index 36936f0a173734..e8b3e82eaebede 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/padding.css @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ /* - * Style-override module: "Padding". + * Modern UI module: "Padding". * * Adjusts the internal padding of common workbench surfaces to give content * more breathing room. * - * All rules are gated behind the `.style-override` class, which the - * StyleOverridesContribution toggles onto the workbench container(s) when the + * All rules are gated behind the `.modern-ui` class, which the + * ModernUIContribution toggles onto the workbench container(s) when the * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * Without that class these rules never apply. */ @@ -32,37 +32,37 @@ */ /* Header: inset the section title / actions to line up with the rows below. */ -.style-override .monaco-pane-view .pane:not(.chat-viewpane-container) > .pane-header { +.modern-ui .monaco-pane-view .pane:not(.chat-viewpane-container) > .pane-header { padding-left: var(--vscode-spacing-size40); padding-right: 0; margin: 0 var(--vscode-spacing-size40); } /* Body: inset each row box, leaving the scrollbar pinned to the pane edge. */ -.style-override .monaco-pane-view .pane-body:not(.chat-viewpane) .monaco-list-row { +.modern-ui .monaco-pane-view .pane-body:not(.chat-viewpane) .monaco-list-row { left: var(--vscode-spacing-size40); right: var(--vscode-spacing-size40); width: auto; } /* Tighten part chrome from 35px to 32px. - * KEEP IN SYNC WITH: part.ts PartLayout.AREA_HEIGHT_STYLE_OVERRIDE */ -.style-override.monaco-workbench .part > .title, -.style-override.monaco-workbench .part > .header-or-footer { + * KEEP IN SYNC WITH: part.ts PartLayout.AREA_HEIGHT_MODERN_UI */ +.modern-ui.monaco-workbench .part > .title, +.modern-ui.monaco-workbench .part > .header-or-footer { height: 32px; padding-left: var(--vscode-spacing-size20); } -.style-override.monaco-workbench .part > .title { +.modern-ui.monaco-workbench .part > .title { padding-left: var(--vscode-spacing-size40); padding-right: var(--vscode-spacing-size40); } -.style-override.monaco-workbench .part > .title > .title-label { +.modern-ui.monaco-workbench .part > .title > .title-label { line-height: 32px; } -.style-override.monaco-workbench .part > .title > .title-actions { +.modern-ui.monaco-workbench .part > .title > .title-actions { height: 32px; padding-left: var(--vscode-spacing-size40); } @@ -72,22 +72,22 @@ * configuration dropdown) the extra room by letting the actions grow while the * title label stays non-greedy. */ -.style-override.monaco-workbench .part > .title.has-start-debug-action-item > .title-actions { +.modern-ui.monaco-workbench .part > .title.has-start-debug-action-item > .title-actions { flex: 2; } -.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .editor-actions { +.modern-ui.monaco-workbench .part.editor > .content .editor-group-container > .title .editor-actions { padding: 0 var(--vscode-spacing-size40); } -.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .editor-actions .actions-container > li:last-child, -.style-override.monaco-workbench .part > .title .title-actions .actions-container > li:last-child, -.style-override.monaco-workbench .part > .title .global-actions .actions-container > li:last-child { +.modern-ui.monaco-workbench .part.editor > .content .editor-group-container > .title .editor-actions .actions-container > li:last-child, +.modern-ui.monaco-workbench .part > .title .title-actions .actions-container > li:last-child, +.modern-ui.monaco-workbench .part > .title .global-actions .actions-container > li:last-child { margin-right: 0; } /* Inset the part title label (e.g. "Explorer") to align with the content below. */ -.style-override.monaco-workbench .part > .title > .title-label { +.modern-ui.monaco-workbench .part > .title > .title-label { padding-left: var(--vscode-spacing-size80, 8px); overflow: hidden; white-space: nowrap; @@ -101,15 +101,15 @@ * except in the Run and Debug view where the header actions (play button + * launch configuration dropdown) need the horizontal space instead. */ -.style-override.monaco-workbench .part > .title:not(.has-start-debug-action-item) > .title-label { +.modern-ui.monaco-workbench .part > .title:not(.has-start-debug-action-item) > .title-label { flex: 2; } -.style-override.monaco-workbench .pane-body .agent-sessions-viewer .monaco-scrollable-element { +.modern-ui.monaco-workbench .pane-body .agent-sessions-viewer .monaco-scrollable-element { padding: 0 var(--vscode-spacing-size80); } -.style-override.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row { +.modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row { background-color: transparent; > .monaco-tl-row { @@ -117,7 +117,7 @@ } } -.style-override.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-item-row { +.modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-item-row { padding-bottom: var(--vscode-spacing-size40); .agent-session-item { @@ -129,7 +129,7 @@ } } -.style-override.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-section-row { +.modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-section-row { padding-bottom: var(--vscode-spacing-size40); .agent-session-section { @@ -137,7 +137,7 @@ } } -.style-override.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row:not(.drop-target).focused { +.modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row:not(.drop-target).focused { background-color: transparent; outline: none; @@ -146,7 +146,7 @@ } } -.style-override.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row:not(.drop-target).selected { +.modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row:not(.drop-target).selected { background-color: transparent; outline: none; @@ -155,7 +155,7 @@ } } -.style-override.monaco-workbench .agent-sessions-viewer .monaco-list:not(.drop-target):not(.dragging) .monaco-list-row.agent-session-list-row:not(.drop-target):hover:not(.selected):not(.focused) { +.modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list:not(.drop-target):not(.dragging) .monaco-list-row.agent-session-list-row:not(.drop-target):hover:not(.selected):not(.focused) { background-color: transparent; outline: none; @@ -164,7 +164,7 @@ } } -.style-override.monaco-workbench .agent-sessions-viewer .monaco-list:focus .monaco-list-row.agent-session-list-row:not(.drop-target).focused { +.modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list:focus .monaco-list-row.agent-session-list-row:not(.drop-target).focused { background-color: transparent; outline: none; @@ -173,7 +173,7 @@ } } -.style-override.monaco-workbench .agent-sessions-viewer .monaco-list:focus .monaco-list-row.agent-session-list-row:not(.drop-target).selected { +.modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list:focus .monaco-list-row.agent-session-list-row:not(.drop-target).selected { background-color: transparent; outline: none; @@ -182,41 +182,41 @@ } } -.style-override.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row:is(.focused, .selected), -.style-override.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row:hover { +.modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row:is(.focused, .selected), +.modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row:hover { outline: none; } -.style-override.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row.focused > .monaco-tl-row { +.modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row.focused > .monaco-tl-row { outline: var(--vscode-strokeThickness) dotted var(--vscode-list-inactiveFocusOutline); outline-offset: calc(var(--vscode-strokeThickness) * -1); } -.style-override.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row.focused.selected > .monaco-tl-row { +.modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row.focused.selected > .monaco-tl-row { outline-color: var(--vscode-contrastActiveBorder, var(--vscode-list-inactiveFocusOutline)); } -.style-override.monaco-workbench .agent-sessions-viewer .monaco-list:focus .monaco-list-row.agent-session-list-row.focused > .monaco-tl-row, -.context-menu-visible .style-override.monaco-workbench .agent-sessions-viewer .monaco-list.last-focused .monaco-list-row.agent-session-list-row.focused > .monaco-tl-row { +.modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list:focus .monaco-list-row.agent-session-list-row.focused > .monaco-tl-row, +.context-menu-visible .modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list.last-focused .monaco-list-row.agent-session-list-row.focused > .monaco-tl-row { outline: var(--vscode-strokeThickness) solid var(--vscode-list-focusOutline); outline-offset: calc(var(--vscode-strokeThickness) * -1); } -.style-override.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row.selected:not(.focused) > .monaco-tl-row { +.modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row.selected:not(.focused) > .monaco-tl-row { outline: var(--vscode-strokeThickness) dotted var(--vscode-contrastActiveBorder); outline-offset: calc(var(--vscode-strokeThickness) * -1); } -.style-override.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row:hover:not(.focused):not(.selected) > .monaco-tl-row { +.modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list-row.agent-session-list-row:hover:not(.focused):not(.selected) > .monaco-tl-row { outline: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder); outline-offset: calc(var(--vscode-strokeThickness) * -1); } -.style-override.monaco-workbench .agent-sessions-viewer .monaco-list:focus .monaco-list-row.agent-session-list-row.focused.selected > .monaco-tl-row { +.modern-ui.monaco-workbench .agent-sessions-viewer .monaco-list:focus .monaco-list-row.agent-session-list-row.focused.selected > .monaco-tl-row { outline-color: var(--vscode-list-focusAndSelectionOutline, var(--vscode-contrastActiveBorder, var(--vscode-list-focusOutline))); } -.style-override.monaco-workbench .chat-viewpane { +.modern-ui.monaco-workbench .chat-viewpane { .agent-session-item .agent-session-title-toolbar .actions-container { gap: var(--vscode-spacing-size40); } @@ -232,11 +232,11 @@ } } -.style-override.monaco-workbench .monaco-pane-view .pane > .pane-header > .actions { +.modern-ui.monaco-workbench .monaco-pane-view .pane > .pane-header > .actions { margin-right: 0px; } -.style-override.monaco-workbench .chat-viewpane.has-sessions-control .agent-sessions-container { +.modern-ui.monaco-workbench .chat-viewpane.has-sessions-control .agent-sessions-container { .agent-sessions-title-container { padding: 0 0 var(--vscode-spacing-size40) var(--vscode-spacing-size120); } @@ -245,118 +245,118 @@ /* Give the settings / profile (global) actions at the bottom of the default * vertical activity bar a 4px bottom margin so they sit consistently above the * window edge, matching the pane header margins. */ -.style-override.monaco-workbench .activitybar:not(.top):not(.bottom) > .content > :not(.composite-bar):last-child { +.modern-ui.monaco-workbench .activitybar:not(.top):not(.bottom) > .content > :not(.composite-bar):last-child { margin-bottom: calc(var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); } -.style-override.monaco-workbench .activitybar:not(.top):not(.bottom) > .content > .composite-bar { +.modern-ui.monaco-workbench .activitybar:not(.top):not(.bottom) > .content > .composite-bar { margin-top: calc(var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); } -.style-override.monaco-workbench .part.basepanel:is(.bottom, .top, .left, .right) .composite.title { +.modern-ui.monaco-workbench .part.basepanel:is(.bottom, .top, .left, .right) .composite.title { padding-right: var(--vscode-spacing-size40); padding-left: var(--vscode-spacing-size20); overflow: visible; } -.style-override.monaco-workbench .part.basepanel > .composite.title > .title-actions { +.modern-ui.monaco-workbench .part.basepanel > .composite.title > .title-actions { min-width: 0; } -.style-override.monaco-workbench .part.basepanel > .composite.title > .global-actions { +.modern-ui.monaco-workbench .part.basepanel > .composite.title > .global-actions { position: relative; z-index: 1; flex: 0 0 auto; } -.style-override.monaco-workbench .quick-input-titlebar { +.modern-ui.monaco-workbench .quick-input-titlebar { padding: var(--vscode-spacing-size20) 0 0; } -.style-override.monaco-workbench .quick-input-widget-overlay .quick-input-titlebar { +.modern-ui.monaco-workbench .quick-input-widget-overlay .quick-input-titlebar { padding-top: 0; } -.style-override.monaco-workbench .quick-input-list .quick-input-list-entry-action-bar { +.modern-ui.monaco-workbench .quick-input-list .quick-input-list-entry-action-bar { margin-right: 0; } -.style-override.monaco-workbench .quick-input-list .quick-input-list-entry { +.modern-ui.monaco-workbench .quick-input-list .quick-input-list-entry { padding: 0 var(--vscode-spacing-size40); } -.style-override.monaco-workbench .quick-input-list .quick-input-list-entry .quick-input-list-entry-keybinding { +.modern-ui.monaco-workbench .quick-input-list .quick-input-list-entry .quick-input-list-entry-keybinding { margin-right: 0; } -.style-override.monaco-workbench .quick-input-list .quick-input-list-entry-action-bar .action-label:last-child { +.modern-ui.monaco-workbench .quick-input-list .quick-input-list-entry-action-bar .action-label:last-child { margin-right: 0; margin-left: var(--vscode-spacing-size40); } -.style-override.monaco-workbench .quick-input-list .quick-input-list-entry .quick-input-list-separator { +.modern-ui.monaco-workbench .quick-input-list .quick-input-list-entry .quick-input-list-separator { margin-right: 0; margin-left: var(--vscode-spacing-size40); } -.style-override.monaco-workbench .scm-view .monaco-tl-contents > div { +.modern-ui.monaco-workbench .scm-view .monaco-tl-contents > div { padding-right: var(--vscode-spacing-size40); } -.style-override.monaco-workbench .search-view .search-widgets-container { +.modern-ui.monaco-workbench .search-view .search-widgets-container { margin: 0 var(--vscode-spacing-size80) 0 var(--vscode-spacing-size20); } -.style-override.monaco-workbench .extensions-viewlet > .header { +.modern-ui.monaco-workbench .extensions-viewlet > .header { padding: var(--vscode-spacing-size40) var(--vscode-spacing-size80); } -.style-override.monaco-workbench .extensions-viewlet > .extensions .extension-view-header .count-badge-wrapper { +.modern-ui.monaco-workbench .extensions-viewlet > .extensions .extension-view-header .count-badge-wrapper { margin-right: var(--vscode-spacing-size60); } -.style-override.monaco-workbench .monaco-count-badge { +.modern-ui.monaco-workbench .monaco-count-badge { padding: var(--vscode-spacing-size40) var(--vscode-spacing-size60); min-width: 19px; min-height: 19px; } -.style-override.monaco-workbench .interactive-session .interactive-input-part { +.modern-ui.monaco-workbench .interactive-session .interactive-input-part { padding-bottom: 0; } /* Keep xterm row rounding below the content so the first line has a stable inset. */ -.style-override.monaco-workbench .part.panel .pane-body.integrated-terminal .xterm { +.modern-ui.monaco-workbench .part.panel .pane-body.integrated-terminal .xterm { top: 0; bottom: auto; padding-block: var(--vscode-spacing-size80); } -.style-override.monaco-workbench .part.panel .pane-body.integrated-terminal .xterm .xterm-helpers { +.modern-ui.monaco-workbench .part.panel .pane-body.integrated-terminal .xterm .xterm-helpers { top: var(--vscode-spacing-size80); } -.style-override.monaco-workbench .scm-view .monaco-list-row .resource-group .actions-container, -.style-override.monaco-workbench .scm-view .monaco-list-row .resource .actions-container { +.modern-ui.monaco-workbench .scm-view .monaco-list-row .resource-group .actions-container, +.modern-ui.monaco-workbench .scm-view .monaco-list-row .resource .actions-container { gap: var(--vscode-spacing-size20); } -.style-override.monaco-workbench .scm-view .monaco-list-row .resource > .name > .monaco-icon-label::after { +.modern-ui.monaco-workbench .scm-view .monaco-list-row .resource > .name > .monaco-icon-label::after { margin-right: var(--vscode-spacing-size40); } -.style-override.monaco-workbench .monaco-count-badge.long { +.modern-ui.monaco-workbench .monaco-count-badge.long { padding: var(--vscode-spacing-size20) var(--vscode-spacing-size40); min-height: 18px; } -.style-override.monaco-workbench .debug-pane .debug-call-stack .session > .state.label { +.modern-ui.monaco-workbench .debug-pane .debug-call-stack .session > .state.label { margin-right: 0; } -.style-override.monaco-workbench .sidebar .has-composite-bar.header-or-footer .monaco-action-bar .actions-container { +.modern-ui.monaco-workbench .sidebar .has-composite-bar.header-or-footer .monaco-action-bar .actions-container { gap: var(--vscode-spacing-size20); } -.style-override.monaco-workbench .monaco-pane-view .pane:not(.merged-header) .monaco-progress-container { +.modern-ui.monaco-workbench .monaco-pane-view .pane:not(.merged-header) .monaco-progress-container { top: var(--vscode-spacing-size240); } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css b/src/vs/workbench/contrib/modernUI/browser/media/paneHeaders.css similarity index 74% rename from src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css rename to src/vs/workbench/contrib/modernUI/browser/media/paneHeaders.css index f65cdcc55f610f..637d4a97f7b64c 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/paneHeaders.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ /* - * Style-override module: "Pane Headers". + * Modern UI module: "Pane Headers". * * Restyles view pane (sidebar / panel section) headers: * - replaces the full-width separator lines with shorter, inset ones, @@ -15,8 +15,8 @@ * inline styles by the PaneView (src/vs/base/browser/ui/splitview/paneview.ts), * so the resets below must use `!important` to win over those inline values. * - * All rules are gated behind the `.style-override` class, which the - * StyleOverridesContribution toggles onto the workbench container(s) when the + * All rules are gated behind the `.modern-ui` class, which the + * ModernUIContribution toggles onto the workbench container(s) when the * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * Without that class these rules never apply. */ @@ -25,8 +25,8 @@ * Remove the full-width separator lines / borders (set inline by the PaneView) * so we can redraw them inset below. */ -.style-override .monaco-pane-view .pane > .pane-header, -.style-override .monaco-pane-view .pane { +.modern-ui .monaco-pane-view .pane > .pane-header, +.modern-ui .monaco-pane-view .pane { border: none !important; } @@ -36,7 +36,7 @@ * header border color the PaneView would, falling back across sidebar / panel * tokens so it is visible in both locations. */ -.style-override .monaco-pane-view .pane > .pane-header::before { +.modern-ui .monaco-pane-view .pane > .pane-header::before { content: ""; position: absolute; top: 0; @@ -48,13 +48,13 @@ } /* The first section in a pane has no separator above it. */ -.style-override .monaco-pane-view .split-view-view:first-of-type > .pane > .pane-header::before { +.modern-ui .monaco-pane-view .split-view-view:first-of-type > .pane > .pane-header::before { display: none; } -.style-override .monaco-pane-view .pane, -.style-override.floating-panels .part.sidebar, -.style-override.floating-panels .part.auxiliarybar { +.modern-ui .monaco-pane-view .pane, +.modern-ui.floating-panels .part.sidebar, +.modern-ui.floating-panels .part.auxiliarybar { background-color: var(--vscode-sideBar-background, var(--vscode-panel-background)) !important; } @@ -62,21 +62,21 @@ * reads as part of the panel / side bar body rather than a tinted strip. The * inline header background from the PaneView is overridden here; the hover tint * below still wins via its higher specificity. */ -.style-override .monaco-pane-view .pane > .pane-header { +.modern-ui .monaco-pane-view .pane > .pane-header { border-radius: var(--vscode-cornerRadius-small); background-color: var(--vscode-sideBar-background, var(--vscode-panel-background)) !important; } /* * Add vertical breathing room to the headers. Keep this value in sync with - * PANE_HEADER_OVERRIDE_SIZE so CSS presentation and split-view layout agree. + * MODERN_UI_PANE_HEADER_SIZE so CSS presentation and split-view layout agree. */ -.style-override .monaco-pane-view { +.modern-ui .monaco-pane-view { --pane-header-size: 28px; } /* Tint the header background on hover (overrides the inline header background). */ -.style-override .monaco-pane-view .pane > .pane-header:hover { +.modern-ui .monaco-pane-view .pane > .pane-header:hover { background-color: var(--vscode-list-hoverBackground) !important; } @@ -85,7 +85,7 @@ * outline comes from the global `.monaco-workbench [tabindex="0"]:focus` rule. * `:not(:focus-visible)` keeps the visible focus ring for keyboard users. */ -.style-override .monaco-pane-view .pane > .pane-header:focus:not(:focus-visible) { +.modern-ui .monaco-pane-view .pane > .pane-header:focus:not(:focus-visible) { outline: none !important; } @@ -94,10 +94,10 @@ * stroke right on the 1px section separator so it looks clipped. Negate the * smallest spacing token so the keyboard focus ring clears the separator. */ -.style-override .monaco-pane-view .pane > .pane-header:focus { +.modern-ui .monaco-pane-view .pane > .pane-header:focus { outline-offset: calc(var(--vscode-spacing-size20) * -1); } -.style-override.monaco-workbench .part > .title > .title-actions .start-debug-action-item { +.modern-ui.monaco-workbench .part > .title > .title-actions .start-debug-action-item { min-width: 36px; } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css b/src/vs/workbench/contrib/modernUI/browser/media/roundedCorners.css similarity index 69% rename from src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css rename to src/vs/workbench/contrib/modernUI/browser/media/roundedCorners.css index 5f671f3419c6e8..30e4f782a7d2d8 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/roundedCorners.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ /* - * Style-override module: "Rounded Corners". + * Modern UI module: "Rounded Corners". * * Applies a disciplined, three-tier corner-radius system across the workbench * based on the role each surface plays: @@ -17,8 +17,8 @@ * - Controls (4px): all interactable UI controls (text inputs, selects, * list/tree rows, ...). * - * All rules are gated behind the `.style-override` class, which - * the StyleOverridesContribution toggles onto the workbench container(s) when + * All rules are gated behind the `.modern-ui` class, which + * the ModernUIContribution toggles onto the workbench container(s) when * the `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * Without that class these rules never apply. */ @@ -28,30 +28,30 @@ * ========================================================================== */ /* Text inputs and find inputs */ -.style-override .monaco-inputbox, -.style-override .monaco-findInput, -.style-override .monaco-findInput .monaco-inputbox { +.modern-ui .monaco-inputbox, +.modern-ui .monaco-findInput, +.modern-ui .monaco-findInput .monaco-inputbox { border-radius: var(--vscode-cornerRadius-small); } -.style-override .browser-root .browser-url-container { +.modern-ui .browser-root .browser-url-container { border-radius: var(--vscode-cornerRadius-small); } /* Select / dropdown controls */ -.style-override .monaco-select-box, -.style-override .monaco-select-box-dropdown-container { +.modern-ui .monaco-select-box, +.modern-ui .monaco-select-box-dropdown-container { border-radius: var(--vscode-cornerRadius-small); } /* List / tree rows (including the notification center list rows) */ -.style-override .monaco-list .monaco-list-row:not(.separator) { +.modern-ui .monaco-list .monaco-list-row:not(.separator) { border-radius: var(--vscode-cornerRadius-small); } /* Action bar labels / keybindings (toolbar icon buttons) */ -.style-override .monaco-action-bar .action-label, -.style-override .monaco-action-bar .action-item .keybinding { +.modern-ui .monaco-action-bar .action-label, +.modern-ui .monaco-action-bar .action-item .keybinding { border-radius: var(--vscode-cornerRadius-small); } @@ -63,21 +63,21 @@ * the two segments meet. Round only the outer edges of the pair — left corners * on the primary label, right corners on the chevron — so the seam stays flat. */ -.style-override .monaco-action-bar .action-item.action-dropdown-item > .action-label { +.modern-ui .monaco-action-bar .action-item.action-dropdown-item > .action-label { border-radius: var(--vscode-cornerRadius-small) 0 0 var(--vscode-cornerRadius-small); } -.style-override .monaco-action-bar .action-item.action-dropdown-item > .monaco-dropdown .action-label { +.modern-ui .monaco-action-bar .action-item.action-dropdown-item > .monaco-dropdown .action-label { border-radius: 0 var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0; } -.style-override .monaco-action-bar .action-item.action-dropdown-item.empty > .action-label { +.modern-ui .monaco-action-bar .action-item.action-dropdown-item.empty > .action-label { border-radius: var(--vscode-cornerRadius-small); } /* Update indicator (title-bar "Update" button — base CSS uses medium, but as a * control it belongs to the controls tier). */ -.style-override .monaco-action-bar .update-indicator { +.modern-ui .monaco-action-bar .update-indicator { border-radius: var(--vscode-cornerRadius-small); } @@ -89,41 +89,41 @@ */ /* All-corners segments */ -.style-override .agent-status-pill, -.style-override .agent-status-badge, -.style-override .agent-status-badge-section:only-child, -.style-override .agent-status-esc-button, -.style-override .agent-status-enter-button { +.modern-ui .agent-status-pill, +.modern-ui .agent-status-badge, +.modern-ui .agent-status-badge-section:only-child, +.modern-ui .agent-status-esc-button, +.modern-ui .agent-status-enter-button { border-radius: var(--vscode-cornerRadius-small); } /* Leading-edge segments (rounded left, flat right) */ -.style-override .agent-status-pill .agent-status-input-area, -.style-override .agent-status-command-center-toolbar, -.style-override .agent-status-badge-section:first-child, -.style-override .agent-status-badge-section.sparkle .action-container { +.modern-ui .agent-status-pill .agent-status-input-area, +.modern-ui .agent-status-command-center-toolbar, +.modern-ui .agent-status-badge-section:first-child, +.modern-ui .agent-status-badge-section.sparkle .action-container { border-radius: var(--vscode-cornerRadius-small) 0 0 var(--vscode-cornerRadius-small); } /* Trailing-edge segments (flat left, rounded right) */ -.style-override .agent-status-badge-section:last-child, -.style-override .agent-status-badge-section.sparkle:last-child .dropdown-action-container { +.modern-ui .agent-status-badge-section:last-child, +.modern-ui .agent-status-badge-section.sparkle:last-child .dropdown-action-container { border-radius: 0 var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0; } /* Intentional flat seams where a segment butts up against its neighbour */ -.style-override .agent-status-line-separator + .agent-status-input-area, -.style-override .agent-status-pill.compact-mode .agent-status-badge-section.sparkle .action-container { +.modern-ui .agent-status-line-separator + .agent-status-input-area, +.modern-ui .agent-status-pill.compact-mode .agent-status-badge-section.sparkle .action-container { border-radius: 0; } /* Scrollbar sliders (panels, editor, lists — all consistent) */ -.style-override .monaco-scrollable-element > .scrollbar > .slider { +.modern-ui .monaco-scrollable-element > .scrollbar > .slider { border-radius: var(--vscode-cornerRadius-small); } /* Match the minimap viewport slider to the adjacent editor scrollbar. */ -.style-override .monaco-editor .minimap-slider .minimap-slider-horizontal { +.modern-ui .monaco-editor .minimap-slider .minimap-slider-horizontal { border-radius: var(--vscode-cornerRadius-small); } @@ -142,12 +142,12 @@ * `modified-in-monaco-diff-editor` class lives on the `.monaco-editor` root, so * target the slider as a descendant. */ -.style-override .monaco-diff-editor.has-diff-overview .modified-in-monaco-diff-editor .monaco-scrollable-element > .scrollbar.vertical > .slider { +.modern-ui .monaco-diff-editor.has-diff-overview .modified-in-monaco-diff-editor .monaco-scrollable-element > .scrollbar.vertical > .slider { border-radius: var(--vscode-cornerRadius-small) 0 0 var(--vscode-cornerRadius-small); } /* Terminal uses xterm.js' own scrollbar (.xterm-scrollable-element) */ -.style-override .xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider { +.modern-ui .xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider { border-radius: var(--vscode-cornerRadius-small); /* Inset the slider so both left and right rounded corners are visible */ left: calc(var(--vscode-strokeThickness)); @@ -155,7 +155,7 @@ } /* Editor sticky scroll container */ -.style-override .monaco-editor .sticky-widget { +.modern-ui .monaco-editor .sticky-widget { border-radius: 0; overflow: hidden; width: 100%; @@ -171,7 +171,7 @@ * inner tier so the whole workbench reads as a set of distinct rounded * surfaces. `overflow: hidden` clips their content to the rounded corners. */ -.style-override .monaco-grid-view .part:is(.sidebar, .panel, .auxiliarybar) { +.modern-ui .monaco-grid-view .part:is(.sidebar, .panel, .auxiliarybar) { overflow: hidden; } @@ -180,7 +180,7 @@ * (and the focused row draws an outline) on this element, so round it to the * inner tier to match. */ -.style-override .settings-editor .settings-tree-container .setting-item-contents.settings-row-inner-container { +.modern-ui .settings-editor .settings-tree-container .setting-item-contents.settings-row-inner-container { border-radius: var(--vscode-cornerRadius-medium); } @@ -189,29 +189,29 @@ * ========================================================================== */ /* Quick input (command palette, quick open) */ -.style-override .quick-input-widget { +.modern-ui .quick-input-widget { border-radius: var(--vscode-cornerRadius-large); overflow: hidden; } /* Editor / workbench hover, suggest and parameter hint widgets */ -.style-override .monaco-hover, -.style-override .editor-widget.suggest-widget, -.style-override .monaco-editor .parameter-hints-widget { +.modern-ui .monaco-hover, +.modern-ui .editor-widget.suggest-widget, +.modern-ui .monaco-editor .parameter-hints-widget { border-radius: var(--vscode-cornerRadius-large); overflow: hidden; } /* Only boundary rows adopt the overlay's outer corners; interior rows keep the Control radius. */ -.style-override .editor-widget.suggest-widget .monaco-list .monaco-list-row:not(.separator):first-child { +.modern-ui .editor-widget.suggest-widget .monaco-list .monaco-list-row:not(.separator):first-child { border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) 0 0; } -.style-override .editor-widget.suggest-widget .monaco-list .monaco-list-row:not(.separator):last-child { +.modern-ui .editor-widget.suggest-widget .monaco-list .monaco-list-row:not(.separator):last-child { border-radius: 0 0 var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large); } /* Context menus and general menu surfaces */ -.style-override .monaco-menu .monaco-action-bar.vertical { +.modern-ui .monaco-menu .monaco-action-bar.vertical { border-radius: var(--vscode-cornerRadius-large); } @@ -221,7 +221,7 @@ * header its top corners and the list its bottom corners in one place. Anchor * the rule to the workbench root to override the base control-tier radius. */ -.style-override.monaco-workbench > .notifications-center { +.modern-ui.monaco-workbench > .notifications-center { border-radius: var(--vscode-cornerRadius-large); } @@ -234,33 +234,33 @@ * inner radius shows through. Matching the base structure also gives these * rules enough specificity to replace its control-tier radius. */ -.style-override.monaco-workbench > .notifications-toasts .notifications-list-container, -.style-override.monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast, -.style-override.monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast .monaco-scrollable-element, -.style-override.monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast .monaco-list:not(.element-focused):focus:before, -.style-override.monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast .monaco-list-row { +.modern-ui.monaco-workbench > .notifications-toasts .notifications-list-container, +.modern-ui.monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast, +.modern-ui.monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast .monaco-scrollable-element, +.modern-ui.monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast .monaco-list:not(.element-focused):focus:before, +.modern-ui.monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast .monaco-list-row { border-radius: var(--vscode-cornerRadius-large); } /* Modal dialogs */ -.style-override .monaco-dialog-box { +.modern-ui .monaco-dialog-box { border-radius: var(--vscode-cornerRadius-large); overflow: hidden; } /* Editor find / replace widget */ -.style-override .monaco-editor .find-widget { +.modern-ui .monaco-editor .find-widget { border-radius: var(--vscode-cornerRadius-large); } /* Rename input widget */ -.style-override .monaco-editor .rename-box { +.modern-ui .monaco-editor .rename-box { border-radius: var(--vscode-cornerRadius-large); overflow: hidden; } /* Color picker widget */ -.style-override .colorpicker-widget { +.modern-ui .colorpicker-widget { border-radius: var(--vscode-cornerRadius-large); overflow: hidden; } @@ -268,24 +268,24 @@ /* ============================================================================= * Sashes — round the hover/drag affordance ends (mirrors the Agents window). * ========================================================================== */ -.style-override .monaco-sash:before { +.modern-ui .monaco-sash:before { border-radius: calc(var(--vscode-sash-hover-size) / 2); } /* Keep the diff editor's bottom boundary from clipping the sash radius. */ -.style-override .monaco-diff-editor > .monaco-sash.vertical:before { +.modern-ui .monaco-diff-editor > .monaco-sash.vertical:before { height: calc(100% - (var(--vscode-sash-hover-size) / 2)); } -.style-override .monaco-sash:not(.disabled) > .orthogonal-drag-handle { +.modern-ui .monaco-sash:not(.disabled) > .orthogonal-drag-handle { border-radius: var(--vscode-sash-size); } -.style-override.monaco-workbench .pane-body.integrated-terminal .tabs-container.has-text .terminal-tabs-chat-entry .terminal-tabs-entry { +.modern-ui.monaco-workbench .pane-body.integrated-terminal .tabs-container.has-text .terminal-tabs-chat-entry .terminal-tabs-entry { border-radius: var(--vscode-cornerRadius-small); } -.style-override.monaco-workbench .terminal-resize-overlay { +.modern-ui.monaco-workbench .terminal-resize-overlay { border-radius: var(--vscode-cornerRadius-large); } @@ -295,7 +295,7 @@ * activity-bar badge is a separate `.badge .badge-content` element, not a * `.monaco-count-badge`, so it is unaffected by these rules.) */ -.style-override.monaco-workbench .monaco-count-badge:not(.long) { +.modern-ui.monaco-workbench .monaco-count-badge:not(.long) { border-radius: var(--vscode-cornerRadius-circle); } @@ -305,10 +305,10 @@ * stadium-shaped capsules; the controls-tier radius keeps them as rectangular * pills, matching the base `countBadge.css` intent where `.long` is squared off. */ -.style-override.monaco-workbench .monaco-count-badge.long { +.modern-ui.monaco-workbench .monaco-count-badge.long { border-radius: var(--vscode-cornerRadius-small); } -.style-override.monaco-workbench .part.panel .scm-view .scm-editor-container { +.modern-ui.monaco-workbench .part.panel .scm-view .scm-editor-container { border-radius: var(--vscode-cornerRadius-small); } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/sashHandles.css b/src/vs/workbench/contrib/modernUI/browser/media/sashHandles.css similarity index 72% rename from src/vs/workbench/contrib/styleOverrides/browser/media/sashHandles.css rename to src/vs/workbench/contrib/modernUI/browser/media/sashHandles.css index 36d155afdd0045..6cf5b7ef35eb37 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/sashHandles.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/sashHandles.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ /* - * Style-override module: "Sash Handles". + * Modern UI module: "Sash Handles". * * With the Modern UI floating-panel treatment the workbench parts read as * separate cards with a gap between them, which hides where one part ends and @@ -17,14 +17,14 @@ * existing full-length highlight (see base sash.css), so behaviour while * dragging is unchanged. * - * All rules are gated behind the `.style-override` class, which the - * StyleOverridesContribution toggles onto the workbench container(s) when the + * All rules are gated behind the `.modern-ui` class, which the + * ModernUIContribution toggles onto the workbench container(s) when the * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. * Without that class these rules never apply. */ /* Persistent grip handle, centered on the sash, drawn via the spare pseudo. */ -.style-override .monaco-sash:not(.disabled)::after { +.modern-ui .monaco-sash:not(.disabled)::after { content: ''; position: absolute; pointer-events: none; @@ -35,12 +35,12 @@ opacity: 0.75; } -.monaco-enable-motion .style-override .monaco-sash:not(.disabled)::after { +.monaco-enable-motion .modern-ui .monaco-sash:not(.disabled)::after { transition: opacity 0.1s ease-out; } /* Vertical sash: three dots stacked vertically, centered. */ -.style-override .monaco-sash.vertical:not(.disabled)::after { +.modern-ui .monaco-sash.vertical:not(.disabled)::after { top: 50%; left: 50%; transform: translate(-50%, -50%); @@ -49,7 +49,7 @@ } /* Horizontal sash: three dots in a row, centered. */ -.style-override .monaco-sash.horizontal:not(.disabled)::after { +.modern-ui .monaco-sash.horizontal:not(.disabled)::after { left: 50%; top: 50%; transform: translate(-50%, -50%); @@ -57,35 +57,35 @@ color: color-mix(in srgb, var(--vscode-foreground) 30%, transparent); } -:is(.hc-black, .hc-light).style-override .monaco-sash:not(.disabled)::after { +:is(.hc-black, .hc-light).modern-ui .monaco-sash:not(.disabled)::after { background: var(--vscode-foreground); color: var(--vscode-foreground); opacity: 1; } /* While hovering/dragging, defer to the base full-length highlight. */ -.style-override .monaco-sash.hover:not(.disabled)::after, -.style-override .monaco-sash.active:not(.disabled)::after { +.modern-ui .monaco-sash.hover:not(.disabled)::after, +.modern-ui .monaco-sash.active:not(.disabled)::after { opacity: 0; } /* Only mark boundaries between top-level parts. Sashes inside a part (editor * splits, tree/view resizers) live within `.part`, so suppress their grip. */ -.style-override .part .monaco-sash::after { +.modern-ui .part .monaco-sash::after { content: none; } /* Hide grips for collapsed parts. Their reveal sashes remain enabled at an * extent, and the panel grip can also be clipped by the status bar. */ -.style-override.nopanel .monaco-sash.horizontal::after, -.style-override.nosidebar .monaco-sash.vertical.primary-sidebar-sash::after { +.modern-ui.nopanel .monaco-sash.horizontal::after, +.modern-ui.nosidebar .monaco-sash.vertical.primary-sidebar-sash::after { content: none; } @media (forced-colors: active) { - .style-override .monaco-sash:not(.disabled)::after, - .style-override .monaco-sash.vertical:not(.disabled)::after, - .style-override .monaco-sash.horizontal:not(.disabled)::after { + .modern-ui .monaco-sash:not(.disabled)::after, + .modern-ui .monaco-sash.vertical:not(.disabled)::after, + .modern-ui .monaco-sash.horizontal:not(.disabled)::after { background: CanvasText; color: CanvasText; opacity: 1; diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/shadows.css b/src/vs/workbench/contrib/modernUI/browser/media/shadows.css similarity index 64% rename from src/vs/workbench/contrib/styleOverrides/browser/media/shadows.css rename to src/vs/workbench/contrib/modernUI/browser/media/shadows.css index ac65ee8164c659..d89e7dd0cc7f08 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/shadows.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/shadows.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ /* - * Style-override module: "No Part Shadows". + * Modern UI module: "No Part Shadows". * * The `workbench.shadows` setting (default on) casts elevation shadows from the * workbench parts onto their neighbours: the title bar, the activity bar, the @@ -17,12 +17,12 @@ * dialogs and notifications) are intentionally left alone — these are preserved * even when `workbench.shadows` is disabled. * - * All rules are gated behind the `.style-override` class, which the - * StyleOverridesContribution toggles onto the workbench container(s) when the + * All rules are gated behind the `.modern-ui` class, which the + * ModernUIContribution toggles onto the workbench container(s) when the * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. */ -.style-override.monaco-workbench { +.modern-ui.monaco-workbench { /* * Use zero-offset transparent shadows instead of `none` because these * variables are interpolated into multi-value box-shadow declarations. @@ -33,20 +33,20 @@ --vscode-shadow-sm: 0 0 0 0 transparent; } -.style-override.monaco-workbench .part.titlebar, -.style-override.monaco-workbench .part.activitybar, -.style-override.monaco-workbench.nosidebar .part.activitybar, -.style-override.monaco-workbench.activitybar-right .part.activitybar, -.style-override.monaco-workbench .part.editor .tabs-container > .tab, -.style-override.monaco-workbench .part.editor .tabs-container > .tab:hover:not(.active), -.style-override.monaco-workbench .part.editor .tabs-container > .tab.active { +.modern-ui.monaco-workbench .part.titlebar, +.modern-ui.monaco-workbench .part.activitybar, +.modern-ui.monaco-workbench.nosidebar .part.activitybar, +.modern-ui.monaco-workbench.activitybar-right .part.activitybar, +.modern-ui.monaco-workbench .part.editor .tabs-container > .tab, +.modern-ui.monaco-workbench .part.editor .tabs-container > .tab:hover:not(.active), +.modern-ui.monaco-workbench .part.editor .tabs-container > .tab.active { box-shadow: none !important; } -.style-override.monaco-workbench:not(.hc-black):not(.hc-light) .terminal-resize-overlay { +.modern-ui.monaco-workbench:not(.hc-black):not(.hc-light) .terminal-resize-overlay { box-shadow: var(--vscode-shadow-lg); } -.style-override.monaco-workbench .monaco-diff-editor.side-by-side .editor.modified { +.modern-ui.monaco-workbench .monaco-diff-editor.side-by-side .editor.modified { border-left: 1px solid var(--vscode-surface-border); } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/statusBar.css b/src/vs/workbench/contrib/modernUI/browser/media/statusBar.css similarity index 69% rename from src/vs/workbench/contrib/styleOverrides/browser/media/statusBar.css rename to src/vs/workbench/contrib/modernUI/browser/media/statusBar.css index 82f1dc055d59d1..9784fab6002731 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/statusBar.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/statusBar.css @@ -16,28 +16,28 @@ * the left (square left, round right), `.compact-right` joins on the right (square * right, round left), and a middle item stays square on both sides. */ -.style-override .part.statusbar > .items-container > .statusbar-item, -.style-override .part.statusbar > .items-container > .statusbar-item > .statusbar-item-label { +.modern-ui .part.statusbar > .items-container > .statusbar-item, +.modern-ui .part.statusbar > .items-container > .statusbar-item > .statusbar-item-label { border-radius: var(--vscode-cornerRadius-small); } -.style-override .part.statusbar > .items-container > .statusbar-item.compact-left, -.style-override .part.statusbar > .items-container > .statusbar-item.compact-left > .statusbar-item-label { +.modern-ui .part.statusbar > .items-container > .statusbar-item.compact-left, +.modern-ui .part.statusbar > .items-container > .statusbar-item.compact-left > .statusbar-item-label { border-top-left-radius: 0; border-bottom-left-radius: 0; } -.style-override .part.statusbar > .items-container > .statusbar-item.compact-right, -.style-override .part.statusbar > .items-container > .statusbar-item.compact-right > .statusbar-item-label { +.modern-ui .part.statusbar > .items-container > .statusbar-item.compact-right, +.modern-ui .part.statusbar > .items-container > .statusbar-item.compact-right > .statusbar-item-label { border-top-right-radius: 0; border-bottom-right-radius: 0; } -.style-override .part.statusbar > .left-items > .statusbar-item.visible-background-color-neighbor { +.modern-ui .part.statusbar > .left-items > .statusbar-item.visible-background-color-neighbor { margin-left: var(--vscode-spacing-size40); } -.style-override .part.statusbar > .right-items > .statusbar-item.visible-background-color-neighbor { +.modern-ui .part.statusbar > .right-items > .statusbar-item.visible-background-color-neighbor { margin-right: var(--vscode-spacing-size40); } @@ -56,14 +56,14 @@ * tint doubles the alpha for a subtle lift; themes that customize the color get * their own stacked tint. */ -.style-override .part.statusbar > .items-container > .statusbar-item:is(.compact-left, .compact-right) > a.statusbar-item-label:hover:not(.disabled) { +.modern-ui .part.statusbar > .items-container > .statusbar-item:is(.compact-left, .compact-right) > a.statusbar-item-label:hover:not(.disabled) { background-image: linear-gradient(var(--vscode-statusBarItem-compactHoverBackground), var(--vscode-statusBarItem-compactHoverBackground)); } -.style-override .part.statusbar > .items-container > .statusbar-item.right.last-visible-item > .statusbar-item-label { +.modern-ui .part.statusbar > .items-container > .statusbar-item.right.last-visible-item > .statusbar-item-label { padding: 0 var(--vscode-spacing-size60); } -.style-override .part.statusbar > .items-container > .statusbar-item.left.first-visible-item > .statusbar-item-label { +.modern-ui .part.statusbar > .items-container > .statusbar-item.left.first-visible-item > .statusbar-item-label { padding-right: var(--vscode-spacing-size40); } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css similarity index 99% rename from src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css rename to src/vs/workbench/contrib/modernUI/browser/media/tabs.css index e3a8477b7d96bf..c7a00ec71e59b4 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ /* - * Style-override module: "Tabs". + * Modern UI module: "Tabs". * * Shared modern tab presentation used by the regular workbench and the Agents * window: transparent, rounded, borderless pills with a subtle active highlight. * * All rules are gated behind the tab-specific `.modern-ui-tabs` class. - * StyleOverridesContribution toggles it on regular workbench containers when + * ModernUIContribution toggles it on regular workbench containers when * `workbench.experimental.modernUI` is enabled, while the Agents workbench * applies it independently of the broader Modern UI modules. */ @@ -630,7 +630,7 @@ background-color: transparent !important; } -.style-override.modern-ui-tabs .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item { +.modern-ui.modern-ui-tabs .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item { height: var(--vscode-spacing-size320); min-height: var(--vscode-spacing-size320); max-height: var(--vscode-spacing-size320); diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/titlebar.css b/src/vs/workbench/contrib/modernUI/browser/media/titlebar.css similarity index 55% rename from src/vs/workbench/contrib/styleOverrides/browser/media/titlebar.css rename to src/vs/workbench/contrib/modernUI/browser/media/titlebar.css index 8d2a9d10097d04..abe7dd5157ac61 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/titlebar.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/titlebar.css @@ -4,16 +4,16 @@ *--------------------------------------------------------------------------------------------*/ /* - * Style-override module: "Title Bar". + * Modern UI module: "Title Bar". * * Adds a small amount of top padding to the title-bar left/center/right * containers so their items read as vertically centered. Scoped to macOS for - * now. All rules are gated behind the `.style-override` class, which the - * StyleOverridesContribution toggles onto the workbench container(s) when the + * now. All rules are gated behind the `.modern-ui` class, which the + * ModernUIContribution toggles onto the workbench container(s) when the * Modern UI Update setting is enabled. */ -.style-override.monaco-workbench.mac .part.titlebar > .titlebar-container > .titlebar-left, -.style-override.monaco-workbench.mac .part.titlebar > .titlebar-container > .titlebar-center, -.style-override.monaco-workbench.mac .part.titlebar > .titlebar-container > .titlebar-right { +.modern-ui.monaco-workbench.mac .part.titlebar > .titlebar-container > .titlebar-left, +.modern-ui.monaco-workbench.mac .part.titlebar > .titlebar-container > .titlebar-center, +.modern-ui.monaco-workbench.mac .part.titlebar > .titlebar-container > .titlebar-right { padding-top: var(--vscode-spacing-size20); } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts b/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts similarity index 77% rename from src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts rename to src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts index a1ac5ee72d716a..8dbe2f2c4aba16 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts +++ b/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts @@ -11,17 +11,17 @@ import { DEFAULT_SCROLLBAR_SIZE, setGlobalDefaultScrollbarSize } from '../../../ import { DEFAULT_NOTIFICATION_ROW_HEIGHT, setNotificationRowHeight } from '../../../browser/parts/notifications/notificationsViewer.js'; import { DEFAULT_PANE_HEADER_SIZE, setGlobalPaneHeaderSize } from '../../../../base/browser/ui/splitview/paneview.js'; -/** Reduced scrollbar size (px) applied when the style-override experiment is on. */ -const SCROLLBAR_OVERRIDE_SIZE = 8; +/** Reduced scrollbar size (px) applied when Modern UI is on. */ +const MODERN_UI_SCROLLBAR_SIZE = 8; -/** Reduced collapsed notification row height (px) applied when the style-override experiment is on. */ -const NOTIFICATION_ROW_OVERRIDE_HEIGHT = 34; +/** Reduced collapsed notification row height (px) applied when Modern UI is on. */ +const MODERN_UI_NOTIFICATION_ROW_HEIGHT = 34; -/** Increased pane header size (px) applied when the style-override experiment is on. */ -const PANE_HEADER_OVERRIDE_SIZE = 28; +/** Increased pane header size (px) applied when Modern UI is on. */ +const MODERN_UI_PANE_HEADER_SIZE = 28; -// Bundle the CSS for every style-override module. Every file gates all of its -// rules behind the single `.style-override` ancestor class, so the styles are +// Bundle the CSS for every Modern UI module. Every file gates all of its +// rules behind the single `.modern-ui` ancestor class, so the styles are // inert until that class is toggled onto the workbench container(s) below. import './media/activityBar.css'; import './media/commandCenter.css'; @@ -39,7 +39,7 @@ import './media/tabs.css'; import './media/titlebar.css'; import '../../../services/themes/browser/modernTabColorCustomizations.js'; -interface IStyleOverrideModule { +interface IModernUIModule { readonly id: string; /** * Whether this module changes layout metrics. Toggling such a module requires @@ -50,23 +50,23 @@ interface IStyleOverrideModule { /** * The single class toggled onto the workbench container(s) when the Modern UI - * Update experiment is enabled. Every style-override module's CSS is gated - * behind this class (`.style-override ...`), so all modules are applied together + * Update experiment is enabled. Every Modern UI module's CSS is gated + * behind this class (`.modern-ui ...`), so all modules are applied together * as a group. */ -const STYLE_OVERRIDE_CLASS = 'style-override'; +const MODERN_UI_CLASS = 'modern-ui'; const MODERN_UI_TABS_CLASS = 'modern-ui-tabs'; const MODERN_UI_UPPERCASE_VIEW_HEADERS_CLASS = 'modern-ui-uppercase-view-headers'; /** - * The fixed catalog of built-in style-override modules. The CSS for each module + * The fixed catalog of built-in Modern UI modules. The CSS for each module * ships with the product (imported above) and is gated behind the shared - * `.style-override` class. All modules are enabled together as part of the + * `.modern-ui` class. All modules are enabled together as part of the * Modern UI Update experiment (`LayoutSettings.MODERN_UI`). This catalog is * retained to track per-module metadata (e.g. whether a module is * layout-affecting). */ -const STYLE_OVERRIDE_MODULES: readonly IStyleOverrideModule[] = [ +const MODERN_UI_MODULES: readonly IModernUIModule[] = [ { id: 'activityBar' }, { id: 'commandCenter' }, { id: 'editorBorder' }, @@ -85,16 +85,16 @@ const STYLE_OVERRIDE_MODULES: readonly IStyleOverrideModule[] = [ ]; /** - * A contribution that toggles the built-in CSS style-override modules on or off + * A contribution that toggles the built-in CSS Modern UI modules on or off * as a group, based on the `workbench.experimental.modernUI` setting. When the * Modern UI Update experiment is enabled, all modules are applied together; * otherwise none are. */ -export class StyleOverridesContribution extends Disposable implements IWorkbenchContribution { +export class ModernUIContribution extends Disposable implements IWorkbenchContribution { - static readonly ID = 'workbench.contrib.styleOverrides'; + static readonly ID = 'workbench.contrib.modernUI'; - private readonly hasLayoutAffectingModule = STYLE_OVERRIDE_MODULES.some(m => m.layoutAffecting); + private readonly hasLayoutAffectingModule = MODERN_UI_MODULES.some(m => m.layoutAffecting); /** Whether a layout-affecting module was active at the last applied selection. */ private layoutAffectingActive = false; @@ -156,27 +156,27 @@ export class StyleOverridesContribution extends Disposable implements IWorkbench } private applyTo(container: HTMLElement, enabled: boolean, useUppercaseViewHeaders: boolean): void { - container.classList.toggle(STYLE_OVERRIDE_CLASS, enabled); + container.classList.toggle(MODERN_UI_CLASS, enabled); container.classList.toggle(MODERN_UI_TABS_CLASS, enabled); container.classList.toggle(MODERN_UI_UPPERCASE_VIEW_HEADERS_CLASS, useUppercaseViewHeaders); } private applyScrollbarSize(enabled: boolean): void { - setGlobalDefaultScrollbarSize(enabled ? SCROLLBAR_OVERRIDE_SIZE : DEFAULT_SCROLLBAR_SIZE); + setGlobalDefaultScrollbarSize(enabled ? MODERN_UI_SCROLLBAR_SIZE : DEFAULT_SCROLLBAR_SIZE); } private applyNotificationRowHeight(enabled: boolean): void { - setNotificationRowHeight(enabled ? NOTIFICATION_ROW_OVERRIDE_HEIGHT : DEFAULT_NOTIFICATION_ROW_HEIGHT); + setNotificationRowHeight(enabled ? MODERN_UI_NOTIFICATION_ROW_HEIGHT : DEFAULT_NOTIFICATION_ROW_HEIGHT); } private applyPaneHeaderSize(enabled: boolean): void { - setGlobalPaneHeaderSize(enabled ? PANE_HEADER_OVERRIDE_SIZE : DEFAULT_PANE_HEADER_SIZE); + setGlobalPaneHeaderSize(enabled ? MODERN_UI_PANE_HEADER_SIZE : DEFAULT_PANE_HEADER_SIZE); } override dispose(): void { // Remove the class this contribution added so it leaves no DOM state behind. for (const container of this.layoutService.containers) { - container.classList.remove(STYLE_OVERRIDE_CLASS); + container.classList.remove(MODERN_UI_CLASS); container.classList.remove(MODERN_UI_TABS_CLASS); container.classList.remove(MODERN_UI_UPPERCASE_VIEW_HEADERS_CLASS); } @@ -187,4 +187,4 @@ export class StyleOverridesContribution extends Disposable implements IWorkbench } } -registerWorkbenchContribution2(StyleOverridesContribution.ID, StyleOverridesContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ModernUIContribution.ID, ModernUIContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/styleOverrides/test/browser/styleOverrides.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts similarity index 97% rename from src/vs/workbench/contrib/styleOverrides/test/browser/styleOverrides.contribution.test.ts rename to src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index 1626131f407125..8b551b01efcb2e 100644 --- a/src/vs/workbench/contrib/styleOverrides/test/browser/styleOverrides.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -23,9 +23,9 @@ import { ColorThemeData } from '../../../../services/themes/common/colorThemeDat import { generateColorThemeCSS } from '../../../../services/themes/browser/colorThemeCss.js'; import '../../../../browser/parts/activitybar/media/activityaction.css'; import '../../../../browser/parts/media/paneCompositePart.css'; -import { StyleOverridesContribution } from '../../browser/styleOverrides.contribution.js'; +import { ModernUIContribution } from '../../browser/modernUI.contribution.js'; -class StyleOverridesTestPane extends Pane { +class ModernUITestPane extends Pane { constructor() { super({ title: 'Test', minimumBodySize: 0, maximumBodySize: 0 }); @@ -37,7 +37,7 @@ class StyleOverridesTestPane extends Pane { protected layoutBody(height: number, width: number): void { } } -class StyleOverridesTestLayoutService extends TestLayoutService { +class ModernUITestLayoutService extends TestLayoutService { override mainContainer = document.createElement('div'); override containers = [this.mainContainer]; @@ -85,7 +85,7 @@ function createCompositeAction(root: HTMLElement, titleHeight: number, checked: return { actionItem, actionLabel, indicator }; } -suite('StyleOverridesContribution', () => { +suite('ModernUIContribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); const colorRegistry = Registry.as(ColorRegistryExtensions.ColorContribution); @@ -97,10 +97,10 @@ suite('StyleOverridesContribution', () => { [LayoutSettings.MODERN_UI_UPPERCASE_VIEW_HEADERS]: true, }); store.add(configurationService.onDidChangeConfigurationEmitter); - const layoutService = new StyleOverridesTestLayoutService(); + const layoutService = new ModernUITestLayoutService(); store.add(layoutService.onDidAddContainerEmitter); - store.add(new StyleOverridesContribution(configurationService, layoutService)); - const pane = store.add(new StyleOverridesTestPane()); + store.add(new ModernUIContribution(configurationService, layoutService)); + const pane = store.add(new ModernUITestPane()); const paneView = document.createElement('div'); paneView.classList.add('monaco-pane-view'); paneView.appendChild(pane.element); @@ -113,10 +113,10 @@ suite('StyleOverridesContribution', () => { layoutService.addContainer(auxiliaryContainer, auxiliaryDisposables); const startupState = { - mainEnabled: layoutService.mainContainer.classList.contains('style-override'), + mainEnabled: layoutService.mainContainer.classList.contains('modern-ui'), mainTabsEnabled: layoutService.mainContainer.classList.contains('modern-ui-tabs'), mainUppercaseViewHeaders: layoutService.mainContainer.classList.contains('modern-ui-uppercase-view-headers'), - auxiliaryEnabled: auxiliaryContainer.classList.contains('style-override'), + auxiliaryEnabled: auxiliaryContainer.classList.contains('modern-ui'), auxiliaryTabsEnabled: auxiliaryContainer.classList.contains('modern-ui-tabs'), auxiliaryUppercaseViewHeaders: auxiliaryContainer.classList.contains('modern-ui-uppercase-view-headers'), paneHeaderSize: pane.minimumSize, @@ -135,10 +135,10 @@ suite('StyleOverridesContribution', () => { assert.deepStrictEqual({ startupState, - mainEnabledAfterToggle: layoutService.mainContainer.classList.contains('style-override'), + mainEnabledAfterToggle: layoutService.mainContainer.classList.contains('modern-ui'), mainTabsEnabledAfterToggle: layoutService.mainContainer.classList.contains('modern-ui-tabs'), mainUppercaseViewHeadersAfterToggle: layoutService.mainContainer.classList.contains('modern-ui-uppercase-view-headers'), - auxiliaryEnabledAfterToggle: auxiliaryContainer.classList.contains('style-override'), + auxiliaryEnabledAfterToggle: auxiliaryContainer.classList.contains('modern-ui'), auxiliaryTabsEnabledAfterToggle: auxiliaryContainer.classList.contains('modern-ui-tabs'), auxiliaryUppercaseViewHeadersAfterToggle: auxiliaryContainer.classList.contains('modern-ui-uppercase-view-headers'), paneHeaderSizeAfterToggle: pane.minimumSize, @@ -177,9 +177,9 @@ suite('StyleOverridesContribution', () => { [LayoutSettings.MODERN_UI_UPPERCASE_VIEW_HEADERS]: false, }); store.add(configurationService.onDidChangeConfigurationEmitter); - const layoutService = new StyleOverridesTestLayoutService(); + const layoutService = new ModernUITestLayoutService(); store.add(layoutService.onDidAddContainerEmitter); - store.add(new StyleOverridesContribution(configurationService, layoutService)); + store.add(new ModernUIContribution(configurationService, layoutService)); layoutService.mainContainer.classList.add('monaco-workbench'); const paneView = appendElement(layoutService.mainContainer, 'monaco-pane-view'); @@ -245,10 +245,10 @@ suite('StyleOverridesContribution', () => { test('pane composite actions fill regular and Agents headers', () => { const regularRoot = document.createElement('div'); - regularRoot.className = 'monaco-workbench style-override modern-ui-tabs'; + regularRoot.className = 'monaco-workbench modern-ui modern-ui-tabs'; document.body.appendChild(regularRoot); store.add(toDisposable(() => regularRoot.remove())); - // Taller container than the fixed 32px override, so the override is verified rather than a 100% fallback. + // Taller container than the fixed 32px Modern UI height, so the fixed height is verified rather than a 100% fallback. const regular = createCompositeAction(regularRoot, 40, true); const regularIcon = createCompositeAction(regularRoot, 40, true, true); const regularIconBadge = appendElement(regularIcon.actionItem, 'badge compact'); @@ -299,7 +299,7 @@ suite('StyleOverridesContribution', () => { test('pane composite actions use regular label weight', () => { const regularRoot = document.createElement('div'); - regularRoot.className = 'monaco-workbench style-override modern-ui-tabs'; + regularRoot.className = 'monaco-workbench modern-ui modern-ui-tabs'; document.body.appendChild(regularRoot); store.add(toDisposable(() => regularRoot.remove())); const regular = createCompositeAction(regularRoot, 40, true); @@ -326,7 +326,7 @@ suite('StyleOverridesContribution', () => { test('pane composite overflow uses the icon foreground', () => { const root = document.createElement('div'); - root.className = 'monaco-workbench style-override modern-ui-tabs'; + root.className = 'monaco-workbench modern-ui modern-ui-tabs'; root.style.setProperty('--vscode-icon-foreground', '#123456'); document.body.appendChild(root); store.add(toDisposable(() => root.remove())); @@ -340,7 +340,7 @@ suite('StyleOverridesContribution', () => { test('preserves Modern UI activity indicators, badges and horizontal pane dividers', () => { const root = document.createElement('div'); - root.className = 'monaco-workbench style-override modern-ui-tabs'; + root.className = 'monaco-workbench modern-ui modern-ui-tabs'; root.style.setProperty('--activity-bar-action-height', '36px'); root.style.setProperty('--activity-bar-width', '36px'); root.style.setProperty('--vscode-cornerRadius-small', '4px'); @@ -406,7 +406,7 @@ suite('StyleOverridesContribution', () => { test('uses the editor surface border color', () => { const root = document.createElement('div'); - root.className = 'monaco-workbench style-override floating-panels'; + root.className = 'monaco-workbench modern-ui floating-panels'; root.style.setProperty('--vscode-editor-border', '#123456'); root.style.setProperty('--vscode-surface-border', '#654321'); document.body.appendChild(root); @@ -452,7 +452,7 @@ suite('StyleOverridesContribution', () => { test('hides collapsed primary side bar grips without hiding constrained auxiliary sash grips', () => { const root = document.createElement('div'); - root.className = 'monaco-workbench style-override nosidebar nopanel'; + root.className = 'monaco-workbench modern-ui nosidebar nopanel'; document.body.appendChild(root); store.add(toDisposable(() => root.remove())); @@ -995,7 +995,7 @@ suite('StyleOverridesContribution', () => { test('keeps panel global actions above overflowing title actions', () => { const root = document.createElement('div'); - root.className = 'monaco-workbench style-override'; + root.className = 'monaco-workbench modern-ui'; root.style.setProperty('--vscode-panel-background', '#123456'); document.body.appendChild(root); store.add(toDisposable(() => root.remove())); diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css b/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css deleted file mode 100644 index 04fe8e8478d27d..00000000000000 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css +++ /dev/null @@ -1,265 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* - * Activity Bar — replace the active (selected) item's left highlight border - * with a filled, rounded background that sits behind the icon. - */ - -/* Drop the 2px left border indicator on the active item. */ -.style-override .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator:before { - border-left: none !important; -} - -/* The indicator owns the inset background for both active and hovered items. */ -.style-override .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator, -.style-override .activitybar > .content:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked):hover .active-item-indicator { - z-index: 0; - top: 0; - left: calc((var(--activity-bar-width, 36px) - var(--activity-bar-action-height, 36px) + 4px) / 2); - width: calc(var(--activity-bar-action-height, 36px) - 4px); - height: calc(var(--activity-bar-action-height, 36px) - 4px); - border-radius: var(--vscode-cornerRadius-small); -} - -.style-override .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator { - background-color: var(--vscode-modernActivityBar-activeBackground); -} - -.style-override .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .action-label.codicon { - color: var(--vscode-modernActivityBar-activeForeground) !important; -} - -.style-override .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .action-label.uri-icon { - background-color: var(--vscode-modernActivityBar-activeForeground) !important; -} - -.style-override .activitybar > .content:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked):hover .active-item-indicator { - background-color: var(--vscode-modernActivityBar-hoverBackground); -} - -.style-override .activitybar > .content:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked):hover .action-label.codicon { - color: var(--vscode-modernActivityBar-hoverForeground) !important; -} - -.style-override .activitybar > .content:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked):hover .action-label.uri-icon { - background-color: var(--vscode-modernActivityBar-hoverForeground) !important; -} - -/* Compact: minimal horizontal inset — 24×24px box centered in the 28px item. */ -.style-override .activitybar.compact > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator, -.style-override .activitybar.compact > .content:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked):hover .active-item-indicator { - width: calc(var(--activity-bar-action-height, 28px) - 4px); - height: calc(var(--activity-bar-action-height, 28px) - 4px); -} - -:is(.hc-black, .hc-light).style-override .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator { - border-radius: var(--vscode-cornerRadius-small); - background-color: transparent; - outline: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder); - outline-offset: calc(-1 * var(--vscode-strokeThickness)); -} - -:is(.hc-black, .hc-light).style-override.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator { - display: block; -} - -:is(.hc-black, .hc-light).style-override .activitybar > .content:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked):hover .active-item-indicator { - border-radius: var(--vscode-cornerRadius-small); - background-color: transparent; - outline: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder); - outline-offset: calc(-1 * var(--vscode-strokeThickness)); -} - -/* - * Horizontal Activity Bar — top / bottom position on the primary sidebar, panel - * (bottom) and auxiliary bar. When the activity bar is moved to the top or - * bottom of a part, its composites render in a horizontal `.composite-bar` - * inside the part's header/footer (`.pane-composite-part > .header-or-footer`) - * rather than the dedicated activity bar. By default the active item is marked - * with a thin top/bottom border line plus a flat, full-bleed fill. Mirror the - * default (side) treatment so both layouts feel consistent: drop the line and - * the flat fill, and paint an inset, rounded background box behind the icon for - * the active and hovered items. - * - * The box geometry is symmetric (centered) within the ~35px tall icon item so - * it reads as a soft, selected chip — the horizontal analogue of the square box - * used in the default position. - */ - -/* - * Keep the icon (and its badge) above the background box. Unlike the dedicated - * activity bar, the composite-bar label is not promoted in the stacking order, - * so lift it explicitly; otherwise the indicator (appended last in the DOM) - * would paint over the icon. - */ -.style-override .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon :is(.action-label, .badge) { - position: relative; - z-index: 1; -} - -/* Drop the flat, full-bleed active fill — it is replaced by the rounded box. */ -.style-override .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon.checked { - background-color: transparent; -} - -/* - * Drop the thin top/bottom active border line. It is kept while the item is - * keyboard focused so the focus indicator still shows. - */ -.style-override .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon.checked:not(:focus) .active-item-indicator:before { - display: none; -} - -/* Active item: inset, rounded background box behind the icon. */ -.style-override .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) > .composite-bar > .monaco-action-bar .action-item.icon.checked:not(:active) .active-item-indicator { - z-index: 0; - top: 50%; - left: var(--vscode-spacing-size20); - width: 24px; - height: 24px; - border-radius: var(--vscode-cornerRadius-small); - transform: translateY(-50%); -} - -/* - * Hover item: identical rounded box. Skipped on the active item (which already - * shows the box) and while dragging (the action-item `::before`/`::after` are - * reused for the drop-line indicators). - */ -.style-override .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) > .composite-bar > .monaco-action-bar .action-item.icon:not(.checked):not(:active):hover .active-item-indicator { - z-index: 0; - top: 50%; - left: var(--vscode-spacing-size20); - width: 24px; - height: 24px; - border-radius: var(--vscode-cornerRadius-small); - transform: translateY(-50%); -} - -.style-override .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .action-label.codicon::before, -.style-override.monaco-workbench .part.sidebar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label::before, -.style-override.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label::before { - left: 0; -} - -.style-override .pane-composite-part.auxiliarybar > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .action-label.uri-icon { - top: 0px; - left: 0px; -} - -/* Render the composite icons at the standard 16px size. */ -.style-override .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .action-label.codicon { - font-size: 16px; - line-height: 12px; - font-weight: var(--vscode-fontWeight-regular); -} - -/* - * Size the "Additional Views" overflow control (`.codicon-more`) to a 24px - * square so it matches the other 24px composite controls. `box-sizing` keeps the - * 16px glyph centered within the box. - */ -.style-override.monaco-workbench .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-label.codicon-more { - box-sizing: border-box; - width: 24px; - height: 24px; - color: var(--vscode-icon-foreground) !important; -} - -/* - * Center the 24px overflow control within its item. The item is wider than the - * label where its width is not pinned to 24px, so without this the label sits at - * the flex start (left) and the glyph reads as horizontally off-center. - */ -.style-override.monaco-workbench .part.auxiliarybar > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-label.codicon-more::before { - position: relative; - left: 0; -} - -/* - * Drop the divider between the horizontal activity bar and the part body. The - * rounded box gives the active item its own shape, so the full-width rule under - * the top (header) bar — and over the bottom (footer) bar — is removed for the - * cleaner, default-position look. `.monaco-workbench` is added to outrank the - * base part rule. - */ -.style-override.monaco-workbench .pane-composite-part:not(.empty) > .header { - border-bottom: none; - overflow: visible; -} - -.style-override.monaco-workbench .pane-composite-part:not(.empty) > .footer { - border-top: none; -} - -.style-override.monaco-workbench .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact { - overflow: visible; -} - -/* When badge-content is empty (icon-only via ::before, e.g. the update badge), use uniform padding so it stays circular. */ -.style-override.monaco-workbench .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .badge .badge-content:empty { - padding: var(--vscode-spacing-size20); -} - -.style-override.monaco-workbench .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact .badge-content:empty { - padding: 0; - border-radius: var(--vscode-cornerRadius-circle); -} - -.style-override.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content { - top: 18px; - right: 3px; -} - -.style-override.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content:empty { - padding: 0; - border-radius: var(--vscode-cornerRadius-circle); - width: 16px; -} - -.style-override.monaco-workbench .activitybar.compact > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content { - top: 13px; - right: 3px; -} - -.style-override.monaco-workbench .activitybar.compact > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content:empty { - padding: 0; - border-radius: var(--vscode-cornerRadius-circle); - width: 13px; -} - -.style-override.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator { - overflow: visible; -} - -.style-override.monaco-workbench .pane-composite-part > .header-or-footer.header > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before { - bottom: 8px; - border-radius: var(--vscode-cornerRadius-small); -} - -.style-override.monaco-workbench .pane-composite-part > .header-or-footer.header > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:focus .active-item-indicator:before { - bottom: 0; -} - -.style-override.monaco-workbench .pane-composite-part > .header-or-footer.footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before { - border-radius: var(--vscode-cornerRadius-small); - top: 3px; -} - -.style-override.monaco-workbench .pane-composite-part > .header-or-footer.footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:focus .active-item-indicator:before { - top: 0; -} -.style-override.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .profile-badge .profile-text-overlay { - top: 16px; -} - -.style-override.monaco-workbench .activitybar.compact > .content :not(.monaco-menu) > .monaco-action-bar .profile-badge .profile-text-overlay { - top: 10px; -} - -.style-override .pane-composite-part > :is(.title, .header-or-footer) > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon:not(.checked) .action-label.codicon.codicon-more:hover { - background: transparent; -} diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index b6dac0130cd24a..e3864c53280c07 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -57,7 +57,7 @@ import { LayoutSettings } from '../../../../services/layout/browser/layoutServic import { TestContextService } from '../../../common/workbenchTestServices.js'; import { workbenchInstantiationService } from '../../workbenchTestServices.js'; import { ComponentFixtureAdditionalTheme, ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; -import '../../../../contrib/styleOverrides/browser/media/tabs.css'; +import '../../../../contrib/modernUI/browser/media/tabs.css'; import './editorTabBar.fixture.css'; // ============================================================================ diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 8ef5da51c47d94..562b6f7554d735 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -341,8 +341,8 @@ import './contrib/externalTerminal/browser/externalTerminal.contribution.js'; // Relauncher import './contrib/relauncher/browser/relauncher.contribution.js'; -// Style Overrides (experimental) -import './contrib/styleOverrides/browser/styleOverrides.contribution.js'; +// Modern UI (experimental) +import './contrib/modernUI/browser/modernUI.contribution.js'; // Tasks import './contrib/tasks/browser/task.contribution.js'; From b26ccfd07c2f0d9581b1cd5b9c7d2084618691a8 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Tue, 18 Aug 2026 13:16:33 +0200 Subject: [PATCH 04/18] sharedProcess: Ignore missing language pack cache (#331432) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/languagePackCachedDataCleaner.ts | 15 ++++- .../languagePackCachedDataCleaner.test.ts | 62 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 src/vs/code/test/electron-utility/sharedProcess/contrib/languagePackCachedDataCleaner.test.ts diff --git a/src/vs/code/electron-utility/sharedProcess/contrib/languagePackCachedDataCleaner.ts b/src/vs/code/electron-utility/sharedProcess/contrib/languagePackCachedDataCleaner.ts index f3f351ebaa49bb..6a68dd2a0464a1 100644 --- a/src/vs/code/electron-utility/sharedProcess/contrib/languagePackCachedDataCleaner.ts +++ b/src/vs/code/electron-utility/sharedProcess/contrib/languagePackCachedDataCleaner.ts @@ -56,7 +56,10 @@ export class LanguagePackCachedDataCleaner extends Disposable { } } - private async cleanUpLanguagePackCache(): Promise { + /** + * Public for testing. + */ + async cleanUpLanguagePackCache(): Promise { this.logService.trace('[language pack cache cleanup]: Starting to clean up unused language packs.'); try { @@ -89,7 +92,15 @@ export class LanguagePackCachedDataCleaner extends Disposable { const now = Date.now(); for (const packEntry of Object.keys(installed)) { const folder = join(cacheDir, packEntry); - const entries = await Promises.readdir(folder); + let entries: string[]; + try { + entries = await Promises.readdir(folder); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + continue; + } + throw error; + } for (const entry of entries) { if (entry === 'tcf.json') { continue; diff --git a/src/vs/code/test/electron-utility/sharedProcess/contrib/languagePackCachedDataCleaner.test.ts b/src/vs/code/test/electron-utility/sharedProcess/contrib/languagePackCachedDataCleaner.test.ts new file mode 100644 index 00000000000000..62b37f8aac33ae --- /dev/null +++ b/src/vs/code/test/electron-utility/sharedProcess/contrib/languagePackCachedDataCleaner.test.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import * as fs from 'fs'; +import { tmpdir } from 'os'; +import { errorHandler } from '../../../../../base/common/errors.js'; +import { join } from '../../../../../base/common/path.js'; +import { Promises } from '../../../../../base/node/pfs.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { getRandomTestPath } from '../../../../../base/test/node/testUtils.js'; +import { INativeEnvironmentService } from '../../../../../platform/environment/common/environment.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { LanguagePackCachedDataCleaner } from '../../../../electron-utility/sharedProcess/contrib/languagePackCachedDataCleaner.js'; + +suite('LanguagePackCachedDataCleaner', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let testDir: string; + + setup(async () => { + testDir = getRandomTestPath(tmpdir(), 'vsctests', 'languagePackCachedDataCleaner'); + await fs.promises.mkdir(join(testDir, 'clp'), { recursive: true }); + }); + + teardown(async () => { + await Promises.rm(testDir); + }); + + test('ignores an installed language pack without cached data', async () => { + await fs.promises.writeFile(join(testDir, 'languagepacks.json'), JSON.stringify({ + de: { + hash: 'language-pack-hash', + extensions: [] + } + })); + + const environmentService = { + isBuilt: false, + userDataPath: testDir + } as INativeEnvironmentService; + const productService = { + quality: 'insider' + } as IProductService; + const unexpectedErrors: Error[] = []; + const unexpectedErrorHandler = errorHandler.getUnexpectedErrorHandler(); + errorHandler.setUnexpectedErrorHandler(error => unexpectedErrors.push(error)); + + try { + const cleaner = disposables.add(new LanguagePackCachedDataCleaner(environmentService, new NullLogService(), productService)); + await cleaner.cleanUpLanguagePackCache(); + + assert.deepStrictEqual(unexpectedErrors, []); + } finally { + errorHandler.setUnexpectedErrorHandler(unexpectedErrorHandler); + } + }); +}); From f5e4ab37d84f6bf65c9267b543677bd4263d7e87 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 18 Aug 2026 21:23:09 +1000 Subject: [PATCH 05/18] =?UTF-8?q?agentHost:=20fix=20/fleet=20turn=20owners?= =?UTF-8?q?hip=20after=20Plan=20=E2=86=92=20Interactive=20(#331410)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * agentHost: fix /fleet turn ownership after Plan → Interactive Running `/fleet` immediately after switching from Plan to Interactive left the agent stuck ("plan approval declined twice", clarification UI "No active turn"). Two compounding lifecycle defects in CopilotAgentSession caused it: - RC1: the effective SDK agent mode was applied only after runtime slash-command invocation, and the non-`agent-prompt` early return skipped it entirely, so `/fleet` executed under a stale Plan mode. - RC2: `/fleet` starts an asynchronous SDK agent loop but `commands.invoke` returns a synchronous `completed` result, so the AHP turn was completed and cleared ~100ms in, orphaning fleet's plan review, ask_user, tool calls, and final output. Fix (VS Code-only, temporary until the SDK returns `agent-prompt` for `/fleet`): - Apply the effective mode before invoking any non-skill runtime command, retaining the later application for `agent-prompt` overrides. - Route canonical built-in `/fleet` through the dedicated `rpc.fleet.start` RPC and keep the AHP turn alive until `session.idle`. Reject attachments, run the shared best-effort preflight (mode/permission/sandbox/MCP), and guard aborts during preflight and racing the RPC so no orphaned loop or duplicate terminal action can occur. Refs microsoft/vscode-internalbacklog#8837 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * agentHost: capture /fleet abort token before slash-command resolution An abort during `resolveSlashCommand` (which awaits `rpc.commands.list`) drives a terminal `session.idle` that resets the live abort token and leaves the pending turn open. `_startFleet` then captured `this._abortToken` after that await, observing the fresh (uncancelled) token and starting an autonomous fleet loop after the user cancelled — the turn-identity guard cannot catch it because the pending turn is deliberately left open for the queued-message case. Capture the turn's abort token at the top of `_send`, before any dispatch await, and thread it into `_startFleet` so its guards honor an abort that raced slash-command resolution (discarding the turn instead of starting the loop). Add a regression test that fires the abort from an `rpc.commands.list` seam (before the token capture) and asserts no fleet loop starts and no turn remains. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../node/copilot/copilotAgentSession.ts | 106 ++++- .../test/node/copilotAgentSession.test.ts | 420 ++++++++++++++++++ 2 files changed, 524 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index efb40c165a7547..0b354d74a2a99e 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -2120,6 +2120,14 @@ export class CopilotAgentSession extends Disposable { private async _send(prompt: string, attachments: readonly MessageAttachment[] | undefined, mode: CopilotSdkMode | undefined): Promise { this._logService.info(`[Copilot:${this.sessionId}] sendMessage called: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}" (${attachments?.length ?? 0} attachments)`); + // Capture the turn's abort token before any dispatch await. Resolving a slash + // command awaits `rpc.commands.list`; an abort during that await drives a terminal + // `session.idle` that resets the live token (and leaves a `pending` turn open). + // Reading `this._abortToken` afterwards — e.g. inside `_startFleet` — would then + // observe a fresh, uncancelled token and miss the abort, starting an autonomous + // fleet loop after cancellation. The captured reference reliably reflects it. + const abortToken = this._abortToken; + const slashCommand = parseLeadingSlashCommand(prompt); if (slashCommand?.command === 'compact') { try { @@ -2185,8 +2193,17 @@ export class CopilotAgentSession extends Disposable { prompt = configAction.strippedPrompt; } else if (slashCommand) { const runtimeSlashCommand = await this._slashCommandProvider.resolveSlashCommand(slashCommand.command); + // TEMPORARY WORKAROUND (#8837): route built-in /fleet via fleet.start to keep the AHP turn open; this bypasses commands.invoke telemetry/gating and should be removed once invoke returns agent-prompt. + if (runtimeSlashCommand && runtimeSlashCommand.kind === 'builtin' && runtimeSlashCommand.name === 'fleet') { + await this._startFleet(slashCommand.rest, attachments, mode, abortToken); + return; + } // Skills can be passed as is to the runtime. if (runtimeSlashCommand && runtimeSlashCommand.kind !== 'skill') { + // Apply the effective mode before invoking the runtime command so it runs + // under the correct SDK mode (issue #8837). An `agent-prompt` result may + // override the mode; that override is applied again before `session.send`. + await this.applyMode(mode); let result: CopilotCommandInvocationResult; try { result = await this._wrapper.session.rpc.commands.invoke({ @@ -2241,13 +2258,98 @@ export class CopilotAgentSession extends Disposable { const sdkAttachments = await this._toSdkAttachments(attachments); + await this._prepareSdkTurn(mode); + const traceContext = this._otelService.getSessionTraceContext(this.sessionId, this.resourceUri.toString()); + await this._otelService.withTraceContext(traceContext, () => this._wrapper.session.send({ prompt, attachments: sdkAttachments?.length ? sdkAttachments : undefined })); + this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`); + } + + /** + * Applies the per-turn SDK configuration shared by every operation that starts + * an agent loop (normal `session.send` and the `/fleet` start path): agent mode, + * permission mode, sandbox, and MCP enablement. Mode and sandbox keep their + * existing best-effort semantics. + */ + private async _prepareSdkTurn(mode: CopilotSdkMode | undefined): Promise { await this.applyMode(mode); await this.syncPermissionMode('turn-start'); await this._applyEffectiveSandboxConfig(); await this._reconcileMcpServerEnablement(); + } + + /** + * Temporary `/fleet` compatibility path (issue #8837): starts the SDK's fleet + * agent loop via the dedicated `rpc.fleet.start` RPC and keeps the AHP turn open + * until the SDK's terminal `session.idle`, rather than completing it as `commands.invoke` + * would. Remove once the runtime returns an `agent-prompt` result for `/fleet`. + */ + private async _startFleet(rest: string, attachments: readonly MessageAttachment[] | undefined, mode: CopilotSdkMode | undefined, abortToken: CancellationToken): Promise { + if (attachments?.length) { + // `rpc.fleet.start` accepts only a prompt; fail loudly rather than silently dropping attachments. + throw new Error(localize('copilotAgent.fleet.attachmentsUnsupported', "Attachments are not supported with the /fleet command.")); + } + const startingTurn = this._currentTurn; + // `abortToken` is captured by the caller before the dispatch await (slash-command + // resolution), so it reliably reflects an abort that raced that await: an aborted + // `session.idle` resets the live token, so reading `this._abortToken` here could + // observe a fresh post-abort token and miss the cancellation. + await this._prepareSdkTurn(mode); + // Preflight awaits several RPCs; if an abort or terminal idle raced it, do not + // start the fleet loop at all — starting it would orphan an autonomous run. + if (!startingTurn || this._currentTurn !== startingTurn) { + this._logService.warn(`[Copilot:${this.sessionId}] fleet turn ended during preflight; not starting fleet`); + return; + } + if (abortToken.isCancellationRequested) { + this._logService.warn(`[Copilot:${this.sessionId}] aborted during fleet preflight; not starting fleet`); + this.discardActiveTurn(); + return; + } const traceContext = this._otelService.getSessionTraceContext(this.sessionId, this.resourceUri.toString()); - await this._otelService.withTraceContext(traceContext, () => this._wrapper.session.send({ prompt, attachments: sdkAttachments?.length ? sdkAttachments : undefined })); - this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`); + let result: { started: boolean }; + try { + result = await this._otelService.withTraceContext(traceContext, () => this._wrapper.session.rpc.fleet.start(rest ? { prompt: rest } : {})); + } catch (err) { + // A terminal `session.idle` already ended this turn while the RPC was in + // flight — idle is authoritative, so never emit a second terminal action. + if (!startingTurn || this._currentTurn !== startingTurn) { + this._logService.warn(`[Copilot:${this.sessionId}] rpc.fleet.start rejected after its turn already ended`, err); + return; + } + // An abort raced the RPC; the client already finalized the protocol turn via + // cancellation, so drop our handle rather than surfacing another error. + if (abortToken.isCancellationRequested) { + this._logService.warn(`[Copilot:${this.sessionId}] rpc.fleet.start rejected after abort; discarding turn`, err); + this.discardActiveTurn(); + return; + } + throw err; + } + if (!startingTurn || this._currentTurn !== startingTurn) { + // A terminal `session.idle` already ended this turn while the RPC was in flight. + if (!result.started) { + this._logService.warn(`[Copilot:${this.sessionId}] rpc.fleet.start returned started=false after its turn already ended`); + } + return; + } + if (abortToken.isCancellationRequested) { + // An abort raced the RPC and left this turn `pending` (the idle handler keeps + // pending turns open), so it will never receive a completing idle. Drop the + // handle instead of promoting it to `running`, which would strand the chat. + this._logService.warn(`[Copilot:${this.sessionId}] rpc.fleet.start settled after abort; discarding turn`); + this.discardActiveTurn(); + return; + } + if (result.started) { + // `fleet.start` only acknowledges activation; the SDK agent loop keeps + // running and the existing `session.idle` handler completes this turn. + // Promote the turn to `running` now so an abort before the first SDK event + // tears it down instead of stranding a `pending` turn. + startingTurn.markRunning(); + this._logService.info(`[Copilot:${this.sessionId}] rpc.fleet.start succeeded; retaining turn until session idle`); + return; + } + throw new Error(localize('copilotAgent.fleet.notStarted', "Fleet could not be started.")); } private async _toSdkAttachments(attachments: readonly MessageAttachment[] | undefined): Promise { diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 0a8b1669f1710b..231bc6644bdcc7 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -93,6 +93,19 @@ class MockCopilotSession { readonly compactCalls: unknown[] = []; readonly commandListCalls: unknown[] = []; readonly commandInvokeCalls: Array<{ name: string; input?: string }> = []; + readonly fleetStartCalls: Array<{ prompt?: string }> = []; + fleetStartResult: { started: boolean } = { started: true }; + fleetStartError: unknown = undefined; + /** Awaited inside `rpc.fleet.start` so a test can hold the RPC in flight. */ + fleetStartGate: Promise | undefined; + /** Invoked inside `rpc.fleet.start` before it settles, so a test can emit SDK events (e.g. a racing `session.idle`). */ + onFleetStart: (() => void) | undefined = undefined; + /** Invoked inside `rpc.mode.set`, so a test can race an abort against turn preflight. */ + onModeSet: (() => void) | undefined = undefined; + /** Invoked inside `rpc.commands.list`, so a test can race an abort against slash-command resolution (before the fleet turn captures its token). */ + onCommandList: (() => void) | undefined = undefined; + /** Ordered log of the SDK RPCs/sends relevant to turn preflight, so tests can assert cross-RPC ordering. */ + readonly operationLog: string[] = []; readonly mcpEnableCalls: Array<{ serverName: string }> = []; readonly mcpDisableCalls: Array<{ serverName: string }> = []; readonly mcpStartServerCalls: Array<{ serverName: string }> = []; @@ -222,6 +235,7 @@ class MockCopilotSession { // Stubs for methods the wrapper / session class calls async send(request: unknown) { + this.operationLog.push('send'); this.sendRequests.push(request); return `message-${this.sendRequests.length}`; } @@ -241,12 +255,15 @@ class MockCopilotSession { mode: { get: async () => ({ mode: 'interactive' as const }), set: async (params: { mode: 'interactive' | 'plan' | 'autopilot' }) => { + this.operationLog.push('mode.set'); this.modeSetCalls.push({ mode: params.mode }); + this.onModeSet?.(); }, }, permissions: { setAllowAll: async (params: { mode?: PermissionAllowAllMode }) => { const mode = params.mode ?? 'off'; + this.operationLog.push('permissions.setAllowAll'); this.permissionModeSetCalls.push(mode); return { success: this.permissionModeSetSuccess, enabled: mode === 'on', mode }; }, @@ -278,13 +295,27 @@ class MockCopilotSession { commands: { list: async (params?: unknown) => { this.commandListCalls.push(params ?? null); + this.onCommandList?.(); return this.commandListResult; }, invoke: async (params: { name: string; input?: string }) => { + this.operationLog.push('commands.invoke'); this.commandInvokeCalls.push(params); return this.commandInvokeResult; }, }, + fleet: { + start: async (params: { prompt?: string }) => { + this.operationLog.push('fleet.start'); + this.fleetStartCalls.push(params); + this.onFleetStart?.(); + await this.fleetStartGate; + if (this.fleetStartError !== undefined) { + throw this.fleetStartError; + } + return this.fleetStartResult; + }, + }, tasks: { list: async () => { this.backgroundTaskListCalls++; @@ -349,6 +380,7 @@ class MockCopilotSession { options: { update: async (params: { sandboxConfig?: unknown; isExperimentalMode?: boolean }) => { if (params.sandboxConfig !== undefined) { + this.operationLog.push('options.update:sandbox'); this.sandboxConfigUpdates.push(params.sandboxConfig); } if (params.isExperimentalMode !== undefined) { @@ -2224,6 +2256,394 @@ suite('CopilotAgentSession', () => { }); }); + suite('/fleet lifecycle (issue #8837)', () => { + const fleetCommand = (aliases?: string[]) => ({ + name: 'fleet', + kind: 'builtin' as const, + description: 'Fan the plan out across parallel subagents', + allowDuringAgentExecution: false, + ...(aliases ? { aliases } : {}), + }); + + test('canonical built-in /fleet starts via rpc.fleet.start, keeps the turn open, and completes once on idle', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + mockSession.commandListResult = { commands: [fleetCommand()] }; + + await session.send('/fleet the full analysis', undefined, 'turn-fleet', 'interactive'); + + assert.deepStrictEqual({ + fleetStartCalls: mockSession.fleetStartCalls, + commandInvokeCalls: mockSession.commandInvokeCalls, + sendRequests: mockSession.sendRequests, + turnCompleteBeforeIdle: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, + hasActiveTurn: session.hasActiveTurn, + }, { + fleetStartCalls: [{ prompt: 'the full analysis' }], + commandInvokeCalls: [], + sendRequests: [], + turnCompleteBeforeIdle: 0, + hasActiveTurn: true, + }); + + mockSession.fire('assistant.message_delta', { deltaContent: 'Deploying fleet' } as SessionEventPayload<'assistant.message_delta'>['data']); + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + + const actions = getActions(signals); + const responseParts = actions.filter(a => a.type === ActionType.ChatResponsePart) as ChatResponsePartAction[]; + const turnComplete = actions.filter(a => a.type === ActionType.ChatTurnComplete) as ChatTurnCompleteAction[]; + assert.deepStrictEqual({ + responsePartTurnIds: responseParts.map(a => a.turnId), + turnCompleteTurnIds: turnComplete.map(a => a.turnId), + hasActiveTurn: session.hasActiveTurn, + }, { + responsePartTurnIds: ['turn-fleet'], + turnCompleteTurnIds: ['turn-fleet'], + hasActiveTurn: false, + }); + }); + + test('fleet preflight applies mode, permission, and sandbox before fleet.start and skips commands.invoke and the outer send', async () => { + const { session, mockSession } = await createAgentSession(disposables); + mockSession.commandListResult = { commands: [fleetCommand()] }; + + await session.send('/fleet go', undefined, 'turn-fleet', 'interactive'); + + const log = mockSession.operationLog; + const modeIdx = log.indexOf('mode.set'); + const permIdx = log.indexOf('permissions.setAllowAll'); + const sandboxIdx = log.indexOf('options.update:sandbox'); + const startIdx = log.indexOf('fleet.start'); + assert.deepStrictEqual({ + strictPreflightOrder: modeIdx >= 0 && permIdx > modeIdx && sandboxIdx > permIdx && startIdx > sandboxIdx, + fleetStartCount: log.filter(op => op === 'fleet.start').length, + invokedGenericCommand: log.includes('commands.invoke'), + sentThroughNormalSend: log.includes('send'), + }, { + strictPreflightOrder: true, + fleetStartCount: 1, + invokedGenericCommand: false, + sentThroughNormalSend: false, + }); + }); + + test('a client command named `fleet` still routes through commands.invoke', async () => { + const { session, mockSession } = await createAgentSession(disposables); + mockSession.commandListResult = { + commands: [{ name: 'fleet', kind: 'client', description: 'A client command', allowDuringAgentExecution: true }], + }; + mockSession.commandInvokeResult = { kind: 'completed', message: 'client fleet done' }; + + await session.send('/fleet go', undefined, 'turn-fleet', 'interactive'); + + assert.deepStrictEqual({ + fleetStartCalls: mockSession.fleetStartCalls, + commandInvokeCalls: mockSession.commandInvokeCalls, + }, { + fleetStartCalls: [], + commandInvokeCalls: [{ name: 'fleet', input: 'go' }], + }); + }); + + test('resolves fleet via alias and case-insensitively, and omits an empty prompt', async () => { + const { session, mockSession } = await createAgentSession(disposables); + mockSession.commandListResult = { commands: [fleetCommand(['fl'])] }; + + await session.send('/FLEET the full analysis', undefined, 'turn-fleet-1', 'interactive'); + await session.send('/fl', undefined, 'turn-fleet-2', 'interactive'); + + assert.deepStrictEqual(mockSession.fleetStartCalls, [ + { prompt: 'the full analysis' }, + {}, + ]); + }); + + test('rejects /fleet attachments before any SDK side effect', async () => { + const { session, mockSession } = await createAgentSession(disposables); + mockSession.commandListResult = { commands: [fleetCommand()] }; + + await assert.rejects(session.send( + '/fleet go', + [{ type: MessageAttachmentKind.Resource, uri: 'file:///workspace/file.ts', label: 'file.ts', displayKind: 'document' }], + 'turn-fleet', + 'interactive', + )); + + assert.deepStrictEqual({ + operationLog: mockSession.operationLog, + fleetStartCalls: mockSession.fleetStartCalls, + commandInvokeCalls: mockSession.commandInvokeCalls, + sendRequests: mockSession.sendRequests, + hasActiveTurn: session.hasActiveTurn, + }, { + operationLog: [], + fleetStartCalls: [], + commandInvokeCalls: [], + sendRequests: [], + hasActiveTurn: false, + }); + }); + + test('started:false surfaces an error and clears the turn without an SDK send', async () => { + const { session, mockSession } = await createAgentSession(disposables); + mockSession.commandListResult = { commands: [fleetCommand()] }; + mockSession.fleetStartResult = { started: false }; + + await assert.rejects(session.send('/fleet go', undefined, 'turn-fleet', 'interactive')); + + assert.deepStrictEqual({ + fleetStartCalls: mockSession.fleetStartCalls, + sendRequests: mockSession.sendRequests, + commandInvokeCalls: mockSession.commandInvokeCalls, + hasActiveTurn: session.hasActiveTurn, + }, { + fleetStartCalls: [{ prompt: 'go' }], + sendRequests: [], + commandInvokeCalls: [], + hasActiveTurn: false, + }); + }); + + test('a fleet.start rejection propagates and clears the turn', async () => { + const { session, mockSession } = await createAgentSession(disposables); + mockSession.commandListResult = { commands: [fleetCommand()] }; + mockSession.fleetStartError = new Error('fleet boom'); + + await assert.rejects(session.send('/fleet go', undefined, 'turn-fleet', 'interactive'), /fleet boom/); + assert.strictEqual(session.hasActiveTurn, false); + }); + + test('a session.idle racing fleet.start settlement completes the turn exactly once', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + mockSession.commandListResult = { commands: [fleetCommand()] }; + // Emit the terminal idle while `fleet.start` is still in flight. + mockSession.onFleetStart = () => mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + + await session.send('/fleet go', undefined, 'turn-fleet', 'interactive'); + + const turnComplete = getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete) as ChatTurnCompleteAction[]; + assert.deepStrictEqual({ + turnCompleteTurnIds: turnComplete.map(a => a.turnId), + hasActiveTurn: session.hasActiveTurn, + }, { + turnCompleteTurnIds: ['turn-fleet'], + hasActiveTurn: false, + }); + }); + + test('a fleet.start rejection after idle already completed the turn does not emit a second terminal action', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + mockSession.commandListResult = { commands: [fleetCommand()] }; + mockSession.fleetStartError = new Error('late boom'); + mockSession.onFleetStart = () => mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + + // Must not reject: idle is authoritative once it has completed the turn. + await session.send('/fleet go', undefined, 'turn-fleet', 'interactive'); + + const actions = getActions(signals); + assert.deepStrictEqual({ + turnCompleteCount: actions.filter(a => a.type === ActionType.ChatTurnComplete).length, + chatErrorCount: actions.filter(a => a.type === ActionType.ChatError).length, + hasActiveTurn: session.hasActiveTurn, + }, { + turnCompleteCount: 1, + chatErrorCount: 0, + hasActiveTurn: false, + }); + }); + + test('an aborted idle after a successful fleet start tears the turn down without a success completion', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + mockSession.commandListResult = { commands: [fleetCommand()] }; + + // Marking the turn running ensures an abort before the first SDK event tears it down. + await session.send('/fleet go', undefined, 'turn-fleet', 'interactive'); + mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); + + assert.deepStrictEqual({ + turnCompleteCount: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, + hasActiveTurn: session.hasActiveTurn, + }, { + turnCompleteCount: 0, + hasActiveTurn: false, + }); + }); + + test('an abort racing fleet.start settlement discards the turn without a success completion', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + mockSession.commandListResult = { commands: [fleetCommand()] }; + // Abort while `fleet.start` is in flight, then deliver the aborted terminal + // idle (which resets the live abort token and leaves the pending turn open). + mockSession.onFleetStart = () => { + void session.abort(); + mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); + }; + + await session.send('/fleet go', undefined, 'turn-fleet', 'interactive'); + + assert.deepStrictEqual({ + turnCompleteCount: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, + chatErrorCount: getActions(signals).filter(a => a.type === ActionType.ChatError).length, + hasActiveTurn: session.hasActiveTurn, + }, { + turnCompleteCount: 0, + chatErrorCount: 0, + hasActiveTurn: false, + }); + }); + + test('an abort during fleet preflight does not start the fleet loop', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + mockSession.commandListResult = { commands: [fleetCommand()] }; + // Abort while the shared preflight (mode/permission/sandbox/MCP) is running, + // before `fleet.start` would be invoked. + mockSession.onModeSet = () => { + void session.abort(); + mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); + }; + + await session.send('/fleet go', undefined, 'turn-fleet', 'interactive'); + + assert.deepStrictEqual({ + fleetStartCalls: mockSession.fleetStartCalls, + sentThroughNormalSend: mockSession.operationLog.includes('send'), + turnCompleteCount: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, + chatErrorCount: getActions(signals).filter(a => a.type === ActionType.ChatError).length, + hasActiveTurn: session.hasActiveTurn, + }, { + fleetStartCalls: [], + sentThroughNormalSend: false, + turnCompleteCount: 0, + chatErrorCount: 0, + hasActiveTurn: false, + }); + }); + + test('an abort during slash-command resolution does not start the fleet loop', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + mockSession.commandListResult = { commands: [fleetCommand()] }; + // Abort while `rpc.commands.list` (slash-command resolution) is in flight — + // before `_startFleet` runs. The aborted `session.idle` resets the live token + // and leaves the pending turn open, so only a token captured before this await + // reflects the cancellation. Reading a fresh token would start the fleet loop + // after the user cancelled. + mockSession.onCommandList = () => { + void session.abort(); + mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); + }; + + await session.send('/fleet go', undefined, 'turn-fleet', 'interactive'); + + assert.deepStrictEqual({ + fleetStartCalls: mockSession.fleetStartCalls, + sentThroughNormalSend: mockSession.operationLog.includes('send'), + turnCompleteCount: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete).length, + chatErrorCount: getActions(signals).filter(a => a.type === ActionType.ChatError).length, + hasActiveTurn: session.hasActiveTurn, + }, { + fleetStartCalls: [], + sentThroughNormalSend: false, + turnCompleteCount: 0, + chatErrorCount: 0, + hasActiveTurn: false, + }); + }); + + test('binds the fleet user.message event id to the original turn (message after RPC resolves)', async () => { + const sessionDatabase = new TestSessionDatabase(); + const { session, mockSession } = await createAgentSession(disposables, { sessionDatabase }); + mockSession.commandListResult = { commands: [fleetCommand()] }; + + await session.send('/fleet go', undefined, 'turn-fleet', 'interactive'); + mockSession.fire('user.message', { content: 'Fleet deployed: go' } as SessionEventPayload<'user.message'>['data'], { id: 'evt-fleet' }); + + assert.deepStrictEqual(sessionDatabase.setTurnEventIdCalls, [{ turnId: 'turn-fleet', eventId: 'evt-fleet' }]); + }); + + test('binds the fleet user.message event id to the original turn (message before RPC resolves)', async () => { + const sessionDatabase = new TestSessionDatabase(); + const { session, mockSession } = await createAgentSession(disposables, { sessionDatabase }); + mockSession.commandListResult = { commands: [fleetCommand()] }; + mockSession.onFleetStart = () => mockSession.fire('user.message', { content: 'Fleet deployed: go' } as SessionEventPayload<'user.message'>['data'], { id: 'evt-fleet' }); + + await session.send('/fleet go', undefined, 'turn-fleet', 'interactive'); + + assert.deepStrictEqual(sessionDatabase.setTurnEventIdCalls, [{ turnId: 'turn-fleet', eventId: 'evt-fleet' }]); + }); + + test('plan review and ask_user during an active fleet turn do not hit the no-active-turn fallback', async () => { + const { session, runtime, mockSession, signals } = await createAgentSession(disposables); + mockSession.commandListResult = { commands: [fleetCommand()] }; + mockSession.planReadResult = { exists: true, content: '## Plan', path: '/sessions/abc/plan.md' }; + + await session.send('/fleet the full analysis', undefined, 'turn-fleet', 'interactive'); + + const planReviewPromise = runtime.handleExitPlanModeRequest( + { summary: '## Plan summary', actions: ['interactive'], recommendedAction: 'interactive' }, + { sessionId: 'test-session-1' }, + ); + const askUserPromise = runtime.handleUserInputRequest({ question: 'Proceed?' }, { sessionId: 'test-session-1' }); + + // Plan review awaits `rpc.plan.read()` before raising its request, so let the microtask settle. + await timeout(0); + const inputRequests = getActions(signals).filter(a => a.type === ActionType.ChatInputRequested); + assert.strictEqual(inputRequests.length, 2, 'plan review and ask_user should each raise an input request while fleet is active'); + + session.dispose(); + assert.deepStrictEqual({ + planReview: await planReviewPromise, + askUser: await askUserPromise, + }, { + planReview: { approved: false }, + askUser: { answer: '', wasFreeform: true }, + }); + }); + }); + + test('applies the effective mode before invoking a non-fleet runtime command', async () => { + const { session, mockSession } = await createAgentSession(disposables); + // Establish a stale prior SDK mode, then clear setup noise from the trace. + await session.applyMode('plan'); + mockSession.operationLog.length = 0; + mockSession.commandListResult = { + commands: [{ name: 'env', kind: 'builtin', description: 'Show environment', allowDuringAgentExecution: true }], + }; + mockSession.commandInvokeResult = { kind: 'completed', message: 'done' }; + + await session.send('/env', undefined, 'turn-env', 'interactive'); + + const log = mockSession.operationLog; + assert.deepStrictEqual({ + modeBeforeInvoke: log.indexOf('mode.set') >= 0 && log.indexOf('mode.set') < log.indexOf('commands.invoke'), + lastModeApplied: mockSession.modeSetCalls.at(-1), + }, { + modeBeforeInvoke: true, + lastModeApplied: { mode: 'interactive' }, + }); + }); + + test('reapplies an agent-prompt mode override before the SDK send', async () => { + const { session, mockSession } = await createAgentSession(disposables); + mockSession.commandListResult = { + commands: [{ name: 'refine', kind: 'builtin', description: 'Refine', allowDuringAgentExecution: true }], + }; + mockSession.commandInvokeResult = { kind: 'agent-prompt', prompt: 'do it', displayPrompt: 'do it', mode: 'autopilot' }; + + await session.send('/refine now', undefined, 'turn-refine', 'interactive'); + + const log = mockSession.operationLog; + assert.deepStrictEqual({ + modeSetCalls: mockSession.modeSetCalls, + sendRequests: mockSession.sendRequests, + firstModeBeforeInvoke: log.indexOf('mode.set') < log.indexOf('commands.invoke'), + overrideModeAfterInvokeBeforeSend: log.lastIndexOf('mode.set') > log.indexOf('commands.invoke') && log.lastIndexOf('mode.set') < log.indexOf('send'), + }, { + modeSetCalls: [{ mode: 'interactive' }, { mode: 'autopilot' }], + sendRequests: [{ prompt: 'do it', attachments: undefined }], + firstModeBeforeInvoke: true, + overrideModeAfterInvokeBeforeSend: true, + }); + }); + test('emits accumulated Copilot usage metadata', async () => { const { session, mockSession, signals } = await createAgentSession(disposables); From c38c9ed1cf710a7c68acadd18db22fec795267d5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:32:29 +0000 Subject: [PATCH 06/18] Disable Alt+click Close Others for single-tab groups (#331435) * Initial plan * Fix single-tab close others enablement Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> --- src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 9727c59b45e4ee..eb84da5e5a3c8f 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -1624,6 +1624,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Alt swaps a visible Close action to Close Others; Unpin is unaffected. const wantsCloseOthersAction = hasCloseAction && this.wantsCloseOthersAction; + this.closeOtherEditorTabsInGroupAction.enabled = this.groupView.count > 1; let tabAction; if (hasAction) { From ebaaae902157f381f02fc8f9cdf73cd571c7bb96 Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Tue, 18 Aug 2026 18:20:03 +0500 Subject: [PATCH 07/18] tunnels: preserve reconnect pause reasons (#331103) * tunnels: fix: preserve reconnect pause reasons Fixes #331102 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7a940617-0dcd-4977-ac27-8078a8937524 * tunnels: fix: honor pauses during discovery Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7a940617-0dcd-4977-ac27-8078a8937524 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7a940617-0dcd-4977-ac27-8078a8937524 --- .../REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md | 1 + .../browser/tunnelAgentHost.contribution.ts | 86 +++++++++-------- .../tunnelAgentHost.contribution.test.ts | 92 ++++++++++++++----- 3 files changed, 120 insertions(+), 59 deletions(-) diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md index 605b16c40fb363..bb5646bfea745f 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md @@ -81,6 +81,7 @@ Decoupling these allows copilot sessions from different providers (local CLI, re - SSH config host connections use resolved `IdentityFile` and `IdentityAgent` values from `ssh -G`; encrypted private keys are prompted for a passphrase through the same quick-input bridge as keyboard-interactive auth. - Startup SSH auto-reconnect treats keyboard-interactive cancellation as an intentional pause and does not schedule another reconnect attempt. Host key denial pauses until an explicit reconnect so background retries cannot repeatedly reject a key that requires user review. - A manual SSH reconnect from the host picker bypasses that paused auto-reconnect state and starts a fresh reconnect attempt for stored SSH hosts; host-picker disconnect/cancel for SSH uses the SSH service instead of removing the stored host. +- Tunnel auto-reconnect preserves why each host paused: focus or browser network recovery resumes only exhausted retry budgets, authentication additions resume only authentication pauses, and an offline host resumes only after discovery confirms it is online. Merely focusing the window never attempts an untracked or known-offline cached tunnel. - `vscodeAgents.sshConnect/attempt` records each complete SSH plus AHP initialization attempt from the initial connection and stored-host reconnect paths, with connect/reconnect, user-initiated, attempt number, duration, success, retry intent, and a bounded failure category. It never records host names, addresses, aliases, or raw error messages. - VS Code remote transports declare their route in AHP initialize metadata (`dev_tunnel`, `ssh`, `wsl`, `remote_extension_host`, `direct_websocket`, or `web_pub_sub`). Agent Host product telemetry combines that declaration with the host-observed physical transport and launcher kind; message telemetry retains the initiating client id and route. - `ITunnelHostService` is a required dependency of the tunnel agent host contribution on every target, because tunnel discovery filters out the locally hosted tunnel. Hosting is CLI-backed and therefore impossible in a browser, so web registers an inert implementation that reports a permanently inactive sharing state rather than leaving the service unregistered. Omitting it fails construction of the whole contribution and silently disables tunnel discovery. diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts index f03a4ad1d43a90..c70c0d3713fd86 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts @@ -42,6 +42,8 @@ const RECONNECT_MAX_ATTEMPTS = 10; /** Minimum gap between event-triggered reconnect resumes. */ const RESUME_RATE_LIMIT_MS = 10_000; +type TunnelReconnectTrigger = 'wake' | 'focus' | 'sessionAdded'; + export class TunnelAgentHostContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'sessions.contrib.tunnelAgentHostContribution'; @@ -63,8 +65,8 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc private readonly _reconnectTimeouts = new Map>(); /** Consecutive failed auto-reconnect attempts per address. */ private readonly _reconnectAttempts = new Map(); - /** Addresses whose auto-reconnect loop has paused after too many failures. */ - private readonly _reconnectPaused = new Set(); + /** Why auto-reconnect is paused for each address. */ + private readonly _reconnectPauseReasons = new Map(); /** * Addresses whose provider currently holds a live connection. Tracked * separately from {@link _previousStatuses} so a drop is still detected when @@ -72,7 +74,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc * way down. */ private readonly _wiredAddresses = new Set(); - /** Timestamp of the last wake-triggered resume, to rate-limit rapid tab toggles. */ + /** Timestamp of the last focus/wake-triggered resume, to rate-limit rapid tab toggles. */ private _lastResumeAt = 0; /** @@ -616,7 +618,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc /** Clear retry-backoff and pause state for an address. */ private _clearReconnectBackoff(address: string): void { this._reconnectAttempts.delete(address); - this._reconnectPaused.delete(address); + this._reconnectPauseReasons.delete(address); } /** Drop all reconnect + telemetry state for an address (e.g. on removal). */ @@ -665,10 +667,15 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc private _pauseReconnect(address: string, reason: TunnelConnectFailureReason): void { this._cancelReconnect(address); this._reconnectAttempts.delete(address); - this._reconnectPaused.add(address); + this._reconnectPauseReasons.set(address, reason); + const resumeCondition = reason === 'hostOffline' + ? 'a status check that confirms the host is online' + : reason === 'auth' || reason === 'authExpired' + ? 'an authentication session change' + : `${isWeb ? 'network-online or ' : ''}window focus`; this._logService.info( `[TunnelAgentHost] Pausing auto-reconnect for ${address} (${reason}); ` + - `will resume on ${isWeb ? 'network-online, ' : ''}window focus, session change, or a status check that confirms the host is online.` + `will resume on ${resumeCondition}.` ); const session = this._connectSessions.get(address); if (session) { @@ -746,60 +753,60 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc } /** - * Invoked on a browser network, window-focus, or authentication event. Kicks off an - * immediate attempt for any disconnected cached tunnel. + * Resume paused reconnects that the given recovery signal can resolve. * * Rate-limited: at most one resume per RESUME_RATE_LIMIT_MS so that - * rapid tab toggling can't hammer a permanently broken endpoint with - * an unbounded number of attempt bursts. Resumes the normal backoff - * sequence (by clearing the pause flag) rather than zeroing the - * attempt counter. + * rapid focus/network events cannot start unbounded retry bursts. */ - private _resumeReconnects(trigger: 'wake' | 'focus' | 'sessionAdded'): void { + private _resumeReconnects(trigger: TunnelReconnectTrigger): void { if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { return; } - // Rate-limit rapid recovery events (e.g. alt-tab bursts or - // flaky Wi-Fi toggling online/offline) so we don't hammer the relay - // with immediate retries. This is an event-smoothing gate, not an - // error-backoff — that's handled by `_scheduleReconnect`. - const now = Date.now(); - if (now - this._lastResumeAt < RESUME_RATE_LIMIT_MS) { - return; - } - this._lastResumeAt = now; - - const cached = this._getProviderTunnels(); - for (const tunnel of cached) { + const resumableAddresses: string[] = []; + for (const tunnel of this._getProviderTunnels()) { const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`; - if (this._pendingConnects.has(address)) { + const reason = this._reconnectPauseReasons.get(address); + if (!reason || !this._canResumeReconnect(reason, trigger) || this._pendingConnects.has(address)) { continue; } - const live = this._remoteAgentHostService.connections.find(c => c.address === address); - if (live && RemoteAgentHostConnectionStatus.isConnected(live.status)) { - continue; + const live = this._remoteAgentHostService.connections.find(connection => connection.address === address); + if (!live || !RemoteAgentHostConnectionStatus.isConnected(live.status)) { + resumableAddresses.push(address); } + } + if (resumableAddresses.length === 0) { + return; + } - this._logService.info(`[TunnelAgentHost] Resuming reconnect for ${address} (trigger: ${trigger})`); - // If we were paused (exhausted the backoff budget), give a fresh - // budget since the wake event is itself evidence the environment - // has changed. Otherwise keep the current attempt counter so an - // in-progress backoff isn't short-circuited. - if (this._reconnectPaused.has(address)) { - this._clearReconnectBackoff(address); + if (trigger !== 'sessionAdded') { + const now = Date.now(); + if (now - this._lastResumeAt < RESUME_RATE_LIMIT_MS) { + return; } + this._lastResumeAt = now; + } + + for (const address of resumableAddresses) { + this._logService.info(`[TunnelAgentHost] Resuming reconnect for ${address} (trigger: ${trigger})`); + this._clearReconnectBackoff(address); this._scheduleReconnect(address, /*immediate*/ true); } } + private _canResumeReconnect(reason: TunnelConnectFailureReason, trigger: TunnelReconnectTrigger): boolean { + return trigger === 'sessionAdded' + ? reason === 'auth' || reason === 'authExpired' + : reason === 'maxAttemptsReached'; + } + /** Drop reconnect state for addresses whose tunnel is no longer cached. */ private _pruneReconnectState(): void { const cachedAddresses = new Set(this._getProviderTunnels().map(t => `${TUNNEL_ADDRESS_PREFIX}${t.tunnelId}`)); const tracked = new Set([ ...this._reconnectTimeouts.keys(), ...this._reconnectAttempts.keys(), - ...this._reconnectPaused, + ...this._reconnectPauseReasons.keys(), ...this._connectSessions.keys(), ]); for (const address of tracked) { @@ -896,7 +903,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc if (info && info.hostConnectionCount > 0) { provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connected); - if (this._reconnectPaused.has(address)) { + if (this._reconnectPauseReasons.get(address) === 'hostOffline') { this._logService.info( `[TunnelAgentHost] Confirmed host online for paused ${address}; auto-resuming reconnect.` ); @@ -923,6 +930,9 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc if (this._tunnelService.isAutoConnectSuppressed(tunnel.tunnelId)) { continue; } + if (this._reconnectPauseReasons.has(address)) { + continue; + } const alreadyConnected = this._remoteAgentHostService.connections.some( c => c.address === address && RemoteAgentHostConnectionStatus.isConnected(c.status) ); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/tunnelAgentHost.contribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/tunnelAgentHost.contribution.test.ts index 21a2c3058e8ecd..1fe24c755bda35 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/tunnelAgentHost.contribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/tunnelAgentHost.contribution.test.ts @@ -33,6 +33,7 @@ import { ITelemetryService } from '../../../../../../platform/telemetry/common/t import { IAuthenticationService } from '../../../../../../workbench/services/authentication/common/authentication.js'; import { IHostService } from '../../../../../../workbench/services/host/browser/host.js'; import { ITunnelHostService } from '../../../../../../workbench/contrib/chat/common/tunnelHost.js'; +import type { TunnelConnectFailureReason } from '../../../../../common/sessionsTelemetry.js'; import { ISessionsProvider } from '../../../../../services/sessions/common/sessionsProvider.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; import { IAgentHostFilterService } from '../../../../../services/agentHostFilter/common/agentHostFilter.js'; @@ -385,7 +386,7 @@ suite('TunnelAgentHostContribution', () => { ); }); - test('resumes a max-attempts pause on focus and rate-limits repeated focus changes', () => { + test('recovery signals resume only compatible pause reasons', () => { const tunnelService = store.add(new StubTunnelService()); const remoteService = store.add(new StubRemoteAgentHostService()); const providersService = store.add(new StubSessionsProvidersService()); @@ -404,41 +405,70 @@ suite('TunnelAgentHostContribution', () => { instantiationService.stub(ITunnelHostService, store.add(new StubTunnelHostService())); instantiationService.stub(IAgentHostFilterService, new StubFilterService() as unknown as IAgentHostFilterService); const contribution = store.add(instantiationService.createInstance(TestTunnelContribution)); - const address = `${TUNNEL_ADDRESS_PREFIX}tunnel-focus`; - tunnelService.setCached([{ tunnelId: 'tunnel-focus', clusterId: 'use', name: 'Focus Tunnel' }]); + const maxAttemptsAddress = `${TUNNEL_ADDRESS_PREFIX}tunnel-max-attempts`; + const offlineAddress = `${TUNNEL_ADDRESS_PREFIX}tunnel-offline`; + const authAddress = `${TUNNEL_ADDRESS_PREFIX}tunnel-auth`; + tunnelService.setCached([ + { tunnelId: 'tunnel-max-attempts', clusterId: 'use', name: 'Max Attempts Tunnel' }, + { tunnelId: 'tunnel-offline', clusterId: 'use', name: 'Offline Tunnel' }, + { tunnelId: 'tunnel-auth', clusterId: 'use', name: 'Auth Tunnel' }, + { tunnelId: 'tunnel-idle', clusterId: 'use', name: 'Idle Tunnel' }, + ]); const testable = contribution as unknown as { - _reconnectPaused: Set; + _reconnectPauseReasons: Map; _reconnectTimeouts: Map>; + _resumeReconnects(trigger: 'sessionAdded'): void; }; - testable._reconnectPaused.add(address); + testable._reconnectPauseReasons.set(maxAttemptsAddress, 'maxAttemptsReached'); + testable._reconnectPauseReasons.set(offlineAddress, 'hostOffline'); + testable._reconnectPauseReasons.set(authAddress, 'authExpired'); hostService.fireFocus(true); const firstResume = { - paused: testable._reconnectPaused.has(address), + paused: [...testable._reconnectPauseReasons], timers: [...testable._reconnectTimeouts.keys()], }; - testable._reconnectPaused.add(address); + testable._reconnectPauseReasons.set(maxAttemptsAddress, 'maxAttemptsReached'); hostService.fireFocus(true); const rateLimitedResume = { - paused: testable._reconnectPaused.has(address), + paused: [...testable._reconnectPauseReasons], + timers: [...testable._reconnectTimeouts.keys()], + }; + + testable._resumeReconnects('sessionAdded'); + const sessionResume = { + paused: [...testable._reconnectPauseReasons], timers: [...testable._reconnectTimeouts.keys()], }; assert.deepStrictEqual( - { firstResume, rateLimitedResume }, + { firstResume, rateLimitedResume, sessionResume }, { - firstResume: { paused: false, timers: [address] }, - rateLimitedResume: { paused: true, timers: [address] }, + firstResume: { + paused: [[offlineAddress, 'hostOffline'], [authAddress, 'authExpired']], + timers: [maxAttemptsAddress], + }, + rateLimitedResume: { + paused: [[offlineAddress, 'hostOffline'], [authAddress, 'authExpired'], [maxAttemptsAddress, 'maxAttemptsReached']], + timers: [maxAttemptsAddress], + }, + sessionResume: { + paused: [[offlineAddress, 'hostOffline'], [maxAttemptsAddress, 'maxAttemptsReached']], + timers: [maxAttemptsAddress, authAddress], + }, }, ); }); - test('confirmed online tunnel resumes a max-attempts pause during status check', async () => { + test('status checks resume only host-offline pauses and auto-connect preserves other pauses', async () => { const tunnelService = store.add(new StubTunnelService()); const remoteService = store.add(new StubRemoteAgentHostService()); const providersService = store.add(new StubSessionsProvidersService()); - const configurationService = new TestConfigurationService({ [RemoteAgentHostsEnabledSettingId]: true }); + const configurationService = new TestConfigurationService({ + [RemoteAgentHostsEnabledSettingId]: true, + [RemoteAgentHostAutoConnectSettingId]: true, + }); const hostService = new StubHostService(); const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(ITunnelAgentHostService, tunnelService as unknown as ITunnelAgentHostService); @@ -453,22 +483,42 @@ suite('TunnelAgentHostContribution', () => { instantiationService.stub(ITunnelHostService, store.add(new StubTunnelHostService())); instantiationService.stub(IAgentHostFilterService, new StubFilterService() as unknown as IAgentHostFilterService); const contribution = store.add(instantiationService.createInstance(TestTunnelContribution)); - const tunnelId = 'tunnel-online'; - const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`; - tunnelService.setCached([{ tunnelId, clusterId: 'use', name: 'Online Tunnel' }]); - tunnelService.setListed([{ tunnelId, clusterId: 'use', name: 'Online Tunnel', tags: [], protocolVersion: 5, hostConnectionCount: 1 }]); + const offlineAddress = `${TUNNEL_ADDRESS_PREFIX}tunnel-offline`; + const authAddress = `${TUNNEL_ADDRESS_PREFIX}tunnel-auth`; + const maxAttemptsAddress = `${TUNNEL_ADDRESS_PREFIX}tunnel-max-attempts`; + tunnelService.setCached([ + { tunnelId: 'tunnel-offline', clusterId: 'use', name: 'Offline Tunnel' }, + { tunnelId: 'tunnel-auth', clusterId: 'use', name: 'Auth Tunnel' }, + { tunnelId: 'tunnel-max-attempts', clusterId: 'use', name: 'Max Attempts Tunnel' }, + ]); + tunnelService.setListed([ + { tunnelId: 'tunnel-offline', clusterId: 'use', name: 'Offline Tunnel', tags: [], protocolVersion: 5, hostConnectionCount: 1 }, + { tunnelId: 'tunnel-auth', clusterId: 'use', name: 'Auth Tunnel', tags: [], protocolVersion: 5, hostConnectionCount: 1 }, + { tunnelId: 'tunnel-max-attempts', clusterId: 'use', name: 'Max Attempts Tunnel', tags: [], protocolVersion: 5, hostConnectionCount: 1 }, + ]); const testable = contribution as unknown as { - _reconnectPaused: Set; + _reconnectPauseReasons: Map; _reconnectTimeouts: Map>; _silentStatusCheck(): Promise; }; - testable._reconnectPaused.add(address); + testable._reconnectPauseReasons.set(offlineAddress, 'hostOffline'); + testable._reconnectPauseReasons.set(authAddress, 'authExpired'); + testable._reconnectPauseReasons.set(maxAttemptsAddress, 'maxAttemptsReached'); await testable._silentStatusCheck(); + await Promise.resolve(); assert.deepStrictEqual( - { paused: testable._reconnectPaused.has(address), timers: [...testable._reconnectTimeouts.keys()] }, - { paused: false, timers: [address] }, + { + paused: [...testable._reconnectPauseReasons], + connects: tunnelService.connectCalls.map(call => call.tunnel.tunnelId), + timers: [...testable._reconnectTimeouts.keys()], + }, + { + paused: [[authAddress, 'authExpired'], [maxAttemptsAddress, 'maxAttemptsReached']], + connects: ['tunnel-offline'], + timers: [], + }, ); }); From f489b728ba96a9a31351e25658adf0e2b6325f3a Mon Sep 17 00:00:00 2001 From: SteVen Batten Date: Tue, 18 Aug 2026 06:24:00 -0700 Subject: [PATCH 08/18] fix localization format string parsing (#331236) --- extensions/copilot/src/util/vs/nls.ts | 2 +- src/vs/base/test/common/nls.test.ts | 30 +++++++++++++++++++++++++++ src/vs/nls.ts | 2 +- 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 src/vs/base/test/common/nls.test.ts diff --git a/extensions/copilot/src/util/vs/nls.ts b/extensions/copilot/src/util/vs/nls.ts index 9c8ac4ba436132..f405b683a69885 100644 --- a/extensions/copilot/src/util/vs/nls.ts +++ b/extensions/copilot/src/util/vs/nls.ts @@ -33,7 +33,7 @@ function _format(message: string, args: (string | number | boolean | undefined | result = message; } else { result = message.replace(/\{(\d+)\}/g, (match, rest) => { - const index = rest[0]; + const index = parseInt(rest, 10); const arg = args[index]; let result = match; if (typeof arg === 'string') { diff --git a/src/vs/base/test/common/nls.test.ts b/src/vs/base/test/common/nls.test.ts new file mode 100644 index 00000000000000..aa73a6151d58b1 --- /dev/null +++ b/src/vs/base/test/common/nls.test.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { strictEqual } from 'assert'; +import { localize, localize2 } from '../../../nls.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; + +suite('NLS', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('localize replaces placeholders by index', () => { + strictEqual(localize('key0', '{0} {1} {2}', 'a', 'b', 'c'), 'a b c'); + }); + + test('localize replaces multi digit placeholders', () => { + const args = ['a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9', 'a10', 'a11']; + strictEqual(localize('key1', '{0} {1} {9} {10} {11}', ...args), 'a0 a1 a9 a10 a11'); + }); + + test('localize keeps placeholders that are not replaceable', () => { + strictEqual(localize('key2', '{0} {1}', 'a', {} as unknown as string), 'a {1}'); + }); + + test('localize2 replaces multi digit placeholders', () => { + const args = ['a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9', 'a10']; + strictEqual(localize2('key3', '{10}', ...args).value, 'a10'); + }); +}); diff --git a/src/vs/nls.ts b/src/vs/nls.ts index 51644b01193f7b..8383ae24d96d4d 100644 --- a/src/vs/nls.ts +++ b/src/vs/nls.ts @@ -31,7 +31,7 @@ function _format(message: string, args: (string | number | boolean | undefined | result = message; } else { result = message.replace(/\{(\d+)\}/g, (match, rest) => { - const index = rest[0]; + const index = parseInt(rest, 10); const arg = args[index]; let result = match; if (typeof arg === 'string') { From 2c314930badda55e455337a5ead546f37dae2051 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 18 Aug 2026 16:23:05 +0200 Subject: [PATCH 09/18] sessions: Open GitHub items in GitHub extension (#331471) * sessions: open pull requests in GitHub extension Use the GitHub Pull Requests URI handler when available, retain the external browser fallback, and keep PR editors free of docked Details. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: open issues in GitHub extension Use the GitHub Pull Requests issue URI handler when available and treat issue overview editors as self-contained in the single-pane layout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: reuse pull request opening action Preserve explicit cross-repository pull request identities and route the Changes toolbar through the shared GitHub-aware action. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT.md | 8 +- src/vs/sessions/SESSIONS_LIST.md | 4 +- .../contrib/changes/browser/changesView.ts | 1 - .../changes/browser/changesViewActions.ts | 12 +- .../test/browser/changesViewActions.test.ts | 28 ++++ .../browser/sessionsChatAccessibilityHelp.ts | 2 + .../contrib/github/browser/issueActions.ts | 40 ++++- .../github/browser/pullRequestActions.ts | 73 +++++++-- .../sessions/contrib/github/common/types.ts | 3 + .../github/test/browser/issueActions.test.ts | 149 ++++++++++++++++++ .../test/browser/pullRequestActions.test.ts | 134 ++++++++++++++-- .../singlePaneDetailPanelCoordinator.ts | 4 +- .../singlePaneExistingSessionStrategy.ts | 26 ++- .../singlePaneNewSessionStrategy.ts | 28 ++-- .../singlePane/singlePaneSharedHelpers.ts | 13 ++ .../desktopSessionLayoutController.test.ts | 67 +++++++- .../browser/sessionInputBanners.ts | 6 +- .../browser/sessionInputBanners.fixture.ts | 2 +- 18 files changed, 521 insertions(+), 79 deletions(-) create mode 100644 src/vs/sessions/contrib/github/test/browser/issueActions.test.ts diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index f5aec24ad4f09a..09dcaf90ccb321 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -200,7 +200,7 @@ The Sessions Part (`SessionsPart` in [browser/parts/sessionsPart.ts](src/vs/sess A `SessionView` ([browser/parts/sessionView.ts](src/vs/sessions/browser/parts/sessionView.ts)) is a single leaf in the Sessions Part's internal grid. It hosts: -- A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (the contributed workspace folder / changes / pull request buttons), and the session toolbars (Run, Open in VS Code, New Chat). The status icon ([browser/sessionStatusIcon.ts](src/vs/sessions/browser/sessionStatusIcon.ts)) shows the live spinner/status glyph for in-progress / needs-input / error states; in terminal/default states the title shows the read/unread **dot indicator** (filled link-colored dot when unread, small muted dot when read) — neither the session type icon nor the PR icon is shown in the title, since the pull request is surfaced in the meta row instead. (The status icon's `completedStateIcon` argument is generic: the header passes nothing so it falls back to the dot indicator, while the sessions list still passes the PR icon.) The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; by default each contributed action renders as a consistent compact secondary `Button` with an inline `icon title` label via `SessionHeaderMetaActionViewItem` ([browser/parts/sessionHeaderMetaActionViewItem.ts](src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts)) unless it registers its own action view item (spacing between the pills comes from the meta row's `gap`, no separator dot). The files view contributes the workspace folder pill (order -10, so it leads the row, gated by the per-view `SessionHasWorkspaceContext` key which `SessionView` sets when the session has a workspace label, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the workspace icon — cloud / folder / worktree per workspace kind, where a session whose isolated worktree is still being created (`ISession.worktreePending`) already shows the worktree icon — plus the workspace label, and a hover showing the working-directory path and git branch (replaced by a "Creating worktree…" note while the worktree is pending, since the reported folder and branch are still those of the checkout the session was started from), registered from `contrib/files/browser/workspaceFolderActions.ts`) that, when activated, opens the Files view. The changes view contributes the diff stats as a clickable menu item (order 0, gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's **Branch Changes** changeset, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the diff-multiple icon, a `{n} files` label, and the live `+insertions -deletions` counts, registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. The pill always reflects the **Branch Changes** changeset (the branch-vs-base diff) — located in `IActiveSession.changesets` by the shared `BRANCH_CHANGES_CHANGESET_ID` (`services/sessions/common/session.ts`), falling back to `IActiveSession.changes` when absent — so it is independent of whichever changeset the Changes view currently has selected. While a session's isolated worktree is still being created (`ISession.worktreePending`) the key stays `false`, so the checkout's own changes are never attributed to the session. The GitHub contribution similarly contributes a pull request button (order 1, so it follows the changes button, gated by the per-view `SessionHasPullRequestContext` key, registered from `contrib/github/browser/pullRequestActions.ts`): one pull request renders its live icon + `#`, opens that pull request on activation, and shows the repository/date/title/description/branch hover; several render the first (most recent) pull request's live icon + ` Pull Requests` and open a sticky, keyboard-accessible picker listing each pull request's live state icon, number, and truncated title. The first pull request remains the active projection used by the sessions list, CI/review actions, and context menus, while the header alone keeps the retained history's PR, CI, and review models live for its picker. The same contribution adds an issue button (order 2, so it follows the pull request button) for the GitHub issues the session's user messages referenced (gated by the per-view `SessionHasIssuesContext` key, registered from `contrib/github/browser/issueActions.ts`): a single issue renders as `#` and hovers to the issue title/description, while several render as ` issues` and open a sticky picker listing each issue on click; the leading icon reflects the aggregate live issue state (open green, closed-as-completed purple, closed as not planned/duplicate muted). Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The built-in rename action is registered from `contrib/sessions/browser/sessionsActions.ts` and uses `ISessionsPartService` to find the matching `SessionView`, which delegates to the header's inline rename control. The **Chats** (Conversations) menu is rendered in the meta row at the end of the pills (`Menus.SessionHeaderMeta`, order 100) — it appears once the session has more than one **committed (non-draft)** chat, or when the active chat has subagents. +- A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (the contributed workspace folder / changes / pull request buttons), and the session toolbars (Run, Open in VS Code, New Chat). The status icon ([browser/sessionStatusIcon.ts](src/vs/sessions/browser/sessionStatusIcon.ts)) shows the live spinner/status glyph for in-progress / needs-input / error states; in terminal/default states the title shows the read/unread **dot indicator** (filled link-colored dot when unread, small muted dot when read) — neither the session type icon nor the PR icon is shown in the title, since the pull request is surfaced in the meta row instead. (The status icon's `completedStateIcon` argument is generic: the header passes nothing so it falls back to the dot indicator, while the sessions list still passes the PR icon.) The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; by default each contributed action renders as a consistent compact secondary `Button` with an inline `icon title` label via `SessionHeaderMetaActionViewItem` ([browser/parts/sessionHeaderMetaActionViewItem.ts](src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts)) unless it registers its own action view item (spacing between the pills comes from the meta row's `gap`, no separator dot). The files view contributes the workspace folder pill (order -10, so it leads the row, gated by the per-view `SessionHasWorkspaceContext` key which `SessionView` sets when the session has a workspace label, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the workspace icon — cloud / folder / worktree per workspace kind, where a session whose isolated worktree is still being created (`ISession.worktreePending`) already shows the worktree icon — plus the workspace label, and a hover showing the working-directory path and git branch (replaced by a "Creating worktree…" note while the worktree is pending, since the reported folder and branch are still those of the checkout the session was started from), registered from `contrib/files/browser/workspaceFolderActions.ts`) that, when activated, opens the Files view. The changes view contributes the diff stats as a clickable menu item (order 0, gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's **Branch Changes** changeset, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the diff-multiple icon, a `{n} files` label, and the live `+insertions -deletions` counts, registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. The pill always reflects the **Branch Changes** changeset (the branch-vs-base diff) — located in `IActiveSession.changesets` by the shared `BRANCH_CHANGES_CHANGESET_ID` (`services/sessions/common/session.ts`), falling back to `IActiveSession.changes` when absent — so it is independent of whichever changeset the Changes view currently has selected. While a session's isolated worktree is still being created (`ISession.worktreePending`) the key stays `false`, so the checkout's own changes are never attributed to the session. The GitHub contribution similarly contributes a pull request button (order 1, so it follows the changes button, gated by the per-view `SessionHasPullRequestContext` key, registered from `contrib/github/browser/pullRequestActions.ts`): one pull request renders its live icon + `#`, opens that pull request on activation, and shows the repository/date/title/description/branch hover; several render the first (most recent) pull request's live icon + ` Pull Requests` and open a sticky, keyboard-accessible picker listing each pull request's live state icon, number, and truncated title. The first pull request remains the active projection used by the sessions list, CI/review actions, and context menus, while the header alone keeps the retained history's PR, CI, and review models live for its picker. The same contribution adds an issue button (order 2, so it follows the pull request button) for the GitHub issues the session's user messages referenced (gated by the per-view `SessionHasIssuesContext` key, registered from `contrib/github/browser/issueActions.ts`): a single issue renders as `#` and hovers to the issue title/description, while several render as ` issues` and open a sticky picker listing each issue on click; the leading icon reflects the aggregate live issue state (open green, closed-as-completed purple, closed as not planned/duplicate muted). Pull request and issue activations use the GitHub Pull Requests extension URI handlers when the extension is available, falling back to opening the GitHub URL externally. Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The built-in rename action is registered from `contrib/sessions/browser/sessionsActions.ts` and uses `ISessionsPartService` to find the matching `SessionView`, which delegates to the header's inline rename control. The **Chats** (Conversations) menu is rendered in the meta row at the end of the pills (`Menus.SessionHeaderMeta`, order 100) — it appears once the session has more than one **committed (non-draft)** chat, or when the active chat has subagents. - A **chat groups grid** below the header ([browser/parts/chatGroupsView.ts](src/vs/sessions/browser/parts/chatGroupsView.ts)) — a `SerializableGrid` of one or more **chat groups** that partition the session's open chats (see §4.1.1). It fills the full session width. - A floating toolbar overlay ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts), `SessionViewFloatingToolbar`) shown for not-yet-created sessions in place of the header. @@ -248,7 +248,7 @@ Concrete implementations live under `contrib/chat/` and are obtained via `IChatV The `NewChatView` input uses the control-tier corner radius for its send button, so the primary action is a rounded square in both desktop and phone layouts rather than a circular control. The focus outline follows the same control-tier shape. The input toolbar owns the spacing between adjacent actions through a shared flex gap rather than button-specific margins. -`ChatView` mounts session input banners directly above the chat input. Fix Checks and Address Comments wait for that session's chat model before running; while waiting, the primary action is disabled and a border progress indicator appears after one second. The standard comments banner follows the primary button accent, while the CI failures banner uses its orange warning accent for the card, primary action, and progress border. +`ChatView` mounts session input banners directly above the chat input. Fix Checks and Address Comments wait for that session's chat model before running; while waiting, the primary action is disabled and a border progress indicator appears after one second. Reveal on the CI failures banner opens the session's pull request through the shared Open Pull Request action. The standard comments banner follows the primary button accent, while the CI failures banner uses its orange warning accent for the card, primary action, and progress border. The shared chat input can show a transparent VS Code pet overlay above the composer. `/vscode-pet` toggles the persisted preference in active chats and the new-session composer. The state hooks for idle, sleeping, processing, confirmation, completion, and activation remain wired, but currently every state shows the same idle buddy: blue in Stable and green in Insiders/development builds. Active chats anchor the pet to the actual input row so confirmation and question widgets above it do not add spacing, while the new-session composer anchors it to its input-area wrapper. Cursor-tracked pupils render over eye-less derivative sprites so movement cannot expose the original baked-in eyes; the source PNG and GIF assets remain unchanged. Enabling makes the pet hop into place; disabling makes it duck away before its image source is unloaded. Both transitions are interruptible and skipped when reduced motion is enabled. Hovering the pet invites the user to show it some love and teases future interactions. @@ -325,8 +325,8 @@ The entire third-pane redesign is gated behind the experimental setting `session - Revealing the side pane from *closed* (`setEditorHidden(false)`, e.g. the session-header Changes button opening the Changes editor) passes `Sizing.Distribute` to `SerializableGrid.setViewVisible`. The grid already knows the revealed view's location, so it distributes that containing split and Sessions and the side pane receive equal space without either part computing pixels, percentages, or a split reference. A genuinely user-chosen width still takes precedence. Double-clicking the outer Sessions/editor sash has a separate reset policy: with Details visible, it preserves the current Details width and splits all remaining width equally between Sessions and editor content, without a 600px cap. Grid minimum widths still take precedence in narrow layouts. Hiding Details after this reset restores an equal Sessions/Editor split, even when the reset itself did not visibly move the sash. With Details hidden the reset retains native equal distribution, and in detail-only mode it resets Details to 300px. - Side-pane sizes and sash-reset intent are **workbench-level, not per session**: the editor grid node width is owned by the workbench grid and persisted globally (`workbench.sessions.partSizes`), so switching between sessions keeps the same side-pane width and pending reset behavior — the layout controller does not track or restore either per session. The pending reset survives later sash resizes, is consumed when Details hides, and is cancelled by hiding the Editor. The workbench persists the docked side-pane geometry across reloads via `_savePartSizes` on `onWillSaveState`, restored by `createDesktopGridDescriptor`. Because the docked detail (auxiliary bar) lives **inside** the editor grid node, the persisted editor value is the pure editor-content width: `_persistedEditorWidth` subtracts the docked detail width **only when the detail is visible**, mirroring the descriptor, which adds it back only when the detail is visible. While Editor is hidden, `_savePartSizes` preserves the last valid editor-content width instead of accepting the cached 300px detail-only node as an Editor width. Subtracting the detail width unconditionally (the earlier bug) shrank an **Editor-only** session's side pane by the detail width on every reload, compounding toward zero. - `_dockedEditorSizeBeforeHide` is captured whenever Editor hides while Details remains visible. The shared node must first shrink to the Details width, including layout-driven transitions into a New Session's Files-only composition. That combined width restores only when Editor reopens beside visible Details; an Editor-only composition restores the persisted pure Editor-content width and clears the composition-specific memento. Whole-side-pane closure still hides Editor before Details; the subsequent Details hide collapses the node without replacing the captured pre-collapse width. -- **Hide Editor** / **Show Editor** remain registered commands, and their `MenuId.EditorTitleLayout` contributions are retained with an always-false `when` clause so neither action renders in the single-pane UI. Their command implementations remain available: Hide reveals the auxiliary bar, hides the editor part, and restores the sessions list; Show reveals the editor area via `revealEditorPartExplicitly()` and focuses the editor group. The full-width editor header's trailing layout-action host continues to hold **Toggle Details** alone; Toggle Details is hidden for Browser/Search tabs, which have no detail. Opening a file or diff from the detail panel reveals the editor again. If the detail-panel toggle hides the detail while editor content is hidden, it reveals the editor content instead of leaving the pane empty; **Toggle Side Panel** remains the separate action that can hide both. -- Changes opens as a **custom `SessionChangesEditor`** (the multi-diff editor; in single-pane its *Branch Changes* dropdown + diff-stats + primary actions render in the full-width header part above, so the editor itself is header-less and the diff fills the pane). Each file header shows the live `+insertions -deletions` counts from the selected changeset alongside the file label. Clicking a Branch Changes file honors the same `sessions.changes.openSingleFileDiff` setting and Alt inversion as the standard layout, opening either a docked single-file diff or revealing the file in this multi-diff editor. When Changes details are visible, the full-width header exposes the List/Tree view-mode action for both the multi-file Changes editor and every docked single-file diff editor, including binary or custom-editor fallbacks; it shows the inline/side-by-side diff toggle only for the multi-file editor and text diff panes that support that layout. The auxiliary bar's composite tab strip + title are hidden, and the New/Existing Session strategies map the active editor tab to the detail container (Changes → files + Checks, File → Explorer, Browser → hidden). +- **Hide Editor** / **Show Editor** remain registered commands, and their `MenuId.EditorTitleLayout` contributions are retained with an always-false `when` clause so neither action renders in the single-pane UI. Their command implementations remain available: Hide reveals the auxiliary bar, hides the editor part, and restores the sessions list; Show reveals the editor area via `revealEditorPartExplicitly()` and focuses the editor group. The full-width editor header's trailing layout-action host continues to hold **Toggle Details** alone; Toggle Details is hidden for Browser, Pull Request, Issue, and Search tabs, which have no detail. Browser, Pull Request, and Issue editors hide the docked Details panel while their editor area is visible. Opening a file or diff from the detail panel reveals the editor again. If the detail-panel toggle hides the detail while editor content is hidden, it reveals the editor content instead of leaving the pane empty; **Toggle Side Panel** remains the separate action that can hide both. +- Changes opens as a **custom `SessionChangesEditor`** (the multi-diff editor; in single-pane its *Branch Changes* dropdown + diff-stats + primary actions render in the full-width header part above, so the editor itself is header-less and the diff fills the pane). Each file header shows the live `+insertions -deletions` counts from the selected changeset alongside the file label. Clicking a Branch Changes file honors the same `sessions.changes.openSingleFileDiff` setting and Alt inversion as the standard layout, opening either a docked single-file diff or revealing the file in this multi-diff editor. When Changes details are visible, the full-width header exposes the List/Tree view-mode action for both the multi-file Changes editor and every docked single-file diff editor, including binary or custom-editor fallbacks; it shows the inline/side-by-side diff toggle only for the multi-file editor and text diff panes that support that layout. The auxiliary bar's composite tab strip + title are hidden, and the New/Existing Session strategies map the active editor tab to the detail container (Changes → files + Checks, File → Explorer, Browser/Pull Request/Issue → hidden). - **Run Code Review** renders as the first inline action on the right while the single-pane Changes editor area is visible. When the editor area is collapsed, it moves into the first group of the right-side `...` overflow, followed by a separator and the remaining overflow actions. - Closing the last editor tab is lifecycle-owned: Existing Session hides both regions, New Session applies its Empty Files fallback, and Quick Chat leaves the side pane open. Opening any tab reveals the editor part again, and `DetailPanelController` restores the matching detail content for File/Changes tabs. - **Editor-area tab collapse:** when the editor area is hidden (detail-only), the single-pane controller closes **every non-docked** editor tab (anything not `instanceof DockedEditorInput`) so only the docked Changes and Files tabs remain, capturing each closable one's untyped input **and tab index** (`editor.toUntyped()`); when the editor area is shown again the captured ones are reopened **at their original positions** (`SinglePaneDockedTabsCoordinator._collapseNonManagedTabs` / `_restoreCollapsedTabs`, constructed by `SinglePaneLayoutController._registerAuxiliaryControllers` and owned by `SinglePaneExistingSessionStrategy`). It is serialized on the shared docked-tab `Sequencer`, skipped during a layout-driven restore (`ISinglePaneLayoutContext.isRestoringSessionLayout`), and the capture is dropped on a session change. Non-restorable tabs (e.g. an **untitled Search editor**, whose `toUntyped()` returns `undefined`) are still closed but not restored; dirty editors are closed too (the workbench save/confirm flow applies), so they don't linger in a "closed" editor area. diff --git a/src/vs/sessions/SESSIONS_LIST.md b/src/vs/sessions/SESSIONS_LIST.md index 8767babd5f792c..476ce1d2705d83 100644 --- a/src/vs/sessions/SESSIONS_LIST.md +++ b/src/vs/sessions/SESSIONS_LIST.md @@ -189,7 +189,9 @@ The sessions list defines menu IDs that contributions can target to add actions. | Menu | Constant | Where it appears | Use for | |------|----------|------------------|---------| | `SessionItemToolbar` | `SessionItemToolbarMenuId` | Inline toolbar on each session row (hover on desktop, always on mobile) | Primary actions like pin and archive/mark as done. Group `navigation` for icons, other groups for overflow. | -| `SessionItemContextMenu` | `SessionItemContextMenuId` | Right-click context menu on session rows | Secondary actions like rename, mark read/unread, and "Open Pull Request" (in the `navigation`/open group, gated on `sessionHasPullRequest`). Groups: `navigation`, `0_pin`, `0_read`, `1_edit`. | +| `SessionItemContextMenu` | `SessionItemContextMenuId` | Right-click context menu on session rows | Secondary actions like rename, mark read/unread, and "Open Pull Request" (gated on `sessionHasPullRequest`). Groups: `navigation`, `0_pin`, `0_read`, `1_edit`, `2_pullRequest`. | + +The Open Pull Request action shared by the session context menu and header uses the GitHub Pull Requests extension URI handler when the extension is available and otherwise opens the pull request externally. The header's pull request picker follows the same behavior. ### Section Header Menu diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 962caf36e2851c..9fbeeab8d25759 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -2039,7 +2039,6 @@ registerAction2(ChangesDiffStatsAction); /** * Opens the Changes view and reveals (expands + focuses) the CI checks section. - * Used by the CI failures banner above the chat input. */ class RevealCIChecksAction extends Action2 { static readonly ID = REVEAL_CI_CHECKS_COMMAND_ID; diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index d16b31432329a9..4bdc5cd1d2a72f 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -11,9 +11,9 @@ import { URI } from '../../../../base/common/uri.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, IAction2Options, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { TOGGLE_DIFF_SIDE_BY_SIDE } from '../../../../workbench/browser/parts/editor/diffEditorCommands.js'; @@ -26,6 +26,7 @@ import { Menus } from '../../../browser/menus.js'; import { SessionHasChangesContext, SessionIsCreatedContext, SinglePaneDiffEditorInputActiveContext, SinglePaneLayoutEnabledContext } from '../../../common/contextkeys.js'; import { logChangesViewViewModeChange } from '../../../common/sessionsTelemetry.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { OPEN_PULL_REQUEST_ACTION_ID } from '../../github/common/types.js'; import { ActiveSessionContextKeys, CHANGES_VIEW_ID, ChangesContextKeys, ChangesViewMode, SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING } from '../common/changes.js'; import { IChangesViewService } from '../common/changesViewService.js'; import { CHANGES_HEADER_ACTIONS_ID } from './changesView.js'; @@ -108,19 +109,14 @@ class OpenPullRequestAction extends Action2 { } async run(accessor: ServicesAccessor): Promise { - const openerService = accessor.get(IOpenerService); + const commandService = accessor.get(ICommandService); const sessionsService = accessor.get(ISessionsService); const activeSession = sessionsService.activeSession.get(); if (!activeSession) { return; } - const gitHubInfo = activeSession.workspace.get()?.folders[0]?.gitRepository?.gitHubInfo.get(); - if (!gitHubInfo?.pullRequest?.uri) { - return; - } - - await openerService.open(gitHubInfo.pullRequest.uri); + await commandService.executeCommand(OPEN_PULL_REQUEST_ACTION_ID, activeSession); } } diff --git a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts index ac8a90799ae901..a4f14b2c7bc21f 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts @@ -5,15 +5,21 @@ import assert from 'assert'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { constObservable } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { isIMenuItem, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; import { isICommandActionToggleInfo } from '../../../../../platform/action/common/action.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { Context } from '../../../../../platform/contextkey/browser/contextKeyService.js'; import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { EditorContextKeys } from '../../../../../editor/common/editorContextKeys.js'; import { ActiveEditorContext, AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, MainEditorAreaVisibleContext, TextCompareEditorActiveContext } from '../../../../../workbench/common/contextkeys.js'; import { Menus } from '../../../../browser/menus.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ChangesContextKeys, ChangesViewMode } from '../../common/changes.js'; import { IsPhoneLayoutContext, SessionHasChangesContext, SessionHasWorkspaceContext, SessionIsCreatedContext, SinglePaneDiffEditorInputActiveContext, SinglePaneLayoutEnabledContext } from '../../../../common/contextkeys.js'; import { SessionChangesEditor } from '../../browser/sessionChangesEditor.js'; @@ -47,6 +53,28 @@ suite('Changes View Actions', () => { }); }); + test('Open Pull Request delegates to the shared GitHub action', async () => { + const activeSession = new class extends mock() { }; + const calls: { readonly commandId: string; readonly args: readonly unknown[] }[] = []; + const instantiationService = new TestInstantiationService(); + instantiationService.stub(ICommandService, new class extends mock() { + override async executeCommand(commandId: string, ...args: unknown[]): Promise { + calls.push({ commandId, args }); + return undefined; + } + }); + instantiationService.stub(ISessionsService, new class extends mock() { + override readonly activeSession = constObservable(activeSession); + }); + + await instantiationService.invokeFunction(accessor => CommandsRegistry.getCommand('workbench.action.agentSessions.openPullRequest')!.handler(accessor)); + + assert.deepStrictEqual(calls, [{ + commandId: 'workbench.agentSessions.action.openPullRequest', + args: [activeSession], + }]); + }); + test('primary header actions gate themselves to the single-pane Changes editor', () => { const items = MenuRegistry.getMenuItems(Menus.SessionsEditorHeaderPrimary) .filter(isIMenuItem) diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 2647b6d7f89a02..b6dc7e8277cfd6 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -41,6 +41,8 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.inputBackground', "Press Alt+Enter to start the session in the background without navigating into it. The started session appears in the Chat Sessions view.")); content.push(localize('sessionsChat.workspace', "Shift+Tab to navigate to the workspace picker and choose a workspace for your session.")); content.push(localize('sessionsChat.pullRequestSession', "In a repository section of the sessions list, activate Create Session from Pull Request to open a searchable pull request picker. Pull requests are grouped by review and assignment status. Use the arrow keys to navigate, Enter to create the session, and Escape to close the picker.")); + content.push(localize('sessionsChat.githubReferences', "Pull request and issue pills in the session header open their GitHub item in the GitHub Pull Requests extension when it is available. Pills that represent several items open a keyboard-accessible picker.")); + content.push(localize('sessionsChat.failingChecksPullRequest', "When the active session has failing checks, use Reveal in the banner above the input to open its pull request, or use Fix Checks to ask the agent to address the failures.")); content.push(localize('sessionsChat.pickFolderQuickPick', "To choose a folder from a searchable list instead, use the New Session in Folder command{0}.", '')); content.push(localize('sessionsChat.quickChat', "To start a workspace-less quick chat, use the New Quick Chat command{0} or the plus button on the Chats section in the sessions list. A quick chat has no workspace, so the workspace picker does not apply and the Toggle Side Panel command is disabled.", '')); content.push(localize('sessionsChat.mobileConfig', "On mobile, the mode and model pickers appear as tappable chips below the input. Tap a chip to open a bottom sheet where you can change the selection.")); diff --git a/src/vs/sessions/contrib/github/browser/issueActions.ts b/src/vs/sessions/contrib/github/browser/issueActions.ts index d9cddc05dc7ccb..db344264322b3e 100644 --- a/src/vs/sessions/contrib/github/browser/issueActions.ts +++ b/src/vs/sessions/contrib/github/browser/issueActions.ts @@ -20,7 +20,9 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; +import { IURLService } from '../../../../platform/url/common/url.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; import { Menus } from '../../../browser/menus.js'; import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; @@ -29,7 +31,7 @@ import { ISessionContext } from '../../../services/sessions/browser/sessionConte import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { IGitHubIssueRef, ISession } from '../../../services/sessions/common/session.js'; -import { computeAggregateIssueIcon, computeIssueIcon, GitHubIssueState, IGitHubIssue } from '../common/types.js'; +import { computeAggregateIssueIcon, computeIssueIcon, GitHubIssueState, IGitHubIssue, OPEN_ISSUE_ACTION_ID } from '../common/types.js'; import { IGitHubService } from './githubService.js'; import { createIssueHoverElement } from './issueHover.js'; import { createGitHubReferenceListElement } from './githubReferenceList.js'; @@ -42,8 +44,15 @@ interface IResolvedSessionIssue { // --- Open Issue action +const githubPullRequestsExtensionId = 'github.vscode-pull-request-github'; +const openIssueWebviewPath = '/open-issue-webview'; + +class IssueActionContext { + constructor(readonly issue: IGitHubIssueRef) { } +} + class OpenIssueAction extends Action2 { - static readonly ID = 'workbench.agentSessions.action.openIssue'; + static readonly ID = OPEN_ISSUE_ACTION_ID; constructor() { super({ @@ -64,16 +73,33 @@ class OpenIssueAction extends Action2 { }); } - override async run(accessor: ServicesAccessor, session?: IActiveSession | ISession | ISession[]): Promise { + override async run(accessor: ServicesAccessor, sessionOrContext?: IActiveSession | ISession | ISession[] | IssueActionContext): Promise { const openerService = accessor.get(IOpenerService); const sessionsService = accessor.get(ISessionsService); + const extensionService = accessor.get(IExtensionService); + const urlService = accessor.get(IURLService); - const targetSession = (Array.isArray(session) ? session[0] : session) ?? sessionsService.activeSession.get(); - const issue = getSessionIssues(targetSession)[0]; + const target = (Array.isArray(sessionOrContext) ? sessionOrContext[0] : sessionOrContext) ?? sessionsService.activeSession.get(); + const issue = target instanceof IssueActionContext ? target.issue : getSessionIssues(target)[0]; if (!issue) { return; } + if (await extensionService.getExtension(githubPullRequestsExtensionId)) { + const uri = urlService.create({ + authority: githubPullRequestsExtensionId, + path: openIssueWebviewPath, + query: JSON.stringify({ + owner: issue.owner, + repo: issue.repo, + issueNumber: issue.number, + }), + }); + if (await urlService.open(uri, { trusted: true })) { + return; + } + } + await openerService.open(issue.uri, { openExternal: true }); } } @@ -246,6 +272,8 @@ export class OpenIssueActionViewItem extends SessionHeaderMetaActionViewItem { } const entries = issues.map(({ ref, issue }) => ({ + owner: ref.owner, + repo: ref.repo, number: ref.number, title: issue?.title, icon: issue ? computeIssueIcon(issue.state, issue.stateReason) : computeIssueIcon(GitHubIssueState.Open, undefined), @@ -256,7 +284,7 @@ export class OpenIssueActionViewItem extends SessionHeaderMetaActionViewItem { const hover = this._hoverService.showInstantHover({ content: createGitHubReferenceListElement(entries, entry => { this._hoverService.hideHover(); - this._openerService.open(entry.uri, { openExternal: true }); + this.actionRunner.run(this._action, new IssueActionContext(entry)); }), target, position: { hoverPosition: HoverPosition.BELOW }, diff --git a/src/vs/sessions/contrib/github/browser/pullRequestActions.ts b/src/vs/sessions/contrib/github/browser/pullRequestActions.ts index 694099691be1af..0bba900427dbbd 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestActions.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestActions.ts @@ -22,7 +22,9 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; +import { IURLService } from '../../../../platform/url/common/url.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; import { Menus } from '../../../browser/menus.js'; import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; @@ -31,7 +33,7 @@ import { ISessionContext } from '../../../services/sessions/browser/sessionConte import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { IGitHubPullRequestRef, ISession } from '../../../services/sessions/common/session.js'; -import { computePullRequestIcon, GitHubPullRequestState, IGitHubPullRequest, IPullRequestIconStatus } from '../common/types.js'; +import { computePullRequestIcon, GitHubPullRequestState, IGitHubPullRequest, IPullRequestIconStatus, OPEN_PULL_REQUEST_ACTION_ID } from '../common/types.js'; import { IGitHubService } from './githubService.js'; import { GitHubReferenceList, IGitHubReferenceListEntry } from './githubReferenceList.js'; import { createPullRequestHoverElement } from './pullRequestHover.js'; @@ -52,13 +54,22 @@ interface IPullRequestIdentity { } interface IPullRequestListEntry extends IGitHubReferenceListEntry { + readonly owner: string; + readonly repo: string; readonly uri: URI; } // --- Open Pull Request action +const githubPullRequestsExtensionId = 'github.vscode-pull-request-github'; +const openPullRequestWebviewPath = '/open-pull-request-webview'; + +class PullRequestActionContext { + constructor(readonly pullRequest: IGitHubPullRequestRef) { } +} + class OpenPullRequestAction extends Action2 { - static readonly ID = 'workbench.agentSessions.action.openPullRequest'; + static readonly ID = OPEN_PULL_REQUEST_ACTION_ID; constructor() { super({ @@ -83,25 +94,57 @@ class OpenPullRequestAction extends Action2 { }); } - override async run(accessor: ServicesAccessor, session?: IActiveSession | ISession | ISession[]): Promise { - const openerService = accessor.get(IOpenerService); + override async run(accessor: ServicesAccessor, sessionOrContext?: IActiveSession | ISession | ISession[] | PullRequestActionContext): Promise { const sessionsService = accessor.get(ISessionsService); - const targetSession = (Array.isArray(session) ? session[0] : session) ?? sessionsService.activeSession.get(); - const pullRequestUri = getSessionPullRequestUri(targetSession); - if (!pullRequestUri) { + const target = (Array.isArray(sessionOrContext) ? sessionOrContext[0] : sessionOrContext) ?? sessionsService.activeSession.get(); + const pullRequest = target instanceof PullRequestActionContext ? target.pullRequest : getSessionPullRequest(target); + if (!pullRequest) { return; } - await openerService.open(pullRequestUri, { openExternal: true }); + const extensionService = accessor.get(IExtensionService); + const urlService = accessor.get(IURLService); + const openerService = accessor.get(IOpenerService); + if (await extensionService.getExtension(githubPullRequestsExtensionId)) { + const uri = urlService.create({ + authority: githubPullRequestsExtensionId, + path: openPullRequestWebviewPath, + query: JSON.stringify({ + owner: pullRequest.owner, + repo: pullRequest.repo, + pullRequestNumber: pullRequest.number, + }), + }); + if (await urlService.open(uri, { trusted: true })) { + return; + } + } + + await openerService.open(pullRequest.uri, { openExternal: true }); } } registerAction2(OpenPullRequestAction); // --- Copy Pull Request URL action -function getSessionPullRequestUri(session: ISession | undefined): URI | undefined { - return session?.workspace.get()?.folders[0]?.gitRepository?.gitHubInfo.get()?.pullRequest?.uri; +function getSessionPullRequest(session: ISession | undefined): IGitHubPullRequestRef | undefined { + const gitHubInfo = session?.workspace.get()?.folders[0]?.gitRepository?.gitHubInfo.get(); + const pullRequestRef = gitHubInfo?.pullRequests?.[0]; + if (pullRequestRef) { + return pullRequestRef; + } + if (!gitHubInfo?.pullRequest) { + return undefined; + } + + return { + owner: gitHubInfo.owner, + repo: gitHubInfo.repo, + number: gitHubInfo.pullRequest.number, + uri: gitHubInfo.pullRequest.uri, + icon: gitHubInfo.pullRequest.icon, + }; } class CopyPullRequestUrlAction extends Action2 { @@ -126,12 +169,12 @@ class CopyPullRequestUrlAction extends Action2 { const sessionsService = accessor.get(ISessionsService); const targetSession = (Array.isArray(session) ? session[0] : session) ?? sessionsService.activeSession.get(); - const pullRequestUri = getSessionPullRequestUri(targetSession); - if (!pullRequestUri) { + const pullRequest = getSessionPullRequest(targetSession); + if (!pullRequest) { return; } - await clipboardService.writeText(pullRequestUri.toString(true)); + await clipboardService.writeText(pullRequest.uri.toString(true)); } } registerAction2(CopyPullRequestUrlAction); @@ -328,7 +371,7 @@ export class OpenPullRequestActionViewItem extends SessionHeaderMetaActionViewIt const list = new GitHubReferenceList(this._getPullRequestListEntries(pullRequests), entry => { this._hoverService.hideHover(); - this._openerService.open(entry.uri, { openExternal: true }); + this.actionRunner.run(this._action, new PullRequestActionContext(entry)); }); list.element.onkeydown = event => { if (event.key === 'Escape') { @@ -363,6 +406,8 @@ export class OpenPullRequestActionViewItem extends SessionHeaderMetaActionViewIt private _getPullRequestListEntries(pullRequests: readonly IResolvedSessionPullRequest[]): readonly IPullRequestListEntry[] { return pullRequests.map(({ ref, pullRequest, icon, status }) => ({ + owner: ref.owner, + repo: ref.repo, number: ref.number, title: pullRequest?.title, icon, diff --git a/src/vs/sessions/contrib/github/common/types.ts b/src/vs/sessions/contrib/github/common/types.ts index 3c165cf9378b7d..1080cc97fe0e70 100644 --- a/src/vs/sessions/contrib/github/common/types.ts +++ b/src/vs/sessions/contrib/github/common/types.ts @@ -6,6 +6,9 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { themeColorFromId, ThemeIcon } from '../../../../base/common/themables.js'; +export const OPEN_PULL_REQUEST_ACTION_ID = 'workbench.agentSessions.action.openPullRequest'; +export const OPEN_ISSUE_ACTION_ID = 'workbench.agentSessions.action.openIssue'; + //#region Session Context /** diff --git a/src/vs/sessions/contrib/github/test/browser/issueActions.test.ts b/src/vs/sessions/contrib/github/test/browser/issueActions.test.ts new file mode 100644 index 00000000000000..f58577e6c3c9d1 --- /dev/null +++ b/src/vs/sessions/contrib/github/test/browser/issueActions.test.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { constObservable } from '../../../../../base/common/observable.js'; +import { URI, UriComponents } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { IExtensionDescription } from '../../../../../platform/extensions/common/extensions.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IOpenURLOptions, IURLService } from '../../../../../platform/url/common/url.js'; +import { IExtensionService } from '../../../../../workbench/services/extensions/common/extensions.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import '../../browser/issueActions.js'; + +function createSessionWithIssue(issueUri: URI): ISession { + const workspaceUri = URI.from({ scheme: 'test', path: '/workspace' }); + const workspace: ISessionWorkspace = { + uri: workspaceUri, + label: 'workspace', + icon: Codicon.folder, + folders: [{ + root: workspaceUri, + workingDirectory: workspaceUri, + name: 'workspace', + description: undefined, + gitRepository: { + uri: workspaceUri, + workTreeUri: undefined, + baseBranchName: undefined, + gitHubInfo: constObservable({ + owner: 'owner', + repo: 'repo', + issues: [{ owner: 'owner', repo: 'repo', number: 7, uri: issueUri }], + }), + }, + }], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }; + return new class extends mock() { + override readonly workspace = constObservable(workspace); + }; +} + +class TestURLService extends mock() { + readonly opened: { readonly uri: URI; readonly options: IOpenURLOptions | undefined }[] = []; + + override create(options?: Partial): URI { + return URI.from({ scheme: 'code-oss', ...options }); + } + + override async open(uri: URI, options?: IOpenURLOptions): Promise { + this.opened.push({ uri, options }); + return true; + } +} + +class TestOpenerService extends mock() { + readonly opened: { readonly resource: URI; readonly openExternal: boolean | undefined }[] = []; + + override async open(resource: URI, options?: { readonly openExternal?: boolean }): Promise { + this.opened.push({ resource, openExternal: options?.openExternal }); + return true; + } +} + +suite('Issue Actions', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('Open Issue opens externally when the GitHub Pull Requests extension is unavailable', async () => { + const issueUri = URI.parse('https://github.com/owner/repo/issues/7'); + const session = createSessionWithIssue(issueUri); + const instantiationService = new TestInstantiationService(); + const urlService = new TestURLService(); + const openerService = new TestOpenerService(); + instantiationService.stub(IExtensionService, new class extends mock() { + override async getExtension(): Promise { + return undefined; + } + }); + instantiationService.stub(IOpenerService, openerService); + instantiationService.stub(IURLService, urlService); + instantiationService.stub(ISessionsService, new class extends mock() { + override readonly activeSession = constObservable(undefined); + }); + + await instantiationService.invokeFunction(accessor => CommandsRegistry.getCommand('workbench.agentSessions.action.openIssue')!.handler(accessor, session)); + + assert.deepStrictEqual({ + handledUris: urlService.opened, + opened: openerService.opened, + }, { + handledUris: [], + opened: [{ resource: issueUri, openExternal: true }], + }); + }); + + test('Open Issue uses the GitHub Pull Requests extension when available', async () => { + const issueUri = URI.parse('https://github.com/owner/repo/issues/7'); + const session = createSessionWithIssue(issueUri); + const instantiationService = new TestInstantiationService(); + const urlService = new TestURLService(); + const openerService = new TestOpenerService(); + instantiationService.stub(IExtensionService, new class extends mock() { + override async getExtension(): Promise { + return new class extends mock() { }; + } + }); + instantiationService.stub(IOpenerService, openerService); + instantiationService.stub(IURLService, urlService); + instantiationService.stub(ISessionsService, new class extends mock() { + override readonly activeSession = constObservable(undefined); + }); + + await instantiationService.invokeFunction(accessor => CommandsRegistry.getCommand('workbench.agentSessions.action.openIssue')!.handler(accessor, session)); + + assert.deepStrictEqual({ + handledUris: urlService.opened.map(({ uri, options }) => ({ + scheme: uri.scheme, + authority: uri.authority, + path: uri.path, + query: JSON.parse(uri.query), + trusted: options?.trusted, + })), + opened: openerService.opened, + }, { + handledUris: [{ + scheme: 'code-oss', + authority: 'github.vscode-pull-request-github', + path: '/open-issue-webview', + query: { + owner: 'owner', + repo: 'repo', + issueNumber: 7, + }, + trusted: true, + }], + opened: [], + }); + }); +}); diff --git a/src/vs/sessions/contrib/github/test/browser/pullRequestActions.test.ts b/src/vs/sessions/contrib/github/test/browser/pullRequestActions.test.ts index f56cd29ddf8170..a76e270ba1f5a6 100644 --- a/src/vs/sessions/contrib/github/test/browser/pullRequestActions.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/pullRequestActions.test.ts @@ -6,21 +6,24 @@ import assert from 'assert'; import { Codicon } from '../../../../../base/common/codicons.js'; import { constObservable } from '../../../../../base/common/observable.js'; -import { URI } from '../../../../../base/common/uri.js'; +import { URI, UriComponents } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { isIMenuItem, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { IExtensionDescription } from '../../../../../platform/extensions/common/extensions.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IOpenURLOptions, IURLService } from '../../../../../platform/url/common/url.js'; +import { IExtensionService } from '../../../../../workbench/services/extensions/common/extensions.js'; import { Menus } from '../../../../browser/menus.js'; import { SessionHasPullRequestContext } from '../../../../common/contextkeys.js'; -import { ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { IGitHubPullRequestRef, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import '../../browser/pullRequestActions.js'; -function createSessionWithPullRequest(pullRequestUri: URI | undefined): ISession { +function createSessionWithPullRequest(pullRequestUri: URI | undefined, pullRequestRef?: IGitHubPullRequestRef): ISession { const workspaceUri = URI.from({ scheme: 'test', path: '/workspace' }); const workspace: ISessionWorkspace = { uri: workspaceUri, @@ -39,6 +42,7 @@ function createSessionWithPullRequest(pullRequestUri: URI | undefined): ISession owner: 'owner', repo: 'repo', pullRequest: { number: 1, uri: pullRequestUri }, + pullRequests: pullRequestRef ? [pullRequestRef] : undefined, }), } : undefined, }], @@ -50,6 +54,28 @@ function createSessionWithPullRequest(pullRequestUri: URI | undefined): ISession }; } +class TestURLService extends mock() { + readonly opened: { readonly uri: URI; readonly options: IOpenURLOptions | undefined }[] = []; + + override create(options?: Partial): URI { + return URI.from({ scheme: 'code-oss', ...options }); + } + + override async open(uri: URI, options?: IOpenURLOptions): Promise { + this.opened.push({ uri, options }); + return true; + } +} + +class TestOpenerService extends mock() { + readonly opened: { readonly resource: URI; readonly openExternal: boolean | undefined }[] = []; + + override async open(resource: URI, options?: { readonly openExternal?: boolean }): Promise { + this.opened.push({ resource, openExternal: options?.openExternal }); + return true; + } +} + suite('Pull Request Actions', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -111,25 +137,111 @@ suite('Pull Request Actions', () => { assert.deepStrictEqual(clipboardService.writes, []); }); - test('Open Pull Request opens the pull request URL externally', async () => { + test('Open Pull Request opens the pull request URL externally when the GitHub Pull Requests extension is unavailable', async () => { const pullRequestUri = URI.parse('https://github.com/owner/repo/pull/1'); const session = createSessionWithPullRequest(pullRequestUri); const instantiationService = new TestInstantiationService(); - const openerService = new class extends mock() { - readonly opened: { readonly resource: URI; readonly openExternal: boolean | undefined }[] = []; - override async open(resource: URI, options?: { readonly openExternal?: boolean }): Promise { - this.opened.push({ resource, openExternal: options?.openExternal }); - return true; + const urlService = new TestURLService(); + const openerService = new TestOpenerService(); + instantiationService.stub(IExtensionService, new class extends mock() { + override async getExtension(): Promise { + return undefined; } - }; + }); instantiationService.stub(IOpenerService, openerService); + instantiationService.stub(IURLService, urlService); instantiationService.stub(ISessionsService, new class extends mock() { override readonly activeSession = constObservable(undefined); }); await instantiationService.invokeFunction(accessor => CommandsRegistry.getCommand('workbench.agentSessions.action.openPullRequest')!.handler(accessor, session)); - assert.deepStrictEqual(openerService.opened, [{ resource: pullRequestUri, openExternal: true }]); + assert.deepStrictEqual({ + handledUris: urlService.opened, + opened: openerService.opened, + }, { + handledUris: [], + opened: [{ resource: pullRequestUri, openExternal: true }], + }); + }); + + test('Open Pull Request prefers the explicit pull request repository identity', async () => { + const pullRequestUri = URI.parse('https://github.com/upstream/project/pull/7'); + const session = createSessionWithPullRequest(pullRequestUri, { + owner: 'upstream', + repo: 'project', + number: 7, + uri: pullRequestUri, + }); + const instantiationService = new TestInstantiationService(); + const urlService = new TestURLService(); + instantiationService.stub(IExtensionService, new class extends mock() { + override async getExtension(): Promise { + return new class extends mock() { }; + } + }); + instantiationService.stub(IOpenerService, new TestOpenerService()); + instantiationService.stub(IURLService, urlService); + instantiationService.stub(ISessionsService, new class extends mock() { + override readonly activeSession = constObservable(undefined); + }); + + await instantiationService.invokeFunction(accessor => CommandsRegistry.getCommand('workbench.agentSessions.action.openPullRequest')!.handler(accessor, session)); + + assert.deepStrictEqual(JSON.parse(urlService.opened[0].uri.query), { + owner: 'upstream', + repo: 'project', + pullRequestNumber: 7, + }); + }); + + test('Open Pull Request uses the GitHub Pull Requests extension when available', async () => { + const pullRequestUri = URI.parse('https://github.com/owner/repo/pull/1'); + const session = createSessionWithPullRequest(pullRequestUri); + + const instantiationService = new TestInstantiationService(); + const requestedExtensionIds: string[] = []; + const urlService = new TestURLService(); + const openerService = new TestOpenerService(); + instantiationService.stub(IExtensionService, new class extends mock() { + override async getExtension(id: string): Promise { + requestedExtensionIds.push(id); + return new class extends mock() { }; + } + }); + instantiationService.stub(IOpenerService, openerService); + instantiationService.stub(IURLService, urlService); + instantiationService.stub(ISessionsService, new class extends mock() { + override readonly activeSession = constObservable(undefined); + }); + + await instantiationService.invokeFunction(accessor => CommandsRegistry.getCommand('workbench.agentSessions.action.openPullRequest')!.handler(accessor, session)); + + assert.deepStrictEqual({ + requestedExtensionIds, + handledUris: urlService.opened.map(({ uri, options }) => ({ + scheme: uri.scheme, + authority: uri.authority, + path: uri.path, + query: JSON.parse(uri.query), + trusted: options?.trusted, + })), + opened: openerService.opened, + }, { + requestedExtensionIds: ['github.vscode-pull-request-github'], + handledUris: [{ + scheme: 'code-oss', + authority: 'github.vscode-pull-request-github', + path: '/open-pull-request-webview', + query: { + owner: 'owner', + repo: 'repo', + pullRequestNumber: 1, + }, + trusted: true, + }], + opened: [], + }); }); }); diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDetailPanelCoordinator.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDetailPanelCoordinator.ts index 7448a83363adf6..617a9665213bba 100644 --- a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDetailPanelCoordinator.ts +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDetailPanelCoordinator.ts @@ -18,7 +18,7 @@ import { SESSIONS_FILES_CONTAINER_ID } from '../../../files/browser/files.contri export const enum DetailPanelTarget { Hidden, - BrowserHidden, + EditorHidden, Changes, ChangesForced, Files, @@ -87,7 +87,7 @@ export class SinglePaneDetailPanelCoordinator extends Disposable { await this._viewsService.openViewContainer(SESSIONS_FILES_CONTAINER_ID, false); return; case DetailPanelTarget.Hidden: - case DetailPanelTarget.BrowserHidden: + case DetailPanelTarget.EditorHidden: case DetailPanelTarget.Preserve: return; } diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneExistingSessionStrategy.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneExistingSessionStrategy.ts index 06067ef30cc976..d387b436ae1cee 100644 --- a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneExistingSessionStrategy.ts +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneExistingSessionStrategy.ts @@ -17,7 +17,6 @@ import { KeybindingWeight } from '../../../../../platform/keybinding/common/keyb import { AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, MainEditorAreaVisibleContext } from '../../../../../workbench/common/contextkeys.js'; import { GroupModelChangeKind } from '../../../../../workbench/common/editor.js'; import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; -import { BrowserEditorInput } from '../../../../../workbench/contrib/browserView/common/browserEditorInput.js'; import { IEditorGroupsService } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; @@ -29,7 +28,7 @@ import { IActiveSession } from '../../../../services/sessions/common/sessionsMan import { ISessionChangesService } from '../../../changes/browser/sessionChangesService.js'; import { DetailPanelTarget, SinglePaneDetailPanelCoordinator } from './singlePaneDetailPanelCoordinator.js'; import { SinglePaneDockedTabsCoordinator } from './singlePaneDockedTabsCoordinator.js'; -import { isChangesEditorInput, isFileEditorInput, isMainPartEmpty } from './singlePaneSharedHelpers.js'; +import { isChangesEditorInput, isEditorWithoutDockedDetails, isFileEditorInput, isMainPartEmpty } from './singlePaneSharedHelpers.js'; import { ISinglePaneLayoutContext, SinglePaneLayoutStrategy } from './singlePaneLayoutStrategy.js'; import { SessionVisibilityProfile, SinglePaneVisibilityProfileStore } from './singlePaneVisibilityProfileStore.js'; @@ -55,6 +54,7 @@ export class SinglePaneExistingSessionStrategy extends SinglePaneLayoutStrategy private _managedTabs: SinglePaneDockedTabsCoordinator | undefined; private _detailHiddenTransiently = false; + private _detailHiddenByEditor = false; private _changingDetailTransiently = false; constructor( @@ -324,6 +324,7 @@ export class SinglePaneExistingSessionStrategy extends SinglePaneLayoutStrategy this._register(this._layoutService.onDidChangePartVisibility(event => { if (event.partId === Parts.AUXILIARYBAR_PART && event.source !== 'resize') { this._detailHiddenTransiently = false; + this._detailHiddenByEditor = false; } })); } @@ -334,18 +335,20 @@ export class SinglePaneExistingSessionStrategy extends SinglePaneLayoutStrategy } const detailVisible = this._layoutService.isVisible(Parts.AUXILIARYBAR_PART); - if (target === DetailPanelTarget.Hidden || target === DetailPanelTarget.BrowserHidden) { - if (!revealOnly && detailVisible) { + if (target === DetailPanelTarget.Hidden || target === DetailPanelTarget.EditorHidden) { + if ((target === DetailPanelTarget.EditorHidden || !revealOnly) && detailVisible) { this._detailHiddenTransiently = true; + this._detailHiddenByEditor = target === DetailPanelTarget.EditorHidden; this._setDetailHiddenTransiently(true); } return; } - if (!this._detailHiddenTransiently || revealOnly || !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + if (!this._detailHiddenTransiently || (revealOnly && !this._detailHiddenByEditor) || !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { return; } this._detailHiddenTransiently = false; + this._detailHiddenByEditor = false; this._setDetailHiddenTransiently(false); } @@ -363,20 +366,15 @@ export class SinglePaneExistingSessionStrategy extends SinglePaneLayoutStrategy return this._ctx.isRestoringSessionLayout ? DetailPanelTarget.Preserve : DetailPanelTarget.Hidden; } - if (editorMaximized) { - return DetailPanelTarget.Changes; + if (activeEditor && isEditorWithoutDockedDetails(activeEditor)) { + return editorPartVisible ? DetailPanelTarget.EditorHidden : DetailPanelTarget.Changes; } - if (!activeEditor) { + if (editorMaximized) { return DetailPanelTarget.Changes; } - if (activeEditor instanceof BrowserEditorInput) { - // Browser has no detail of its own, so it only hides the panel while the editor - // area is visible; once hidden, fall back to Changes instead of leaving it blank. - if (editorPartVisible) { - return DetailPanelTarget.BrowserHidden; - } + if (!activeEditor) { return DetailPanelTarget.Changes; } diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneNewSessionStrategy.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneNewSessionStrategy.ts index 814e659a546429..3a4f3d91ce284a 100644 --- a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneNewSessionStrategy.ts +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneNewSessionStrategy.ts @@ -16,7 +16,6 @@ import { import { EditorActivation } from '../../../../../platform/editor/common/editor.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; -import { BrowserEditorInput } from '../../../../../workbench/contrib/browserView/common/browserEditorInput.js'; import { IEditorGroup, IEditorGroupsService, @@ -33,6 +32,7 @@ import { } from './singlePaneDetailPanelCoordinator.js'; import { isChangesEditorInput, + isEditorWithoutDockedDetails, isFileEditorInput, isMainPartEmpty, } from './singlePaneSharedHelpers.js'; @@ -48,6 +48,7 @@ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { private _pendingEntryHideSessionKey: string | undefined; private _pendingSidePaneOpenHideSessionKey: string | undefined; private _detailHiddenTransiently = false; + private _detailHiddenByEditor = false; constructor( ctx: ISinglePaneLayoutContext, @@ -406,6 +407,7 @@ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { event.source !== 'resize' ) { this._detailHiddenTransiently = false; + this._detailHiddenByEditor = false; } }), ); @@ -427,10 +429,14 @@ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { ); if ( target === DetailPanelTarget.Hidden || - target === DetailPanelTarget.BrowserHidden + target === DetailPanelTarget.EditorHidden ) { - if (!revealOnly && detailVisible) { + if ( + (target === DetailPanelTarget.EditorHidden || !revealOnly) && + detailVisible + ) { this._detailHiddenTransiently = true; + this._detailHiddenByEditor = target === DetailPanelTarget.EditorHidden; this._layoutService.setAuxiliaryBarHiddenForResize(true); } return; @@ -438,12 +444,13 @@ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { if ( !this._detailHiddenTransiently || - revealOnly || + (revealOnly && !this._detailHiddenByEditor) || !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) ) { return; } this._detailHiddenTransiently = false; + this._detailHiddenByEditor = false; this._layoutService.setAuxiliaryBarHiddenForResize(false); } @@ -458,6 +465,10 @@ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { // Session where an empty group means the whole side pane was closed — so, unlike // Existing, New never hides on an empty group. + if (activeEditor && isEditorWithoutDockedDetails(activeEditor)) { + return editorPartVisibleObs.read(reader) ? DetailPanelTarget.EditorHidden : DetailPanelTarget.Files; + } + if (editorMaximizedObs.read(reader)) { return DetailPanelTarget.Changes; } @@ -466,15 +477,6 @@ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { return DetailPanelTarget.Files; } - if (activeEditor instanceof BrowserEditorInput) { - // Browser has no detail of its own, so it only hides the panel while the editor - // area is visible; once hidden, fall back to Files instead of leaving it blank. - if (editorPartVisibleObs.read(reader)) { - return DetailPanelTarget.BrowserHidden; - } - return DetailPanelTarget.Files; - } - if (isChangesEditorInput(activeEditor, this._sessionChangesService)) { return DetailPanelTarget.ChangesForced; } diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneSharedHelpers.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneSharedHelpers.ts index d1d78acf0fb4c8..7dd73b78cf7328 100644 --- a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneSharedHelpers.ts +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneSharedHelpers.ts @@ -5,6 +5,7 @@ import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; import { DiffEditorInput } from '../../../../../workbench/common/editor/diffEditorInput.js'; +import { BrowserEditorInput } from '../../../../../workbench/contrib/browserView/common/browserEditorInput.js'; import { FileEditorInput } from '../../../../../workbench/contrib/files/browser/editors/fileEditorInput.js'; import { MultiDiffEditorInput } from '../../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.js'; import { WebviewInput } from '../../../../../workbench/contrib/webviewPanel/browser/webviewEditorInput.js'; @@ -17,6 +18,8 @@ const MARKDOWN_EDITOR_VIEW_TYPES = new Set([ 'vscode.markdown.editor', 'vscode.markdown.preview.editor', ]); +const PULL_REQUEST_OVERVIEW_VIEW_TYPE = 'PullRequestOverview'; +const ISSUE_OVERVIEW_VIEW_TYPE = 'IssueOverview'; /** Whether every group in the main editor part is empty (used by both the detail-panel and side-pane-visibility logic to detect an empty side pane). */ export function isMainPartEmpty(editorGroupsService: IEditorGroupsService): boolean { @@ -44,3 +47,13 @@ export function isFileEditorInput(editor: EditorInput): boolean { } return editor instanceof EmptyFileEditorInput || editor instanceof FileEditorInput; } + +/** Whether `editor` owns its full presentation and must hide the docked Details panel. */ +export function isEditorWithoutDockedDetails(editor: EditorInput): boolean { + return editor instanceof BrowserEditorInput + || (editor instanceof WebviewInput + && (editor.viewType === PULL_REQUEST_OVERVIEW_VIEW_TYPE + || editor.providerId === PULL_REQUEST_OVERVIEW_VIEW_TYPE + || editor.viewType === ISSUE_OVERVIEW_VIEW_TYPE + || editor.providerId === ISSUE_OVERVIEW_VIEW_TYPE)); +} diff --git a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts index 19f2d85074e9e5..21262258025760 100644 --- a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts @@ -324,7 +324,7 @@ suite('LayoutController (desktop)', () => { }); }); - test('[single-pane] restores the detail panel after a browser tab hides it', async () => { + test('[single-pane] hides details for self-contained editors and restores them for files', async () => { createSinglePaneController({ activateAux: true }); await timeout(0); const hasDockedDetails = () => harness.contextKeyService.getContextKeyValue(HasDockedDetailsContext.key); @@ -366,6 +366,71 @@ suite('LayoutController (desktop)', () => { 'file tabs should reopen the Files container after browser hides it' ); + harness.setPartHiddenCalls = []; + const pullRequestEditor = Object.create(WebviewInput.prototype) as WebviewInput; + Object.defineProperties(pullRequestEditor, { + viewType: { value: 'mainThreadWebview-PullRequestOverview' }, + providerId: { value: 'PullRequestOverview' }, + }); + harness.activeEditorInput = pullRequestEditor; + harness.onDidActiveEditorChange.fire(); + assert.strictEqual(hasDockedDetails(), false, 'pull request target should clear the editor chevron context'); + await timeout(0); + + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + 'pull request editors should hide the detail panel' + ); + + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.setPartHiddenCalls = []; + const issueEditor = Object.create(WebviewInput.prototype) as WebviewInput; + Object.defineProperties(issueEditor, { + viewType: { value: 'mainThreadWebview-IssueOverview' }, + providerId: { value: 'IssueOverview' }, + }); + harness.activeEditorInput = issueEditor; + harness.onDidActiveEditorChange.fire(); + assert.strictEqual(hasDockedDetails(), false, 'issue target should clear the editor chevron context'); + await timeout(0); + + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + 'issue editors should hide the detail panel' + ); + + harness.activeEditorInput = pullRequestEditor; + harness.onDidActiveEditorChange.fire(); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.setPartHiddenCalls = []; + harness.visibleSessionsObs.set([session, makeSession(URI.parse('session:2'))], undefined); + await timeout(0); + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + 'pull request editors should hide the detail panel when multiple sessions are visible' + ); + + harness.setPartHiddenCalls = []; + harness.activeEditorInput = store.add(new EmptyFileEditorInput(undefined, harness.layoutService)); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false), + 'file editors should restore details after a pull request editor while multiple sessions are visible' + ); + + harness.activeEditorInput = pullRequestEditor; + harness.onDidActiveEditorChange.fire(); + await timeout(0); + harness.visibleSessionsObs.set([session], undefined); + + harness.editorMaximized = true; + harness.onDidChangeEditorMaximized.fire(); + assert.strictEqual(hasDockedDetails(), false, 'maximized pull request editors should keep the editor chevron context clear'); + harness.editorMaximized = false; + harness.onDidChangeEditorMaximized.fire(); + // A search tab (any non-changes/non-file editor) has no detail panel, so // the chevron context must clear just like the browser tab does. harness.activeEditorInput = store.add(new TestStubEditorInput(URI.parse('search-editor://test'))); diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts index deeac1cee7e27d..8b3e630a4aeb27 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts @@ -19,8 +19,8 @@ import { ISessionsService } from '../../../services/sessions/browser/sessionsSer import { SessionStatus } from '../../../services/sessions/common/session.js'; import { IGitHubService } from '../../github/browser/githubService.js'; import { GitHubPullRequestCIModel } from '../../github/browser/models/githubPullRequestCIModel.js'; -import { GitHubCheckStatus } from '../../github/common/types.js'; -import { getFailedChecks, REVEAL_CI_CHECKS_COMMAND_ID, submitFixCIChecks } from '../../changes/browser/checksActions.js'; +import { GitHubCheckStatus, OPEN_PULL_REQUEST_ACTION_ID } from '../../github/common/types.js'; +import { getFailedChecks, submitFixCIChecks } from '../../changes/browser/checksActions.js'; import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedbackService } from '../../agentFeedback/browser/agentFeedbackService.js'; import type { ISessionChatPillsDebugData } from '../../chat/browser/sessionChatInputToolbarDebug.js'; import { ISessionInputBanner, SessionInputBannerWidget } from './sessionInputBannerWidget.js'; @@ -230,7 +230,7 @@ export class SessionInputBanners extends Disposable { }, { label: localize('ci.revealChecks', "Reveal"), - run: () => { if (!state.debug) { void this._executeCommand(REVEAL_CI_CHECKS_COMMAND_ID); } }, + run: () => { if (!state.debug) { void this._executeCommand(OPEN_PULL_REQUEST_ACTION_ID); } }, }, ], dismiss: () => { if (!state.debug) { this._dismiss(STORAGE_KEY_CI_DISMISSED, this._ciDismissed, state.sessionId); } }, diff --git a/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts index 472dad4e99018d..3fc85c22585aa8 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts @@ -60,7 +60,7 @@ function ciBanner(failed: number, completed: number, pending: number): ISessionI dismissTooltip: 'Hide for this session', actions: [ { label: 'Fix Checks', primary: true, run: () => console.log('Fix Checks') }, - { label: 'Reveal', run: () => console.log('Reveal Checks') }, + { label: 'Reveal', run: () => console.log('Open Pull Request') }, ], dismiss: () => console.log('Dismiss CI banner'), }; From c4c09dd4fcccf91f0d9985ef3b3191d2f60896f6 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:36:00 +0200 Subject: [PATCH 10/18] base: disable split-action dropdown when its primary action is disabled (#331458) Disable feedback dropdown for empty input Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/base/browser/ui/dropdown/dropdown.ts | 21 +++++++++++++++---- .../ui/dropdown/dropdownActionViewItem.ts | 11 +++++++++- .../test/browser/feedbackInputWidget.test.ts | 17 ++++++++++++--- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/vs/base/browser/ui/dropdown/dropdown.ts b/src/vs/base/browser/ui/dropdown/dropdown.ts index f2c1b4e551420c..1370116839cd32 100644 --- a/src/vs/base/browser/ui/dropdown/dropdown.ts +++ b/src/vs/base/browser/ui/dropdown/dropdown.ts @@ -25,6 +25,7 @@ export interface ILabelRenderer { export interface IBaseDropdownOptions { label?: string; labelRenderer?: ILabelRenderer; + isEnabled?: () => boolean; } export class BaseDropdown extends ActionRunner { @@ -39,17 +40,17 @@ export class BaseDropdown extends ActionRunner { private hover: IManagedHover | undefined; - constructor(container: HTMLElement, options: IBaseDropdownOptions) { + constructor(container: HTMLElement, private readonly baseOptions: IBaseDropdownOptions) { super(); this._element = append(container, $('.monaco-dropdown')); this._label = append(this._element, $('.dropdown-label')); - let labelRenderer = options.labelRenderer; + let labelRenderer = baseOptions.labelRenderer; if (!labelRenderer) { labelRenderer = (container: HTMLElement): IDisposable | null => { - container.textContent = options.label || ''; + container.textContent = baseOptions.label || ''; return null; }; @@ -61,6 +62,10 @@ export class BaseDropdown extends ActionRunner { for (const event of [EventType.MOUSE_DOWN, GestureEventType.Tap]) { this._register(addDisposableListener(this._label, event, e => { + if (baseOptions.isEnabled?.() === false) { + return; + } + if (isMouseEvent(e) && e.button !== 0) { // prevent right click trigger to allow separate context menu (https://github.com/microsoft/vscode/issues/151064) return; @@ -79,6 +84,10 @@ export class BaseDropdown extends ActionRunner { if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { EventHelper.stop(e, true); // https://github.com/microsoft/vscode/issues/57997 + if (baseOptions.isEnabled?.() === false) { + return; + } + if (this.visible) { this.hide(); } else { @@ -114,7 +123,7 @@ export class BaseDropdown extends ActionRunner { } show(): void { - if (!this.visible) { + if (!this.visible && this.baseOptions.isEnabled?.() !== false) { this.visible = true; this._onDidChangeVisibility.fire(true); } @@ -208,6 +217,10 @@ export class DropdownMenu extends BaseDropdown { } override show(): void { + if (this._options.isEnabled?.() === false) { + return; + } + super.show(); this.element.classList.add('active'); diff --git a/src/vs/base/browser/ui/dropdown/dropdownActionViewItem.ts b/src/vs/base/browser/ui/dropdown/dropdownActionViewItem.ts index 7dfd2f3ddb1370..e718aa4288fea2 100644 --- a/src/vs/base/browser/ui/dropdown/dropdownActionViewItem.ts +++ b/src/vs/base/browser/ui/dropdown/dropdownActionViewItem.ts @@ -84,6 +84,7 @@ export class DropdownMenuActionViewItem extends BaseActionViewItem { const options: IDropdownMenuOptions = { contextMenuProvider: this.contextMenuProvider, labelRenderer: labelRenderer, + isEnabled: () => this.action.enabled, menuAsChild: this.options.menuAsChild, actions: isActionsArray ? this.menuActionsOrProvider as IAction[] : undefined, actionProvider: isActionsArray ? undefined : this.menuActionsOrProvider as IActionProvider, @@ -183,6 +184,7 @@ export class DropdownMenuActionViewItem extends BaseActionViewItem { const disabled = !this.action.enabled; this.actionItem?.classList.toggle('disabled', disabled); this.element?.classList.toggle('disabled', disabled); + this.element?.setAttribute('aria-disabled', String(disabled)); } } @@ -221,7 +223,7 @@ export class ActionWithDropdownActionViewItem extends ActionViewItem { separator.classList.toggle('prominent', menuActionClassNames.includes('prominent')); append(this.element, separator); - this.dropdownMenuActionViewItem = this._register(new DropdownMenuActionViewItem(this._register(new Action('dropdownAction', nls.localize('moreActions', "More Actions..."))), menuActionsProvider, this.contextMenuProvider, { + this.dropdownMenuActionViewItem = this._register(new DropdownMenuActionViewItem(this._register(new Action('dropdownAction', nls.localize('moreActions', "More Actions..."), undefined, this.action.enabled)), menuActionsProvider, this.contextMenuProvider, { classNames: ['dropdown', ...ThemeIcon.asClassNameArray(Codicon.dropDownButton), ...menuActionClassNames], hoverDelegate: this.options.hoverDelegate, keybindingProvider: (this.options).keybindingProvider, @@ -261,4 +263,11 @@ export class ActionWithDropdownActionViewItem extends ActionViewItem { super.setFocusable(focusable); this.dropdownMenuActionViewItem?.setFocusable(focusable); } + + protected override updateEnabled(): void { + super.updateEnabled(); + if (this.dropdownMenuActionViewItem) { + this.dropdownMenuActionViewItem.action.enabled = this.action.enabled; + } + } } diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/feedbackInputWidget.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/feedbackInputWidget.test.ts index 6ba3f0b1e346ee..badf114f149195 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/feedbackInputWidget.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/feedbackInputWidget.test.ts @@ -163,12 +163,23 @@ suite('FeedbackInputWidget', () => { showContextMenu: delegate => contextMenuDelegate = delegate, }, })); - widget.inputElement.value = 'Feedback'; - widget.updateActionEnabled(); - const dropdown = widget.domNode.querySelector('.monaco-dropdown .dropdown-label'); assert.ok(dropdown); dropdown.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })); + const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }); + Object.defineProperty(enterEvent, 'keyCode', { get: () => 13 }); + dropdown.dispatchEvent(enterEvent); + assert.deepStrictEqual({ + ariaDisabled: dropdown.querySelector('.action-label')?.getAttribute('aria-disabled'), + contextMenuShown: contextMenuDelegate !== undefined, + }, { + ariaDisabled: 'true', + contextMenuShown: false, + }); + + widget.inputElement.value = 'Feedback'; + widget.updateActionEnabled(); + dropdown.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })); assert.ok(contextMenuDelegate); assert.deepStrictEqual(contextMenuDelegate.getActions().map(action => ({ From 540fb004e7b438e036c85461d04b3e0309a064b0 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 18 Aug 2026 16:39:35 +0200 Subject: [PATCH 11/18] change delay to 12 hrs by default (#331468) * change delay to 12 hrs by default * feedback * update policy --- build/lib/policies/policyData.jsonc | 2 +- .../contrib/extensions/browser/extensions.contribution.ts | 2 +- .../contrib/extensions/browser/extensionsWorkbenchService.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 0b19e32efcd8e5..69e258d2d2ac38 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -790,7 +790,7 @@ } }, "type": "number", - "default": 2, + "default": 12, "included": true }, { diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index b04aae0a2ff0e4..a60cb5f0e0b3f8 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -171,7 +171,7 @@ Registry.as(ConfigurationExtensions.Configuration) }, 'extensions.autoUpdateDelay': { type: 'number', - default: 2, + default: 12, minimum: 0, markdownDescription: localize('extensions.autoUpdateDelay', "Controls the delay in hours after an extension update is published before it is automatically installed. Only applies when `#extensions.autoUpdate#` is set to `on`. This delay helps avoid installing potentially problematic updates immediately after release."), scope: ConfigurationScope.APPLICATION, diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index a58d77d1291a0e..b60ffca7db1ed4 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -1265,7 +1265,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } getAutoUpdateDelay(): number { - const delayHours = this.configurationService.getValue(AutoUpdateDelayConfigurationKey) ?? 2; + const delayHours = this.configurationService.getValue(AutoUpdateDelayConfigurationKey) ?? 12; return delayHours * 60 * 60 * 1000; // Convert hours to milliseconds } From 958eb5eec0a5378653b8f756da038825d70fb702 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Tue, 18 Aug 2026 15:39:39 +0100 Subject: [PATCH 12/18] Refine header border and separator styles for modern UI (#331467) style(paneHeaders): refine header border and separator styles for modern UI Co-authored-by: mrleemurray --- .../modernUI/browser/media/paneHeaders.css | 23 +++--- .../browser/modernUI.contribution.test.ts | 72 +++++++++++++++++++ 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/paneHeaders.css b/src/vs/workbench/contrib/modernUI/browser/media/paneHeaders.css index 637d4a97f7b64c..7c81868a5a2ece 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/paneHeaders.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/paneHeaders.css @@ -11,9 +11,9 @@ * - rounds the header corners, and * - adds a background tint on hover. * - * The pane border (`border-top`) and the pane's left border are applied as - * inline styles by the PaneView (src/vs/base/browser/ui/splitview/paneview.ts), - * so the resets below must use `!important` to win over those inline values. + * The pane header border (`border-top`) is applied as an inline style by the + * PaneView (src/vs/base/browser/ui/splitview/paneview.ts), so the reset below + * must use `!important` to win over that inline value. * * All rules are gated behind the `.modern-ui` class, which the * ModernUIContribution toggles onto the workbench container(s) when the @@ -22,11 +22,11 @@ */ /* - * Remove the full-width separator lines / borders (set inline by the PaneView) - * so we can redraw them inset below. + * Remove the full-width header separator line (set inline by the PaneView) so + * we can redraw it inset below. The pane's inline left border is retained + * because it separates horizontally stacked panel sections. */ -.modern-ui .monaco-pane-view .pane > .pane-header, -.modern-ui .monaco-pane-view .pane { +.modern-ui .monaco-pane-view .pane > .pane-header { border: none !important; } @@ -47,8 +47,13 @@ background-color: var(--vscode-sideBarSectionHeader-border, var(--vscode-panelSectionHeader-border, var(--vscode-panel-border))); } -/* The first section in a pane has no separator above it. */ -.modern-ui .monaco-pane-view .split-view-view:first-of-type > .pane > .pane-header::before { +/* + * The first section in a vertical stack has no separator above it. Horizontally + * arranged panes are the only section in their column, so their left pane border + * separates columns without an additional horizontal line above the header. + */ +.modern-ui .monaco-pane-view .split-view-view:first-of-type > .pane > .pane-header::before, +.modern-ui .monaco-pane-view .pane.horizontal > .pane-header::before { display: none; } diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index 8b551b01efcb2e..b57a9f50e90422 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { getWindow } from '../../../../../base/browser/dom.js'; +import { Orientation } from '../../../../../base/browser/ui/sash/sash.js'; import { Pane } from '../../../../../base/browser/ui/splitview/paneview.js'; import { Color } from '../../../../../base/common/color.js'; import { Emitter } from '../../../../../base/common/event.js'; @@ -96,6 +97,7 @@ suite('ModernUIContribution', () => { [LayoutSettings.MODERN_UI]: true, [LayoutSettings.MODERN_UI_UPPERCASE_VIEW_HEADERS]: true, }); + store.add(configurationService.onDidChangeConfigurationEmitter); const layoutService = new ModernUITestLayoutService(); store.add(layoutService.onDidAddContainerEmitter); @@ -171,6 +173,76 @@ suite('ModernUIContribution', () => { }); }); + test('preserves horizontal panel section borders without drawing separators above column headers', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui'; + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const verticalPaneView = appendElement(root, 'monaco-pane-view'); + const firstVerticalPane = store.add(new ModernUITestPane()); + firstVerticalPane.style({ + dropBackground: undefined, + headerForeground: undefined, + headerBackground: undefined, + headerBorder: '#00FF00', + leftBorder: undefined, + }); + appendElement(verticalPaneView, 'split-view-view').appendChild(firstVerticalPane.element); + + const followingVerticalPane = store.add(new ModernUITestPane()); + followingVerticalPane.style({ + dropBackground: undefined, + headerForeground: undefined, + headerBackground: undefined, + headerBorder: '#00FF00', + leftBorder: undefined, + }); + appendElement(verticalPaneView, 'split-view-view').appendChild(followingVerticalPane.element); + + const horizontalPaneView = appendElement(root, 'monaco-pane-view'); + const firstHorizontalPane = store.add(new ModernUITestPane()); + firstHorizontalPane.orientation = Orientation.HORIZONTAL; + firstHorizontalPane.style({ + dropBackground: undefined, + headerForeground: undefined, + headerBackground: undefined, + headerBorder: undefined, + leftBorder: '#FF0000', + }); + appendElement(horizontalPaneView, 'split-view-view').appendChild(firstHorizontalPane.element); + + const followingHorizontalPane = store.add(new ModernUITestPane()); + followingHorizontalPane.orientation = Orientation.HORIZONTAL; + followingHorizontalPane.style({ + dropBackground: undefined, + headerForeground: undefined, + headerBackground: undefined, + headerBorder: undefined, + leftBorder: '#FF0000', + }); + appendElement(horizontalPaneView, 'split-view-view').appendChild(followingHorizontalPane.element); + + const targetWindow = getWindow(root); + assert.deepStrictEqual({ + firstVerticalHeaderSeparatorVisible: targetWindow.getComputedStyle(firstVerticalPane.draggableElement!, '::before').display !== 'none', + followingVerticalHeaderSeparatorVisible: targetWindow.getComputedStyle(followingVerticalPane.draggableElement!, '::before').display !== 'none', + followingVerticalHeaderBorderTopWidth: targetWindow.getComputedStyle(followingVerticalPane.draggableElement!).borderTopWidth, + firstHorizontalHeaderSeparatorVisible: targetWindow.getComputedStyle(firstHorizontalPane.draggableElement!, '::before').display !== 'none', + followingHorizontalHeaderSeparatorVisible: targetWindow.getComputedStyle(followingHorizontalPane.draggableElement!, '::before').display !== 'none', + followingHorizontalPaneBorderLeftWidth: targetWindow.getComputedStyle(followingHorizontalPane.element).borderLeftWidth, + followingHorizontalPaneBorderLeftColor: targetWindow.getComputedStyle(followingHorizontalPane.element).borderLeftColor, + }, { + firstVerticalHeaderSeparatorVisible: false, + followingVerticalHeaderSeparatorVisible: true, + followingVerticalHeaderBorderTopWidth: '0px', + firstHorizontalHeaderSeparatorVisible: false, + followingHorizontalHeaderSeparatorVisible: false, + followingHorizontalPaneBorderLeftWidth: '1px', + followingHorizontalPaneBorderLeftColor: 'rgb(255, 0, 0)', + }); + }); + test('toggles uppercase view headers without relayout', async () => { const configurationService = new TestConfigurationService({ [LayoutSettings.MODERN_UI]: true, From 523712bcf6ee6d2e5f331fe65a0d1d264b7e6125 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Tue, 18 Aug 2026 10:40:11 -0400 Subject: [PATCH 13/18] Limit the surface which renders promo notifications (#331465) --- .../automations/browser/automationDialog.ts | 2 ++ .../chat/browser/chatPromoNotification.ts | 10 ++++-- .../contrib/chat/browser/widget/chatWidget.ts | 6 ++++ .../widget/input/chatInputModelUtils.ts | 11 ++++++ .../input/chatInputNotificationService.ts | 7 ++++ .../input/chatInputNotificationWidget.ts | 8 +++++ .../browser/widget/input/chatInputPart.ts | 20 +++++++++-- .../browser/chatPromoNotification.test.ts | 26 ++++++++++++++ .../widget/input/chatInputModelUtils.test.ts | 18 ++++++++++ .../input/chatInputNotificationWidget.test.ts | 35 +++++++++++++++++++ 10 files changed, 138 insertions(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index d54d53f3ee4402..a2d569e5edbba5 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -1015,6 +1015,8 @@ export function renderForm( telemetrySource: 'automations.dialog', }, widgetViewKindTag: 'automations-dialog', + // A scheduling form, not a chat about to be sent: keep promos out. + isTransientChat: true, inputEditorMinLines: 3, // The dialog renders the composer flush with its form column (the // `.interactive-input-part` margin is zeroed in CSS), so there is no diff --git a/src/vs/workbench/contrib/chat/browser/chatPromoNotification.ts b/src/vs/workbench/contrib/chat/browser/chatPromoNotification.ts index 9d815277bbf547..c194a7f55cd2d1 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPromoNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPromoNotification.ts @@ -16,9 +16,10 @@ const DISMISSED_PROMOS_STORAGE_KEY = 'chat.dismissedPromoIds'; /** * Surfaces a model's promo as a chat input notification, scoped to the harness - * (chat session type) of the model that carries it. Dismissals are persisted by - * promo id in application storage, so they survive reloads and apply to every - * open window. + * (chat session type) of the model that carries it. Promos only render where a + * model switch is still plausible: persistent chat surfaces whose session has + * not started yet. Dismissals are persisted by promo id in application storage, + * so they survive reloads and apply to every open window. */ export class ChatPromoNotificationContribution extends Disposable implements IWorkbenchContribution { @@ -99,6 +100,9 @@ export class ChatPromoNotificationContribution extends Disposable implements IWo dismissible: true, autoDismissOnMessage: false, deferForNewUsers: true, + // Only actionable while the user is still choosing how to start work. + hideInTransientChats: true, + hideInStartedSessions: true, sessionTypes: [harness], }); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 03bc8529279051..c6105d7679229e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -160,6 +160,11 @@ export function isChatInputWindow(widget: IChatWidget): boolean { return isIChatResourceViewContext(widget.viewContext) && Boolean(widget.viewContext.isChatInputWindow); } +/** Whether the widget is a short-lived, single-task chat surface. */ +function isTransientChat(widget: IChatWidget): boolean { + return widget.location !== ChatAgentLocation.Chat || isInlineChat(widget) || isQuickChat(widget); +} + export function getImmediateSilentSlashCommandPart(parsedRequest: IParsedChatRequest): ChatRequestSlashCommandPart | undefined { return parsedRequest.parts.find((part): part is ChatRequestSlashCommandPart => part instanceof ChatRequestSlashCommandPart @@ -2334,6 +2339,7 @@ export class ChatWidget extends Disposable implements IChatWidget { inputEditorMinLines: this.viewOptions.inputEditorMinLines, inputEditorMaxHeight: this.viewOptions.inputEditorMaxHeight, deferredNotificationsEnabled: this.viewOptions.deferredNotificationsEnabled, + isTransientChat: isTransientChat(this), widgetViewKindTag: this.getWidgetViewKindTag(), defaultMode: this.viewOptions.defaultMode, sessionTypePickerDelegate: this.viewOptions.sessionTypePickerDelegate, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelUtils.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelUtils.ts index e9d6ade0ecf4c3..518c63bc5780a1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelUtils.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelUtils.ts @@ -162,6 +162,17 @@ export function isNewConversation(sessionResource: URI, hasNoRequests: boolean): && (getChatSessionType(sessionResource) === localChatSessionType || isUntitledChatSession(sessionResource)); } +/** + * Whether a chat input counts as bound to a conversation that is already underway. + * + * An unbound input counts as started: a session switch clears the bound model for the duration of + * an async load while the outgoing session type is still published, and a notice must not surface + * in that window. Only a model seen to be request-free is unstarted. + */ +export function isSessionStarted(hasBoundModel: boolean, hasRequests: boolean): boolean { + return !hasBoundModel || hasRequests; +} + /** * Whether the persisted per-session-type model should be restored (into the picker) when the * input switches to a session. diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts index 3d15208f796810..74666b7af81803 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationService.ts @@ -69,6 +69,13 @@ export interface IChatInputNotification { readonly autoDismissOnMessage: boolean; /** Whether this notification should be hidden until the user has prior chat usage. */ readonly deferForNewUsers?: boolean; + /** + * Whether to hide this notification in transient chat surfaces (inline, + * terminal, quick chat, floating chat input window). + */ + readonly hideInTransientChats?: boolean; + /** Whether to hide this notification once its session has a request. */ + readonly hideInStartedSessions?: boolean; /** * Optional allow-list of chat session types that should display this * notification. When undefined, the notification renders in every chat diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts index 5e683e962f9e28..4368f32a3884e9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.ts @@ -69,6 +69,10 @@ export interface IChatInputNotificationDelegate { readonly modelTargetChatSessionType?: IObservable; readonly sessionResource?: IObservable; readonly deferredNotificationsEnabled?: IObservable; + /** Whether this input is a transient surface (inline, terminal, quick chat, chat input window). */ + readonly isTransientChat?: boolean; + /** Whether the session this input is bound to already has a request. */ + readonly sessionStarted?: IObservable; readonly openModelPicker?: () => void; /** Returns false to open this input's model picker as a fallback. */ readonly switchToModel?: (modelIdentifier: string) => boolean; @@ -102,6 +106,7 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu private _modelTargetChatSessionType: string | undefined; private _sessionResource: URI | undefined; private _deferredNotificationsEnabled = true; + private _sessionStarted = false; private _visible = false; private _slot: HTMLElement | undefined; @@ -130,6 +135,7 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu this._modelTargetChatSessionType = this._delegate?.modelTargetChatSessionType?.read(reader); this._sessionResource = this._delegate?.sessionResource?.read(reader); this._deferredNotificationsEnabled = this._delegate?.deferredNotificationsEnabled?.read(reader) ?? true; + this._sessionStarted = this._delegate?.sessionStarted?.read(reader) ?? false; this._render(); })); } @@ -199,6 +205,8 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu private _matchesSession(notification: IChatInputNotification): boolean { return (!notification.deferForNewUsers || this._deferredNotificationsEnabled) + && !(notification.hideInTransientChats && this._delegate?.isTransientChat) + && !(notification.hideInStartedSessions && this._sessionStarted) && isChatInputNotificationApplicableToSession(notification, this._modelTargetChatSessionType, this._sessionResource); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 67ab7228193fa2..0c198fbad4a070 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -105,9 +105,9 @@ import { ChatInputModelSelectionController, IChatInputModelSelectionRuntime } fr import { ChatModelConfigurationStore } from './chatModelConfigurationStore.js'; import { ChatModelSelectionDiagnostics } from './chatModelSelectionDiagnostics.js'; import { deserializeUntitledInputAttachments, deserializeUntitledInputState, serializeUntitledInputAttachments, serializeUntitledInputState } from './chatInputStatePersistence.js'; -import { ChatInputStateOrigin, IChatModelInputState, IChatRequestModeInfo, IChatRequestModel, IInputModel, IIntendedModelHolder, IntendedModelSlot, logChangesToStateModel } from '../../../common/model/chatModel.js'; +import { ChatInputStateOrigin, IChatModel, IChatModelInputState, IChatRequestModeInfo, IChatRequestModel, IInputModel, IIntendedModelHolder, IntendedModelSlot, logChangesToStateModel } from '../../../common/model/chatModel.js'; import { isInConversationModelChoice, ModelSelectionReason, resolveConfiguredModel, RestoredModelReason } from '../../../common/modelSelection.js'; -import { filterModelsForSession, hasModelsTargetingSession, isModelHiddenInPicker, isModelSupportedForInlineChat, isModelSupportedForMode, isNewConversation, mergeModelsWithCache, shouldDropAgnosticDraftModel, shouldResetOnModelListChange, shouldRestorePerTypeModelOnSessionSwitch } from './chatInputModelUtils.js'; +import { filterModelsForSession, hasModelsTargetingSession, isModelHiddenInPicker, isModelSupportedForInlineChat, isModelSupportedForMode, isNewConversation, isSessionStarted, mergeModelsWithCache, shouldDropAgnosticDraftModel, shouldResetOnModelListChange, shouldRestorePerTypeModelOnSessionSwitch } from './chatInputModelUtils.js'; import { getChatSessionType, isUntitledChatSession, LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { IChatResponseViewModel, isResponseVM } from '../../../common/model/chatViewModel.js'; import { IChatAgentService } from '../../../common/participants/chatAgents.js'; @@ -208,6 +208,8 @@ export interface IChatInputPartOptions { inputEditorMinLines?: number; inputEditorMaxHeight?: number; deferredNotificationsEnabled?: boolean; + /** Whether this input is a transient surface (inline, terminal, quick chat, chat input window). */ + isTransientChat?: boolean; widgetViewKindTag: string; /** * Optional delegate for the session target picker. @@ -777,9 +779,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private get _currentSessionType(): string | undefined { return this._currentSessionTypeObservable.get(); } private set _currentSessionType(value: string | undefined) { this._currentSessionTypeObservable.set(value, undefined); } private readonly _currentSessionResourceObservable = observableValue(this, undefined); + private readonly _currentSessionModelObservable = observableValue(this, undefined); private readonly _deferredNotificationsEnabled = observableValue(this, true); private _isFirstWorkbenchSession: boolean | undefined; + /** Whether the session this input is bound to already has a request. */ + private readonly _sessionStarted = derived(this, reader => { + const model = this._currentSessionModelObservable.read(reader); + return isSessionStarted(!!model, !!model?.lastRequestObs.read(reader)); + }); + private readonly _notificationModelTargetChatSessionType = derived(this, reader => this._pendingDelegationTargetObservable.read(reader) ?? this._currentSessionTypeObservable.read(reader) @@ -1522,6 +1531,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } this._currentSessionType = getChatSessionType(forSessionResource); + // The incoming chat model lands with the view model change; drop the outgoing one now so + // session-scoped notices are never judged against the model this input is letting go of. + this._currentSessionModelObservable.set(undefined, undefined); this._inputModel = model; this._inputModelSessionResource = forSessionResource; this._modelSyncDisposables.clear(); @@ -2799,6 +2811,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge modelTargetChatSessionType: this._notificationModelTargetChatSessionType, sessionResource: this._currentSessionResourceObservable, deferredNotificationsEnabled: this._deferredNotificationsEnabled, + isTransientChat: this.options.isTransientChat, + sessionStarted: this._sessionStarted, openModelPicker: () => this.openModelPicker(), switchToModel: modelIdentifier => this.switchModelByIdentifier(modelIdentifier, /* storeSelection */ true, /* isUserAction */ true), onDidChangeVisibility: (visible, focusTarget) => this.noticeHost.setOccupied(ChatInputNoticeLane.Notification, visible, focusTarget), @@ -2984,6 +2998,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private reconcileSessionTypeForViewModelChange(e: IChatWidgetViewModelChangeEvent, transaction: ITransaction): void { this._currentSessionResourceObservable.set(e.currentSessionResource, transaction); + this._currentSessionModelObservable.set(this._widget?.viewModel?.model, transaction); // Track the current session type and re-initialize model selection // when the session type changes (different session types may have // different model pools via targetChatSessionType). @@ -3026,6 +3041,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._widget = widget; this.updateDeferredNotificationsEligibility(); this._currentSessionResourceObservable.set(widget.viewModel?.sessionResource, undefined); + this._currentSessionModelObservable.set(widget.viewModel?.model, undefined); this.getVisibleOptionGroupsModeAndUpdateContextKeys(this.getCurrentSessionResource()); // Initialize lock state when rendering with a pre-selected session provider (e.g., welcome view restore) diff --git a/src/vs/workbench/contrib/chat/test/browser/chatPromoNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatPromoNotification.test.ts index e3fde7b6c1e55b..f119fda499641a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatPromoNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatPromoNotification.test.ts @@ -126,6 +126,32 @@ suite('ChatPromoNotificationContribution', () => { }]); }); + test('scopes promos to unstarted persistent chats', () => { + const notifService = createMockNotificationService(disposables); + const { service: lmService } = createMockLanguageModelsService([{ + identifier: 'copilot:gpt-5.5', + metadata: { name: 'GPT-5.5', id: 'gpt-5.5', promo: { id: 'promo-1', discountPercent: 20, message: 'Get 20% off' } }, + }], disposables); + const storageService = disposables.add(new InMemoryStorageService()); + + disposables.add(new ChatPromoNotificationContribution( + lmService, + notifService.service, + storageService, + )); + + const notification = notifService.getNotification(); + assert.deepStrictEqual({ + hideInTransientChats: notification?.hideInTransientChats, + hideInStartedSessions: notification?.hideInStartedSessions, + autoDismissOnMessage: notification?.autoDismissOnMessage, + }, { + hideInTransientChats: true, + hideInStartedSessions: true, + autoDismissOnMessage: false, + }); + }); + test('renders the server message for a 0% promo', () => { const notifService = createMockNotificationService(disposables); const { service: lmService } = createMockLanguageModelsService([{ diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputModelUtils.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputModelUtils.test.ts index 8302c855bf6703..43703e687a3602 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputModelUtils.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputModelUtils.test.ts @@ -21,6 +21,7 @@ import { isModelSupportedForMode, isModelValidForSession, isNewConversation, + isSessionStarted, mergeModelsWithCache, resolveModelFromSyncState, shouldDropAgnosticDraftModel, @@ -321,6 +322,23 @@ suite('ChatInputModelUtils', () => { }); }); + suite('isSessionStarted', () => { + + test('only a bound, request-free session counts as unstarted', () => { + assert.deepStrictEqual({ + boundAndEmpty: isSessionStarted(true, false), + boundWithRequests: isSessionStarted(true, true), + unbound: isSessionStarted(false, false), + }, { + boundAndEmpty: false, + boundWithRequests: true, + // A session switch unbinds the model for the duration of an async + // load; staying "started" keeps notices out of that window. + unbound: true, + }); + }); + }); + suite('hasModelsTargetingSession', () => { test('returns false when session type is undefined', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNotificationWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNotificationWidget.test.ts index 6e9a1141bf8e4d..78131d4a9b5e2e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNotificationWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNotificationWidget.test.ts @@ -210,6 +210,41 @@ suite('ChatInputNotificationWidget', () => { }); }); + test('skips notifications that opt out of transient chats without hiding others', () => { + const transient = createWidget({ delegate: { isTransientChat: true } }); + const persistent = createWidget({ delegate: { isTransientChat: false } }); + const rendered = (widget: ChatInputNotificationWidget) => widget.domNode.querySelector('.chat-input-notification-header')?.textContent; + + for (const { notificationService } of [transient, persistent]) { + showNotification(notificationService, { id: 'ordinary', message: 'Ordinary notification', actions: [] }); + showNotification(notificationService, { id: 'promotion', message: 'Model promotion', actions: [], hideInTransientChats: true }); + } + + assert.deepStrictEqual({ + transient: rendered(transient.widget), + persistent: rendered(persistent.widget), + }, { + transient: 'Ordinary notification', + persistent: 'Model promotion', + }); + }); + + test('reactively hides notifications that opt out of started sessions', () => { + const sessionStarted = observableValue('sessionStarted', false); + const { widget, notificationService } = createWidget({ delegate: { sessionStarted } }); + showNotification(notificationService, { id: 'ordinary', message: 'Ordinary notification', actions: [] }); + showNotification(notificationService, { id: 'promotion', message: 'Model promotion', actions: [], hideInStartedSessions: true }); + + const rendered = () => widget.domNode.querySelector('.chat-input-notification-header')?.textContent; + const before = rendered(); + sessionStarted.set(true, undefined); + + assert.deepStrictEqual({ before, after: rendered() }, { + before: 'Model promotion', + after: 'Ordinary notification', + }); + }); + test('standard workbench defers notifications for the first session only', () => { const deferredNotificationsEnabled = observableValue('deferredNotificationsEnabled', true); let hasSessions = false; From 001bbef682023faf497a3b79c5332bca1dec95ad Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Tue, 18 Aug 2026 15:44:09 +0100 Subject: [PATCH 14/18] Update tab hover styles and add test for tab action fading (#331476) style: update tab hover styles and add test for tab action fading Co-authored-by: mrleemurray --- .../contrib/modernUI/browser/media/tabs.css | 6 ++-- .../browser/modernUI.contribution.test.ts | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css index c7a00ec71e59b4..e8bad7c5910f0b 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css @@ -89,7 +89,7 @@ row-gap: 0; } -.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab:not(.selected):hover { +.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:not(.selected):hover { background-color: transparent !important; } @@ -377,8 +377,8 @@ top: var(--vscode-spacing-size20); } -.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title .tabs-container > .tab:not(.close-action-off):hover > .tab-actions::before, -.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title .tabs-container > .tab:not(.close-action-off) > .tab-actions:focus-within::before { +.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title:not(.tab-actions-reserve-space) .tabs-container > .tab:not(.dirty):not(.sticky):not(.close-action-off):hover > .tab-actions::before, +.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title:not(.tab-actions-reserve-space) .tabs-container > .tab:not(.dirty):not(.sticky):not(.close-action-off) > .tab-actions:focus-within::before { content: ''; position: absolute; top: 0; diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index b57a9f50e90422..fc6045c170e3cf 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -765,6 +765,36 @@ suite('ModernUIContribution', () => { }); }); + test('fades tab actions only when they overlay clean tabs', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui-tabs'; + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const group = appendElement(appendElement(appendElement(root, 'part editor'), 'content'), 'editor-group-container active'); + const getFadeContent = (titleClassName: string, tabClassName: string): string => { + const title = appendElement(group, titleClassName); + const tab = appendElement(appendElement(title, 'tabs-container'), tabClassName); + const actions = appendElement(tab, 'tab-actions'); + const action = appendElement(actions, 'action-label'); + action.tabIndex = 0; + action.focus(); + return getWindow(actions).getComputedStyle(actions, '::before').content; + }; + + assert.deepStrictEqual({ + overlaid: getFadeContent('title', 'tab'), + reserved: getFadeContent('title tab-actions-reserve-space', 'tab'), + dirty: getFadeContent('title', 'tab dirty'), + sticky: getFadeContent('title', 'tab sticky'), + }, { + overlaid: '""', + reserved: 'none', + dirty: 'none', + sticky: 'none', + }); + }); + test('uses legacy color customizations for Modern UI editor tabs only', () => { const theme = ColorThemeData.createUnloadedTheme('vs-dark', { [editorBackground]: '#000000', From 7870b732baf4720e9483c90b5f8840b65afd39c2 Mon Sep 17 00:00:00 2001 From: Ben Villalobos Date: Tue, 18 Aug 2026 07:50:44 -0700 Subject: [PATCH 15/18] build: increase CG notice timeout (#331399) Allow all configured notice generation retries to complete before the task times out. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b641365-3b26-4e50-864d-d8f4fd22126a --- build/azure-pipelines/product-quality-checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/azure-pipelines/product-quality-checks.yml b/build/azure-pipelines/product-quality-checks.yml index 6e2a9602dfb3fe..6eaa5d5ae1deb4 100644 --- a/build/azure-pipelines/product-quality-checks.yml +++ b/build/azure-pipelines/product-quality-checks.yml @@ -155,7 +155,7 @@ jobs: inputs: outputfile: $(Build.SourcesDirectory)/ThirdPartyNotices.generated.txt retryCountOnTaskFailure: 3 - timeoutInMinutes: 15 + timeoutInMinutes: 20 continueOnError: true condition: and(succeeded(), eq(lower(variables['VSCODE_CIBUILD']), 'false')) From a91fc86199061ab47849c0493101a4d28193c38a Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" <122617954+vs-code-engineering[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:20:50 +0000 Subject: [PATCH 16/18] Update distro commit (main) (#331407) * Update distro commit to 5475972d * chore: trigger CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: vs-code-engineering[bot] <122617954+vs-code-engineering[bot]@users.noreply.github.com> Co-authored-by: Martin Aeschlimann Co-authored-by: Dmitriy Vasyura Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7916d34c223886..d19766a08bbd34 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.135.0", - "distro": "c125b2a2432ff78b2d1f7b8ed8b0c67cf3af6187", + "distro": "5475972d5042caa842cdccf21488c4d0728ca0c1", "author": { "name": "Microsoft Corporation" }, From b50a541238d62fa760fa3ddb29668a1677138182 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Tue, 18 Aug 2026 16:22:34 +0100 Subject: [PATCH 17/18] Modern UI: Enhance notification center styling (#331479) Enhance notification center styling by adjusting border-radius for the last row Co-authored-by: mrleemurray --- .../contrib/modernUI/browser/media/roundedCorners.css | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/roundedCorners.css b/src/vs/workbench/contrib/modernUI/browser/media/roundedCorners.css index 30e4f782a7d2d8..d0c11dceb720c9 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/roundedCorners.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/roundedCorners.css @@ -217,14 +217,17 @@ /* * Notification center (floats above the status bar). It already clips its - * children with `overflow: hidden`, so rounding the container alone gives the - * header its top corners and the list its bottom corners in one place. Anchor - * the rule to the workbench root to override the base control-tier radius. + * children with `overflow: hidden`. Match the final row's bottom corners to the + * container so its focused and hover backgrounds follow the outer surface. */ .modern-ui.monaco-workbench > .notifications-center { border-radius: var(--vscode-cornerRadius-large); } +.modern-ui.monaco-workbench > .notifications-center .notifications-list-container .monaco-list-row:last-child { + border-radius: var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large); +} + /* * Notification toasts. The visible, shadowed surface is the nested * `.notifications-list-container`, wrapped by `.notification-toast` (and its From c0b328e1b609cc2ee8744f625eeb37fdfe34aa91 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:24:48 +0000 Subject: [PATCH 18/18] Fix inconsistent Omni input window height growth (#331233) --- .../browser/chatInputWindow/chatInputWindowService.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index e0749a05ff931d..efceae4c8c5bf9 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -642,7 +642,15 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind let pendingBounds: IRectangle | undefined; let applyingBounds = false; const getRowHeight = () => { - let contentHeight = Math.ceil(widget.contentHeight); + // Measure the input's rendered height synchronously rather than + // reading `widget.contentHeight`, which is backed by a + // ResizeObserver and lags a frame behind. `layout()` re-lays the + // editor synchronously, so the observable is stale until the + // observer fires next frame; sizing the window to that stale height + // while the editor grows (e.g. pasting text or cycling input + // history) clips the new lines and makes the window grow + // inconsistently until the observer catches up. + let contentHeight = Math.ceil(widget.input.element.offsetHeight); if (widget.attachmentModel.size > 0) { contentHeight += Math.max(0, CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT - widget.input.inputRowHeight); }